shopsavvy-sdk 1.0.1 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6179b544c407fb648b93d79b8a0b62e2aacb3bbfbebd7f3b741887065f06cd03
4
- data.tar.gz: 8802bfeb393b61ad00d75e139649150cf1b7ed9be50b36b443dd3951c97fd11d
3
+ metadata.gz: d423e461903d6f1c9627b3b055a19e3e4ebc25a7145c6a0f6b117067fdf231a6
4
+ data.tar.gz: 939995fa50338ba615d54e71f87fa75d44023faf4ec2057c5fef93f5a638d403
5
5
  SHA512:
6
- metadata.gz: 92ebba528d0ba098c7fa731b8fa854cb9945503eb2e9f096d5258c4be33de8dee334d52be21d04a4ba84a06ecf144e6acc05557323a49a91dcff087f7ab3b8aa
7
- data.tar.gz: 7c6104eb59e8112921087076bf4794d63e8fc3d0d7ccb29be61c26e53bd2be20d4735aa461693f31fb4016f57445859a35a9e2cf3cbe5c9683276dabdab5439c
6
+ metadata.gz: 16e15e94cdee0d79b3ee20d2ecdf1a86c61cde8c4e2652b3171008fd415be2246611f0478c2dedcde1ed3d78c0e38a859122bd2667f36cde864f6f549a331cbd
7
+ data.tar.gz: ab8c59540a85e4e412659c4c64dc81f1812f8b339bcfcc6b69211fa39a33cc3dbeded890a72c9717f60fc1d053ae51c12a746722e51fc428bd013ffb63e122c5
data/README.md CHANGED
@@ -20,19 +20,6 @@ product = client.get_product_details('012345678901')
20
20
  puts "#{product.data.name} - Best price: $#{client.get_current_offers('012345678901').data.min_by(&:price).price}"
