Class: Transport::HttpSellerApiConnection

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

Constant Summary collapse

VALID_HTTP_METHODS =
%w[get put post patch delete head]
RESTRICTED_OPERATIONS =
[
  'purchaseOrders', # getOrders, getOrder
  'shippingLabels', # getShippingLabels, getShippingLabel
  'packingSlips', # getPackingSlips, getPackingSlip
  'customerInvoices', # getCustomerInvoices, getCustomerInvoice
  'orders', # getOrders, getOrder
  'shipments' # getEligibleShipmentServices, getShipment, cancelShipment, createShipment, getAdditionalSellerInputs
]

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpSellerApiConnection

Returns a new instance of HttpSellerApiConnection.



17
18
19
20
21
22
23
24
# File 'app/services/transport/http_seller_api_connection.rb', line 17

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)



15
16
17
# File 'app/services/transport/http_seller_api_connection.rb', line 15

def logger
  @logger
end

#profileObject (readonly)



15
16
17
# File 'app/services/transport/http_seller_api_connection.rb', line 15

def profile
  @profile
end

Instance Method Details

#fetch_access_tokenObject

"access_token":"Atza|IwEBIOMV8JNnAexample","refresh_token":"Atzr|IwEBIA9IPJ2L20HGLR1Hexample8","token_type":"bearer","expires_in":3600



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

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.to_s)['access_token']
end

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



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'app/services/transport/http_seller_api_connection.rb', line 221

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&.code)
  JSON.parse(response.to_s)['restrictedDataToken']
end

#restricted_operation?(method, parsed_uri) ⇒ Boolean

Returns:

  • (Boolean)


239
240
241
242
243
244
245
246
247
248
249
250
# File 'app/services/transport/http_seller_api_connection.rb', line 239

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 = {}) ⇒ Object



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

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&.code)
  { success: successful?(http_res), http_result: http_res, attempt_number_reached: attempt_number_reached }
end

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



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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'app/services/transport/http_seller_api_connection.rb', line 137

def send_request(method, url, data = '', headers = {})
  http_obj = HTTP.use(logging: { logger: }).timeout(180)
  headers.each do |k, v|
    http_obj = http_obj.headers(k => v)
  end
  http_res = nil
  payload = {}
  payload[:body] = data if data.present?
  attempt_number_reached = 0

  # Here let us treat set of various timeouts differently than 429s
  num_attempts = 0
  begin
    num_attempts += 1
    http_res = http_obj.send(method.downcase.to_sym, url, **payload)
    if http_res.status == 429 # Too Many Requests
      # Raise an exception to retry request with exponential backoff
      retry_after = http_res.headers['Retry-After']&.to_i
      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 #{num_attempts}, retry after #{retry_after}"
      )
    end
  rescue *(Retryable::TIMEOUT_CLASSES - [HTTP::RateLimitExceededError]) => e
    attempt_number_reached = num_attempts
    logger.error e if num_attempts.positive? && e
    logger.debug('Seller API retry', url: url, method: method, attempt: num_attempts, status: http_res&.code)
    raise unless num_attempts <= 5

    # For timeouts use the escalation retry recipe up to 5 times
    sleep(15 + (15 * num_attempts)) # 15 seconds then 15 seconds more for each attempt
    retry
  rescue HTTP::RateLimitExceededError => e
    # 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.
    attempt_number_reached = num_attempts
    logger.warn "Amazon SP-API 429 on attempt #{num_attempts} for #{url}. Bubbling up for ECL-based retry."
    raise
  end

  # Retryable.retryable(tries: 5, sleep: lambda { |n| 4**n }, on: Retryable::TIMEOUT_CLASSES) do |attempt_number, exception|
  #   logger.error exception if attempt_number.positive? && exception
  #   logger.info "Sending data: #{data} to #{url} using transporter method: #{method}, http_res: #{http_res.inspect}, attempt: #{attempt_number}, last exception: #{exception.inspect}"
  #   attempt_number_reached = attempt_number + 1
  #   http_res = http_obj.send(method.downcase.to_sym, url, **payload)
  #   retry_after = http_res.headers['Retry-After']&.to_i
  #   if http_res.status == 429 # Too Many Requests
  #     # Raise an exception to retry request with exponential backoff
  #     raise HTTP::RateLimitExceededError.new(
  #       status_code: 429,
  #       headers: http_res.headers,
  #       retry_after: retry_after,
  #       message: "429 returned with result headers: #{http_res.headers} on attempt #{attempt_number}, retry after #{retry_after}"
  #     )
  #   end
  # end

  { http_res: http_res, attempt_number_reached: attempt_number_reached }
end

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



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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
# File 'app/services/transport/http_seller_api_connection.rb', line 46

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

Returns:

  • (Boolean)


37
38
39
40
41
42
43
44
# File 'app/services/transport/http_seller_api_connection.rb', line 37

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.code.to_i
  res = true if (code >= 200 and code < 300) && errs.empty?
  res
end