Class: Transport::HttpWalmartSellerApiConnection

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

Constant Summary collapse

VALID_HTTP_METHODS =
%w[get put post patch delete head]

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ HttpWalmartSellerApiConnection

Returns a new instance of HttpWalmartSellerApiConnection.



14
15
16
17
18
19
20
21
22
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 14

def initialize(options = {})
  @options = options
  if options[:profile_data]
    @profile = options[:profile_data]
  elsif (profile = @options[:profile])
    @profile = Heatwave::Configuration.fetch(profile&.to_sym)
  end
  @logger = options[:logger] || Rails.logger
end

Instance Attribute Details

#loggerObject (readonly)

Returns the value of attribute logger.



12
13
14
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 12

def logger
  @logger
end

#profileObject (readonly)

Returns the value of attribute profile.



12
13
14
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 12

def profile
  @profile
end

Instance Method Details

#fetch_access_tokenObject

"access_token":"eyJraWQiOiJkNGRhYTc1Ni1hYWY5LTQ4MjEtYWMwMy05NDQ3YWQ3MGM2MjIiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiZGlyIn0..YmwFNA2vAdV9gUkp.pTkEq87kHTtVEV62S3zmOuIpFeP9mTdhIVzp_LIjHgSm329sRIVjUqWObgpfhV5_HCyPdPc0eJjuPvRufMy0MkNqTZWKVq6wSnbl2kCEzARWsnNHLhLiIOygLmNnE92CQcagvCDOn2YKoLsmrmmVZNZ5qQabo2igNBu2mBpXA56oaYuGUsooqtqtddxu4n8ul8HNxheEcqHsyvm9DoVK8Tx8GY7BDzz2LqXPVKBWs1eYo1WbQNLQ9n3V3ZhFxT-OqR78RR03TS8B9wUmnTjPzpvJfKI-ccMFyDUDnkRTIS-Xm57uqCLEXxrW0X8IisWrvCiJQSegsUYZPwkcUJtulD_aytrFebo5Y59CN5xWgwKxGBRGVBTOsSav4ZWH_cR79OHGM59uivRMxHyD4oZFJtxN5q2pd_9MJFzASpkWmMMpwdjZXH8irws8nCFz5lKvtDbjppPO6l5JYzw1UyS1qpvDT-14FHXXtH7FXYTRc9iWzv1n-w3DMXO585h5X4nqsIanjH-z47n0zujkpAWmEHJml18bF1RQIPcVUCN_c5zvuXVR7ErtPFI4KBG0axh3q_rVKtKwvrvfmZ0uEEnuK5R30-fR3tvr9z7MEBZCDgDAI6mVspypcB_U95-mz0UJ_DWFQZnHKx8nqxbSOIVFoYebfE77g24iAZCkZUSK6iJGPQ5AjtyTn2eB9Rvn.YGvpfqpjJtEQ2AprW34Dqg","token_type":"Bearer","expires_in":900



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 214

def fetch_access_token
  auth_payload = 'grant_type=client_credentials'
  headers = {
    'accept' => 'application/json',
    'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(profile[:client_id], profile[:client_secret]),
    'Content-Type' => 'application/x-www-form-urlencoded',
    'WM_QOS.CORRELATION_ID' => SecureRandom.uuid,
    'WM_SVC.NAME' => 'Walmart Marketplace'
  }
  # Add WM_MARKET header for non-US markets (e.g., 'ca' for Canada)
  # See: https://developer.walmart.com/ca-marketplace/docs/authentication
  headers['WM_MARKET'] = profile[:market] if profile[:market].present?
  # Add WM_CONSUMER.CHANNEL.TYPE header (required for Canada)
  headers['WM_CONSUMER.CHANNEL.TYPE'] = profile[:channel_type] if profile[:channel_type].present?

  res = send_request('POST', @profile[:auth_url], auth_payload, headers)
  response = res[:http_res]
  JSON.parse(response.to_s)['access_token']
end

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



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 99

def send_authenticated_request(token, method, data, url, headers = {})
  # See: https://developer.walmart.com/us-marketplace/reference/tokenapi
  ac = headers.dig('accept') || 'application/json' # allow accept in headers to prevail
  ct = headers.dig('Content-Type') || 'application/json' # allow Content-Type in headers to prevail
  headers_for_request = headers.merge({
    'WM_SEC.ACCESS_TOKEN' => token,
    'WM_QOS.CORRELATION_ID' => SecureRandom.uuid,
    'WM_SVC.NAME' => 'Walmart Marketplace',
    'accept' => ac,
    'Content-Type' => ct
  })
  # Add WM_MARKET header for non-US markets (e.g., 'ca' for Canada)
  # See: https://developer.walmart.com/ca-marketplace/docs/authentication
  headers_for_request['WM_MARKET'] = profile[:market] if profile[:market].present?
  # Add WM_CONSUMER.CHANNEL.TYPE header (required for Canada)
  headers_for_request['WM_CONSUMER.CHANNEL.TYPE'] = profile[:channel_type] if profile[:channel_type].present?

  send_request(method, url, data, headers_for_request)
