sp_api_clients 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +161 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/lib/amazon_sp_clients/adapter_loader.rb +28 -0
- data/lib/amazon_sp_clients/api_client.rb +309 -0
- data/lib/amazon_sp_clients/api_error.rb +37 -0
- data/lib/amazon_sp_clients/api_response.rb +54 -0
- data/lib/amazon_sp_clients/configuration.rb +144 -0
- data/lib/amazon_sp_clients/marketplaces.rb +63 -0
- data/lib/amazon_sp_clients/middlewares/raise_error.rb +166 -0
- data/lib/amazon_sp_clients/session.rb +124 -0
- data/lib/amazon_sp_clients/sp_fba_inventory.rb +8 -0
- data/lib/amazon_sp_clients/sp_feeds_2021.rb +8 -0
- data/lib/amazon_sp_clients/sp_fulfillment_outbound_2020.rb +8 -0
- data/lib/amazon_sp_clients/sp_listings_items_2021.rb +8 -0
- data/lib/amazon_sp_clients/sp_orders_v0.rb +8 -0
- data/lib/amazon_sp_clients/sp_reports_2021.rb +8 -0
- data/lib/amazon_sp_clients/sp_tokens_2021.rb +8 -0
- data/lib/amazon_sp_clients/sp_vdf_inventory_v1.rb +8 -0
- data/lib/amazon_sp_clients/sp_vdf_orders_v1.rb +8 -0
- data/lib/amazon_sp_clients/sp_vdf_shipping_v1.rb +8 -0
- data/lib/amazon_sp_clients/sp_vendor_invoices.rb +8 -0
- data/lib/amazon_sp_clients/sp_vendor_orders.rb +8 -0
- data/lib/amazon_sp_clients/sp_vendor_transaction_status.rb +8 -0
- data/lib/amazon_sp_clients/sp_vendors_shipments.rb +8 -0
- data/lib/amazon_sp_clients/token_exchange_auth.rb +82 -0
- data/lib/amazon_sp_clients/uploader.rb +77 -0
- data/lib/amazon_sp_clients/v2/api.rb +43 -0
- data/lib/amazon_sp_clients/v2/apis/fba_inventory.rb +80 -0
- data/lib/amazon_sp_clients/v2/apis/feeds_2021.rb +86 -0
- data/lib/amazon_sp_clients/v2/apis/fulfillment_outbound_2020.rb +184 -0
- data/lib/amazon_sp_clients/v2/apis/listings_items_2021.rb +144 -0
- data/lib/amazon_sp_clients/v2/apis/orders_v0.rb +165 -0
- data/lib/amazon_sp_clients/v2/apis/reports_2021.rb +117 -0
- data/lib/amazon_sp_clients/v2/apis/tokens_2021.rb +25 -0
- data/lib/amazon_sp_clients/v2/apis/vdf_inventory_v1.rb +26 -0
- data/lib/amazon_sp_clients/v2/apis/vdf_orders_v1.rb +61 -0
- data/lib/amazon_sp_clients/v2/apis/vdf_shipping_v1.rb +139 -0
- data/lib/amazon_sp_clients/v2/apis/vendor_invoices.rb +25 -0
- data/lib/amazon_sp_clients/v2/apis/vendor_orders.rb +106 -0
- data/lib/amazon_sp_clients/v2/apis/vendor_transaction_status.rb +25 -0
- data/lib/amazon_sp_clients/v2/apis/vendors_shipments.rb +120 -0
- data/lib/amazon_sp_clients/v2/apis.rb +82 -0
- data/lib/amazon_sp_clients/v2/client.rb +198 -0
- data/lib/amazon_sp_clients/v2/config.rb +85 -0
- data/lib/amazon_sp_clients/v2/credentials.rb +91 -0
- data/lib/amazon_sp_clients/v2/documents.rb +115 -0
- data/lib/amazon_sp_clients/v2/error_mapper.rb +235 -0
- data/lib/amazon_sp_clients/v2/errors.rb +117 -0
- data/lib/amazon_sp_clients/v2/lwa.rb +100 -0
- data/lib/amazon_sp_clients/v2/rdt.rb +104 -0
- data/lib/amazon_sp_clients/v2/token.rb +39 -0
- data/lib/amazon_sp_clients/v2.rb +33 -0
- data/lib/amazon_sp_clients/version.rb +3 -0
- data/lib/amazon_sp_clients.rb +69 -0
- data/lib/sp_api_clients.rb +6 -0
- data/sp_api_clients.gemspec +53 -0
- data/vendor/fba_inventory/lib/sp_fba_inventory/api/fba_inventory_api.rb +289 -0
- data/vendor/fba_inventory/lib/sp_fba_inventory.rb +12 -0
- data/vendor/feeds_2021/lib/sp_feeds_2021/api/feeds_api.rb +367 -0
- data/vendor/feeds_2021/lib/sp_feeds_2021.rb +12 -0
- data/vendor/fulfillment_outbound_2020/lib/sp_fulfillment_outbound_2020/api/fba_outbound_api.rb +845 -0
- data/vendor/fulfillment_outbound_2020/lib/sp_fulfillment_outbound_2020.rb +12 -0
- data/vendor/listings_items_2021/lib/sp_listings_items_2021/api/listings_api.rb +480 -0
- data/vendor/listings_items_2021/lib/sp_listings_items_2021.rb +12 -0
- data/vendor/orders_v0/lib/sp_orders_v0/api/orders_v0_api.rb +593 -0
- data/vendor/orders_v0/lib/sp_orders_v0/api/shipment_api.rb +83 -0
- data/vendor/orders_v0/lib/sp_orders_v0.rb +13 -0
- data/vendor/reports_2021/lib/sp_reports_2021/api/reports_api.rb +530 -0
- data/vendor/reports_2021/lib/sp_reports_2021.rb +12 -0
- data/vendor/tokens_2021/lib/sp_tokens_2021/api/tokens_api.rb +77 -0
- data/vendor/tokens_2021/lib/sp_tokens_2021.rb +12 -0
- data/vendor/vdf_inventory_v1/lib/sp_vdf_inventory_v1/api/update_inventory_api.rb +83 -0
- data/vendor/vdf_inventory_v1/lib/sp_vdf_inventory_v1.rb +12 -0
- data/vendor/vdf_orders_v1/lib/sp_vdf_orders_v1/api/vendor_orders_api.rb +217 -0
- data/vendor/vdf_orders_v1/lib/sp_vdf_orders_v1.rb +12 -0
- data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/customer_invoices_api.rb +152 -0
- data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/vendor_shipping_api.rb +264 -0
- data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/vendor_shipping_labels_api.rb +208 -0
- data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1.rb +14 -0
- data/vendor/vendor_invoices/lib/sp_vendor_invoices/api/vendor_payments_api.rb +77 -0
- data/vendor/vendor_invoices/lib/sp_vendor_invoices.rb +12 -0
- data/vendor/vendor_orders/lib/sp_vendor_orders/api/vendor_orders_api.rb +323 -0
- data/vendor/vendor_orders/lib/sp_vendor_orders.rb +12 -0
- data/vendor/vendor_transaction_status/lib/sp_vendor_transaction_status/api/vendor_transaction_api.rb +75 -0
- data/vendor/vendor_transaction_status/lib/sp_vendor_transaction_status.rb +12 -0
- data/vendor/vendors_shipments/lib/sp_vendors_shipments/api/vendor_shipping_api.rb +337 -0
- data/vendor/vendors_shipments/lib/sp_vendors_shipments.rb +12 -0
- metadata +321 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'amazon_sp_clients/v2/errors'
|
|
4
|
+
require 'amazon_sp_clients/v2/token'
|
|
5
|
+
|
|
6
|
+
module AmazonSpClients
|
|
7
|
+
module V2
|
|
8
|
+
# Where the client gets the access token for each request. Every
|
|
9
|
+
# kind answers one message, +access_token+, and returns the value to
|
|
10
|
+
# the caller: the token is never parked on a shared object and read
|
|
11
|
+
# back in a second step, so a request cannot carry another thread's
|
|
12
|
+
# token.
|
|
13
|
+
module Credentials
|
|
14
|
+
# Asks a block for the current token before every request. This
|
|
15
|
+
# is the production path: the host refreshes tokens out of band
|
|
16
|
+
# and the block returns whatever it holds now, so a client that
|
|
17
|
+
# runs for hours keeps sending fresh tokens.
|
|
18
|
+
#
|
|
19
|
+
# Credentials::Callback.new { store.access_token }
|
|
20
|
+
#
|
|
21
|
+
# The block's value goes on the wire as it is; a missing token
|
|
22
|
+
# surfaces as Amazon's own 401 or 403.
|
|
23
|
+
class Callback
|
|
24
|
+
# @yieldreturn [String] the current access token
|
|
25
|
+
def initialize(&block)
|
|
26
|
+
raise ArgumentError, 'a block returning the access token is required' unless block
|
|
27
|
+
|
|
28
|
+
@block = block
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String]
|
|
32
|
+
def access_token = @block.call
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Exchanges a refresh token with LWA and caches the result until it
|
|
36
|
+
# expires. One exchange per expiry, even when many threads ask at
|
|
37
|
+
# once: the expiry check runs again under the lock.
|
|
38
|
+
class RefreshToken
|
|
39
|
+
# @param lwa [LWA] anything answering +exchange(refresh_token:)+ with a Token
|
|
40
|
+
# @param refresh_token [String] the refresh token to start from
|
|
41
|
+
def initialize(lwa, refresh_token)
|
|
42
|
+
@lwa = lwa
|
|
43
|
+
@initial_refresh_token = refresh_token
|
|
44
|
+
@token = nil
|
|
45
|
+
@mutex = Mutex.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] a fresh access token, exchanging when needed
|
|
49
|
+
def access_token
|
|
50
|
+
token = @token
|
|
51
|
+
return token.access_token if token && !token.expired?
|
|
52
|
+
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
token = @token
|
|
55
|
+
return token.access_token if token && !token.expired?
|
|
56
|
+
|
|
57
|
+
token = exchange
|
|
58
|
+
@token = token
|
|
59
|
+
token.access_token
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The refresh token the next exchange will send: the latest one
|
|
64
|
+
# LWA returned, else the initial one. Persist it if it changes.
|
|
65
|
+
#
|
|
66
|
+
# @return [String]
|
|
67
|
+
def refresh_token
|
|
68
|
+
@token&.refresh_token || @initial_refresh_token
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Refresh tokens stay out of consoles and error trackers.
|
|
72
|
+
#
|
|
73
|
+
# @return [String]
|
|
74
|
+
def inspect
|
|
75
|
+
"#<#{self.class.name} refresh_token=#{FILTERED} token=#{@token.inspect}>"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# LWA may leave refresh_token out of the response; keep the one
|
|
81
|
+
# we have, so the stored Token always knows what to send next.
|
|
82
|
+
def exchange
|
|
83
|
+
token = @lwa.exchange(refresh_token: refresh_token)
|
|
84
|
+
return token if token.refresh_token
|
|
85
|
+
|
|
86
|
+
token.with(refresh_token: refresh_token)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
require 'faraday'
|
|
6
|
+
require 'multi_xml'
|
|
7
|
+
require 'amazon_sp_clients/adapter_loader'
|
|
8
|
+
require 'amazon_sp_clients/v2/config'
|
|
9
|
+
require 'amazon_sp_clients/v2/errors'
|
|
10
|
+
require 'amazon_sp_clients/v2/error_mapper'
|
|
11
|
+
|
|
12
|
+
module AmazonSpClients
|
|
13
|
+
module V2
|
|
14
|
+
# Feed and report documents live on presigned S3 urls: outside the
|
|
15
|
+
# SP-API host, without the access token. A feed goes out in three
|
|
16
|
+
# steps (create the document, PUT the content to its url, create the
|
|
17
|
+
# feed); feed results and reports come back by GET, gzipped when the
|
|
18
|
+
# document says so.
|
|
19
|
+
#
|
|
20
|
+
# Every +document+ argument is the payload Hash the Feeds or Reports
|
|
21
|
+
# API returned for it (+:url+, +:compressionAlgorithm+).
|
|
22
|
+
class Documents
|
|
23
|
+
# Value of +compressionAlgorithm+ for gzipped documents.
|
|
24
|
+
GZIP = 'GZIP'
|
|
25
|
+
# Set on uploads; read on downloads to pick the parser.
|
|
26
|
+
CONTENT_TYPE_HEADER = 'Content-Type'
|
|
27
|
+
# Content types parsed as JSON; anything else is parsed as XML.
|
|
28
|
+
JSON_TYPES = /json/i
|
|
29
|
+
|
|
30
|
+
# @param config [Config] timeouts and user agent
|
|
31
|
+
def initialize(config)
|
|
32
|
+
@config = config
|
|
33
|
+
@errors = ErrorMapper.new(:documents)
|
|
34
|
+
@conn = build_connection
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param document [Hash] payload of createFeedDocument
|
|
38
|
+
# @param content_type [String] the one given to createFeedDocument
|
|
39
|
+
# @param body [String] feed content
|
|
40
|
+
# @return [nil]
|
|
41
|
+
# @raise [DocumentError, ConnectionError]
|
|
42
|
+
def upload(document, content_type, body)
|
|
43
|
+
send_request(:put, url_of(document), body, CONTENT_TYPE_HEADER => content_type)
|
|
44
|
+
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param document [Hash] payload of getFeedDocument
|
|
49
|
+
# @return [Hash] the processing report, string-keyed; JSON or XML by content type
|
|
50
|
+
# @raise [DocumentError, ConnectionError]
|
|
51
|
+
def download_feed_result(document)
|
|
52
|
+
response = fetch(document)
|
|
53
|
+
|
|
54
|
+
parse(inflate(response.body, document), response.headers[CONTENT_TYPE_HEADER])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @param document [Hash] payload of getReportDocument
|
|
58
|
+
# @return [String] the report; UTF-8 tagged when it was gzipped,
|
|
59
|
+
# otherwise as delivered
|
|
60
|
+
# @raise [DocumentError, ConnectionError]
|
|
61
|
+
def download_report_document(document)
|
|
62
|
+
inflate(fetch(document).body, document)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_connection
|
|
68
|
+
options = { timeout: @config.timeout, open_timeout: @config.open_timeout }
|
|
69
|
+
headers = { 'User-Agent' => @config.user_agent }
|
|
70
|
+
|
|
71
|
+
Faraday.new(request: options, headers: headers) do |conn|
|
|
72
|
+
conn.adapter Faraday::Adapter::HTTPClient
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fetch(document)
|
|
77
|
+
send_request(:get, url_of(document), nil, {})
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The payload of createFeedDocument, getFeedDocument or
|
|
81
|
+
# getReportDocument as ApiResponse#payload returns it: symbol keys.
|
|
82
|
+
def url_of(document)
|
|
83
|
+
document.fetch(:url) do
|
|
84
|
+
keys = document.keys.inspect
|
|
85
|
+
raise ArgumentError, "document payload without a :url key (keys: #{keys})"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def send_request(method, url, body, headers)
|
|
90
|
+
response = @conn.run_request(method, url, body, headers)
|
|
91
|
+
|
|
92
|
+
@errors.check!(response)
|
|
93
|
+
rescue *ErrorMapper::TRANSPORT_ERRORS => e
|
|
94
|
+
raise @errors.transport_error(e, method: method, url: url)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Zlib.gunzip returns binary; the documents are text.
|
|
98
|
+
def inflate(body, document)
|
|
99
|
+
return body unless document[:compressionAlgorithm].to_s.casecmp?(GZIP)
|
|
100
|
+
|
|
101
|
+
Zlib.gunzip(body).force_encoding(Encoding::UTF_8)
|
|
102
|
+
rescue Zlib::Error => e
|
|
103
|
+
raise DocumentError, "could not inflate the document: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse(body, content_type)
|
|
107
|
+
return JSON.parse(body) if content_type.to_s.match?(JSON_TYPES)
|
|
108
|
+
|
|
109
|
+
MultiXml.parse(body)
|
|
110
|
+
rescue JSON::ParserError, MultiXml::ParseError => e
|
|
111
|
+
raise DocumentError, "could not parse the document: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'faraday'
|
|
6
|
+
require 'httpclient'
|
|
7
|
+
require 'amazon_sp_clients/api_error'
|
|
8
|
+
require 'amazon_sp_clients/v2/errors'
|
|
9
|
+
|
|
10
|
+
module AmazonSpClients
|
|
11
|
+
module V2
|
|
12
|
+
# Turns a Faraday response or a transport exception into the V2
|
|
13
|
+
# error for it. One mapper per service, because the three services
|
|
14
|
+
# V2 talks to fail in different shapes:
|
|
15
|
+
#
|
|
16
|
+
# :api SP-API; body {"errors": [{code, message, details}]}
|
|
17
|
+
# :lwa token endpoint; body {"error", "error_description"}
|
|
18
|
+
# :documents presigned S3 urls; the url carries the credential
|
|
19
|
+
class ErrorMapper
|
|
20
|
+
# The three services V2 talks to.
|
|
21
|
+
SERVICES = %i[api lwa documents].freeze
|
|
22
|
+
|
|
23
|
+
# Exceptions the HTTPClient adapter lets out of a request: Faraday's
|
|
24
|
+
# own, plus the ones it re-raises untouched. Nothing else is
|
|
25
|
+
# wrapped, so an exception delivered by Thread#raise passes through.
|
|
26
|
+
TRANSPORT_ERRORS = [
|
|
27
|
+
Faraday::Error, HTTPClient::KeepAliveDisconnected, SystemCallError, IOError, SocketError
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# Too Many Requests; also what LWA answers when the token endpoint
|
|
31
|
+
# is called too often.
|
|
32
|
+
THROTTLED_STATUS = 429
|
|
33
|
+
# SP-API statuses with their own error class; other 4xx and 5xx
|
|
34
|
+
# fall to ClientError and ServerError.
|
|
35
|
+
STATUS_ERRORS = {
|
|
36
|
+
400 => BadRequestError,
|
|
37
|
+
401 => UnauthorizedError,
|
|
38
|
+
403 => ForbiddenError,
|
|
39
|
+
404 => NotFoundError,
|
|
40
|
+
THROTTLED_STATUS => ThrottledError
|
|
41
|
+
}.freeze
|
|
42
|
+
# Statuses mapped to ClientError when not in STATUS_ERRORS.
|
|
43
|
+
CLIENT_ERROR_STATUSES = (400...500)
|
|
44
|
+
# Statuses mapped to ServerError.
|
|
45
|
+
SERVER_ERROR_STATUSES = (500...600)
|
|
46
|
+
|
|
47
|
+
# LWA error codes with their own class; other codes raise AuthError.
|
|
48
|
+
LWA_ERRORS = {
|
|
49
|
+
'invalid_grant' => InvalidGrantError,
|
|
50
|
+
'invalid_client' => InvalidClientError
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Response header carrying Amazon's request id.
|
|
54
|
+
REQUEST_ID_HEADER = 'x-amzn-RequestId'
|
|
55
|
+
# Response header carrying the usage plan rate, in requests per second.
|
|
56
|
+
RATE_LIMIT_HEADER = 'x-amzn-RateLimit-Limit'
|
|
57
|
+
|
|
58
|
+
# The error ends up in consumer logs; keep secrets out of it.
|
|
59
|
+
FILTERED = V2::FILTERED
|
|
60
|
+
# Request headers replaced by FILTERED on the error.
|
|
61
|
+
SENSITIVE_HEADERS = %w[authorization x-amz-access-token x-amz-security-token].freeze
|
|
62
|
+
|
|
63
|
+
# @param service [Symbol] one of SERVICES
|
|
64
|
+
# @raise [ArgumentError] on an unknown service
|
|
65
|
+
def initialize(service)
|
|
66
|
+
raise ArgumentError, "unknown service #{service.inspect}" unless SERVICES.include?(service)
|
|
67
|
+
|
|
68
|
+
@service = service
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param response [Faraday::Response]
|
|
72
|
+
# @return [Faraday::Response] the same response, when it is a 2xx
|
|
73
|
+
# @raise [Error] the mapped error otherwise
|
|
74
|
+
def check!(response)
|
|
75
|
+
env = response.env
|
|
76
|
+
raise ConnectionError.new('no status in response', **context(env)) if env.status.nil?
|
|
77
|
+
return response if response.success?
|
|
78
|
+
|
|
79
|
+
raise error_for(env)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A 2xx response whose body could not be used, with the same
|
|
83
|
+
# redacted context every other error carries.
|
|
84
|
+
#
|
|
85
|
+
# @param message [String]
|
|
86
|
+
# @param response [Faraday::Response]
|
|
87
|
+
# @return [ParseError]
|
|
88
|
+
def parse_error(message, response)
|
|
89
|
+
ParseError.new(message, **context(response.env))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Wraps an exception raised by the transport. Call it inside the
|
|
93
|
+
# rescue, so Ruby records the original exception as +cause+.
|
|
94
|
+
#
|
|
95
|
+
# @param exception [Exception]
|
|
96
|
+
# @param method [Symbol] HTTP method of the failed request
|
|
97
|
+
# @param url [String] full url of the failed request
|
|
98
|
+
# @return [ConnectionError]
|
|
99
|
+
def transport_error(exception, method:, url:)
|
|
100
|
+
klass = exception.is_a?(Faraday::TimeoutError) ? TimeoutError : ConnectionError
|
|
101
|
+
|
|
102
|
+
klass.new("#{exception.class}: #{exception.message}",
|
|
103
|
+
request: { method: method, url: redacted_url(url), path: URI(url).path })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def error_for(env)
|
|
109
|
+
case @service
|
|
110
|
+
when :api then api_error(env)
|
|
111
|
+
when :lwa then lwa_error(env)
|
|
112
|
+
else document_error(env)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def api_error(env)
|
|
117
|
+
parsed = parse_json(env.body)
|
|
118
|
+
errors = api_errors(parsed)
|
|
119
|
+
messages = parsed && api_messages(parsed, errors)
|
|
120
|
+
klass = STATUS_ERRORS.fetch(env.status) { generic_class(env.status) }
|
|
121
|
+
|
|
122
|
+
klass.new("#{env.status} #{summary(env.body, messages)}",
|
|
123
|
+
errors: errors, rate_limit: rate_limit(env), **context(env))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The documented shape is {errors: [{code, message, details}]}; this
|
|
127
|
+
# is the last line of defence, so anything else yields no errors
|
|
128
|
+
# rather than an exception.
|
|
129
|
+
def api_errors(parsed)
|
|
130
|
+
list = parsed.is_a?(Hash) ? parsed[:errors] : parsed
|
|
131
|
+
return [] unless list.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
ApiError.new(list.grep(Hash)).errors
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The documented errors, or the one-line body API Gateway sends when
|
|
137
|
+
# a request never reaches SP-API, e.g. {"message":"Forbidden"}.
|
|
138
|
+
def api_messages(parsed, errors)
|
|
139
|
+
return errors.map { |e| describe(e) } if errors.any?
|
|
140
|
+
|
|
141
|
+
message = parsed[:message] if parsed.is_a?(Hash)
|
|
142
|
+
message.to_s.empty? ? [] : [message.to_s]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def lwa_error(env)
|
|
146
|
+
parsed = parse_json(env.body)
|
|
147
|
+
parsed = nil unless parsed.is_a?(Hash)
|
|
148
|
+
code = parsed&.fetch(:error, nil)
|
|
149
|
+
description = parsed&.fetch(:error_description, nil)
|
|
150
|
+
messages = parsed && [[code, description].compact.join(': ')].reject(&:empty?)
|
|
151
|
+
message = "#{env.status} #{summary(env.body, messages)}"
|
|
152
|
+
return ServerError.new(message, **context(env)) if SERVER_ERROR_STATUSES.cover?(env.status)
|
|
153
|
+
return ThrottledError.new(message, **context(env)) if env.status == THROTTLED_STATUS
|
|
154
|
+
|
|
155
|
+
LWA_ERRORS.fetch(code, AuthError)
|
|
156
|
+
.new(message, code: code, description: description, **context(env))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def document_error(env)
|
|
160
|
+
DocumentError.new("#{env.status} #{env.reason_phrase}".strip, **context(env))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Status text: the parsed error messages, or why there are none.
|
|
164
|
+
# +messages+ is nil when the body was not JSON.
|
|
165
|
+
def summary(body, messages)
|
|
166
|
+
return '(no body)' if body.nil? || body.empty?
|
|
167
|
+
return '(body is not JSON)' if messages.nil?
|
|
168
|
+
return '(no error details)' if messages.empty?
|
|
169
|
+
|
|
170
|
+
messages.join('; ')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def describe(error)
|
|
174
|
+
text = "#{error.code}: #{error.message}"
|
|
175
|
+
return text if error.details.to_s.empty?
|
|
176
|
+
|
|
177
|
+
"#{text} (#{error.details})"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def generic_class(status)
|
|
181
|
+
return ClientError if CLIENT_ERROR_STATUSES.cover?(status)
|
|
182
|
+
return ServerError if SERVER_ERROR_STATUSES.cover?(status)
|
|
183
|
+
|
|
184
|
+
ResponseError
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def parse_json(body)
|
|
188
|
+
return nil if body.nil? || body.empty?
|
|
189
|
+
|
|
190
|
+
JSON.parse(body, symbolize_names: true)
|
|
191
|
+
rescue JSON::ParserError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def context(env)
|
|
196
|
+
{
|
|
197
|
+
status: env.status,
|
|
198
|
+
request_id: env.response_headers&.[](REQUEST_ID_HEADER),
|
|
199
|
+
request: request_details(env),
|
|
200
|
+
response: { status: env.status, headers: env.response_headers, body: env.body }
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def request_details(env)
|
|
205
|
+
{
|
|
206
|
+
method: env.method,
|
|
207
|
+
url: redacted_url(env.url.to_s),
|
|
208
|
+
path: env.url.path,
|
|
209
|
+
headers: redacted_headers(env.request_headers),
|
|
210
|
+
body: @service == :lwa ? FILTERED : env.request_body
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A copy that keeps Faraday's case-insensitive lookup.
|
|
215
|
+
def redacted_headers(headers)
|
|
216
|
+
redacted = Faraday::Utils::Headers.new(headers || {})
|
|
217
|
+
SENSITIVE_HEADERS.each do |name|
|
|
218
|
+
redacted[name] = FILTERED unless redacted[name].nil?
|
|
219
|
+
end
|
|
220
|
+
redacted
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Presigned S3 urls carry their credential in the query string.
|
|
224
|
+
def redacted_url(url)
|
|
225
|
+
return url unless @service == :documents
|
|
226
|
+
|
|
227
|
+
URI(url).tap { |uri| uri.query = nil }.to_s
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def rate_limit(env)
|
|
231
|
+
env.response_headers&.[](RATE_LIMIT_HEADER)&.to_f
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AmazonSpClients
|
|
4
|
+
module V2
|
|
5
|
+
# Replaces a secret wherever V2 renders one: error payloads and
|
|
6
|
+
# inspect output.
|
|
7
|
+
FILTERED = '[FILTERED]'
|
|
8
|
+
|
|
9
|
+
# Base class of every error V2 raises.
|
|
10
|
+
#
|
|
11
|
+
# Match on the class and on +code+, never on the message. Rescue
|
|
12
|
+
# subclasses before their parents: ThrottledError, UnauthorizedError,
|
|
13
|
+
# ForbiddenError and NotFoundError before ClientError; TimeoutError
|
|
14
|
+
# before ConnectionError. The exception that caused a transport
|
|
15
|
+
# error is available as +cause+.
|
|
16
|
+
class Error < StandardError
|
|
17
|
+
# @return [Integer, nil] HTTP status, when a response was received
|
|
18
|
+
attr_reader :status
|
|
19
|
+
# @return [String, nil] Amazon's request id (x-amzn-RequestId)
|
|
20
|
+
attr_reader :request_id
|
|
21
|
+
# @return [Hash, nil] method, url, path, headers and body of the
|
|
22
|
+
# request; secrets redacted
|
|
23
|
+
attr_reader :request
|
|
24
|
+
# @return [Hash, nil] status, headers and body of the response
|
|
25
|
+
attr_reader :response
|
|
26
|
+
|
|
27
|
+
# @param message [String, nil]
|
|
28
|
+
def initialize(message = nil, status: nil, request_id: nil, request: nil, response: nil)
|
|
29
|
+
super(message)
|
|
30
|
+
@status = status
|
|
31
|
+
@request_id = request_id
|
|
32
|
+
@request = request
|
|
33
|
+
@response = response
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The request got no usable response: connection refused, DNS or
|
|
38
|
+
# SSL failure, a dropped keep-alive socket, a malformed response, a
|
|
39
|
+
# response without status.
|
|
40
|
+
class ConnectionError < Error; end
|
|
41
|
+
|
|
42
|
+
# The request timed out while connecting, reading or writing.
|
|
43
|
+
class TimeoutError < ConnectionError; end
|
|
44
|
+
|
|
45
|
+
# A 2xx response whose body is not valid JSON.
|
|
46
|
+
class ParseError < Error; end
|
|
47
|
+
|
|
48
|
+
# The LWA token endpoint rejected the request (4xx).
|
|
49
|
+
class AuthError < Error
|
|
50
|
+
# @return [String, nil] LWA error code, e.g. 'invalid_grant'
|
|
51
|
+
attr_reader :code
|
|
52
|
+
# @return [String, nil] LWA error description
|
|
53
|
+
attr_reader :description
|
|
54
|
+
|
|
55
|
+
# @param message [String, nil]
|
|
56
|
+
# @param code [String, nil]
|
|
57
|
+
# @param description [String, nil]
|
|
58
|
+
def initialize(message = nil, code: nil, description: nil, **context)
|
|
59
|
+
super(message, **context)
|
|
60
|
+
@code = code
|
|
61
|
+
@description = description
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The refresh token is revoked or invalid (LWA +invalid_grant+).
|
|
66
|
+
class InvalidGrantError < AuthError; end
|
|
67
|
+
|
|
68
|
+
# The app credentials are wrong (LWA +invalid_client+).
|
|
69
|
+
class InvalidClientError < AuthError; end
|
|
70
|
+
|
|
71
|
+
# SP-API answered with a non-2xx status.
|
|
72
|
+
class ResponseError < Error
|
|
73
|
+
# @return [Array<AmazonSpClients::ApiError::Error>] parsed SP-API
|
|
74
|
+
# errors (code, message, details); empty when the body had none
|
|
75
|
+
attr_reader :errors
|
|
76
|
+
# @return [Float, nil] x-amzn-RateLimit-Limit header, when present
|
|
77
|
+
attr_reader :rate_limit
|
|
78
|
+
|
|
79
|
+
# @param message [String, nil]
|
|
80
|
+
# @param errors [Array<AmazonSpClients::ApiError::Error>]
|
|
81
|
+
# @param rate_limit [Float, nil]
|
|
82
|
+
def initialize(message = nil, errors: [], rate_limit: nil, **context)
|
|
83
|
+
super(message, **context)
|
|
84
|
+
@errors = errors
|
|
85
|
+
@rate_limit = rate_limit
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [String, nil] code of the first SP-API error
|
|
89
|
+
def code = errors.first&.code
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 429: the request was throttled. Kept out of ClientError on
|
|
93
|
+
# purpose, so a +rescue ClientError+ cannot swallow it.
|
|
94
|
+
class ThrottledError < ResponseError; end
|
|
95
|
+
|
|
96
|
+
# Any 4xx other than 429.
|
|
97
|
+
class ClientError < ResponseError; end
|
|
98
|
+
|
|
99
|
+
# 400.
|
|
100
|
+
class BadRequestError < ClientError; end
|
|
101
|
+
|
|
102
|
+
# 401.
|
|
103
|
+
class UnauthorizedError < ClientError; end
|
|
104
|
+
|
|
105
|
+
# 403.
|
|
106
|
+
class ForbiddenError < ClientError; end
|
|
107
|
+
|
|
108
|
+
# 404.
|
|
109
|
+
class NotFoundError < ClientError; end
|
|
110
|
+
|
|
111
|
+
# 5xx.
|
|
112
|
+
class ServerError < ResponseError; end
|
|
113
|
+
|
|
114
|
+
# A presigned S3 upload or download failed.
|
|
115
|
+
class DocumentError < Error; end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'amazon_sp_clients/adapter_loader'
|
|
6
|
+
require 'amazon_sp_clients/v2/config'
|
|
7
|
+
require 'amazon_sp_clients/v2/errors'
|
|
8
|
+
require 'amazon_sp_clients/v2/error_mapper'
|
|
9
|
+
require 'amazon_sp_clients/v2/token'
|
|
10
|
+
|
|
11
|
+
module AmazonSpClients
|
|
12
|
+
module V2
|
|
13
|
+
# Login with Amazon: exchanges a refresh token for an access token.
|
|
14
|
+
#
|
|
15
|
+
# token = LWA.new(config).exchange(refresh_token: stored_refresh_token)
|
|
16
|
+
# token.access_token # => "Atza|..."
|
|
17
|
+
# token.expires_at # => Time
|
|
18
|
+
#
|
|
19
|
+
# Needs the app credentials on the config. Consumers that keep their
|
|
20
|
+
# own token store call this directly; RefreshToken credentials call
|
|
21
|
+
# it for the client.
|
|
22
|
+
class LWA
|
|
23
|
+
# Login with Amazon lives outside the SP-API hosts.
|
|
24
|
+
TOKEN_HOST = 'https://api.amazon.com'
|
|
25
|
+
# The OAuth 2 token endpoint.
|
|
26
|
+
TOKEN_PATH = '/auth/o2/token'
|
|
27
|
+
# The grant type V2 uses; grantless (client_credentials) has no consumer.
|
|
28
|
+
REFRESH_GRANT = 'refresh_token'
|
|
29
|
+
|
|
30
|
+
# @param config [Config] with client_id and client_secret set
|
|
31
|
+
# @raise [ArgumentError] when the app credentials are missing
|
|
32
|
+
def initialize(config)
|
|
33
|
+
if config.client_id.nil? || config.client_secret.nil?
|
|
34
|
+
raise ArgumentError, 'config needs client_id and client_secret'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@config = config
|
|
38
|
+
@errors = ErrorMapper.new(:lwa)
|
|
39
|
+
@conn = build_connection
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param refresh_token [String]
|
|
43
|
+
# @return [Token]
|
|
44
|
+
# @raise [AuthError] when LWA rejects the grant (e.g. InvalidGrantError)
|
|
45
|
+
# @raise [ConnectionError, ParseError, ServerError]
|
|
46
|
+
def exchange(refresh_token:)
|
|
47
|
+
form = {
|
|
48
|
+
grant_type: REFRESH_GRANT,
|
|
49
|
+
client_id: @config.client_id,
|
|
50
|
+
client_secret: @config.client_secret,
|
|
51
|
+
refresh_token: refresh_token
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
build_token(post(form))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def build_connection
|
|
60
|
+
options = { timeout: @config.timeout, open_timeout: @config.open_timeout }
|
|
61
|
+
headers = { 'User-Agent' => @config.user_agent }
|
|
62
|
+
|
|
63
|
+
Faraday.new(url: TOKEN_HOST, request: options, headers: headers) do |conn|
|
|
64
|
+
conn.request :url_encoded
|
|
65
|
+
conn.adapter Faraday::Adapter::HTTPClient
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def post(form)
|
|
70
|
+
response = @conn.post(TOKEN_PATH, form)
|
|
71
|
+
|
|
72
|
+
@errors.check!(response)
|
|
73
|
+
rescue *ErrorMapper::TRANSPORT_ERRORS => e
|
|
74
|
+
raise @errors.transport_error(e, method: :post, url: "#{TOKEN_HOST}#{TOKEN_PATH}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_token(response)
|
|
78
|
+
body = parse(response)
|
|
79
|
+
expires_in = body[:expires_in]
|
|
80
|
+
|
|
81
|
+
Token.new(
|
|
82
|
+
access_token: body[:access_token],
|
|
83
|
+
token_type: body[:token_type],
|
|
84
|
+
expires_in: expires_in,
|
|
85
|
+
expires_at: expires_in && (Time.now.utc + expires_in),
|
|
86
|
+
refresh_token: body[:refresh_token]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse(response)
|
|
91
|
+
body = JSON.parse(response.body, symbolize_names: true)
|
|
92
|
+
return body if body.is_a?(Hash) && body[:access_token]
|
|
93
|
+
|
|
94
|
+
raise @errors.parse_error('LWA response has no access_token', response)
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
raise @errors.parse_error("LWA response is not JSON: #{e.message}", response)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|