peddler 4.1.1 → 4.3.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +116 -296
  3. data/lib/peddler/api.rb +3 -2
  4. data/lib/peddler/apis/amazon_warehousing_and_distribution_2024_05_09.rb +119 -2
  5. data/lib/peddler/apis/aplus_content_2020_11_01.rb +59 -49
  6. data/lib/peddler/apis/catalog_items_2022_04_01.rb +34 -33
  7. data/lib/peddler/apis/catalog_items_v0.rb +0 -62
  8. data/lib/peddler/apis/easy_ship_2022_03_23.rb +19 -18
  9. data/lib/peddler/apis/feeds_2021_06_30.rb +1 -1
  10. data/lib/peddler/apis/finances_2024_06_01.rb +53 -0
  11. data/lib/peddler/apis/finances_2024_06_19.rb +14 -17
  12. data/lib/peddler/apis/fulfillment_inbound_2024_03_20.rb +10 -5
  13. data/lib/peddler/apis/fulfillment_inbound_v0.rb +2 -170
  14. data/lib/peddler/apis/listings_items_2021_08_01.rb +77 -5
  15. data/lib/peddler/apis/messaging_v1.rb +50 -49
  16. data/lib/peddler/apis/orders_v0.rb +1 -1
  17. data/lib/peddler/apis/product_pricing_v0.rb +3 -2
  18. data/lib/peddler/apis/reports_2021_06_30.rb +1 -1
  19. data/lib/peddler/apis/seller_wallet_2024_03_01.rb +195 -0
  20. data/lib/peddler/apis/sellers_v1.rb +1 -1
  21. data/lib/peddler/apis/shipping_v1.rb +12 -12
  22. data/lib/peddler/apis/shipping_v2.rb +46 -20
  23. data/lib/peddler/apis/uploads_2020_11_01.rb +9 -8
  24. data/lib/peddler/apis/vehicles_2024_11_01.rb +40 -0
  25. data/lib/peddler/apis/vendor_direct_fulfillment_shipping_2021_12_28.rb +40 -27
  26. data/lib/peddler/apis/vendor_invoices_v1.rb +1 -1
  27. data/lib/peddler/apis/vendor_shipments_v1.rb +40 -1
  28. data/lib/peddler/error.rb +11 -1
  29. data/lib/peddler/parsers/openapi_parser_generator.rb +550 -0
  30. data/lib/peddler/parsers/smart_parser.rb +199 -0
  31. data/lib/peddler/token.rb +12 -12
  32. data/lib/peddler/version.rb +1 -1
  33. data/lib/peddler.rb +3 -0
  34. metadata +8 -8
  35. data/lib/peddler/json_feed_document.rb +0 -31
