Skip to content

Commit

Permalink
Implement access token grant
Browse files Browse the repository at this point in the history
Plus some scaffolding
  • Loading branch information
hakanensari committed Aug 31, 2022
1 parent 305fa06 commit 0d3b0a1
Show file tree
Hide file tree
Showing 18 changed files with 474 additions and 18 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Gemfile.lock
.yardoc
bin
coverage
credentials.yml
doc
gemfiles/*.gemfile.lock
pkg
Expand Down
44 changes: 44 additions & 0 deletions lib/selling_partner/access_token.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

require 'selling_partner/client'

module SellingPartner
# @see https://developer-docs.amazon.com/sp-api/docs/connecting-to-the-selling-partner-api
class AccessToken
include Client

attr_reader :client_id, :client_secret

# @param client_id [String] Your LWA client identifier
# @param client_secret [String] Your LWA client secret
def initialize(client_id, client_secret)
@client_id = client_id
@client_secret = client_secret
end

def request_with_refresh_token(refresh_token)
request('refresh_token', refresh_token: refresh_token)
end

def request_for_grantless_operations(scope)
request('client_credentials', scope: scope)
end

private

def request(grant_type, refresh_token: nil, scope: nil)
uri = 'https://api.amazon.com/auth/o2/token'
params = {
grant_type: grant_type,
refresh_token: refresh_token,
scope: scope,
client_id: client_id,
client_secret: client_secret
}.compact

response = client.post(uri, form: params)

response.parse['access_token']
end
end
end
33 changes: 33 additions & 0 deletions lib/selling_partner/api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require 'http'

require 'selling_partner/client'

module SellingPartner
# A Selling Partners API client
class API
include Client

attr_reader :aws_access_key_id, :aws_secret_access_key, :aws_region

# Creates a wrapper to a Selling Partner API
#
# @param aws_access_key_id [String] Your AWS access key identifier
# @param aws_secret_access_key [String] Your AWS secret access key
# @param aws_region [String] The AWS region to which you are directing your call
def initialize(aws_access_key_id:,
aws_secret_access_key:,
aws_region:)
@aws_access_key_id = aws_access_key_id
@aws_secret_access_key = aws_secret_access_key
@aws_region = aws_region
end

private

def endpoint
Endpoint.find!(aws_region).uri
end
end
end
24 changes: 24 additions & 0 deletions lib/selling_partner/client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require 'http'

require 'peddler/version'

module SellingPartner
# @!visibility private
module Client
def client
@client ||= build_client
end

private

def build_client
HTTP::Client.new.headers('User-Agent' => user_agent)
end

def user_agent
"Peddler/#{Peddler::VERSION} (Language=Ruby; #{Socket.gethostname})"
end
end
end
33 changes: 33 additions & 0 deletions lib/selling_partner/endpoint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

module SellingPartner
# @!visibility private
# @see https://developer-docs.amazon.com/sp-api/docs/sp-api-endpoints
class Endpoint
NotFound = Class.new(ArgumentError)

attr_reader :aws_region, :uri

def self.all
@all ||= YAML.load_file(File.join(__dir__, 'endpoint.yml'), symbolize_names: true).map { |values| new(**values) }
end

def self.find(aws_region)
all.find { |endpoint| endpoint.aws_region == aws_region }
end

def self.find!(aws_region)
find(aws_region) || raise(NotFound, %("#{aws_region}" isn't associated with any Selling Partner endpoint))
end

def initialize(aws_region:, uri:)
@aws_region = aws_region
@uri = uri
end

# @see https://developer-docs.amazon.com/sp-api/docs/the-selling-partner-api-sandbox#selling-partner-api-sandbox-endpoints
def sandbox_uri
uri.sub('sellingpartnerapi', 'sandbox.sellingpartnerapi')
end
end
end
6 changes: 6 additions & 0 deletions lib/selling_partner/endpoint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
- aws_region: us-east-1
uri: https://sellingpartnerapi-na.amazon.com
- aws_region: eu-west-1
uri: https://sellingpartnerapi-eu.amazon.com
- aws_region: us-west-2
uri: https://sellingpartnerapi-fe.amazon.com
1 change: 1 addition & 0 deletions peddler.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Gem::Specification.new do |gem|

gem.files = Dir.glob('lib/**/*') + %w[LICENSE README.md]

