├── .rspec ├── lib ├── net_suite │ ├── version.rb │ ├── configuration.rb │ ├── restlet.rb │ ├── client.rb │ └── auth_token.rb └── net_suite.rb ├── .projections.json ├── sig └── net_suite.rbs ├── bin ├── console ├── setup └── rspec ├── spec ├── net_suite │ ├── version_spec.rb │ ├── restlet_spec.rb │ ├── auth_token_spec.rb │ ├── client_spec.rb │ └── configuration_spec.rb └── spec_helper.rb ├── Rakefile ├── .gitignore ├── Gemfile ├── .rubocop.yml ├── .circleci └── config.yml ├── README.md ├── LICENSE └── net_suite.gemspec /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/net_suite/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NetSuite 4 | VERSION = '0.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "lib/*.rb": { "alternate": "spec/{}_spec.rb" }, 3 | "spec/*_spec.rb": { "alternate": "lib/{}.rb" } 4 | } 5 | -------------------------------------------------------------------------------- /sig/net_suite.rbs: -------------------------------------------------------------------------------- 1 | module NetSuite 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'net_suite' 6 | 7 | require 'pry' 8 | Pry.start 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /spec/net_suite/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe NetSuite::VERSION do 4 | it 'has a version number' do 5 | expect(NetSuite::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /vendor/bundle 10 | 11 | Gemfile.lock 12 | 13 | # rspec failure tracking 14 | .rspec_status 15 | 16 | *.DS_Store 17 | 18 | /.pry_history 19 | *.tmp 20 | 21 | /tags 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net_suite' 4 | require 'pry' 5 | require 'webmock/rspec' 6 | 7 | RSpec.configure do |config| 8 | # Enable flags like --only-failures and --next-failure 9 | config.example_status_persistence_file_path = '.rspec_status' 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in net_suite.gemspec 6 | gemspec 7 | 8 | group :development, :test do 9 | gem 'pry', '~> 0.14' 10 | gem 'rake', '~> 13.0' 11 | gem 'rspec', '~> 3.0' 12 | gem 'rspec_junit_formatter', '~> 0.6' 13 | gem 'rubocop', '~> 1.21' 14 | gem 'webmock', '~> 3' 15 | end 16 | -------------------------------------------------------------------------------- /lib/net_suite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'attr_extras' 4 | require 'active_support' 5 | require 'active_support/core_ext' 6 | 7 | module NetSuite 8 | extend ActiveSupport::Autoload 9 | 10 | autoload :AuthToken 11 | autoload :Client 12 | autoload :Configuration 13 | autoload :Restlet 14 | autoload :VERSION 15 | 16 | class Error < StandardError; end 17 | end 18 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 3.1 3 | DisplayCopNames: true 4 | UseCache: true 5 | SuggestExtensions: false 6 | NewCops: enable 7 | 8 | Layout/LineLength: 9 | Max: 152 10 | 11 | Metrics/BlockLength: 12 | Exclude: 13 | - Gemfile 14 | - net_suite.gemspec 15 | - spec/**/*_spec.rb 16 | 17 | Style/Documentation: 18 | Enabled: false 19 | 20 | Style/TrailingCommaInArrayLiteral: 21 | EnforcedStyleForMultiline: comma 22 | 23 | Style/TrailingCommaInHashLiteral: 24 | EnforcedStyleForMultiline: comma 25 | 26 | Style/TrailingCommaInArguments: 27 | EnforcedStyleForMultiline: comma 28 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | docker: 5 | - image: ruby:3.1.2 6 | steps: 7 | - checkout 8 | - run: 9 | name: Install dependencies 10 | command: | 11 | gem install bundler -v 2.3.19 12 | bundle install 13 | when: always 14 | - run: 15 | name: Rubocop 16 | command: bundle exec rubocop 17 | when: always 18 | - run: 19 | name: Run specs 20 | command: | 21 | bundle exec rspec \ 22 | --require rspec_junit_formatter \ 23 | --format progress \ 24 | --format RspecJunitFormatter \ 25 | --out tmp/test-results/rspec.xml 26 | when: always 27 | - store_test_results: 28 | path: tmp/test-results/ 29 | -------------------------------------------------------------------------------- /lib/net_suite/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module NetSuite 4 | class Configuration 5 | rattr_initialize [ 6 | :oauth!, 7 | :restlet!, 8 | :logger!, 9 | { 10 | log_requests: false, 11 | request_timeout: 120, 12 | }, 13 | ] 14 | 15 | alias log_requests? log_requests 16 | 17 | class OAuth 18 | rattr_initialize [ 19 | :api_host!, 20 | :client_id!, 21 | :certificate_id!, 22 | :certificate_private_key!, 23 | :cache!, 24 | { 25 | token_expiration: 3600, 26 | }, 27 | ] 28 | end 29 | 30 | class Restlet 31 | rattr_initialize [ 32 | :api_host!, 33 | { 34 | path: 'app/site/hosting/restlet.nl', 35 | }, 36 | ] 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 12 | 13 | bundle_binstub = File.expand_path('bundle', __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require 'rubygems' 25 | require 'bundler/setup' 26 | 27 | load Gem.bin_path('rspec-core', 'rspec') 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetSuite 2 | 3 | Care/of’s gem for working with NetSuite’s API and OAuth 2.0 authentication 4 | 5 | ## Installation 6 | 7 | Add this line to your Gemfile: 8 | 9 | ```ruby 10 | gem 'net_suite', github: 'careofvitamins/net_suite' 11 | ``` 12 | 13 | ## Usage 14 | 15 | Generate a configuration object: 16 | 17 | ```ruby 18 | config = NetSuite::Configuration.new( 19 | oauth: NetSuite::Configuration::OAuth.new( 20 | api_host: 'https://netsuite-oauth.example.com', 21 | client_id: 'a_client_id', 22 | certificate_id: 'CERTIFICATE_ID', 23 | certificate_private_key: 'PRIVATE_KEY', 24 | cache: Rails.cache, 25 | ), 26 | restlet: NetSuite::Configuration::Restlet.new( 27 | api_host: 'https://netsuite-restlet.example.com', 28 | ), 29 | logger: Rails.logger, 30 | log_requests: true, 31 | request_timeout: 30, 32 | ) 33 | ``` 34 | 35 | Make a request using a restlet: 36 | 37 | ```ruby 38 | restlet = NetSuite::Restlet.new(config) 39 | body = { a: 1 } 40 | restlet.post(body, script: 'script-id', deploy: 'deploy-id') 41 | ``` 42 | 43 | ## Copyright 44 | 45 | © 2022 Care/of Vitamins 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Care/of Vitamins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/net_suite/restlet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | 5 | module NetSuite 6 | class Restlet 7 | rattr_initialize :config 8 | 9 | Faraday::METHODS_WITH_QUERY.each do |method| 10 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 11 | # def get(script:, deploy:, headers: nil) 12 | # client.get(config.restlet.path, { script: script, deploy: deploy }, headers) 13 | # end 14 | 15 | def #{method}(script:, deploy:, headers: nil) 16 | client.#{method}(config.restlet.path, { script: script, deploy: deploy }, headers) 17 | end 18 | RUBY 19 | end 20 | 21 | Faraday::METHODS_WITH_BODY.each do |method| 22 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 23 | # def post(body = nil, script:, deploy:, headers: nil, &block) 24 | # client.post(generate_path(script: script, deploy: deploy), body, headers, &block) 25 | # end 26 | 27 | def #{method}(body = nil, script:, deploy:, headers: nil, &block) 28 | client.#{method}(generate_path(script: script, deploy: deploy), body, headers, &block) 29 | end 30 | RUBY 31 | end 32 | 33 | private 34 | 35 | def generate_path(script:, deploy:) 36 | "#{config.restlet.path}?#{{ script:, deploy: }.to_query}" 37 | end 38 | 39 | def client 40 | @client ||= NetSuite::Client.new(config) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /net_suite.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/net_suite/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'net_suite' 7 | spec.version = NetSuite::VERSION 8 | spec.authors = ['Care/of Vitamins'] 9 | spec.email = ['dev@takecareof.com'] 10 | 11 | spec.summary = 'NetSuite API Client' 12 | spec.description = 'Care/of’s gem for working with NetSuite’s API and OAuth 2.0 authentication' 13 | spec.homepage = 'https://github.com/careofvitamins/net_suite' 14 | spec.required_ruby_version = '>= 3.1.2' 15 | 16 | spec.metadata['homepage_uri'] = spec.homepage 17 | spec.metadata['source_code_uri'] = spec.homepage 18 | 19 | # Specify which files should be added to the gem when it is released. 20 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 21 | spec.files = Dir.chdir(__dir__) do 22 | `git ls-files -z`.split("\x0").reject do |f| 23 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 24 | end 25 | end 26 | spec.bindir = 'exe' 27 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 28 | spec.require_paths = ['lib'] 29 | 30 | spec.add_dependency 'activesupport', '>= 6.1' 31 | spec.add_dependency 'attr_extras', '~> 6.2' 32 | spec.add_dependency 'faraday', '~> 1.10' 33 | spec.add_dependency 'jwt', '~> 2.2' 34 | spec.add_dependency 'oauth2', '~> 2.0' 35 | 36 | # For more information and examples about making a new gem, check out our 37 | # guide at: https://bundler.io/guides/creating_gem.html 38 | spec.metadata['rubygems_mfa_required'] = 'true' 39 | end 40 | -------------------------------------------------------------------------------- /spec/net_suite/restlet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe NetSuite::Restlet do 4 | let(:instance) { described_class.new(config) } 5 | 6 | let(:config) { instance_double(NetSuite::Configuration, restlet: restlet_config) } 7 | 8 | let(:restlet_config) do 9 | NetSuite::Configuration::Restlet.new( 10 | api_host: 'http://restlet.example.com', 11 | path: '/restlet', 12 | ) 13 | end 14 | 15 | let(:client) { instance_double(NetSuite::Client) } 16 | 17 | before do 18 | allow(NetSuite::Client).to receive(:new).with(config).and_return(client) 19 | end 20 | 21 | let(:script) { 'ascript' } 22 | let(:deploy) { 'adeploy' } 23 | let(:headers) { double(:headers) } 24 | 25 | describe 'query methods' do 26 | describe '#get' do 27 | subject { instance.get(script:, deploy:, headers:) } 28 | 29 | it 'calls the client with the correct paramaters' do 30 | expect(client).to receive(:get).with(restlet_config.path, { script:, deploy: }, headers) 31 | 32 | subject 33 | end 34 | end 35 | 36 | describe '#head' do 37 | subject { instance.head(script:, deploy:, headers:) } 38 | 39 | it 'calls the client with the correct paramaters' do 40 | expect(client).to receive(:head).with(restlet_config.path, { script:, deploy: }, headers) 41 | 42 | subject 43 | end 44 | end 45 | 46 | describe '#delete' do 47 | subject { instance.delete(script:, deploy:, headers:) } 48 | 49 | it 'calls the client with the correct paramaters' do 50 | expect(client).to receive(:delete).with(restlet_config.path, { script:, deploy: }, headers) 51 | 52 | subject 53 | end 54 | end 55 | end 56 | 57 | describe 'body methods' do 58 | let(:body) { double(:body) } 59 | let(:generated_path) { "/restlet?deploy=#{deploy}&script=#{script}" } 60 | 61 | describe '#post' do 62 | subject { instance.post(body, script:, deploy:, headers:) } 63 | 64 | it 'calls the client with the correct paramaters' do 65 | expect(client).to receive(:post).with(generated_path, body, headers) 66 | 67 | subject 68 | end 69 | end 70 | 71 | describe '#put' do 72 | subject { instance.put(body, script:, deploy:, headers:) } 73 | 74 | it 'calls the client with the correct paramaters' do 75 | expect(client).to receive(:put).with(generated_path, body, headers) 76 | 77 | subject 78 | end 79 | end 80 | 81 | describe '#patch' do 82 | subject { instance.patch(body, script:, deploy:, headers:) } 83 | 84 | it 'calls the client with the correct paramaters' do 85 | expect(client).to receive(:patch).with(generated_path, body, headers) 86 | 87 | subject 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/net_suite/client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'faraday' 4 | 5 | module NetSuite 6 | class Client 7 | rattr_initialize :config do 8 | @retry_count = 0 9 | end 10 | 11 | Faraday::METHODS_WITH_QUERY.each do |method| 12 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 13 | # def get(url = nil, params = nil, headers = nil) 14 | # with_auth_retry do 15 | # connection.get(url, params, headers) 16 | # end 17 | # end 18 | 19 | def #{method}(url = nil, params = nil, headers = nil) 20 | with_auth_retry do 21 | connection.#{method}(url, params, headers) 22 | end 23 | end 24 | RUBY 25 | end 26 | 27 | Faraday::METHODS_WITH_BODY.each do |method| 28 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 29 | # def post(url = nil, body = nil, headers = nil, &block) 30 | # with_auth_retry do 31 | # connection.post(url, body, headers, &block) 32 | # end 33 | # end 34 | 35 | def #{method}(url = nil, body = nil, headers = nil, &block) 36 | with_auth_retry do 37 | connection.#{method}(url, body, headers, &block) 38 | end 39 | end 40 | RUBY 41 | end 42 | 43 | private 44 | 45 | attr_accessor :retry_count 46 | 47 | def with_auth_retry(&) 48 | initial_response = yield 49 | return initial_response if initial_response.success? 50 | return initial_response unless initial_response.status == 401 51 | 52 | self.retry_count += 1 53 | 54 | yield 55 | end 56 | 57 | def fetch_auth_token 58 | NetSuite::AuthToken.call( 59 | config.oauth, 60 | skip_cache: retry_count.positive?, 61 | ) 62 | end 63 | 64 | def restlet_api_host 65 | config.restlet.api_host 66 | end 67 | 68 | def request_timeout 69 | config.request_timeout&.to_i || 120 70 | end 71 | 72 | def connection 73 | @connection ||= build_connection 74 | end 75 | 76 | def default_headers 77 | { 78 | content_type: 'application/json', 79 | accept: 'application/json', 80 | } 81 | end 82 | 83 | def request_options 84 | { 85 | timeout: request_timeout, 86 | } 87 | end 88 | 89 | def log_requests? 90 | config.logger && config.log_requests? 91 | end 92 | 93 | def build_connection 94 | Faraday.new(url: restlet_api_host, headers: default_headers, request: request_options) do |conn| 95 | conn.adapter Faraday.default_adapter 96 | 97 | conn.request :json 98 | conn.request :authorization, 'Bearer', -> { fetch_auth_token } 99 | conn.response :json, content_type: /\bjson$/ 100 | 101 | conn.response :logger, config.logger, headers: true, bodies: true if log_requests? 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/net_suite/auth_token.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'oauth2' 4 | require 'jwt' 5 | 6 | module NetSuite 7 | class AuthToken 8 | method_object :oauth_config, [{ skip_cache: false }] 9 | 10 | def call 11 | unless skip_cache 12 | token = fetch_cached_token 13 | 14 | return token if token 15 | end 16 | 17 | new_token = client.client_credentials.get_token(token_params) 18 | 19 | write_token_to_cache(new_token) 20 | 21 | new_token.token 22 | end 23 | 24 | delegate( 25 | :api_host, 26 | :client_id, 27 | :certificate_id, 28 | :certificate_private_key, 29 | :token_expiration, 30 | :cache, 31 | to: :oauth_config, 32 | private: true, 33 | ) 34 | 35 | private 36 | 37 | def expiration 38 | token_expiration&.to_i || 3600 39 | end 40 | 41 | ALGORITHM = 'RS512' 42 | TOKEN_URL = 'services/rest/auth/oauth2/v1/token' 43 | CACHE_KEY = 'net_suite_oauth_access_token' 44 | 45 | def write_token_to_cache(token) 46 | return unless cache.present? 47 | 48 | cache&.write(CACHE_KEY, token.to_hash, expires_in: token_expiration) 49 | end 50 | 51 | def fetch_cached_token 52 | return unless cache.present? 53 | 54 | hash_token = cache&.read(CACHE_KEY) 55 | 56 | return unless hash_token 57 | 58 | access_token = OAuth2::AccessToken.from_hash(client, hash_token) 59 | validated_cached_access_token(access_token) 60 | end 61 | 62 | def validated_cached_access_token(access_token) 63 | return unless access_token.token.present? 64 | return if access_token.expired? 65 | 66 | access_token.token 67 | end 68 | 69 | def token_params 70 | { 71 | client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 72 | client_assertion: jwt, 73 | } 74 | end 75 | 76 | def jwt 77 | JWT.encode(jwt_payload, key_pair, ALGORITHM, jwt_additional_header_fields) 78 | end 79 | 80 | def jwt_payload 81 | time = Time.now.to_i 82 | 83 | { 84 | aud: File.join(api_host, TOKEN_URL), # NetSuite token endpoint 85 | iss: client_id, # The Client ID for the integration 86 | scope: 'restlets,rest_webservices', # restlets, rest_webservices, suite_analytics, or all of them, separated by a comma. 87 | iat: time, # Unix timestamp of token issuance 88 | exp: time + token_expiration, # Unix timestamp of token expiration. Cannot be greater than iat + 3600 89 | } 90 | end 91 | 92 | def jwt_additional_header_fields 93 | { 94 | kid: certificate_id, # Certificate Id generated in the Oauth 2.0 client credentials mapping 95 | } 96 | end 97 | 98 | def key_pair 99 | OpenSSL::PKey.read(certificate_private_key) 100 | end 101 | 102 | def client 103 | @client ||= OAuth2::Client.new( 104 | nil, 105 | nil, 106 | site: api_host, 107 | token_url: TOKEN_URL, 108 | raise_errors: false, 109 | ) 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /spec/net_suite/auth_token_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/testing/time_helpers' 4 | 5 | describe NetSuite::AuthToken do 6 | include ActiveSupport::Testing::TimeHelpers 7 | 8 | subject { described_class.call(oauth_config, skip_cache:) } 9 | 10 | before { travel_to(current_time) } 11 | 12 | let(:current_time) { Time.new(2022, 10, 21, 12, 10) } 13 | 14 | let(:oauth_config) do 15 | NetSuite::Configuration::OAuth.new( 16 | api_host: 'http://oauth.example.com', 17 | client_id: 'a_client_id', 18 | certificate_id: public_key, 19 | certificate_private_key: private_key, 20 | cache:, 21 | token_expiration:, 22 | ) 23 | end 24 | 25 | let(:skip_cache) { false } 26 | 27 | let(:cache) { double(:cache) } 28 | let(:token_expiration) { 300 } 29 | 30 | let(:key) { OpenSSL::PKey.generate_key('RSA', rsa_keygen_bits: 4096) } 31 | 32 | let(:public_key) { key.public_key.to_s } 33 | let(:private_key) { key.to_s } 34 | 35 | let(:jwt_value) do 36 | JWT.encode(jwt_payload, OpenSSL::PKey.read(private_key), 'RS512', { kid: public_key }) 37 | end 38 | 39 | let(:jwt_payload) do 40 | { 41 | aud: 'http://oauth.example.com/services/rest/auth/oauth2/v1/token', 42 | iss: 'a_client_id', 43 | scope: 'restlets,rest_webservices', 44 | iat: current_time.to_i, 45 | exp: current_time.to_i + token_expiration, 46 | } 47 | end 48 | 49 | shared_examples 'new token fetched from API' do |warns_on_missing_token: false| 50 | let(:request_body) do 51 | { 52 | client_assertion: jwt_value, 53 | client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 54 | grant_type: 'client_credentials', 55 | } 56 | end 57 | 58 | let(:token_response) do 59 | { 60 | access_token: new_token_value, 61 | expires_at: current_time.to_i + token_expiration, 62 | refresh_token: nil, 63 | token_type: 'Bearer', 64 | } 65 | end 66 | 67 | let(:new_token_value) { 'bbbbbbbbb222222222' } 68 | let(:new_expires_at) { current_time.to_i + token_expiration } 69 | 70 | before do 71 | allow(cache).to receive(:write) 72 | 73 | stub_request(:post, 'http://oauth.example.com/services/rest/auth/oauth2/v1/token') 74 | .with(body: request_body) 75 | .to_return(status: 200, body: token_response.to_json, headers: { content_type: 'application/json' }) 76 | end 77 | 78 | it 'fetches a new token from the API and writes to the cache' do 79 | if warns_on_missing_token 80 | expect do 81 | expect(subject).to eq(new_token_value) 82 | end.to output(/has no token/).to_stderr 83 | else 84 | expect do 85 | expect(subject).to eq(new_token_value) 86 | end.not_to output.to_stderr 87 | end 88 | end 89 | 90 | it 'writes the token to the cache' do 91 | expect(cache).to receive(:write).with('net_suite_oauth_access_token', token_response, expires_in: token_expiration) 92 | 93 | if warns_on_missing_token 94 | expect { subject }.to output(/has no token/).to_stderr 95 | else 96 | expect { subject }.not_to output.to_stderr 97 | end 98 | end 99 | end 100 | 101 | shared_examples 'cached token used' do 102 | it 'returns the token from the cache' do 103 | expect(subject).to eq(token_value) 104 | end 105 | 106 | it 'does not write the token to the cache' do 107 | expect(cache).not_to receive(:write) 108 | 109 | subject 110 | end 111 | end 112 | 113 | context 'when skip_cache is false' do 114 | let(:skip_cache) { false } 115 | 116 | context 'when the serialized OAuth2::AccessToken is present in the cache' do 117 | before { expect(cache).to receive(:read).and_return(cached_token) } 118 | 119 | let(:cached_token) do 120 | { 121 | access_token: token_value, 122 | expires_at: expires_at.to_i, 123 | refresh_token: nil, 124 | token_type: 'Bearer', 125 | } 126 | end 127 | 128 | let(:client) do 129 | OAuth2::Client.new( 130 | nil, 131 | nil, 132 | site: oauth_config.api_host, 133 | token_url: '/services/rest/auth/oauth2/v1/token', 134 | raise_errors: false, 135 | ) 136 | end 137 | 138 | let(:token_value) { 'aaaaaaaaa111111111' } 139 | let(:expires_at) { current_time + 10.days } 140 | 141 | context 'when the serialized OAuth2::AccessToken in the cache is missing a token' do 142 | let(:token_value) { nil } 143 | 144 | it_behaves_like 'new token fetched from API', warns_on_missing_token: true 145 | end 146 | 147 | context 'when the serialized OAuth2::AccessToken in the cache has a token' do 148 | let(:token_value) { 'abcdefghij1234567890' } 149 | 150 | context 'when the token is expired' do 151 | let(:expires_at) { current_time - 10.days } 152 | 153 | it_behaves_like 'new token fetched from API', warns_on_missing_token: false 154 | end 155 | 156 | context 'when the token is not expired' do 157 | let(:expires_at) { current_time + 10.days } 158 | 159 | it_behaves_like 'cached token used' 160 | end 161 | end 162 | end 163 | end 164 | 165 | context 'when skip_cache is true' do 166 | let(:skip_cache) { true } 167 | 168 | before { expect(cache).not_to receive(:read) } 169 | 170 | it_behaves_like 'new token fetched from API', warns_on_missing_token: false 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/net_suite/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe NetSuite::Client do 4 | let(:instance) { described_class.new(config) } 5 | 6 | let(:config) do 7 | NetSuite::Configuration.new( 8 | oauth: oauth_config, 9 | restlet: restlet_config, 10 | logger:, 11 | log_requests:, 12 | request_timeout:, 13 | ) 14 | end 15 | 16 | let(:oauth_config) do 17 | ::NetSuite::Configuration::OAuth.new( 18 | api_host: 'http://oauth.example.com', 19 | client_id: 'a_client_id', 20 | certificate_id: 'aaaaaaaaaaaa', 21 | certificate_private_key: 'bbbbbbbbbbbb', 22 | cache:, 23 | token_expiration:, 24 | ) 25 | end 26 | 27 | let(:cache) { double(:cache) } 28 | let(:token_expiration) { 300 } 29 | 30 | let(:restlet_config) do 31 | NetSuite::Configuration::Restlet.new( 32 | api_host: 'http://restlet.example.com', 33 | path: '/restlet', 34 | ) 35 | end 36 | 37 | let(:logger) { double(:logger) } 38 | 39 | before do 40 | %i[debug info warn error].each do |method| 41 | allow(logger).to receive(method) 42 | end 43 | end 44 | 45 | let(:log_requests) { true } 46 | let(:request_timeout) { 30 } 47 | 48 | let(:path) { '/some_path' } 49 | let(:body) { { 'a' => 'b' } } 50 | let(:params) { {} } 51 | let(:headers) { {} } 52 | 53 | let(:url) { 'http://restlet.example.com/some_path' } 54 | 55 | let(:initial_response_status) { 200 } 56 | let(:initial_response_body) { { 'success' => true } } 57 | let(:initial_request_token) { 'valid_token' } 58 | 59 | let(:subsequent_response_status) { 200 } 60 | let(:subsequent_response_body) { { 'success' => true } } 61 | let(:subsequent_request_token) { 'other_token' } 62 | 63 | let(:request_method) { :get } 64 | 65 | shared_examples 'requests handled properly' do |method:| 66 | before do 67 | allow(NetSuite::AuthToken) 68 | .to receive(:call) 69 | .with(oauth_config, skip_cache: false) 70 | .and_return(initial_request_token) 71 | .once 72 | 73 | allow(NetSuite::AuthToken) 74 | .to receive(:call) 75 | .with(oauth_config, skip_cache: true) 76 | .and_return(subsequent_request_token) 77 | .once 78 | end 79 | 80 | before do 81 | stub_request( 82 | method, 83 | 'http://restlet.example.com/some_path', 84 | ).with( 85 | headers: { 'Authorization' => "Bearer #{initial_request_token}" }, 86 | ).to_return( 87 | status: initial_response_status, 88 | body: initial_response_body.to_json, 89 | headers: { content_type: 'application/json' }, 90 | ) 91 | 92 | stub_request( 93 | method, 94 | 'http://restlet.example.com/some_path', 95 | ).with( 96 | headers: { 'Authorization' => "Bearer #{subsequent_request_token}" }, 97 | ).to_return( 98 | status: subsequent_response_status, 99 | body: subsequent_response_body.to_json, 100 | headers: { content_type: 'application/json' }, 101 | ) 102 | end 103 | 104 | shared_examples 'one request' do |status:| 105 | it 'makes the request once' do 106 | subject 107 | 108 | expect(a_request(method, url)).to have_been_made.once 109 | end 110 | 111 | it 'requests the auth token once' do 112 | expect(NetSuite::AuthToken).to receive(:call).with(oauth_config, skip_cache: false).once.and_return('valid_token') 113 | expect(NetSuite::AuthToken).not_to receive(:call).with(oauth_config, skip_cache: true) 114 | 115 | subject 116 | end 117 | 118 | it 'returns the response' do 119 | expect(subject).to have_attributes(status:, body: initial_response_body) 120 | end 121 | end 122 | 123 | shared_examples 'retried request' do |status:| 124 | let(:initial_request_token) { 'invalid_token' } 125 | let(:subsequent_request_token) { 'valid_token' } 126 | 127 | let(:initial_response_body) { { 'success' => false } } 128 | 129 | context 'when response is a 401' do 130 | let(:initial_response_status) { 401 } 131 | 132 | it 'makes the request twice' do 133 | subject 134 | 135 | expect(a_request(method, url)).to have_been_made.twice 136 | end 137 | 138 | it 'requests auth token twice' do 139 | expect(NetSuite::AuthToken).to receive(:call).with(oauth_config, skip_cache: false).once.and_return(initial_request_token) 140 | expect(NetSuite::AuthToken).to receive(:call).with(oauth_config, skip_cache: true).once.and_return(subsequent_request_token) 141 | 142 | subject 143 | end 144 | 145 | it 'returns the second response' do 146 | expect(subject).to have_attributes(status:, body: subsequent_response_body) 147 | end 148 | end 149 | end 150 | 151 | context 'when response is successful' do 152 | it_behaves_like 'one request', status: 200 153 | end 154 | 155 | context 'when response is a failure' do 156 | it_behaves_like 'retried request', status: 200 157 | 158 | context 'when response is not a 401' do 159 | let(:initial_response_status) { 500 } 160 | 161 | it_behaves_like 'one request', status: 500 162 | end 163 | end 164 | end 165 | 166 | describe 'query methods' do 167 | describe '#get' do 168 | subject { instance.get(path, params, headers) } 169 | 170 | it_behaves_like 'requests handled properly', method: :get 171 | end 172 | 173 | describe '#head' do 174 | subject { instance.head(path, params, headers) } 175 | 176 | it_behaves_like 'requests handled properly', method: :head 177 | end 178 | 179 | describe '#delete' do 180 | subject { instance.delete(path, params, headers) } 181 | 182 | it_behaves_like 'requests handled properly', method: :delete 183 | end 184 | end 185 | 186 | describe 'body methods' do 187 | let(:block) { ->(_request) { something.go } } 188 | let(:something) { double(:something) } 189 | 190 | before { expect(something).to receive(:go).at_least(:once) } 191 | 192 | describe '#post' do 193 | subject { instance.post(path, body, headers, &block) } 194 | 195 | it_behaves_like 'requests handled properly', method: :post 196 | end 197 | 198 | describe '#put' do 199 | subject { instance.put(path, body, headers, &block) } 200 | 201 | it_behaves_like 'requests handled properly', method: :put 202 | end 203 | 204 | describe '#patch' do 205 | subject { instance.patch(path, body, headers, &block) } 206 | 207 | it_behaves_like 'requests handled properly', method: :patch 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /spec/net_suite/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe NetSuite::Configuration do 4 | let(:oauth) { double(:oauth) } 5 | let(:restlet) { double(:restlet) } 6 | let(:logger) { double(:logger) } 7 | 8 | shared_examples 'key error' do |key| 9 | it "raises a KeyError with #{key}" do 10 | expect { subject }.to raise_error(KeyError, /#{key}/) 11 | end 12 | end 13 | 14 | describe 'required arguments' do 15 | context 'when everything is included' do 16 | subject { described_class.new(oauth:, restlet:, logger:) } 17 | 18 | it 'returns a configuration object with the included arguments' do 19 | expect(subject).to have_attributes(oauth:, restlet:, logger:) 20 | end 21 | end 22 | 23 | context 'when oauth is missing' do 24 | subject { described_class.new(restlet:, logger:) } 25 | 26 | it_behaves_like 'key error', ':oauth' 27 | end 28 | 29 | context 'when restlet is missing' do 30 | subject { described_class.new(oauth:, logger:) } 31 | 32 | it_behaves_like 'key error', ':restlet' 33 | end 34 | 35 | context 'when logger is missing' do 36 | subject { described_class.new(oauth:, restlet:) } 37 | 38 | it_behaves_like 'key error', ':logger' 39 | end 40 | end 41 | 42 | context 'optional arguments' do 43 | describe 'log_requests' do 44 | context 'when log_requests is missing' do 45 | subject { described_class.new(oauth:, restlet:, logger:) } 46 | 47 | it 'is configured with false as default' do 48 | expect(subject.log_requests).to eq(false) 49 | end 50 | end 51 | 52 | context 'when log_requests is included' do 53 | subject { described_class.new(oauth:, restlet:, logger:, log_requests: :something) } 54 | 55 | it 'is configured with the provided value' do 56 | expect(subject.log_requests).to eq(:something) 57 | end 58 | end 59 | end 60 | 61 | describe 'request_timeout' do 62 | context 'when request_timeout is missing' do 63 | subject { described_class.new(oauth:, restlet:, logger:) } 64 | 65 | it 'is configured with 120 as default' do 66 | expect(subject.request_timeout).to eq(120) 67 | end 68 | end 69 | 70 | context 'when request_timeout is included' do 71 | subject { described_class.new(oauth:, restlet:, logger:, request_timeout: :something) } 72 | 73 | it 'is configured with the provided value' do 74 | expect(subject.request_timeout).to eq(:something) 75 | end 76 | end 77 | end 78 | end 79 | 80 | describe NetSuite::Configuration::OAuth do 81 | let(:api_host) { double(:api_host) } 82 | let(:client_id) { double(:client_id) } 83 | let(:certificate_id) { double(:certificate_id) } 84 | let(:certificate_private_key) { double(:certificate_private_key) } 85 | let(:cache) { double(:cache) } 86 | 87 | describe 'required arguments' do 88 | context 'when everything is included' do 89 | subject { described_class.new(api_host:, client_id:, certificate_id:, certificate_private_key:, cache:) } 90 | 91 | it 'returns a configuration object with the included arguments' do 92 | expect(subject).to have_attributes(api_host:, client_id:, certificate_id:, certificate_private_key:, cache:) 93 | end 94 | end 95 | 96 | context 'when api_host is missing' do 97 | subject { described_class.new(client_id:, certificate_id:, certificate_private_key:, cache:) } 98 | 99 | it_behaves_like 'key error', :api_host 100 | end 101 | 102 | context 'when client_id is missing' do 103 | subject { described_class.new(api_host:, certificate_id:, certificate_private_key:, cache:) } 104 | 105 | it_behaves_like 'key error', :client_id 106 | end 107 | 108 | context 'when certificate_id is missing' do 109 | subject { described_class.new(api_host:, client_id:, certificate_private_key:, cache:) } 110 | 111 | it_behaves_like 'key error', :certificate_id 112 | end 113 | 114 | context 'when certificate_private_key is missing' do 115 | subject { described_class.new(api_host:, client_id:, certificate_id:, cache:) } 116 | 117 | it_behaves_like 'key error', :certificate_private_key 118 | end 119 | 120 | context 'when cache is missing' do 121 | subject { described_class.new(api_host:, client_id:, certificate_id:, certificate_private_key:) } 122 | 123 | it_behaves_like 'key error', :cache 124 | end 125 | end 126 | 127 | context 'optional arguments' do 128 | describe 'token_expiration' do 129 | context 'when token_expiration is missing' do 130 | subject { described_class.new(api_host:, client_id:, certificate_id:, certificate_private_key:, cache:) } 131 | 132 | it 'is configured with 3600 as default' do 133 | expect(subject.token_expiration).to eq(3600) 134 | end 135 | end 136 | 137 | context 'when token_expiration is included' do 138 | subject { described_class.new(api_host:, client_id:, certificate_id:, certificate_private_key:, cache:, token_expiration: 12) } 139 | 140 | it 'is configured with the provided value' do 141 | expect(subject.token_expiration).to eq(12) 142 | end 143 | end 144 | end 145 | end 146 | end 147 | 148 | describe NetSuite::Configuration::Restlet do 149 | let(:api_host) { double(:api_host) } 150 | 151 | describe 'required arguments' do 152 | context 'when everything is included' do 153 | subject { described_class.new(api_host:) } 154 | 155 | it 'returns a configuration object with the included arguments' do 156 | expect(subject).to have_attributes(api_host:) 157 | end 158 | end 159 | 160 | context 'when api_host is missing' do 161 | subject { described_class.new } 162 | 163 | it_behaves_like 'key error', :api_host 164 | end 165 | end 166 | 167 | context 'optional arguments' do 168 | describe 'path' do 169 | context 'when path is missing' do 170 | subject { described_class.new(api_host:) } 171 | 172 | it 'is configured with default value' do 173 | expect(subject.path).to eq('app/site/hosting/restlet.nl') 174 | end 175 | end 176 | 177 | context 'when path is included' do 178 | subject { described_class.new(api_host:, path: '/something') } 179 | 180 | it 'is configured with the provided value' do 181 | expect(subject.path).to eq('/something') 182 | end 183 | end 184 | end 185 | end 186 | end 187 | end 188 | --------------------------------------------------------------------------------