Class: Transport::HttpSellerApiConnection

Inherits:
Object
  • Object
show all
Defined in:
app/services/transport/http_seller_api_connection.rb

Overview

Service object: http seller api connection.

Constant Summary collapse

VALID_HTTP_METHODS =

Valid http methods.

%w[get put post patch delete head].freeze
RESTRICTED_OPERATIONS =

Restricted operations.

[
  'purchaseOrders', # getOrders, getOrder
  'shippingLabels', # getShippingLabels, getShippingLabel
  'packingSlips', # getPackingSlips, getPackingSlip
  'customerInvoices', # getCustomerInvoices, getCustomerInvoice
  'orders', # getOrders, getOrder
  'shipments' # getEligibleShipmentServices, getShipment, cancelShipment, createShipment, getAdditionalSellerInputs
].freeze
SP_API_HOST_REGIONS =

SP-API endpoint host => AWS SigV4 signing region. Profiles that don't pin
:region (the Vendor Central profiles didn't) fall back to this map instead
of crashing Aws::Sigv4 with MissingRegionError.

{
  'sellingpartnerapi-na.amazon.com' => 'us-east-1',
  'sellingpartnerapi-eu.amazon.com' => 'eu-west-1',
  'sellingpartnerapi-fe.amazon.com' => 'us-west-2'
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpSellerApiConnection

Returns a new instance of HttpSellerApiConnection.

Parameters:

  • options (Hash) (defaults to: {})

    connection configuration

Options Hash (options):

  • :profile (Symbol)

    configuration profile name; the SP-API
    credential set is loaded from Heatwave::Configuration under this key

  • :logger (Logger)

    logger to use (defaults to Rails.logger)



26
27
28
29
30
31
32
33
# File 'app/services/transport/http_seller_api_connection.rb', line 26

def initialize(options = {})
  @options = options
  # if there's a profile, grab the headers to send from it
  if (profile = @options[:profile])
    @profile = Heatwave::Configuration.fetch(profile&.to_sym)
  end
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#loggerObject (readonly)



20
21
22
# File 'app/services/transport/http_seller_api_connection.rb', line 20

def logger
  @logger
end

#profileObject (readonly)



20
21
22
# File 'app/services/transport/http_seller_api_connection.rb', line 20

def profile
  @profile
end

Instance Method Details

#fetch_access_tokenString

Fetches a short-lived SP-API access token (refresh-token grant) for the
+x-amz-access-token+ header. The detached block above shows a sample
request/response.

Returns:

  • (String)

    the SP-API access token

Raises:

  • (HTTP::RateLimitExceededError)

    if the token endpoint returns 429

See Also:



232
233
234
235
236
237
238
239
240
241
# File 'app/services/transport/http_seller_api_connection.rb', line 232

def fetch_access_token
  auth_payload = "grant_type=refresh_token&refresh_token=#{@profile[:refresh_token]}&client_id=#{@profile[:client_id]}&client_secret=#{@profile[:client_secret]}"
  headers = {
    'content-type' => 'application/x-www-form-urlencoded', # NOTE: headers need this rocket form, Amazon reject otherwise
    'cache-control' => 'no-cache'
  }
  res = send_request('POST', @profile[:auth_url], auth_payload, headers)
  response = res[:http_res]
  JSON.parse(response.body)['access_token']
end

#fetch_grantless_access_token(scope: 'sellingpartnerapi::notifications') ⇒ String

Fetches a short-lived access token for a grantless SP-API operation
(client_credentials grant — no selling-partner refresh token involved).
Needed for Notifications API destination management
(createDestination / getDestinations / deleteDestination).

Parameters:

  • scope (String) (defaults to: 'sellingpartnerapi::notifications')

    the LWA scope, e.g. 'sellingpartnerapi::notifications'

Returns:

  • (String)

    the grantless access token

See Also:



251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'app/services/transport/http_seller_api_connection.rb', line 251

def fetch_grantless_access_token(scope: 'sellingpartnerapi::notifications')
  auth_payload = URI.encode_www_form(
    grant_type: 'client_credentials',
    scope: scope,
    client_id: @profile[:client_id],
    client_secret: @profile[:client_secret]
  )
  headers = {
    'content-type' => 'application/x-www-form-urlencoded', # NOTE: headers need this rocket form, Amazon reject otherwise
    'cache-control' => 'no-cache'
  }
  res = send_request('POST', @profile[:auth_url], auth_payload, headers)
  JSON.parse(res[:http_res].body)['access_token']
end

#fetch_restricted_data_token(t, method, parsed_uri, headers) ⇒ String

Exchanges the access token for a Restricted Data Token (RDT) scoped to a
single restricted operation (path + method) — required before reading PII.

Parameters:

  • t (String)

    the SP-API access token to exchange

  • method (String)

    the HTTP verb of the restricted operation

  • parsed_uri (URI)

    the parsed restricted-operation URI (its path scopes the RDT)

  • headers (Hash)

    extra request headers, merged into the signed headers

Returns:

  • (String)

    the restricted data token (used as x-amz-access-token for the PII call)

Raises:

  • (HTTP::RateLimitExceededError)

    if the RDT endpoint returns 429

See Also:



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'app/services/transport/http_seller_api_connection.rb', line 276

def fetch_restricted_data_token(t, method, parsed_uri, headers)
  data = "
    {
      \"restrictedResources\": [
        {
          \"method\": \"#{method}\",
          \"path\": \"#{parsed_uri.path}\"
        }
      ]
    }
  "
  url = URI.join("https://#{profile[:api_host]}", profile[:restricted_data_token_path]).to_s
  res = send_signed_request(t, 'POST', data, url, headers)
  response = res[:http_res]
  logger.debug('Restricted data token request', url: url, method: 'POST', status: response&.status)
  JSON.parse(response.body)['restrictedDataToken']
end

#production_host_required?(method, url) ⇒ Boolean

Whether the request must target the production host even when the profile
carries the sandbox api_host (dev credentials). GET reads use production
because sandbox data is junk; restricted-data-token and feed paths always
did; /notifications/ (destinations + subscriptions config) does too — the
notifications sandbox only returns canned TEST_CASE responses and persists
nothing, so a dev-credential POST would silently no-op
(see doc/tasks/202607152125_AMAZON_SPAPI_SQS_NOTIFICATIONS.md).

Parameters:

  • method (String)

    the HTTP verb

  • url (String)

    the absolute SP-API endpoint

Returns:

  • (Boolean)


305
306
307
308
309
310
# File 'app/services/transport/http_seller_api_connection.rb', line 305

def production_host_required?(method, url)
  (method.downcase == 'get' && url.downcase.index('/transactions').nil?) ||
    url.index('restrictedDataToken').present? ||
    url.index('/feeds/').present? ||
    url.index('/notifications/').present?
end

#restricted_operation?(method, parsed_uri) ⇒ Boolean

Returns:

  • (Boolean)


312
313
314
315
316
317
318
319
320
321
322
323
# File 'app/services/transport/http_seller_api_connection.rb', line 312

def restricted_operation?(method, parsed_uri)
  return false unless method.to_s.downcase.index('get') # only get calls return PII

  restricted_op = false
  RESTRICTED_OPERATIONS.each do |op|
    if parsed_uri.path.include?(op)
      restricted_op = true
      break
    end
  end
  restricted_op
end

#send_data(data, url, method, headers = {}) ⇒ Hash

Sends +data+ to an SP-API endpoint: fetches an access token (and a
restricted-data token for PII operations), SigV4-signs the request, and
returns the parsed result envelope.

Parameters:

  • data (String)

    the request body (JSON for most endpoints)

  • url (String)

    the absolute SP-API endpoint

  • method (String)

    the HTTP verb (e.g. 'POST', 'GET')

  • headers (Hash) (defaults to: {})

    extra request headers, merged into the signed headers

Returns:

  • (Hash)

    +{ success: Boolean, http_result: Faraday::Response, attempt_number_reached: Integer }+

Raises:

  • (HTTP::RateLimitExceededError)

    on a 429 (bubbled up for ECL-based reschedule)



45
46
47
48
49
50
51
52
53
54
# File 'app/services/transport/http_seller_api_connection.rb', line 45

def send_data(data, url, method, headers = {})
  t = fetch_access_token
  parsed_uri = URI.parse(url)
  t = fetch_restricted_data_token(t, method, parsed_uri, headers) if restricted_operation?(method, parsed_uri)
  res = send_signed_request(t, method, data, url, headers)
  http_res = res[:http_res]
  attempt_number_reached = res[:attempt_number_reached]
  logger.debug('Seller API request complete', url: url, method: method, attempts: attempt_number_reached, status: http_res&.status)
  { success: successful?(http_res), http_result: http_res, attempt_number_reached: attempt_number_reached }
end

#send_request(method, url, data = '', headers = {}) ⇒ Hash

Issues a single request through the Faraday #connection. The +:retry+
middleware retries transient timeouts only; a 429 is raised (not retried
in-band) so the caller reschedules via the ECL transmit_after mechanism.

Parameters:

  • method (String)

    the HTTP verb

  • url (String)

    the absolute endpoint

  • data (String) (defaults to: '')

    the request body — SigV4-signed by the caller, so it is sent verbatim

  • headers (Hash) (defaults to: {})

    the fully-resolved (signed) request headers

Returns:

  • (Hash)

    +{ http_res: Faraday::Response, attempt_number_reached: Integer }+

Raises:

  • (HTTP::RateLimitExceededError)

    when the response status is 429



187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/services/transport/http_seller_api_connection.rb', line 187

def send_request(method, url, data = '', headers = {})
  # faraday-retry handles the transient-timeout escalation that used to be a
  # hand-rolled +begin/rescue …; sleep; retry+ loop. 429s are deliberately
  # NOT retried in-band (no +retry_statuses+); they are raised below so the
  # caller can reschedule via the ECL +transmit_after+ mechanism.
  attempts = 1
  http_res = connection(headers, retry_block: ->(**) { attempts += 1 })
             .run_request(method.downcase.to_sym, url, data.presence, nil)

  if http_res.status == 429 # Too Many Requests
    # 429s are not retried here — the caller is responsible for scheduling a retry via the ECL
    # transmit_after mechanism so we don't block the Sidekiq thread. See Edi::Amazon::Sender.
    retry_after = http_res.headers['Retry-After']&.to_i
    logger.warn "Amazon SP-API 429 on attempt #{attempts} for #{url}. Bubbling up for ECL-based retry."
    raise HTTP::RateLimitExceededError.new(
      status_code: 429,
      headers: http_res.headers,
      retry_after: retry_after,
      message: "429 returned with result headers: #{http_res.headers.inspect} on attempt #{attempts}, retry after #{retry_after}"
    )
  end

  { http_res: http_res, attempt_number_reached: attempts }
end

#send_signed_request(token, method, data, url, headers = {}) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'app/services/transport/http_seller_api_connection.rb', line 86

def send_signed_request(token, method, data, url, headers = {})
  # We need a signed request which is a complex four part task...
  # See: https://github.com/amzn/selling-partner-api-docs/blob/main/guides/developer-guide/SellingPartnerApiDeveloperGuideForVendors.md#step-4-create-and-sign-your-request

  # We will use the aws-sdk-ruby gem for this, see: https://github.com/aws/aws-sdk-ruby/blob/master/gems/aws-sigv4/lib/aws-sigv4/signer.rb
  # We had to register an iam credential stack using AWS to get aws keys as part of the credentials stack for Seller API

  require 'aws-sigv4'

  signer = Aws::Sigv4::Signer.new(
    service: 'execute-api',
    region: signer_region,
    # static credentials
    access_key_id: @profile[:aws_access_key_id],
    secret_access_key: @profile[:aws_secret_access_key]
  )

  # Computes a version 4 signature signature. Returns the resultant
  # signature as a hash of headers to apply to your HTTP request. The given
  # request is not modified.
  #
  #     signature = signer.sign_request(
  #       http_method: 'PUT',
  #       url: 'https://domain.com',
  #       headers: {
  #         'Abc' => 'xyz',
  #       },
  #       body: 'body' # String or IO object
  #     )
  #
  #     # Apply the following hash of headers to your HTTP request
  #     signature.headers['host']
  #     signature.headers['x-amz-date']
  #     signature.headers['x-amz-security-token']
  #     signature.headers['x-amz-content-sha256']
  #     signature.headers['authorization']
  #
  # In addition to computing the signature headers, the canonicalized
  # request, string to sign and content sha256 checksum are also available.
  # These values are useful for debugging signature errors returned by AWS.
  #
  #     signature.canonical_request #=> "..."
  #     signature.string_to_sign #=> "..."
  #     signature.content_sha256 #=> "..."

  # Here we want to add the headers outlined here: https://github.com/amzn/selling-partner-api-docs/blob/main/guides/developer-guide/SellingPartnerApiDeveloperGuideForVendors.md#step-2-construct-a-selling-partner-api-uri

  # Here are the HTTP headers that you include in requests to the Selling Partner API:

  # Request headers
  # Name                 Description                                                          Required
  # host                 The Selling Partner endpoint. See Selling Partner API endpoints.     Yes
  # x-amz-access-token   The Login with Amazon access token. See Step 1. Request a Login      Yes
  #                      with Amazon access token.
  # x-amz-date           The date and time of your request                                    Yes
  # user-agent           Your application name and version number, platform, and programming  Yes
  #                      language. These help us diagnose and fix problems you might encounter
  #                      with the service. See Include a User-Agent header in all requests.

  # Here is an example of a Selling Partner API request with URI and headers but no signing information:

  # GET https://sellingpartnerapi-eu.amazon.com/vendor/orders/v1/purchaseOrders?limit={example}&createdAfter={example}&createdBefore={example}&sortOrder={example}&nextToken={example}&includeDetails={example} HTTP/1.1
  # host: https://sellingpartnerapi-eu.amazon.com
  # user-agent: My Selling Tool/2.0 (Language=Java/1.8.0.221; Platform=Windows/10)
  # x-amz-access-token=Atza|IQEBLjAsAhRmHjNgHpi0U-Dme37rR6CuUpSREXAMPLE
  # x-amz-date: 20190430T123600Z

  # this gets the git repo short sha on production as well as development, which will serve for our application version
  git_short_sha = Rails.application.config.x.revision.first(7)

  # NOTE: headers need this rocket form, Amazon reject otherwise
  required_headers = headers.merge({
                                     'host' => (production_host_required?(method, url) ? @profile[:api_host].split('sandbox.').last : @profile[:api_host]),
                                     'x-amz-access-token' => token,
                                     'user-agent' => "Heatwave/#{git_short_sha} (Language=Ruby/#{RUBY_VERSION}; Framework=Rails/#{Rails.version}; Host=crm.warmlyyours.com)"
})

  signature = signer.sign_request(
    http_method: method,
    url:,
    headers: required_headers,
    body: data # String or IO object
  )

  headers_for_request = required_headers.merge({
                                                 'x-amz-access-token' => token
                                               }).merge(signature.headers)

  send_request(method, url, data, headers_for_request)
end

#signer_regionString?

The SigV4 signing region: the profile's explicit :region wins, otherwise
derived from the SP-API api_host (sandbox hosts map to their prod region).

Returns:

  • (String, nil)


82
83
84
# File 'app/services/transport/http_seller_api_connection.rb', line 82

def signer_region
  @profile[:region].presence || SP_API_HOST_REGIONS[@profile[:api_host].to_s.sub('sandbox.', '')]
end

#successful?(http_res) ⇒ Boolean

Whether +http_res+ is a successful SP-API response: a 2xx status with no
+errors+ array in the body (an empty 204 body, e.g. confirmShipment, counts).

Parameters:

  • http_res (Faraday::Response)

    the response to evaluate

Returns:

  • (Boolean)

    true when the status is 2xx and the body carries no errors



61
62
63
64
65
66
67
68
# File 'app/services/transport/http_seller_api_connection.rb', line 61

def successful?(http_res)
  res = false
  errs = []
  errs = JSON.parse(http_res.body.to_s).with_indifferent_access[:errors] || [] if http_res.body.present? # need to deal with a 204 and empty body as a valid response from the shipmentConfirm API endpoint see: https://developer-docs.amazon.com/sp-api/reference/confirmshipment
  code = http_res.status.to_i
  res = true if (code >= 200) && (code < 300) && errs.empty?
  res
end