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

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpSellerApiConnection

Returns a new instance of HttpSellerApiConnection.



23
24
25
26
27
28
29
30
# File 'app/services/transport/http_seller_api_connection.rb', line 23

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)



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

def logger
  @logger
end

#profileObject (readonly)



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

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:



211
212
213
214
215
216
217
218
219
220
# File 'app/services/transport/http_seller_api_connection.rb', line 211

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_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:



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'app/services/transport/http_seller_api_connection.rb', line 232

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

#restricted_operation?(method, parsed_uri) ⇒ Boolean

Returns:

  • (Boolean)


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

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)



42
43
44
45
46
47
48
49
50
51
# File 'app/services/transport/http_seller_api_connection.rb', line 42

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



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'app/services/transport/http_seller_api_connection.rb', line 168

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



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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
# File 'app/services/transport/http_seller_api_connection.rb', line 67

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: @profile[:region], # 'us-east-1',
    # 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, also if a GET request then use production host, because otherwise sandbox data is crap
  required_headers = headers.merge({
                                     'host' => ((method.downcase == 'get' && url.downcase.index('/transactions').nil?) || url.index('restrictedDataToken').present? || url.index('/feeds/').present? ? @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

#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



58
59
60
61
62
63
64
65
# File 'app/services/transport/http_seller_api_connection.rb', line 58

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