gem.add_dependency 'aws-sigv4', '~> 1.0'
gem.add_dependency 'http', '~> 5.0'
gem.add_dependency 'multi_xml', '>= 0.5.0'

Expand Down
35 changes: 35 additions & 0 deletions scripts/api_generator/generate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'fileutils'

FileUtils.rm_rf('lib')

FileUtils.rm_rf('selling-partner-api-models')
`git clone [email protected]:amzn/selling-partner-api-models.git`

specs = Dir.glob('selling-partner-api-models/models/**/*.json').reduce({}) do |hsh, file|
spec = file.split('/')[-2]
hsh.merge(spec => file)
end
threads = []
specs.each_value do |file|
threads << Thread.new do
`openapi-generator generate --global-property apis \
--global-property apiDocs=false \
--global-property apiTests=false \
--additional-properties moduleName=SellingPartner \
--skip-validate-spec \
--generator-name ruby \
--template-dir templates \
--input-spec #{file}`
end
end
threads.each(&:join)

Dir.glob('lib/selling_partner/api/*').each do |file|
FileUtils.mv(file, file.gsub(%r{[/_]api}, ''))
end
FileUtils.rmdir('lib/selling_partner/api')

FileUtils.rm_rf('selling-partner-api-models')
102 changes: 102 additions & 0 deletions scripts/api_generator/templates/api.mustache
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# frozen_string_literal: true

require "selling_partner/api"