end

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



24
25
26
27
28
29
30
31
32
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 24

def send_data(data, url, method, headers = {})
  t = fetch_access_token
  parsed_uri = URI.parse(url)
  res = send_authenticated_request(t, method, data, url, headers)
  http_res = res[:http_res]
  attempt_number_reached = res[:attempt_number_reached]
  logger.debug { "Walmart 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_feed_data(data, url, method = 'POST', headers = {}) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 34

def send_feed_data(data, url, method = 'POST', headers = {})
  t = fetch_access_token
  parsed_uri = URI.parse(url)
  headers_for_feed_file_post = headers.merge({
    'accept' => 'application/json',
    'Content-Type' => 'multipart/form-data'
  })
  # Build JSON file to send
  filename = "walmart_feed_#{Time.now.to_i}.json"
  # Create a temporary file with the JSON data
  tempfile = Tempfile.new(filename)
  begin
    tempfile.write(data)
    tempfile.rewind
    tempfile.flush
    tempfile.fsync

    # Pass the tempfile object directly, not tempfile.read
    file = HTTP::FormData::File.new(tempfile.path, filename: filename)
    feed_data = { file: file }

    res = send_authenticated_request(t, method, feed_data, url, headers_for_feed_file_post)
    http_res = res[:http_res]
    attempt_number_reached = res[:attempt_number_reached]
    logger.debug { "Walmart 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 }
  ensure
    tempfile.close
    tempfile.unlink # Clean up the temporary file
  end
end

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



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
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 119

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 = {}

  # Handle different data types
  if data.is_a?(Hash) && data.key?(:file)
    # This is multipart form data - use :form parameter
    payload[:form] = data
  elsif data.present?
    # This is regular data - use :body parameter
    payload[:body] = data
  end

  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 { "Walmart 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
    # We get 429 throttling over our whole Amazon SP-API channel regardless of merchant ID, probably per SP-API profile/credential. Combine that with daily feeds every 24 hours, but also currently supporting two feeds per SP-API profile: Amazon Seller US and Amazon Seller Canada and we have a recipe for hitting that throttling. NOTE: the ECL transmit_after mechanism only works because most EDI partners are on an hourly execute_..._flow. So, here, unless we wait at least 5 min before retry of sending the feed, we will hit that throttle. This is a quick and dirty way to avoid 429s pending a proper refactor to get finely controlled and robust scheduling of EDI messages. We will need that for when we have all of Eurpoe on a single SP-API profile/credential
    attempt_number_reached = num_attempts
    logger.error e if num_attempts.positive? && e
    logger.debug { "Walmart Seller API retry url=#{url} method=#{method} attempt=#{num_attempts} status=#{http_res&.code}" }
    raise unless num_attempts <= 5

    sleep(15 + (15 * num_attempts)) # 15 seconds then 15 seconds more for each attempt
    retry
  end

  { http_res: http_res, attempt_number_reached: attempt_number_reached }
end

#successful?(http_res) ⇒ Boolean

def send_feed_file(data, remote_filepath, filename, options = {})
http_obj = HTTP
@headers.each do |k, v|
http_obj = http_obj.headers(k.to_s.upcase.dasherize => v) # here the header key goes from say "x_houzz_api_ssl_token" to "X-HOUZZ-API-SSL-TOKEN"
end

Build XML file to send

io = StringIO.new(data)
file = HTTP::FormData::File.new(io, filename:)
options[:file] = file
http_res = http_obj.post(remote_filepath, form: options)
logger.info "Sending data: #data to #remote_filepath using transporter method: post, http_res: #Transport::HttpWalmartSellerApiConnection.http_reshttp_res.inspect"
{ success: successful?(http_res), http_result: http_res }
end

Returns:

  • (Boolean)


80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'app/services/transport/http_walmart_seller_api_connection.rb', line 80

def successful?(http_res)
  res = false
  errs = []
  # Only try to parse JSON if Content-Type indicates JSON
  # For binary responses (PDF, images, etc.), check HTTP status code only
  content_type = http_res.headers['Content-Type']&.to_s || ''
  if content_type.include?('application/json') && http_res.body.present?
    begin
      errs = JSON.parse(http_res.body.to_s).with_indifferent_access[:errors] || []
    rescue JSON::ParserError
      # If JSON parsing fails, treat as non-JSON response (e.g., PDF)
      errs = []
    end
  end
  code = http_res.code.to_i
  res = true if (code >= 200 and code < 300) && errs.empty?
  res
end