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.
Files changed (90) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +161 -0
  3. data/LICENSE +21 -0
  4. data/README.md +284 -0
  5. data/lib/amazon_sp_clients/adapter_loader.rb +28 -0
  6. data/lib/amazon_sp_clients/api_client.rb +309 -0
  7. data/lib/amazon_sp_clients/api_error.rb +37 -0
  8. data/lib/amazon_sp_clients/api_response.rb +54 -0
  9. data/lib/amazon_sp_clients/configuration.rb +144 -0
  10. data/lib/amazon_sp_clients/marketplaces.rb +63 -0
  11. data/lib/amazon_sp_clients/middlewares/raise_error.rb +166 -0
  12. data/lib/amazon_sp_clients/session.rb +124 -0
  13. data/lib/amazon_sp_clients/sp_fba_inventory.rb +8 -0
  14. data/lib/amazon_sp_clients/sp_feeds_2021.rb +8 -0
  15. data/lib/amazon_sp_clients/sp_fulfillment_outbound_2020.rb +8 -0
  16. data/lib/amazon_sp_clients/sp_listings_items_2021.rb +8 -0
  17. data/lib/amazon_sp_clients/sp_orders_v0.rb +8 -0
  18. data/lib/amazon_sp_clients/sp_reports_2021.rb +8 -0
  19. data/lib/amazon_sp_clients/sp_tokens_2021.rb +8 -0
  20. data/lib/amazon_sp_clients/sp_vdf_inventory_v1.rb +8 -0
  21. data/lib/amazon_sp_clients/sp_vdf_orders_v1.rb +8 -0
  22. data/lib/amazon_sp_clients/sp_vdf_shipping_v1.rb +8 -0
  23. data/lib/amazon_sp_clients/sp_vendor_invoices.rb +8 -0
  24. data/lib/amazon_sp_clients/sp_vendor_orders.rb +8 -0
  25. data/lib/amazon_sp_clients/sp_vendor_transaction_status.rb +8 -0
  26. data/lib/amazon_sp_clients/sp_vendors_shipments.rb +8 -0
  27. data/lib/amazon_sp_clients/token_exchange_auth.rb +82 -0
  28. data/lib/amazon_sp_clients/uploader.rb +77 -0
  29. data/lib/amazon_sp_clients/v2/api.rb +43 -0
  30. data/lib/amazon_sp_clients/v2/apis/fba_inventory.rb +80 -0
  31. data/lib/amazon_sp_clients/v2/apis/feeds_2021.rb +86 -0
  32. data/lib/amazon_sp_clients/v2/apis/fulfillment_outbound_2020.rb +184 -0
  33. data/lib/amazon_sp_clients/v2/apis/listings_items_2021.rb +144 -0
  34. data/lib/amazon_sp_clients/v2/apis/orders_v0.rb +165 -0
  35. data/lib/amazon_sp_clients/v2/apis/reports_2021.rb +117 -0
  36. data/lib/amazon_sp_clients/v2/apis/tokens_2021.rb +25 -0
  37. data/lib/amazon_sp_clients/v2/apis/vdf_inventory_v1.rb +26 -0
  38. data/lib/amazon_sp_clients/v2/apis/vdf_orders_v1.rb +61 -0
  39. data/lib/amazon_sp_clients/v2/apis/vdf_shipping_v1.rb +139 -0
  40. data/lib/amazon_sp_clients/v2/apis/vendor_invoices.rb +25 -0
  41. data/lib/amazon_sp_clients/v2/apis/vendor_orders.rb +106 -0
  42. data/lib/amazon_sp_clients/v2/apis/vendor_transaction_status.rb +25 -0
  43. data/lib/amazon_sp_clients/v2/apis/vendors_shipments.rb +120 -0
  44. data/lib/amazon_sp_clients/v2/apis.rb +82 -0
  45. data/lib/amazon_sp_clients/v2/client.rb +198 -0
  46. data/lib/amazon_sp_clients/v2/config.rb +85 -0
  47. data/lib/amazon_sp_clients/v2/credentials.rb +91 -0
  48. data/lib/amazon_sp_clients/v2/documents.rb +115 -0
  49. data/lib/amazon_sp_clients/v2/error_mapper.rb +235 -0
  50. data/lib/amazon_sp_clients/v2/errors.rb +117 -0
  51. data/lib/amazon_sp_clients/v2/lwa.rb +100 -0
  52. data/lib/amazon_sp_clients/v2/rdt.rb +104 -0
  53. data/lib/amazon_sp_clients/v2/token.rb +39 -0
  54. data/lib/amazon_sp_clients/v2.rb +33 -0
  55. data/lib/amazon_sp_clients/version.rb +3 -0
  56. data/lib/amazon_sp_clients.rb +69 -0
  57. data/lib/sp_api_clients.rb +6 -0
  58. data/sp_api_clients.gemspec +53 -0
  59. data/vendor/fba_inventory/lib/sp_fba_inventory/api/fba_inventory_api.rb +289 -0
  60. data/vendor/fba_inventory/lib/sp_fba_inventory.rb +12 -0
  61. data/vendor/feeds_2021/lib/sp_feeds_2021/api/feeds_api.rb +367 -0
  62. data/vendor/feeds_2021/lib/sp_feeds_2021.rb +12 -0
  63. data/vendor/fulfillment_outbound_2020/lib/sp_fulfillment_outbound_2020/api/fba_outbound_api.rb +845 -0
  64. data/vendor/fulfillment_outbound_2020/lib/sp_fulfillment_outbound_2020.rb +12 -0
  65. data/vendor/listings_items_2021/lib/sp_listings_items_2021/api/listings_api.rb +480 -0
  66. data/vendor/listings_items_2021/lib/sp_listings_items_2021.rb +12 -0
  67. data/vendor/orders_v0/lib/sp_orders_v0/api/orders_v0_api.rb +593 -0
  68. data/vendor/orders_v0/lib/sp_orders_v0/api/shipment_api.rb +83 -0
  69. data/vendor/orders_v0/lib/sp_orders_v0.rb +13 -0
  70. data/vendor/reports_2021/lib/sp_reports_2021/api/reports_api.rb +530 -0
  71. data/vendor/reports_2021/lib/sp_reports_2021.rb +12 -0
  72. data/vendor/tokens_2021/lib/sp_tokens_2021/api/tokens_api.rb +77 -0
  73. data/vendor/tokens_2021/lib/sp_tokens_2021.rb +12 -0
  74. data/vendor/vdf_inventory_v1/lib/sp_vdf_inventory_v1/api/update_inventory_api.rb +83 -0
  75. data/vendor/vdf_inventory_v1/lib/sp_vdf_inventory_v1.rb +12 -0
  76. data/vendor/vdf_orders_v1/lib/sp_vdf_orders_v1/api/vendor_orders_api.rb +217 -0
  77. data/vendor/vdf_orders_v1/lib/sp_vdf_orders_v1.rb +12 -0
  78. data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/customer_invoices_api.rb +152 -0
  79. data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/vendor_shipping_api.rb +264 -0
  80. data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1/api/vendor_shipping_labels_api.rb +208 -0
  81. data/vendor/vdf_shipping_v1/lib/sp_vdf_shipping_v1.rb +14 -0
  82. data/vendor/vendor_invoices/lib/sp_vendor_invoices/api/vendor_payments_api.rb +77 -0
  83. data/vendor/vendor_invoices/lib/sp_vendor_invoices.rb +12 -0
  84. data/vendor/vendor_orders/lib/sp_vendor_orders/api/vendor_orders_api.rb +323 -0
  85. data/vendor/vendor_orders/lib/sp_vendor_orders.rb +12 -0
  86. data/vendor/vendor_transaction_status/lib/sp_vendor_transaction_status/api/vendor_transaction_api.rb +75 -0
  87. data/vendor/vendor_transaction_status/lib/sp_vendor_transaction_status.rb +12 -0
  88. data/vendor/vendors_shipments/lib/sp_vendors_shipments/api/vendor_shipping_api.rb +337 -0
  89. data/vendor/vendors_shipments/lib/sp_vendors_shipments.rb +12 -0
  90. 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