module {{moduleName}}
{{#appName}}
# {{{.}}}
{{/appName}}
{{#appDescription}}
#
# {{{.}}}
{{/appDescription}}
{{#operations}}
class {{baseName}} < API
{{#operation}}
{{#summary}}
# {{{.}}}
#
{{/summary}}
{{#notes}}
# {{{.}}}
#
{{/notes}}
{{#allParams}}
{{#required}}
# @param {{paramName}} [{{{dataType}}}] {{description}}
{{/required}}
{{/allParams}}
# @param [Hash] opts the optional parameters
{{#allParams}}
{{^required}}
# @option opts [{{{dataType}}}] :{{paramName}} {{description}}{{#defaultValue}} (default to {{{.}}}){{/defaultValue}}
{{/required}}
{{/allParams}}
# @return [Array<({{{returnType}}}{{^returnType}}nil{{/returnType}}, Integer, Hash)>] {{#returnType}}{{{.}}} data{{/returnType}}{{^returnType}}nil{{/returnType}}, response status code and response headers
def {{operationId}}({{#allParams}}{{#required}}{{paramName}}, {{/required}}{{/allParams}}opts = {})
# resource path
local_var_path = '{{{path}}}'{{#pathParams}}.sub('{' + '{{baseName}}' + '}', CGI.escape({{paramName}}.to_s){{^strictSpecBehavior}}.gsub('%2F', '/'){{/strictSpecBehavior}}){{/pathParams}}

# query parameters
query_params = opts[:query_params] || {}
{{#queryParams}}
{{#required}}
query_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param({{{paramName}}}, :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}{{{paramName}}}{{/collectionFormat}}
{{/required}}
{{/queryParams}}
{{#queryParams}}
{{^required}}
query_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param(opts[:'{{{paramName}}}'], :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}opts[:'{{{paramName}}}']{{/collectionFormat}} if !opts[:'{{{paramName}}}'].nil?
{{/required}}
{{/queryParams}}

# header parameters
header_params = opts[:header_params] || {}
{{#hasProduces}}
# HTTP header 'Accept' (if needed)
header_params['Accept'] = @api_client.select_header_accept([{{#produces}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/produces}}])
{{/hasProduces}}
{{#hasConsumes}}
# HTTP header 'Content-Type'
content_type = @api_client.select_header_content_type([{{#consumes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/consumes}}])
if !content_type.nil?
header_params['Content-Type'] = content_type
end
{{/hasConsumes}}
{{#headerParams}}
{{#required}}
header_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param({{{paramName}}}, :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}{{{paramName}}}{{/collectionFormat}}
{{/required}}
{{/headerParams}}
{{#headerParams}}
{{^required}}
header_params[:'{{{baseName}}}'] = {{#collectionFormat}}@api_client.build_collection_param(opts[:'{{{paramName}}}'], :{{{collectionFormat}}}){{/collectionFormat}}{{^collectionFormat}}opts[:'{{{paramName}}}']{{/collectionFormat}} if !opts[:'{{{paramName}}}'].nil?
{{/required}}
{{/headerParams}}

# http body (model)
post_body = opts[:debug_body]{{#bodyParam}} || @api_client.object_to_http_body({{#required}}{{{paramName}}}{{/required}}{{^required}}opts[:'{{{paramName}}}']{{/required}}){{/bodyParam}}

# return_type
return_type = opts[:debug_return_type]{{#returnType}} || '{{{.}}}'{{/returnType}}

new_options = opts.merge(
:operation => :"{{classname}}.{{operationId}}",
:header_params => header_params,
:query_params => query_params,
:body => post_body,
:auth_names => auth_names,
:return_type => return_type
)

data, status_code, headers = @api_client.call_api(:{{httpMethod}}, local_var_path, new_options)

data, status_code, headers
end
{{^-last}}

{{/-last}}
{{/operation}}
{{/operations}}
end
end
5 changes: 5 additions & 0 deletions test/credentials.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- aws_access_key_id: AWS_ACCESS_KEY_ID
aws_secret_access_key: AWS_SECRET_ACCESS_KEY
aws_region: us-east-1
lwa_client_id: LWA_CLIENT_ID
lwa_client_secret: LWA_CLIENT_SECRET
26 changes: 11 additions & 15 deletions test/credentials.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
# frozen_string_literal: true

require 'forwardable'
require 'vcr'
require 'yaml'

module Credentials
class << self
extend Forwardable
include Enumerable

attr_reader :all

def_delegators :all, :each
%w[credentials.yml credentials.example.yml].each do |filename|
file = File.join(__dir__, filename)
if File.exist?(file)
CREDENTIALS = YAML.load_file(file, symbolize_names: true)
break
end
end


%w[mws.yml mws.example.yml].each do |path|
file = File.expand_path("../#{path}", __FILE__)
if File.exist?(file)
@all = YAML.load_file(file)
break
end
VCR.configure do |c|
CREDENTIALS.map(&:values).flatten.each do |value|
c.filter_sensitive_data('<SCRUBBED>') { value }
end
end
39 changes: 39 additions & 0 deletions test/helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

# Keep SimpleCov at top.
if ENV['COVERAGE']
require 'simplecov'

SimpleCov.start do
add_filter '/test/'
end
end

require 'minitest/autorun'
require 'minitest/focus'
require 'vcr'

VCR.configure do |c|
c.hook_into :webmock
c.cassette_library_dir = 'test/vcr_cassettes'

c.default_cassette_options = {
record: ENV['RECORD'] ? :new_episodes : :none
}
end

class IntegrationTest < MiniTest::Test
def setup
ENV['LIVE'] ? VCR.turn_off! : VCR.insert_cassette(cassette_name)
end

def teardown
VCR.eject_cassette if VCR.turned_on?
end

private

def cassette_name
File.basename($PROGRAM_NAME, '.*').sub('test_', '')
end
end
Loading

0 comments on commit 0d3b0a1

Please sign in to comment.