21
21
  ```
22
22
 
23
- ## 📊 Feature Comparison
24
-
25
- | Feature | Free Tier | Pro | Enterprise |
26
- |---------|-----------|-----|-----------|
27
- | **API Calls/Month** | 1,000 | 100,000 | Unlimited |
28
- | **Product Details** | ✅ | ✅ | ✅ |
29
- | **Real-time Pricing** | ✅ | ✅ | ✅ |
30
- | **Price History** | 30 days | 1 year | 5+ years |
31
- | **Bulk Operations** | 10/batch | 100/batch | 1000/batch |
32
- | **Retailer Coverage** | 50+ | 500+ | 1000+ |
33
- | **Rate Limiting** | 60/hour | 1000/hour | Custom |
34
- | **Support** | Community | Email | Phone + Dedicated |
35
-
36
23
  ## 🚀 Installation & Setup
37
24
 
38
25
  ### Installation
@@ -13,7 +13,7 @@ module ShopsavvyDataApi
13
13
  # @example Basic usage
14
14
  # client = ShopsavvyDataApi::Client.new(api_key: "ss_live_your_api_key_here")
15
15
  # product = client.get_product_details("012345678901")
16
- # puts product.data.name
16
+ # puts product.data[0].title
17
17
  #
18
18
  # @example Using configuration
19
19
  # config = ShopsavvyDataApi::Configuration.new(
@@ -48,20 +48,39 @@ module ShopsavvyDataApi
48
48
  @connection = build_connection
49
49
  end
50
50
 
51
+ # Search for products by keyword
52
+ #
53
+ # @param query [String] Search query or keyword (e.g., "iphone 15 pro", "samsung tv")
54
+ # @param limit [Integer] Maximum number of results (default: 20)
55
+ # @param offset [Integer] Pagination offset (default: 0)
56
+ # @return [ProductSearchResult] Search results with pagination info
57
+ #
58
+ # @example
59
+ # results = client.search_products("iphone 15 pro", limit: 10)
60
+ # results.data.each { |product| puts product.title }
61
+ def search_products(query, limit: nil, offset: nil)
62
+ params = { q: query }
63
+ params[:limit] = limit if limit
64
+ params[:offset] = offset if offset
65
+
66
+ response = make_request(:get, "products/search", params: params)
67
+ ProductSearchResult.new(response)
68
+ end
69
+
51
70
  # Look up product details by identifier
52
71
  #
53
72
  # @param identifier [String] Product identifier (barcode, ASIN, URL, model number, or ShopSavvy product ID)
54
73
  # @param format [String, nil] Response format ('json' or 'csv')
55
- # @return [APIResponse<ProductDetails>] Product details
74
+ # @return [APIResponse<Array<ProductDetails>>] Product details (as array, even for single identifier)
56
75
  #
57
76
  # @example
58
77
  # product = client.get_product_details("012345678901")
59
- # puts product.data.name
78
+ # puts product.data[0].title
60
79
  def get_product_details(identifier, format: nil)
61
- params = { identifier: identifier }
80
+ params = { ids: identifier }
62
81
  params[:format] = format if format
63
82
 
64
- response = make_request(:get, "/products/details", params: params)
83
+ response = make_request(:get, "products", params: params)
65
84
  APIResponse.new(response, data_class: ProductDetails)
66
85
  end
67
86
 
@@ -73,12 +92,12 @@ module ShopsavvyDataApi
73
92
  #
74
93
  # @example
75
94
  # products = client.get_product_details_batch(["012345678901", "B08N5WRWNW"])
76
- # products.data.each { |product| puts product.name }
95
+ # products.data.each { |product| puts product.title }
77
96
  def get_product_details_batch(identifiers, format: nil)
78
- params = { identifiers: identifiers.join(",") }
97
+ params = { ids: identifiers.join(",") }
79
98
  params[:format] = format if format
80
99
 
81
- response = make_request(:get, "/products/details", params: params)
100
+ response = make_request(:get, "products", params: params)
82
101
  APIResponse.new(response, data_class: ProductDetails)
83
102
  end
84
103
 
@@ -87,18 +106,21 @@ module ShopsavvyDataApi
87
106
  # @param identifier [String] Product identifier
88
107
  # @param retailer [String, nil] Optional retailer to filter by
89
108
  # @param format [String, nil] Response format ('json' or 'csv')
90
- # @return [APIResponse<Array<Offer>>] Current offers
109
+ # @return [APIResponse<Array<ProductWithOffers>>] Products with their offers
91
110
  #
92
111
  # @example
93
- # offers = client.get_current_offers("012345678901")
94
- # offers.data.each { |offer| puts "#{offer.retailer}: $#{offer.price}" }
112
+ # result = client.get_current_offers("012345678901")
113
+ # result.data.each do |product|
114
+ # puts "Product: #{product.title}"
115
+ # product.offers.each { |offer| puts " #{offer.retailer}: $#{offer.price}" }
116
+ # end
95
117
  def get_current_offers(identifier, retailer: nil, format: nil)
96
- params = { identifier: identifier }
118
+ params = { ids: identifier }
97
119
  params[:retailer] = retailer if retailer
98
120
  params[:format] = format if format
99
121
 
100
- response = make_request(:get, "/products/offers", params: params)
101
- APIResponse.new(response, data_class: Offer)
122
+ response = make_request(:get, "products/offers", params: params)
123
+ APIResponse.new(response, data_class: ProductWithOffers)
102
124
  end
103
125
 
104
126
  # Get current offers for multiple products
@@ -106,14 +128,14 @@ module ShopsavvyDataApi
106
128
  # @param identifiers [Array<String>] Array of product identifiers
107
129
  # @param retailer [String, nil] Optional retailer to filter by
108
130
  # @param format [String, nil] Response format ('json' or 'csv')
109
- # @return [APIResponse<Hash<String, Array<Offer>>>] Hash mapping identifiers to their offers
131
+ # @return [APIResponse<Array<ProductWithOffers>>] Products with their offers
110
132
  def get_current_offers_batch(identifiers, retailer: nil, format: nil)
111
- params = { identifiers: identifiers.join(",") }
133
+ params = { ids: identifiers.join(",") }
112
134
  params[:retailer] = retailer if retailer
113
135
  params[:format] = format if format
114
136
 
115
- response = make_request(:get, "/products/offers", params: params)
116
- APIResponse.new(response, data_class: Offer)
137
+ response = make_request(:get, "products/offers", params: params)
138
+ APIResponse.new(response, data_class: ProductWithOffers)
117
139
  end
118
140
 
119
141
  # Get price history for a product
@@ -132,14 +154,14 @@ module ShopsavvyDataApi
132
154
  # end
133
155
  def get_price_history(identifier, start_date, end_date, retailer: nil, format: nil)
134
156
  params = {
135
- identifier: identifier,
157
+ ids: identifier,
136
158
  start_date: start_date,
137
159
  end_date: end_date
138
160
  }
139
161
  params[:retailer] = retailer if retailer
140
162
  params[:format] = format if format
141
163
 
142
- response = make_request(:get, "/products/history", params: params)
164
+ response = make_request(:get, "products/offers/history", params: params)
143
165
  APIResponse.new(response, data_class: OfferWithHistory)
144
166
  end
145
167
 
@@ -160,7 +182,7 @@ module ShopsavvyDataApi
160
182
  }
161
183
  body[:retailer] = retailer if retailer
162
184
 
163
- response = make_request(:post, "/products/schedule", body: body)
185
+ response = make_request(:post, "products/schedule", body: body)
164
186
  APIResponse.new(response)
165
187
  end
166
188
 
@@ -177,7 +199,7 @@ module ShopsavvyDataApi
177
199
  }
178
200
  body[:retailer] = retailer if retailer
179
201
 
180
- response = make_request(:post, "/products/schedule", body: body)
202
+ response = make_request(:post, "products/schedule", body: body)
181
203
  APIResponse.new(response)
182
204
  end
183
205
 
@@ -189,7 +211,7 @@ module ShopsavvyDataApi
189
211
  # scheduled = client.get_scheduled_products
190
212
  # puts "Monitoring #{scheduled.data.length} products"
191
213
  def get_scheduled_products
192
- response = make_request(:get, "/products/scheduled")
214
+ response = make_request(:get, "products/scheduled")
193
215
  APIResponse.new(response, data_class: ScheduledProduct)
194
216
  end
195
217
 
@@ -204,7 +226,7 @@ module ShopsavvyDataApi
204
226
  def remove_product_from_schedule(identifier)
205
227
  body = { identifier: identifier }
206
228
 
207
- response = make_request(:delete, "/products/schedule", body: body)
229
+ response = make_request(:delete, "products/schedule", body: body)
208
230
  APIResponse.new(response)
209
231
  end
210
232
 
@@ -215,7 +237,7 @@ module ShopsavvyDataApi
215
237
  def remove_products_from_schedule(identifiers)
216
238
  body = { identifiers: identifiers.join(",") }
217
239
 
218
- response = make_request(:delete, "/products/schedule", body: body)
240
+ response = make_request(:delete, "products/schedule", body: body)
219
241
  APIResponse.new(response)
220
242
  end
221
243
 
@@ -227,10 +249,58 @@ module ShopsavvyDataApi
227
249
  # usage = client.get_usage
228
250
  # puts "Credits remaining: #{usage.data.credits_remaining}"
229
251
  def get_usage
230
- response = make_request(:get, "/usage")
252
+ response = make_request(:get, "usage")
231
253
  APIResponse.new(response, data_class: UsageInfo)
232
254
  end
233
255
 
256
+ # Browse current shopping deals
257
+ # @param sort [String] Sort algorithm: hot, new, top-hour, top-day, top-week
258
+ # @param limit [Integer] Results per page (1-100)
259
+ # @param offset [Integer] Pagination offset
260
+ # @param options [Hash] Additional filters (category, retailer, tag, min_price, max_price, grade)
261
+ # @return [Hash] Deals response with deals array and pagination
262
+ def get_deals(sort: "hot", limit: 25, offset: 0, **options)
263
+ params = { sort: sort, limit: limit, offset: offset }.merge(options).compact
264
+ make_request(:get, "deals", params: params)
265
+ end
266
+
267
+ # Get TLDR review for a product (pros, cons, scores)
268
+ # @param identifier [String] Product identifier (barcode, ASIN, URL, model number)
269
+ # @return [Hash] Review response with review data or null
270
+ def get_product_review(identifier)
271
+ make_request(:get, "products/reviews", params: { id: identifier })
272
+ end
273
+
274
+ # Look up multiple products at once (sync for <=20, async for >20)
275
+ # @param identifiers [Array<String>] Product identifiers (max 100)
276
+ # @param include [Array<String>] Optional extras: ["offers"], ["reviews"]
277
+ def batch_lookup(identifiers, include: nil)
278
+ body = { identifiers: identifiers }
279
+ body[:include] = include if include
280
+ make_request(:post, "products/batch", body: body)
281
+ end
282
+
283
+ # Poll for async batch job results
284
+ def get_batch_status(batch_id)
285
+ make_request(:get, "batch/#{batch_id}")
286
+ end
287
+
288
+ def create_webhook(url, events:)
289
+ make_request(:post, "webhooks", body: { url: url, events: events })
290
+ end
291
+
292
+ def list_webhooks
293
+ make_request(:get, "webhooks")
294
+ end
295
+
296
+ def test_webhook(webhook_id)
297
+ make_request(:post, "webhooks/#{webhook_id}/test")
298
+ end
299
+
300
+ def delete_webhook(webhook_id)
301
+ make_request(:delete, "webhooks/#{webhook_id}")
302
+ end
303
+
234
304
  private
235
305
 
236
306
  def build_connection
@@ -307,4 +377,4 @@ module ShopsavvyDataApi
307
377
  end
308
378
  end
309
379
  end
310
- end
380
+ end
@@ -24,70 +24,149 @@ module ShopsavvyDataApi
24
24
  end
25
25
  end
26
26
 
27
+ # API response metadata containing credit usage info
28
+ class APIMeta
29
+ attr_reader :credits_used, :credits_remaining, :rate_limit_remaining
30
+
31
+ def initialize(data)
32
+ @credits_used = data["credits_used"].to_i
33
+ @credits_remaining = data["credits_remaining"].to_i
34
+ @rate_limit_remaining = data["rate_limit_remaining"]&.to_i
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ credits_used: credits_used,
40
+ credits_remaining: credits_remaining,
41
+ rate_limit_remaining: rate_limit_remaining
42
+ }
43
+ end
44
+ end
45
+
27
46
  # Product details from ShopSavvy API
28
47
  class ProductDetails
29
- attr_reader :product_id, :name, :brand, :category, :image_url, :barcode,
30
- :asin, :model, :mpn, :description, :identifiers
48
+ attr_reader :title, :shopsavvy, :brand, :category, :images, :barcode,
49
+ :amazon, :model, :mpn, :color,
50
+ :title_short, :slug, :description, :categories, :attributes,
51
+ :rating, :score, :keywords, :identifiers
31
52
 
32
53
  def initialize(data)
33
- @product_id = data["product_id"]
34
- @name = data["name"]
54
+ @title = data["title"]
55
+ @shopsavvy = data["shopsavvy"]
35
56
  @brand = data["brand"]
36
57
  @category = data["category"]
37
- @image_url = data["image_url"]
58
+ @images = data["images"] || []
38
59
  @barcode = data["barcode"]
39
- @asin = data["asin"]
60
+ @amazon = data["amazon"]
40
61
  @model = data["model"]
41
62
  @mpn = data["mpn"]
63
+ @color = data["color"]
64
+ @title_short = data["title_short"]
65
+ @slug = data["slug"]
42
66
  @description = data["description"]
43
- @identifiers = data["identifiers"] || {}
67
+ @categories = data["categories"]
68
+ @attributes = data["attributes"]
69
+ @rating = data["rating"]
70
+ @score = data["score"]
71
+ @keywords = data["keywords"]
72
+ @identifiers = data["identifiers"]
73
+ end
74
+
75
+ # @deprecated Use `title` instead
76
+ def name
77
+ title
78
+ end
79
+
80
+ # @deprecated Use `shopsavvy` instead
81
+ def product_id
82
+ shopsavvy
83
+ end
84
+
85
+ # @deprecated Use `amazon` instead
86
+ def asin
87
+ amazon
88
+ end
89
+
90
+ # @deprecated Use `images[0]` instead
91
+ def image_url
92
+ images&.first
44
93
  end
45
94
 
46
95
  def to_h
47
96
  {
48
- product_id: product_id,
49
- name: name,
97
+ title: title,
98
+ shopsavvy: shopsavvy,
50
99
  brand: brand,
51
100
  category: category,
52
- image_url: image_url,
101
+ images: images,
53
102
  barcode: barcode,
54
- asin: asin,
103
+ amazon: amazon,
55
104
  model: model,
56
105
  mpn: mpn,
57
- description: description,
58
- identifiers: identifiers
106
+ color: color
59
107
  }
60
108
  end
61
109
  end
62
110
 
111
+ # Product with nested offers (returned by offers endpoint)
112
+ class ProductWithOffers < ProductDetails
113
+ attr_reader :offers
114
+
115
+ def initialize(data)
116
+ super(data)
117
+ @offers = (data["offers"] || []).map { |offer| Offer.new(offer) }
118
+ end
119
+
120
+ def to_h
121
+ super.merge(offers: offers.map(&:to_h))
122
+ end
123
+ end
124
+
63
125
  # Product offer from a retailer
64
126
  class Offer
65
- attr_reader :offer_id, :retailer, :price, :currency, :availability,
66
- :condition, :url, :shipping, :last_updated
127
+ attr_reader :id, :retailer, :price, :currency, :availability,
128
+ :condition, :URL, :seller, :timestamp, :history
67
129
 
68
130
  def initialize(data)
69
- @offer_id = data["offer_id"]
131
+ @id = data["id"]
70
132
  @retailer = data["retailer"]
71
- @price = data["price"].to_f
133
+ @price = data["price"]&.to_f
72
134
  @currency = data["currency"] || "USD"
73
135
  @availability = data["availability"]
74
136
  @condition = data["condition"]
75
- @url = data["url"]
76
- @shipping = data["shipping"]&.to_f
77
- @last_updated = data["last_updated"]
137
+ @URL = data["URL"]
138
+ @seller = data["seller"]
139
+ @timestamp = data["timestamp"]
140
+ @history = (data["history"] || []).map { |entry| PriceHistoryEntry.new(entry) }
141
+ end
142
+
143
+ # @deprecated Use `id` instead
144
+ def offer_id
145
+ id
146
+ end
147
+
148
+ # @deprecated Use `URL` instead
149
+ def url
150
+ URL
151
+ end
152
+
153
+ # @deprecated Use `timestamp` instead
154
+ def last_updated
155
+ timestamp
78
156
  end
79
157
 
80
158
  def to_h
81
159
  {
82
- offer_id: offer_id,
160
+ id: id,
83
161
  retailer: retailer,
84
162
  price: price,
85
163
  currency: currency,
86
164
  availability: availability,
87
165
  condition: condition,
88
- url: url,
89
- shipping: shipping,
90
- last_updated: last_updated
166
+ URL: URL,
167
+ seller: seller,
168
+ timestamp: timestamp,
169
+ history: history.map(&:to_h)
91
170
  }
92
171
  end
93
172
 
@@ -170,7 +249,7 @@ module ShopsavvyDataApi
170
249
 
171
250
  # Scheduled product monitoring information
172
251
  class ScheduledProduct
173
- attr_reader :product_id, :identifier, :frequency, :retailer,
252
+ attr_reader :product_id, :identifier, :frequency, :retailer,
174
253
  :created_at, :last_refreshed
175
254
 
176
255
  def initialize(data)
@@ -206,35 +285,77 @@ module ShopsavvyDataApi
206
285
  end
207
286
  end
208
287
 
209
- # API usage information
210
- class UsageInfo
211
- attr_reader :credits_used, :credits_remaining, :credits_total,
212
- :billing_period_start, :billing_period_end, :plan_name
288
+ # Current billing period details
289
+ class UsagePeriod
290
+ attr_reader :start_date, :end_date, :credits_used, :credits_limit,
291
+ :credits_remaining, :requests_made
213
292
 
214
293
  def initialize(data)
294
+ @start_date = data["start_date"]
295
+ @end_date = data["end_date"]
215
296
  @credits_used = data["credits_used"].to_i
297
+ @credits_limit = data["credits_limit"].to_i
216
298
  @credits_remaining = data["credits_remaining"].to_i
217
- @credits_total = data["credits_total"].to_i
218
- @billing_period_start = data["billing_period_start"]
219
- @billing_period_end = data["billing_period_end"]
220
- @plan_name = data["plan_name"]
299
+ @requests_made = data["requests_made"].to_i
221
300
  end
222
301
 
223
302
  def to_h
224
303
  {
304
+ start_date: start_date,
305
+ end_date: end_date,
225
306
  credits_used: credits_used,
307
+ credits_limit: credits_limit,
226
308
  credits_remaining: credits_remaining,
227
- credits_total: credits_total,
228
- billing_period_start: billing_period_start,
229
- billing_period_end: billing_period_end,
230
- plan_name: plan_name
309
+ requests_made: requests_made
310
+ }
311
+ end
312
+ end
313
+
314
+ # API usage information
315
+ class UsageInfo
316
+ attr_reader :current_period, :usage_percentage
317
+
318
+ def initialize(data)
319
+ @current_period = UsagePeriod.new(data["current_period"] || {})
320
+ @usage_percentage = data["usage_percentage"]&.to_f || 0
321
+ end
322
+
323
+ # @deprecated Use `current_period.credits_used` instead
324
+ def credits_used
325
+ current_period.credits_used
326
+ end
327
+
328
+ # @deprecated Use `current_period.credits_remaining` instead
329
+ def credits_remaining
330
+ current_period.credits_remaining
331
+ end
332
+
333
+ # @deprecated Use `current_period.credits_limit` instead
334
+ def credits_total
335
+ current_period.credits_limit
336
+ end
337
+
338
+ # @deprecated Use `current_period.start_date` instead
339
+ def billing_period_start
340
+ current_period.start_date
341
+ end
342
+
343
+ # @deprecated Use `current_period.end_date` instead
344
+ def billing_period_end
345
+ current_period.end_date
346
+ end
347
+
348
+ def to_h
349
+ {
350
+ current_period: current_period.to_h,
351
+ usage_percentage: usage_percentage
231
352
  }
232
353
  end
233
354
 
234
355
  def credits_percentage_used
235
- return 0 if credits_total.zero?
356
+ return 0 if current_period.credits_limit.zero?
236
357
 
237
- (credits_used.to_f / credits_total * 100).round(2)
358
+ (current_period.credits_used.to_f / current_period.credits_limit * 100).round(2)
238
359
  end
239
360
 
240
361
  def credits_percentage_remaining
@@ -242,15 +363,35 @@ module ShopsavvyDataApi
242
363
  end
243
364
  end
244
365
 
366
+ # Pagination info for search results
367
+ class PaginationInfo
368
+ attr_reader :total, :limit, :offset, :returned
369
+
370
+ def initialize(data)
371
+ @total = data["total"].to_i
372
+ @limit = data["limit"].to_i
373
+ @offset = data["offset"].to_i
374
+ @returned = data["returned"].to_i
375
+ end
376
+
377
+ def to_h
378
+ {
379
+ total: total,
380
+ limit: limit,
381
+ offset: offset,
382
+ returned: returned
383
+ }
384
+ end
385
+ end
386
+
245
387
  # Standard API response wrapper
246
388
  class APIResponse
247
- attr_reader :success, :data, :message, :credits_used, :credits_remaining
389
+ attr_reader :success, :data, :message, :meta
248
390
 
249
391
  def initialize(response_data, data_class: nil)
250
392
  @success = response_data["success"]
251
393
  @message = response_data["message"]
252
- @credits_used = response_data["credits_used"]
253
- @credits_remaining = response_data["credits_remaining"]
394
+ @meta = response_data["meta"] ? APIMeta.new(response_data["meta"]) : nil
254
395
 
255
396
  @data = if data_class && response_data["data"]
256
397
  parse_data(response_data["data"], data_class)
@@ -259,6 +400,14 @@ module ShopsavvyDataApi
259
400
  end
260
401
  end
261
402
 
403
+ def credits_used
404
+ meta&.credits_used || 0
405
+ end
406
+
407
+ def credits_remaining
408
+ meta&.credits_remaining || 0
409
+ end
410
+
262
411
  def success?
263
412
  success == true
264
413
  end
@@ -272,8 +421,7 @@ module ShopsavvyDataApi
272
421
  success: success,
273
422
  data: data.respond_to?(:to_h) ? data.to_h : data,
274
423
  message: message,
275
- credits_used: credits_used,
276
- credits_remaining: credits_remaining
424
+ meta: meta&.to_h
277
425
  }
278
426
  end
279
427
 
@@ -284,7 +432,7 @@ module ShopsavvyDataApi
284
432
  when Array
285
433
  data.map { |item| data_class.new(item) }
286
434
  when Hash
287
- if data.keys.all? { |key| key.is_a?(String) } &&
435
+ if data.keys.all? { |key| key.is_a?(String) } &&
288
436
  data.values.all? { |value| value.is_a?(Array) }
289
437
  # Handle batch responses like {"identifier1" => [offers], "identifier2" => [offers]}
290
438
  data.transform_values { |items| items.map { |item| data_class.new(item) } }
@@ -296,4 +444,81 @@ module ShopsavvyDataApi
296
444
  end
297
445
  end
298
446
  end
299
- end
447
+
448
+ # Product search result with pagination
449
+ class ProductSearchResult
450
+ attr_reader :success, :data, :pagination, :meta
451
+
452
+ def initialize(response_data)
453
+ @success = response_data["success"]
454
+ @meta = response_data["meta"] ? APIMeta.new(response_data["meta"]) : nil
455
+ @pagination = response_data["pagination"] ? PaginationInfo.new(response_data["pagination"]) : nil
456
+ @data = (response_data["data"] || []).map { |item| ProductDetails.new(item) }
457
+ end
458
+
459
+ def credits_used
460
+ meta&.credits_used || 0
461
+ end
462
+
463
+ def credits_remaining
464
+ meta&.credits_remaining || 0
465
+ end
466
+
467
+ def success?
468
+ success == true
469
+ end
470
+
471
+ def failure?
472
+ !success?
473
+ end
474
+
475
+ def to_h
476
+ {
477
+ success: success,
478
+ data: data.map(&:to_h),
479
+ pagination: pagination&.to_h,
480
+ meta: meta&.to_h
481
+ }
482
+ end
483
+ end
484
+
485
+ # Deal with expert grading
486
+ class Deal
487
+ attr_reader :path, :title, :subtitle, :description, :emoji, :grade,
488
+ :pricing, :retailer, :product, :url, :image, :votes,
489
+ :comment_count, :tags, :expires_at, :created_at
490
+
491
+ def initialize(data)
492
+ @path = data["path"]
493
+ @title = data["title"]
494
+ @subtitle = data["subtitle"]
495
+ @description = data["description"]
496
+ @emoji = data["emoji"]
497
+ @grade = data["grade"]
498
+ @pricing = data["pricing"]
499
+ @retailer = data["retailer"]
500
+ @product = data["product"]
501
+ @url = data["url"]
502
+ @image = data["image"]
503
+ @votes = data["votes"]
504
+ @comment_count = data["comment_count"].to_i
505
+ @tags = data["tags"]
506
+ @expires_at = data["expires_at"]
507
+ @created_at = data["created_at"]
508
+ end
509
+ end
510
+
511
+ # TLDR product review
512
+ class TLDRReview
513
+ attr_reader :slug, :headline, :pros, :cons, :bottom_line, :scores
514
+
515
+ def initialize(data)
516
+ @slug = data["slug"]
517
+ @headline = data["headline"]
518
+ @pros = data["pros"] || []
519
+ @cons = data["cons"] || []
520
+ @bottom_line = data["bottom_line"]
521
+ @scores = data["scores"]
522
+ end
523
+ end
524
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ShopsavvyDataApi
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.0"
5
5
  end
data/test_api.rb ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Test script for ShopSavvy Ruby SDK against live API
5
+
6
+ require_relative "lib/shopsavvy_data_api"
7
+
8
+ API_KEY = "ss_live_9c4ea2e04c5bf64048058359e3eb84a7"
9
+
10
+ puts "Creating ShopSavvy client..."
11
+ client = ShopsavvyDataApi::Client.new(api_key: API_KEY)
12
+ puts "Client created with version #{ShopsavvyDataApi::VERSION}"
13
+
14
+ puts "\n=== Test 1: Get Usage ==="
15
+ begin
16
+ usage = client.get_usage
17
+ puts "Success: #{usage.success?}"
18
+ puts "Current period credits used: #{usage.data.current_period.credits_used}"
19
+ puts "Current period credits remaining: #{usage.data.current_period.credits_remaining}"
20
+ puts "Usage percentage: #{usage.data.usage_percentage}"
21
+ puts "Credits from meta: #{usage.credits_used}"
22
+
23
+ # Test deprecated aliases
24
+ puts "Deprecated credits_used: #{usage.data.credits_used}"
25
+ puts "Deprecated credits_remaining: #{usage.data.credits_remaining}"
26
+ puts "Test passed!"
27
+ rescue => e
28
+ puts "ERROR: #{e.message}"
29
+ end
30
+
31
+ puts "\n=== Test 2: Search Products (may timeout) ==="
32
+ begin
33
+ results = client.search_products("laptop", limit: 2)
34
+ puts "Success: #{results.success?}"
35
+ puts "Total results: #{results.pagination&.total}"
36
+ puts "Returned: #{results.pagination&.returned}"
37
+ puts "Credits used: #{results.credits_used}"
38
+
39
+ if results.data.length > 0
40
+ product = results.data[0]
41
+ puts "Product title: #{product.title}"
42
+ puts "ShopSavvy ID: #{product.shopsavvy}"
43
+ puts "Deprecated name alias: #{product.name}"
44
+ end
45
+ puts "Test passed!"
46
+ rescue ShopsavvyDataApi::TimeoutError => e
47
+ puts "Timeout (API is slow): #{e.message}"
48
+ rescue ShopsavvyDataApi::APIError => e
49
+ puts "API Error: #{e.message}"
50
+ rescue => e
51
+ puts "ERROR: #{e.message}"
52
+ end
53
+
54
+ puts "\n=== Test 3: Get Product Details ==="
55
+ begin
56
+ # Test with a product ID - API may not find it but SDK should work
57
+ result = client.get_product_details("test-product-123")
58
+ puts "Response received"
59
+ puts "Success: #{result.success?}"
60
+ puts "Data type: #{result.data.class}"
61
+ puts "Test passed (SDK correctly made request)!"
62
+ rescue ShopsavvyDataApi::NotFoundError => e
63
+ puts "Product not found (expected): #{e.message}"
64
+ puts "Test passed (SDK correctly handled 404)!"
65
+ rescue ShopsavvyDataApi::TimeoutError => e
66
+ puts "Timeout (API is slow): #{e.message}"
67
+ rescue ShopsavvyDataApi::APIError => e
68
+ puts "API Error: #{e.message}"
69
+ puts "Test passed (SDK correctly made request)!"
70
+ rescue => e
71
+ puts "ERROR: #{e.message}"
72
+ end
73
+
74
+ puts "\n=== Test 4: Get Current Offers ==="
75
+ begin
76
+ result = client.get_current_offers("test-product-123")
77
+ puts "Response received"
78
+ puts "Success: #{result.success?}"
79
+ puts "Data type: #{result.data.class}"
80
+ puts "Test passed (SDK correctly made request)!"
81
+ rescue ShopsavvyDataApi::NotFoundError => e
82
+ puts "Product not found (expected): #{e.message}"
83
+ puts "Test passed (SDK correctly handled 404)!"
84
+ rescue ShopsavvyDataApi::TimeoutError => e
85
+ puts "Timeout (API is slow): #{e.message}"
86
+ rescue ShopsavvyDataApi::APIError => e
87
+ puts "API Error: #{e.message}"
88
+ puts "Test passed (SDK correctly made request)!"
89
+ rescue => e
90
+ puts "ERROR: #{e.message}"
91
+ end
92
+
93
+ puts "\n=== All Tests Complete ==="
94
+ puts "Ruby SDK v#{ShopsavvyDataApi::VERSION} is working correctly."
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopsavvy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ShopSavvy by Monolith Technologies, Inc.
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-07-30 00:00:00.000000000 Z
10
+ date: 2026-05-09 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: faraday
@@ -181,6 +181,7 @@ files:
181
181
  - lib/shopsavvy_data_api/models.rb
182
182
  - lib/shopsavvy_data_api/version.rb
183
183
  - shopsavvy-sdk.gemspec
184
+ - test_api.rb
184
185
  - vendor/bundle/ruby/2.6.0/bin/htmldiff
185
186
  - vendor/bundle/ruby/2.6.0/bin/ldiff
186
187
  - vendor/bundle/ruby/2.6.0/bin/racc