@@ -0,0 +1,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "bigdecimal"
5
+
6
+ module Peddler
7
+ module Parsers
8
+ # Check if Money gem is available for handling monetary values
9
+ begin
10
+ require "money"
11
+ MONEY_GEM_AVAILABLE = true
12
+
13
+ # Configure Money to avoid issues
14
+ Money.rounding_mode = BigDecimal::ROUND_HALF_UP
15
+
16
+ # Avoid locale issues by using currency backend
17
+ if Money.respond_to?(:locale_backend=)
18
+ Money.locale_backend = :currency
19
+ end
20
+ rescue LoadError
21
+ MONEY_GEM_AVAILABLE = false
22
+ end
23
+
24
+ # Use Structs to represent structured data in responses
25
+ DATA_TYPES = {
26
+ # Common Amazon data types that will be converted to structured objects
27
+ money: Struct.new(:currency, :amount, keyword_init: true),
28
+ dimension: Struct.new(:value, :unit, keyword_init: true),
29
+ address: Struct.new(
30
+ :name,
31
+ :address_line1,
32
+ :address_line2,
33
+ :city,
34
+ :state_or_region,
35
+ :postal_code,
36
+ :country_code,
37
+ :phone,
38
+ keyword_init: true,
39
+ ),
40
+ weight: Struct.new(:value, :unit, keyword_init: true),
41
+ daterange: Struct.new(:start, :end, keyword_init: true),
42
+ price: Struct.new(:currency_code, :amount, keyword_init: true),
43
+ }
44
+
45
+ # Generates response parsers based on Amazon's OpenAPI schemas
46
+ class OpenAPIParserGenerator
47
+ # OpenAPI Parser Generator error class
48
+ class Error < StandardError; end
49
+
50
+ # Error when schema cannot be parsed or is invalid
51
+ class InvalidSchemaError < Error; end
52
+
53
+ # Error when operation cannot be found
54
+ class OperationNotFoundError < Error; end
55
+
56
+ # Initialize the generator with an OpenAPI schema
57
+ #
58
+ # @param [String, Hash] schema The OpenAPI schema (file path or parsed hash)
59
+ # @raise [InvalidSchemaError] If the schema is invalid or cannot be parsed
60
+ def initialize(schema)
61
+ @schema = if schema.is_a?(String)
62
+ begin
63
+ JSON.parse(File.read(schema))
64
+ rescue JSON::ParserError => e
65
+ raise InvalidSchemaError, "Failed to parse schema JSON: #{e.message}"
66
+ rescue Errno::ENOENT => e
67
+ raise InvalidSchemaError, "Schema file not found: #{e.message}"
68
+ end
69
+ else
70
+ schema
71
+ end
72
+
73
+ # Basic validation of schema structure
74
+ unless @schema.is_a?(Hash) && @schema["paths"].is_a?(Hash)
75
+ raise InvalidSchemaError, "Invalid schema format: missing or invalid 'paths' property"
76
+ end
77
+ rescue NoMethodError => e
78
+ raise InvalidSchemaError, "Invalid schema structure: #{e.message}"
79
+ end
80
+
81
+ # Generate a parser class for a specific API operation
82
+ #
83
+ # @param [String] operation_id The OpenAPI operationId to generate a parser for
84
+ # @return [Class] A parser class configured for the operation's response schema
85
+ def generate_parser_for_operation(operation_id)
86
+ operation_schema = find_operation_schema(operation_id)
87
+ return unless operation_schema
88
+
89
+ # Use the actual operationId from the schema
90
+ actual_operation_id = operation_schema["operationId"]
91
+
92
+ response_schema = extract_response_schema(operation_schema)
93
+ return unless response_schema
94
+
95
+ create_parser_class(actual_operation_id, response_schema)
96
+ end
97
+
98
+ # Generate parsers for all operations in the schema
99
+ #
100
+ # @return [Hash<String, Class>] A map of operation IDs to parser classes
101
+ def generate_all_parsers
102
+ operations = find_all_operations
103
+ operations.each_with_object({}) do |(op_id, op_schema), parsers|
104
+ response_schema = extract_response_schema(op_schema)
105
+ next unless response_schema
106
+
107
+ parser_class = create_parser_class(op_id, response_schema)
108
+ parsers[op_id] = parser_class if parser_class
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # Find the schema for a specific operation
115
+ #
116
+ # @param [String] operation_id The operation ID to find
117
+ # @return [Hash, nil] The operation schema if found
118
+ def find_operation_schema(operation_id)
119
+ return unless @schema["paths"]
120
+
121
+ @schema["paths"].each_value do |path_item|
122
+ ["get", "post", "put", "delete", "patch"].each do |method|
123
+ next unless path_item[method]
124
+
125
+ # Amazon SP-API sometimes uses camelCase for operation IDs
126
+ op = path_item[method]
127
+ op_id = op["operationId"]
128
+
129
+ # Try exact match first
130
+ return op if op_id == operation_id
131
+
132
+ # Try case-insensitive match as fallback
133
+ return op if op_id.to_s.downcase == operation_id.to_s.downcase
134
+
135
+ # Try camelCase to snake_case conversion
136
+ snake_case_id = op_id.to_s.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
137
+ return op if snake_case_id == operation_id.to_s.downcase
138
+ end
139
+ end
140
+ nil
141
+ end
142
+
143
+ # Find all operations in the schema
144
+ #
145
+ # @return [Hash<String, Hash>] Map of operation IDs to operation schemas
146
+ def find_all_operations
147
+ result = {}
148
+ @schema["paths"].each_value do |path_item|
149
+ ["get", "post", "put", "delete", "patch"].each do |method|
150
+ next unless path_item[method]
151
+
152
+ operation = path_item[method]
153
+ result[operation["operationId"]] = operation if operation["operationId"]
154
+ end
155
+ end
156
+ result
157
+ end
158
+
159
+ # Extract the response schema from an operation
160
+ #
161
+ # @param [Hash] operation_schema The operation schema
162
+ # @return [Hash, nil] The response schema if found
163
+ def extract_response_schema(operation_schema)
164
+ return unless operation_schema["responses"]
165
+
166
+ # Try to get 200 response first, then any 2xx response
167
+ response = operation_schema["responses"]["200"] ||
168
+ operation_schema["responses"].find { |k, _| k.to_s.start_with?("2") }&.last
169
+
170
+ return unless response
171
+
172
+ # Amazon SP-API might have different formats
173
+ if response["content"] && response["content"]["application/json"]
174
+ # Standard OpenAPI 3.0 format
175
+ response["content"]["application/json"]["schema"]
176
+ elsif response["schema"]
177
+ # Some Amazon models use a direct schema property
178
+ response["schema"]
179
+ elsif response["$ref"]
180
+ # Some models use a reference
181
+ resolve_reference(response["$ref"])
182
+ end
183
+ end
184
+
185
+ # Resolve a JSON Schema reference
186
+ #
187
+ # @param [String] ref The reference string (e.g., "#/components/schemas/Order")
188
+ # @return [Hash, nil] The resolved schema or nil if not found
189
+ def resolve_reference(ref)
190
+ return unless ref.is_a?(String) && ref.start_with?("#/")
191
+
192
+ path = ref[2..-1].split("/")
193
+ current = @schema
194
+
195
+ path.each do |segment|
196
+ return nil unless current[segment]
197
+
198
+ current = current[segment]
199
+ end
200
+
201
+ current
202
+ end
203
+
204
+ # Create a parser class for a specific operation and schema
205
+ #
206
+ # @param [String] operation_id The operation ID
207
+ # @param [Hash] response_schema The response schema
208
+ # @return [Class] A parser class for the operation
209
+ def create_parser_class(operation_id, response_schema)
210
+ # Store a reference to the generator for resolving references
211
+ generator = self
212
+
213
+ Class.new do
214
+ # Store schema and operation ID as class methods
215
+ define_singleton_method(:schema) { response_schema }
216
+ define_singleton_method(:operation_id) { operation_id }
217
+
218
+ # Store a reference to the generator for resolving nested references
219
+ define_singleton_method(:generator) { generator }
220
+
221
+ # Initialize the parser
222
+ #
223
+ # @param [Hash] options Parser options
224
+ def initialize(options = {})
225
+ @options = options
226
+ @symbolize_keys = options.fetch(:symbolize_keys, false)
227
+ end
228
+
229
+ # Parse the HTTP response
230
+ #
231
+ # @param [HTTP::Response] response The HTTP response
232
+ # @return [Hash] The parsed response
233
+ def call(response)
234
+ @parsed_response = JSON.parse(response.body.to_s)
235
+ transform_response(self.class.schema, @parsed_response)
236
+ end
237
+
238
+ # Convert the response to a hash
239
+ #
240
+ # @return [Hash] The parsed response hash
241
+ def to_h
242
+ @parsed_response
243
+ end
244
+
245
+ private
246
+
247
+ # Transform the response based on the schema
248
+ #
249
+ # @param [Hash] schema The schema for the current part of the response
250
+ # @param [Hash, Array, Object] data The data to transform
251
+ # @return [Hash, Array, Object] The transformed data
252
+ def transform_response(schema, data)
253
+ case data
254
+ when Hash
255
+ transform_hash(schema, data)
256
+ when Array
257
+ transform_array(schema, data)
258
+ else
259
+ transform_primitive(schema, data)
260
+ end
261
+ end
262
+
263
+ # Transform a hash based on its schema
264
+ #
265
+ # @param [Hash] schema The schema for the hash
266
+ # @param [Hash] hash The hash to transform
267
+ # @return [Hash, Data] The transformed hash or Data object
268
+ def transform_hash(schema, hash)
269
+ # Resolve schema reference if present
270
+ if schema["$ref"] && self.class.generator
271
+ ref_schema = self.class.generator.send(:resolve_reference, schema["$ref"])
272
+ schema = ref_schema if ref_schema
273
+ end
274
+
275
+ # First, check if this hash represents a known type that should be converted to a Data object
276
+ data_type = determine_data_type(schema, hash)
277
+ return transform_to_data_object(data_type, hash) if data_type
278
+
279
+ # Otherwise, process as a regular hash
280
+ result = @symbolize_keys ? {} : hash.class.new
281
+
282
+ hash.each do |k, v|
283
+ key = @symbolize_keys ? k.to_sym : k
284
+
285
+ # Get property schema, possibly resolving references
286
+ property_schema = nil
287
+ if schema["properties"] && schema["properties"][k]
288
+ property_schema = schema["properties"][k]
289
+
290
+ # Resolve property schema reference if present
291
+ if property_schema["$ref"] && self.class.generator
292
+ ref_schema = self.class.generator.send(:resolve_reference, property_schema["$ref"])
293
+ property_schema = ref_schema if ref_schema
294
+ end
295
+ end
296
+
297
+ # Special handling for known Amazon types
298
+ transformed = if money_type?(property_schema)
299
+ transform_money(v)
300
+ elsif datetime_type?(property_schema, k)
301
+ transform_datetime(v)
302
+ elsif dimension_type?(property_schema)
303
+ transform_dimension(v)
304
+ elsif address_type?(property_schema)
305
+ transform_address(v)
306
+ else
307
+ transform_response(property_schema || {}, v)
308
+ end
309
+
310
+ result[key] = transformed
311
+ end
312
+
313
+ result
314
+ end
315
+
316
+ # Determine if a hash should be converted to a Data object
317
+ #
318
+ # @param [Hash] schema The schema for the hash
319
+ # @param [Hash] hash The hash to transform
320
+ # @return [Symbol, nil] The Data type to use, or nil if not a recognized type
321
+ def determine_data_type(schema, hash)
322
+ return :money if money_type?(schema) ||
323
+ (hash.key?("CurrencyCode") && hash.key?("Amount"))
324
+
325
+ return :dimension if dimension_type?(schema) ||
326
+ (hash.key?("Value") && hash.key?("Unit"))
327
+
328
+ return :address if address_type?(schema) ||
329
+ (hash.key?("City") && hash.key?("PostalCode") &&
330
+ hash.key?("CountryCode"))
331
+
332
+ return :weight if hash.key?("Value") && hash.key?("Unit") &&
333
+ schema.dig("properties", "Unit", "enum")&.include?("pounds")
334
+
335
+ return :daterange if hash.key?("Start") && hash.key?("End") &&
336
+ schema.dig("properties", "Start", "type") == "string" &&
337
+ schema.dig("properties", "End", "type") == "string"
338
+
339
+ nil
340
+ end
341
+
342
+ # Transform a hash to a structured object
343
+ #
344
+ # @param [Symbol] data_type The type of structured object to create
345
+ # @param [Hash] hash The hash to transform
346
+ # @return [Struct] The structured object
347
+ def transform_to_data_object(data_type, hash)
348
+ data_class = DATA_TYPES[data_type]
349
+ return transform_response({}, hash) unless data_class
350
+
351
+ case data_type
352
+ when :money
353
+ if defined?(Money) && MONEY_GEM_AVAILABLE
354
+ # Use Money gem for monetary values if available
355
+ amount_cents = (BigDecimal(hash["Amount"]) * 100).to_i
356
+ Money.new(amount_cents, hash["CurrencyCode"])
357
+ else
358
+ # Use Struct as fallback
359
+ data_class.new(
360
+ currency: hash["CurrencyCode"],
361
+ amount: BigDecimal(hash["Amount"]),
362
+ )
363
+ end
364
+ when :dimension, :weight
365
+ data_class.new(
366
+ value: numeric_value(hash["Value"]),
367
+ unit: hash["Unit"],
368
+ )
369
+ when :address
370
+ data_class.new(
371
+ name: hash["Name"],
372
+ address_line1: hash["AddressLine1"],
373
+ address_line2: hash["AddressLine2"],
374
+ city: hash["City"],
375
+ state_or_region: hash["StateOrRegion"],
376
+ postal_code: hash["PostalCode"],
377
+ country_code: hash["CountryCode"],
378
+ phone: hash["Phone"],
379
+ )
380
+ when :daterange
381
+ start_date = transform_datetime(hash["Start"])
382
+ end_date = transform_datetime(hash["End"])
383
+ data_class.new(
384
+ start: start_date,
385
+ end: end_date,
386
+ )
387
+ when :price
388
+ data_class.new(
389
+ currency_code: hash["CurrencyCode"],
390
+ amount: numeric_value(hash["Amount"]),
391
+ )
392
+ else
393
+ # If we don't recognize the type, just process normally
394
+ transform_response({}, hash)
395
+ end
396
+ end
397
+
398
+ # Helper to convert string values to numeric when appropriate
399
+ #
400
+ # @param [String] value The value to convert
401
+ # @return [Numeric] The converted numeric value
402
+ def numeric_value(value)
403
+ return value if value.is_a?(Numeric)
404
+
405
+ value.to_f
406
+ end
407
+
408
+ # Transform an array based on its schema
409
+ #
410
+ # @param [Hash] schema The schema for the array
411
+ # @param [Array] array The array to transform
412
+ # @return [Array] The transformed array
413
+ def transform_array(schema, array)
414
+ items_schema = schema["items"] || {}
415
+ array.map { |item| transform_response(items_schema, item) }
416
+ end
417
+
418
+ # Transform a primitive value based on its schema
419
+ #
420
+ # @param [Hash] schema The schema for the value
421
+ # @param [Object] value The value to transform
422
+ # @return [Object] The transformed value
423
+ def transform_primitive(schema, value)
424
+ case schema["type"]
425
+ when "number", "integer"
426
+ value.to_f
427
+ when "boolean"
428
+ value == true || value == "true"
429
+ else
430
+ value
431
+ end
432
+ end
433
+
434
+ # Check if a schema represents a money type
435
+ #
436
+ # @param [Hash] schema The schema to check
437
+ # @return [Boolean] True if the schema represents money
438
+ def money_type?(schema)
439
+ return false unless schema && schema["properties"]
440
+
441
+ schema["properties"]["CurrencyCode"] &&
442
+ schema["properties"]["Amount"]
443
+ end
444
+
445
+ # Transform a money object
446
+ #
447
+ # @param [Hash] money The money hash to transform
448
+ # @return [Money, Hash] Money object if gem available, otherwise a hash
449
+ def transform_money(money)
450
+ currency_code = money["CurrencyCode"]
451
+ amount_str = money["Amount"]
452
+
453
+ if defined?(Money) && MONEY_GEM_AVAILABLE
454
+ # Convert to cents (Money uses cents as its base unit)
455
+ amount_cents = (BigDecimal(amount_str) * 100).to_i
456
+
457
+ # Create Money object with the specified currency
458
+ Money.new(amount_cents, currency_code)
459
+ else
460
+ # Fallback when Money gem is not available
461
+ amount = BigDecimal(amount_str)
462
+ keys = @symbolize_keys ? [:currency, :amount] : ["currency", "amount"]
463
+ {
464
+ keys[0] => currency_code,
465
+ keys[1] => amount,
466
+ }
467
+ end
468
+ end
469
+
470
+ # Check if a schema represents a datetime
471
+ #
472
+ # @param [Hash] schema The schema to check
473
+ # @param [String] key The key name
474
+ # @return [Boolean] True if the schema represents a datetime
475
+ def datetime_type?(schema, key)
476
+ return false unless schema && schema["type"] == "string"
477
+
478
+ schema["format"] == "date-time" ||
479
+ key.end_with?("Date", "Time", "At")
480
+ end
481
+
482
+ # Transform a datetime string
483
+ #
484
+ # @param [String] datetime The datetime string
485
+ # @return [DateTime] The parsed datetime
486
+ def transform_datetime(datetime)
487
+ # Convert to DateTime for consistent API usage
488
+ # even though RuboCop prefers Time
489
+ DateTime.parse(datetime)
490
+ rescue
491
+ datetime
492
+ end
493
+
494
+ # Check if a schema represents a dimension
495
+ #
496
+ # @param [Hash] schema The schema to check
497
+ # @return [Boolean] True if the schema represents a dimension
498
+ def dimension_type?(schema)
499
+ return false unless schema && schema["properties"]
500
+
501
+ schema["properties"]["Value"] &&
502
+ schema["properties"]["Unit"]
503
+ end
504
+
505
+ # Check if a schema represents an address
506
+ #
507
+ # @param [Hash] schema The schema to check
508
+ # @return [Boolean] True if the schema represents an address
509
+ def address_type?(schema)
510
+ return false unless schema && schema["properties"]
511
+
512
+ # Check for common address fields
513
+ address_fields = ["City", "PostalCode", "CountryCode"]
514
+ address_fields.all? { |field| schema["properties"][field] }
515
+ end
516
+
517
+ # Transform a dimension object
518
+ #
519
+ # @param [Hash] dimension The dimension hash to transform
520
+ # @return [Hash] The transformed dimension object
521
+ def transform_dimension(dimension)
522
+ keys = @symbolize_keys ? [:value, :unit] : ["value", "unit"]
523
+ {
524
+ keys[0] => dimension["Value"].to_f,
525
+ keys[1] => dimension["Unit"],
526
+ }
527
+ end
528
+
529
+ # Transform an address object
530
+ #
531
+ # @param [Hash] address The address hash to transform
532
+ # @return [Struct] A Struct representing the address
533
+ def transform_address(address)
534
+ # Create an Address Struct
535
+ DATA_TYPES[:address].new(
536
+ name: address["Name"],
537
+ address_line1: address["AddressLine1"],
538
+ address_line2: address["AddressLine2"],
539
+ city: address["City"],
540
+ state_or_region: address["StateOrRegion"],
541
+ postal_code: address["PostalCode"],
542
+ country_code: address["CountryCode"],
543
+ phone: address["Phone"],
544
+ )
545
+ end
546
+ end
547
+ end
548
+ end
549
+ end
550
+ end