asindotdev 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 170ee5aab5befec0bdc0e4cf1bc00e0e9ded747cf2ea3a28e357c3d945b78274
4
+ data.tar.gz: 99c1e85a7ec4aa97b72ec8490516bf0f8884a734568f2fd28d00913b5fcaac7e
5
+ SHA512:
6
+ metadata.gz: 3043522567bc7a0cc8d49d4ad405ffc8a09a4e77b5a87bc7464206e1b5e6b6bd68bd4b28bc64c60fb26cb3e1c3a313d269ff9d1b07f201f130ebeb45394b0e54
7
+ data.tar.gz: 0d666a57f3fab1f81bf8f442bfbc92b114cd5cea5412c5e3ba312c1e98a3986ee85ecbbefdbc993208dcd22f859716564910604facb16d2fb9a5830d5854d1b8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # Asin Ruby SDK
2
+
3
+ The official Ruby SDK for the [Asin](https://www.asin.dev) Amazon price tracking API.
4
+
5
+ Track Amazon prices and products with a scheduled watchlist, price history, offers and alerts.
6
+
7
+ The SDK exposes **11 methods**, one per documented endpoint, plus a generic `request`. It only uses the Ruby standard library.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby 3.1 or newer
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ gem install asindotdev
17
+ ```
18
+
19
+ Or in your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "asindotdev"
23
+ ```
24
+
25
+ ## Get your API key
26
+
27
+ [Sign up for Asin](https://www.asin.dev/signup) or sign in, then copy your API key from the [dashboard](https://www.asin.dev/app). The client reads `ENV["ASIN_API_KEY"]` when no key is passed.
28
+
29
+ ## Quick start
30
+
31
+ ```ruby
32
+ require "asin"
33
+
34
+ client = Asin::Client.new(api_key: ENV.fetch("ASIN_API_KEY"))
35
+ response = client.search_amazon(query: "wireless earbuds")
36
+ puts response.data
37
+ ```
38
+
39
+ Keyword arguments use the parameter names of the [API reference](https://www.asin.dev/docs). Unknown names raise `Asin::SerializationError`; use `request` for undocumented parameters.
40
+
41
+ ## HTTP methods
42
+
43
+ Each method uses the HTTP verb of its endpoint. `GET` and `DELETE` send parameters in the query string; `POST` and `PATCH` send them as a JSON body, except `wait` on `/products/{id}/check`, which stays in the query string. Null values are never sent, so `target_price` cannot be removed through the SDK yet.
44
+
45
+ `nil` values are omitted, `false` and `0` are sent, and arrays repeat the parameter name.
46
+
47
+ ## Any endpoint
48
+
49
+ ```ruby
50
+ response = client.request("GET", "/account")
51
+ ```
52
+
53
+ ## Errors
54
+
55
+ ```ruby
56
+ begin
57
+ client.search_amazon(query: "wireless earbuds")
58
+ rescue Asin::APIError => e
59
+ warn "HTTP #{e.status}: #{e.message}"
60
+ end
61
+ ```
62
+
63
+ | Error | When |
64
+ | --- | --- |
65
+ | `Asin::ConfigurationError` | Missing API key, invalid options or unsupported method. |
66
+ | `Asin::SerializationError` | A required parameter is missing, unknown or can't be sent. |
67
+ | `Asin::TransportError` | Network failure or timeout. |
68
+ | `Asin::APIError` | Non-2xx status (`status`, `error_message`, `response_body`, `raw_response`). |
69
+ | `Asin::ResponseDecodingError` | A success response could not be decoded. |
70
+
71
+ All of them inherit from `Asin::Error`.
72
+
73
+ ## Retries
74
+
75
+ Requests are retried up to 3 times after a network error, a `408`, `429`, `500`, `502`, `503` or `504`. Other statuses, such as `400`, `401`, `402` or `404`, and client-side timeouts are not retried. The delay starts at 1 second and grows 1.5× per retry (capped at 30 seconds); a `Retry-After` header wins.
76
+
77
+ ```ruby
78
+ client = Asin::Client.new(max_retries: 5, retry_delay: 0.5)
79
+ client.search_amazon(query: "wireless earbuds", request_options: { max_retries: 0 }) # one request without retries
80
+ ```
81
+
82
+ ## Batch requests
83
+
84
+ `batch` runs the block for every item on 10 threads (`concurrency:`) and returns one `BatchResult` per item, in input order. A failing item never stops the others.
85
+
86
+ ```ruby
87
+ inputs = ["wireless earbuds", "mechanical keyboard", "usb-c hub"]
88
+ results = client.batch(inputs, concurrency: 5) { |value| client.search_amazon(query: value) }
89
+ results.each do |result|
90
+ if result.ok?
91
+ puts result.value.data
92
+ else
93
+ warn "#{inputs[result.index]}: #{result.error.message}"
94
+ end
95
+ end
96
+ ```
97
+
98
+ | Constant | Default | Description |
99
+ | --- | --- | --- |
100
+ | `Asin::Client::DEFAULT_CONCURRENCY` | `10` | Tasks run at the same time by batch |
101
+ | `Asin::Client::DEFAULT_TIMEOUT` | `120` | Request timeout (seconds) |
102
+ | `Asin::Client::MAX_RETRIES` | `3` | Retry attempts after a network error, a 408/429/5xx |
103
+ | `Asin::Client::RETRY_BACKOFF_FACTOR` | `1.5` | Exponential backoff multiplier |
104
+ | `Asin::Client::DEFAULT_RETRY_DELAY` | `1.0` | Delay before the first retry (seconds) |
105
+
106
+ ## Timeouts and custom transports
107
+
108
+ ```ruby
109
+ client = Asin::Client.new(timeout: 60) # seconds, default 120
110
+ client.search_amazon(query: "wireless earbuds", request_options: { timeout: 30 })
111
+ ```
112
+
113
+ A transport is any object with `call(method:, url:, headers:, body:, timeout:)` returning `[status, headers, body]`; the default is `Asin::NetHTTPTransport`.
114
+
115
+ ## Endpoints
116
+
117
+ 11 methods, generated from the [OpenAPI document](https://www.asin.dev/openapi.json).
118
+
119
+ | Method | HTTP | Description |
120
+ | --- | --- | --- |
121
+ | `check_product` | POST `/products/{id}/check` | Asks for a check right away (1 credit), at most once per 5 minutes; earlier calls return the product unchanged. A paused product has to be… |
122
+ | `delete_product` | DELETE `/products/{id}` | Removes the product and its price history. |
123
+ | `fetch_offers` | POST `/offers` | Reads the offers on Amazon for 1 credit, refunded if it doesn't answer. A read made in the last 3 hours, by anyone, is returned instead, fo… |
124
+ | `get_account` | GET `/account` | Your plan, the credits left (monthly allowance and packs), how many products you track and the credits they use per month. |
125
+ | `get_asin_history` | GET `/history` | Every price Asin read for an ASIN on a marketplace over the period, whoever tracks it, oldest first, with the lowest, highest and average o… |
126
+ | `get_offers` | GET `/offers` | Who sells the product and at what price, as last read, cheapest first, with Amazon's own price and the cheapest new and used offers (used i… |
127
+ | `get_product` | GET `/products/{id}` | The product's latest state and every price read of the period, oldest first. The history covers every read of this ASIN on this marketplace… |
128
+ | `list_products` | GET `/products` | Your watchlist, newest first, with each product's latest state and its recent prices (trend). Filter with asin (and marketplace) to know if… |
129
+ | `search_amazon` | GET `/search` | Amazon search results (up to 60, sponsored included), to find the ASIN of a product. Free, limited to 30 searches per hour. |
130
+ | `track_product` | POST `/products` | Adds an ASIN, an Amazon link or a short link (amzn.to, a.co…) to your watchlist. The first check runs in the background within seconds (che… |
131
+ | `update_product` | PATCH `/products/{id}` | Target price, drop alerts, check interval, or pause. Send only what changes. |
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ # Examples
2
+
3
+ ```sh
4
+ gem install asindotdev
5
+ export ASIN_API_KEY="YOUR_API_KEY"
6
+ ruby examples/json.rb
7
+ ```
8
+
9
+ - `json.rb` — call the featured endpoint and print the JSON result.
10
+ - `request.rb` — call any path with `request` and handle API errors.
data/examples/json.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "asin"
5
+
6
+ client = Asin::Client.new # reads ASIN_API_KEY
7
+ response = client.search_amazon(query: "wireless earbuds")
8
+ puts JSON.pretty_generate(response.data)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asin"
4
+
5
+ client = Asin::Client.new # reads ASIN_API_KEY
6
+ begin
7
+ response = client.request("GET", "/account")
8
+ puts response.data if response.json?
9
+ rescue Asin::APIError => e
10
+ warn "HTTP #{e.status}: #{e.message}"
11
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Asin
6
+ # Official Asin API client. Create one instance and reuse it.
7
+ class Client
8
+ include Operations
9
+
10
+ DEFAULT_BASE_URL = "https://www.asin.dev/api/v1"
11
+ DEFAULT_TIMEOUT = 120
12
+ # Tasks run at the same time by #batch.
13
+ DEFAULT_CONCURRENCY = 10
14
+ # Retries after a network error or a 408, 429, 500, 502, 503 or 504 response.
15
+ MAX_RETRIES = 3
16
+ # Each retry waits this many times longer than the previous one (a Retry-After header wins).
17
+ RETRY_BACKOFF_FACTOR = 1.5
18
+ # Seconds before the first retry.
19
+ DEFAULT_RETRY_DELAY = 1.0
20
+ MAX_RETRY_DELAY = 30.0
21
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
22
+ API_KEY_ENVIRONMENT_VARIABLE = "ASIN_API_KEY"
23
+ ERROR_MESSAGE_KEYS = %w[error message error_message detail].freeze
24
+
25
+ # @param api_key [String, nil] defaults to ENV["ASIN_API_KEY"]
26
+ # @param base_url [String] API origin
27
+ # @param timeout [Numeric, nil] seconds per request; nil disables it
28
+ # @param headers [Hash] extra headers for every request
29
+ # @param max_retries [Integer] retries after a network error or a 408/429/5xx response; 0 disables them
30
+ # @param retry_delay [Numeric] seconds before the first retry, growing 1.5x per retry
31
+ # @param transport [#call] custom transport, see {NetHTTPTransport}
32
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, headers: {},
33
+ max_retries: MAX_RETRIES, retry_delay: DEFAULT_RETRY_DELAY, transport: nil)
34
+ api_key = ENV.fetch(API_KEY_ENVIRONMENT_VARIABLE, nil) if api_key.nil?
35
+ if !api_key.is_a?(String) || api_key.strip.empty?
36
+ raise ConfigurationError,
37
+ "A non-blank Asin API key is required. Pass api_key: or set #{API_KEY_ENVIRONMENT_VARIABLE}."
38
+ end
39
+ raise ConfigurationError, "The Asin API key cannot be used in an HTTP header." if api_key.match?(/[\r\n]/)
40
+
41
+ uri = URI.parse(base_url.to_s)
42
+ unless %w[http https].include?(uri.scheme) && uri.host
43
+ raise ConfigurationError, "base_url must be an absolute http or https URL."
44
+ end
45
+ raise ConfigurationError, "timeout must be a positive number or nil." if timeout && !(timeout.is_a?(Numeric) && timeout.positive?)
46
+ raise ConfigurationError, "transport must respond to #call." if transport && !transport.respond_to?(:call)
47
+
48
+ @api_key = api_key
49
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
50
+ @timeout = timeout
51
+ @headers = headers.to_h { |name, value| [name.to_s, value.to_s] }.freeze
52
+ raise ConfigurationError, "max_retries must be a non-negative Integer." unless max_retries.is_a?(Integer) && max_retries >= 0
53
+ raise ConfigurationError, "retry_delay must be a non-negative number." unless retry_delay.is_a?(Numeric) && retry_delay >= 0
54
+
55
+ @max_retries = max_retries
56
+ @retry_delay = retry_delay
57
+ @transport = transport || NetHTTPTransport.new
58
+ end
59
+
60
+ def inspect
61
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
62
+ end
63
+
64
+ # Sends any request, including endpoints released after this SDK version.
65
+ # GET parameters travel in the query string; POST parameters travel in a JSON body.
66
+ # @return [JsonResponse, BinaryResponse]
67
+ def request(method, path, params = nil, timeout: nil, headers: nil, max_retries: nil)
68
+ send_request(method.to_s.upcase, path, params, { timeout: timeout, headers: headers, max_retries: max_retries }, :any)
69
+ end
70
+
71
+ # Runs the block for every item on +concurrency+ threads (default 10) and returns one {BatchResult} per item,
72
+ # in input order. A failing item never stops the others; the client is thread-safe.
73
+ #
74
+ # results = client.batch(ids, concurrency: 5) { |id| client.some_endpoint(query: id) }
75
+ # @return [Array<BatchResult>]
76
+ def batch(items, concurrency: DEFAULT_CONCURRENCY)
77
+ raise ConfigurationError, "concurrency must be a positive Integer." unless concurrency.is_a?(Integer) && concurrency.positive?
78
+ raise ArgumentError, "batch needs a block" unless block_given?
79
+
80
+ list = items.to_a
81
+ results = Array.new(list.size)
82
+ queue = Queue.new
83
+ list.each_with_index { |item, index| queue << [item, index] }
84
+ queue.close
85
+ workers = Array.new([concurrency, list.size].min) do
86
+ Thread.new do
87
+ while (job = queue.pop)
88
+ item, index = job
89
+ results[index] = begin
90
+ BatchResult.new(index: index, value: yield(item), error: nil)
91
+ rescue StandardError => e
92
+ BatchResult.new(index: index, value: nil, error: e)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ workers.each(&:join)
98
+ results
99
+ end
100
+
101
+ private
102
+
103
+ def call_operation(name, params, options)
104
+ endpoint = ENDPOINTS.fetch(name)
105
+ options = (options || {}).transform_keys(&:to_sym)
106
+ method = (options[:method] || endpoint[:methods].first).to_s.upcase
107
+ unless endpoint[:methods].include?(method)
108
+ raise ConfigurationError, "#{endpoint[:path]} supports #{endpoint[:methods].join(', ')}, not #{method}."
109
+ end
110
+
111
+ params = params.transform_keys(&:to_s)
112
+ unknown = params.keys - endpoint[:params]
113
+ unless unknown.empty?
114
+ raise SerializationError.new(
115
+ "Unknown parameter(s) for #{name}: #{unknown.join(', ')}. Use #request for undocumented parameters.",
116
+ unknown.first
117
+ )
118
+ end
119
+ endpoint[:required].each do |required|
120
+ raise SerializationError.new("The \"#{required}\" parameter is required.", required) if params[required].nil?
121
+ end
122
+ send_request(method, endpoint[:path], params, options, endpoint[:response])
123
+ end
124
+
125
+ def send_request(method, path, params, options, expected)
126
+ raise ConfigurationError, "Unsupported HTTP method: #{method.inspect}." unless %w[GET POST PATCH DELETE].include?(method)
127
+ raise ConfigurationError, 'The request path must start with "/".' unless path.is_a?(String) && path.start_with?("/")
128
+
129
+ path, pairs, body = Serialization.serialize(method, path, params)
130
+ url = @base_url + path
131
+ url += "?#{Serialization.encode_query(pairs)}" unless pairs.empty?
132
+ headers = {
133
+ "Accept" => "application/json, */*;q=0.8",
134
+ "User-Agent" => "asin-ruby/#{VERSION}"
135
+ }.merge(@headers).merge((options[:headers] || {}).to_h { |k, v| [k.to_s, v.to_s] })
136
+ headers["Authorization"] = "Bearer #{@api_key}"
137
+ payload = nil
138
+ if body
139
+ headers["Content-Type"] = "application/json"
140
+ payload = JSON.generate(body)
141
+ end
142
+
143
+ max_retries = options[:max_retries] || @max_retries
144
+ attempt = 0
145
+ loop do
146
+ begin
147
+ status, response_headers, response_body = @transport.call(
148
+ method: method, url: url, headers: headers, body: payload, timeout: options[:timeout] || @timeout
149
+ )
150
+ rescue Timeout::Error => e
151
+ raise TransportError, "Asin request timed out: #{e.message}"
152
+ rescue StandardError => e
153
+ raise TransportError, "Asin request failed before an HTTP response was received: #{e.message}" if attempt >= max_retries
154
+
155
+ sleep(retry_delay(attempt, nil))
156
+ attempt += 1
157
+ next
158
+ end
159
+ raw = build_raw_response(status, response_headers, response_body)
160
+ return decode(raw, expected) if (200..299).cover?(raw.status)
161
+ raise api_error(raw) unless retryable?(raw) && attempt < max_retries
162
+
163
+ sleep(retry_delay(attempt, raw.headers["retry-after"]&.first))
164
+ attempt += 1
165
+ end
166
+ end
167
+
168
+ def retryable?(raw)
169
+ RETRYABLE_STATUSES.include?(raw.status)
170
+ end
171
+
172
+ def retry_delay(attempt, retry_after)
173
+ seconds = Float(retry_after, exception: false) if retry_after
174
+ return [seconds, 60].min if seconds && seconds >= 0
175
+
176
+ [@retry_delay * (RETRY_BACKOFF_FACTOR**attempt), MAX_RETRY_DELAY].min
177
+ end
178
+
179
+ def build_raw_response(status, headers, body)
180
+ normalized = {}
181
+ (headers || {}).each { |name, values| normalized[name.to_s.downcase] = Array(values).map(&:to_s).freeze }
182
+ RawResponse.new(
183
+ status: status.to_i,
184
+ headers: normalized.freeze,
185
+ content_type: normalized["content-type"]&.first,
186
+ body: body.to_s.b
187
+ )
188
+ end
189
+
190
+ def api_error(raw)
191
+ parsed = parse_json(raw.body)
192
+ message = nil
193
+ if parsed.is_a?(Hash)
194
+ # The API answers {"error": {"code", "message"}}.
195
+ message = parsed["error"]["message"] if parsed["error"].is_a?(Hash) && parsed["error"]["message"].is_a?(String)
196
+ ERROR_MESSAGE_KEYS.each do |key|
197
+ value = parsed[key]
198
+ if value.is_a?(String) && !value.empty?
199
+ message = value
200
+ break
201
+ end
202
+ end
203
+ end
204
+ APIError.new(raw_response: raw, response_body: parsed == NOT_JSON ? nil : parsed, error_message: message)
205
+ end
206
+
207
+ def decode(raw, expected)
208
+ unless json_media_type?(raw.content_type)
209
+ if expected == :json
210
+ raise ResponseDecodingError.new("Asin returned a successful non-JSON response for a JSON operation.", raw)
211
+ end
212
+
213
+ return BinaryResponse.new(bytes: raw.body, content_type: raw.content_type, raw_response: raw)
214
+ end
215
+ data = parse_json(raw.body)
216
+ if data == NOT_JSON
217
+ raise ResponseDecodingError.new("Asin returned a successful JSON response that could not be decoded.", raw)
218
+ end
219
+
220
+ JsonResponse.new(data: data, raw_response: raw)
221
+ end
222
+
223
+ NOT_JSON = Object.new.freeze
224
+ private_constant :NOT_JSON
225
+
226
+ def parse_json(body)
227
+ JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
228
+ rescue JSON::ParserError, EncodingError
229
+ NOT_JSON
230
+ end
231
+
232
+ def json_media_type?(content_type)
233
+ return false if content_type.nil?
234
+
235
+ media_type = content_type.split(";", 2).first.strip.downcase
236
+ media_type == "application/json" || media_type.end_with?("+json")
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated from the Asin OpenAPI document. Do not edit by hand.
4
+
5
+ module Asin
6
+ # Path, methods, response kind and parameter names of every documented operation.
7
+ ENDPOINTS = {
8
+ "check_product" => {
9
+ path: "/products/{id}/check",
10
+ methods: ["POST"].freeze,
11
+ response: :json,
12
+ required: ["id"].freeze,
13
+ params: ["id", "wait"].freeze
14
+ }.freeze,
15
+ "delete_product" => {
16
+ path: "/products/{id}",
17
+ methods: ["DELETE"].freeze,
18
+ response: :json,
19
+ required: ["id"].freeze,
20
+ params: ["id"].freeze
21
+ }.freeze,
22
+ "fetch_offers" => {
23
+ path: "/offers",
24
+ methods: ["POST"].freeze,
25
+ response: :json,
26
+ required: ["asin"].freeze,
27
+ params: ["asin", "marketplace", "refresh"].freeze
28
+ }.freeze,
29
+ "get_account" => {
30
+ path: "/account",
31
+ methods: ["GET"].freeze,
32
+ response: :json,
33
+ required: [].freeze,
34
+ params: [].freeze
35
+ }.freeze,
36
+ "get_asin_history" => {
37
+ path: "/history",
38
+ methods: ["GET"].freeze,
39
+ response: :json,
40
+ required: ["asin"].freeze,
41
+ params: ["asin", "marketplace", "days"].freeze
42
+ }.freeze,
43
+ "get_offers" => {
44
+ path: "/offers",
45
+ methods: ["GET"].freeze,
46
+ response: :json,
47
+ required: ["asin"].freeze,
48
+ params: ["asin", "marketplace"].freeze
49
+ }.freeze,
50
+ "get_product" => {
51
+ path: "/products/{id}",
52
+ methods: ["GET"].freeze,
53
+ response: :json,
54
+ required: ["id"].freeze,
55
+ params: ["id", "days"].freeze
56
+ }.freeze,
57
+ "list_products" => {
58
+ path: "/products",
59
+ methods: ["GET"].freeze,
60
+ response: :json,
61
+ required: [].freeze,
62
+ params: ["asin", "marketplace", "filter", "page", "limit"].freeze
63
+ }.freeze,
64
+ "search_amazon" => {
65
+ path: "/search",
66
+ methods: ["GET"].freeze,
67
+ response: :json,
68
+ required: ["query"].freeze,
69
+ params: ["query", "marketplace"].freeze
70
+ }.freeze,
71
+ "track_product" => {
72
+ path: "/products",
73
+ methods: ["POST"].freeze,
74
+ response: :json,
75
+ required: ["product"].freeze,
76
+ params: ["product", "marketplace", "target_price", "interval_minutes", "wait"].freeze
77
+ }.freeze,
78
+ "update_product" => {
79
+ path: "/products/{id}",
80
+ methods: ["PATCH"].freeze,
81
+ response: :json,
82
+ required: ["id"].freeze,
83
+ params: ["id", "target_price", "interval_minutes", "alert_on_drop", "paused"].freeze
84
+ }.freeze
85
+ }.freeze
86
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asin
4
+ # Base class for every SDK-defined error.
5
+ class Error < StandardError; end
6
+
7
+ # The client configuration is missing or unusable.
8
+ class ConfigurationError < Error; end
9
+
10
+ # A request value cannot be represented safely on the wire.
11
+ class SerializationError < Error
12
+ attr_reader :param
13
+
14
+ def initialize(message, param = nil)
15
+ super(message)
16
+ @param = param
17
+ end
18
+ end
19
+
20
+ # The request failed before a complete HTTP response was received.
21
+ class TransportError < Error; end
22
+
23
+ # Asin returned a non-success HTTP status.
24
+ class APIError < Error
25
+ attr_reader :status, :error_message, :response_body, :raw_response
26
+
27
+ def initialize(raw_response:, response_body: nil, error_message: nil)
28
+ super(error_message || "Asin API request failed with status #{raw_response.status}.")
29
+ @status = raw_response.status
30
+ @error_message = error_message
31
+ @response_body = response_body
32
+ @raw_response = raw_response
33
+ end
34
+ end
35
+
36
+ # A successful response could not be decoded as the documented representation.
37
+ class ResponseDecodingError < Error
38
+ attr_reader :raw_response
39
+
40
+ def initialize(message, raw_response)
41
+ super(message)
42
+ @raw_response = raw_response
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated from the Asin OpenAPI document. Do not edit by hand.
4
+
5
+ module Asin
6
+ # One method per documented endpoint. Mixed into {Client}.
7
+ module Operations
8
+ # Check the price now. Asks for a check right away (1 credit), at most once per 5 minutes; earlier calls return the product unchanged. A paused product has to be resumed first. The check runs in the background; with wait=true the answer waits for it. Read last_e…
9
+ #
10
+ # +POST /products/{id}/check+
11
+ #
12
+ # @param id [String] (required) product_id returned when the product was added
13
+ # @param wait [Boolean] Answer once the check is done (up to 45 seconds)
14
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
15
+ # @return [JsonResponse]
16
+ def check_product(request_options: nil, **params)
17
+ call_operation("check_product", params, request_options)
18
+ end
19
+
20
+ # Stop tracking a product. Removes the product and its price history.
21
+ #
22
+ # +DELETE /products/{id}+
23
+ #
24
+ # @param id [String] (required) product_id returned when the product was added
25
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
26
+ # @return [JsonResponse]
27
+ def delete_product(request_options: nil, **params)
28
+ call_operation("delete_product", params, request_options)
29
+ end
30
+
31
+ # Read every offer from Amazon. Reads the offers on Amazon for 1 credit, refunded if it doesn't answer. A read made in the last 3 hours, by anyone, is returned instead, for free (charged: false). The answer is the same as GET /offers.
32
+ #
33
+ # +POST /offers+
34
+ #
35
+ # @param asin [String] (required) 10-character ASIN
36
+ # @param marketplace [String] Amazon store, e.g. com for amazon.com
37
+ # @param refresh [Boolean] Read Amazon again unless the last read is under 5 minutes old (1 credit)
38
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
39
+ # @return [JsonResponse]
40
+ def fetch_offers(request_options: nil, **params)
41
+ call_operation("fetch_offers", params, request_options)
42
+ end
43
+
44
+ # Plan and remaining credits. Your plan, the credits left (monthly allowance and packs), how many products you track and the credits they use per month.
45
+ #
46
+ # +GET /account+
47
+ #
48
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
49
+ # @return [JsonResponse]
50
+ def get_account(request_options: nil, **params)
51
+ call_operation("get_account", params, request_options)
52
+ end
53
+
54
+ # Price history of any ASIN. Every price Asin read for an ASIN on a marketplace over the period, whoever tracks it, oldest first, with the lowest, highest and average of those prices. A history of more than 5000 points is thinned evenly, keeping its first and last poi…
55
+ #
56
+ # +GET /history+
57
+ #
58
+ # @param asin [String] (required) 10-character ASIN
59
+ # @param marketplace [String] Amazon store, e.g. com for amazon.com
60
+ # @param days [Integer] History period in days
61
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
62
+ # @return [JsonResponse]
63
+ def get_asin_history(request_options: nil, **params)
64
+ call_operation("get_asin_history", params, request_options)
65
+ end
66
+
67
+ # Offers already known. Who sells the product and at what price, as last read, cheapest first, with Amazon's own price and the cheapest new and used offers (used is only known for Amazon's own outlet). Free: this never reads Amazon or spends a credit. When nobody…
68
+ #
69
+ # +GET /offers+
70
+ #
71
+ # @param asin [String] (required) 10-character ASIN
72
+ # @param marketplace [String] Amazon store, e.g. com for amazon.com
73
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
74
+ # @return [JsonResponse]
75
+ def get_offers(request_options: nil, **params)
76
+ call_operation("get_offers", params, request_options)
77
+ end
78
+
79
+ # Get a product and its price history. The product's latest state and every price read of the period, oldest first. The history covers every read of this ASIN on this marketplace, including those made before you added it, so a product added today can come with months of prices;…
80
+ #
81
+ # +GET /products/{id}+
82
+ #
83
+ # @param id [String] (required) product_id returned when the product was added
84
+ # @param days [Integer] History period in days
85
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
86
+ # @return [JsonResponse]
87
+ def get_product(request_options: nil, **params)
88
+ call_operation("get_product", params, request_options)
89
+ end
90
+
91
+ # List tracked products. Your watchlist, newest first, with each product's latest state and its recent prices (trend). Filter with asin (and marketplace) to know if a product is tracked, or with filter to get the same lists as the app's tabs.
92
+ #
93
+ # +GET /products+
94
+ #
95
+ # @param asin [String] Only this ASIN
96
+ # @param marketplace [String] Only this Amazon store
97
+ # @param filter [String] Only the products of one of the app's watchlist filters. all: Every tracked product (default). drops: Cheaper now than when the product was added. target: At or below its target price. out_of_stock:…
98
+ # @param page [Integer] Page number
99
+ # @param limit [Integer] Products per page
100
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
101
+ # @return [JsonResponse]
102
+ def list_products(request_options: nil, **params)
103
+ call_operation("list_products", params, request_options)
104
+ end
105
+
106
+ # Search Amazon. Amazon search results (up to 60, sponsored included), to find the ASIN of a product. Free, limited to 30 searches per hour.
107
+ #
108
+ # +GET /search+
109
+ #
110
+ # @param query [String] (required) Keywords
111
+ # @param marketplace [String] Amazon store, e.g. com for amazon.com
112
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
113
+ # @return [JsonResponse]
114
+ def search_amazon(request_options: nil, **params)
115
+ call_operation("search_amazon", params, request_options)
116
+ end
117
+
118
+ # Track a product. Adds an ASIN, an Amazon link or a short link (amzn.to, a.co…) to your watchlist. The first check runs in the background within seconds (checking is true until then): poll GET /products/{id}, or send wait: true to get the answer once it's d…
119
+ #
120
+ # +POST /products+
121
+ #
122
+ # @param product [String] (required) ASIN (B09DFCB66S) or Amazon product link
123
+ # @param marketplace [String] Amazon store, e.g. com for amazon.com. A product link sets it on its own.
124
+ # @param target_price [Numeric] Email the owner when the price goes at or below this amount. null removes it.
125
+ # @param interval_minutes [Integer] Minutes between two checks (1440 = daily). Checks faster than daily need a plan or a credit pack: every 60 minutes with a pack or Starter, 30 with Pro, 5 with Business.
126
+ # @param wait [Boolean] Answer once the first check is done (up to 45 seconds)
127
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
128
+ # @return [JsonResponse]
129
+ def track_product(request_options: nil, **params)
130
+ call_operation("track_product", params, request_options)
131
+ end
132
+
133
+ # Change alerts and checks. Target price, drop alerts, check interval, or pause. Send only what changes.
134
+ #
135
+ # +PATCH /products/{id}+
136
+ #
137
+ # @param id [String] (required) product_id returned when the product was added
138
+ # @param target_price [Numeric] Email the owner when the price goes at or below this amount. null removes it.
139
+ # @param interval_minutes [Integer] Minutes between two checks (1440 = daily). Checks faster than daily need a plan or a credit pack: every 60 minutes with a pack or Starter, 30 with Pro, 5 with Business.
140
+ # @param alert_on_drop [Boolean] Email when the price drops or the product is back in stock
141
+ # @param paused [Boolean] Stop checking (and charging) for now; false resumes
142
+ # @param request_options [Hash, nil] +:method+ ("GET"/"POST"), +:timeout+ (seconds), +:headers+
143
+ # @return [JsonResponse]
144
+ def update_product(request_options: nil, **params)
145
+ call_operation("update_product", params, request_options)
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asin
4
+ # Buffered HTTP response: status, lower-cased multi-value headers, content type and body bytes.
5
+ RawResponse = Struct.new(:status, :headers, :content_type, :body, keyword_init: true)
6
+
7
+ # Outcome of one Client#batch item: +value+ when +ok?+, else +error+.
8
+ BatchResult = Struct.new(:index, :value, :error, keyword_init: true) do
9
+ def ok? = error.nil?
10
+ end
11
+
12
+ # A successful JSON response; +data+ is the parsed body (string keys).
13
+ class JsonResponse
14
+ attr_reader :data, :raw_response
15
+
16
+ def initialize(data:, raw_response:)
17
+ @data = data
18
+ @raw_response = raw_response
19
+ end
20
+
21
+ def kind = :json
22
+ def json? = true
23
+ def binary? = false
24
+ end
25
+
26
+ # A successful non-JSON response (image, PDF, HTML…).
27
+ class BinaryResponse
28
+ attr_reader :bytes, :content_type, :raw_response
29
+
30
+ def initialize(bytes:, content_type:, raw_response:)
31
+ @bytes = bytes
32
+ @content_type = content_type
33
+ @raw_response = raw_response
34
+ end
35
+
36
+ def kind = :binary
37
+ def json? = false
38
+ def binary? = true
39
+
40
+ # Writes the body to +path+ and returns the number of bytes written.
41
+ def save(path)
42
+ File.binwrite(path, bytes)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Asin
7
+ # Request serialization: path filling, query pairs and JSON bodies.
8
+ module Serialization
9
+ QUERY_STRING_PARAMS = { "/products/{id}/check" => %w[wait] }.freeze
10
+
11
+ module_function
12
+
13
+ # Fills {name} path segments, then returns [path, query_pairs, body].
14
+ # nil values are omitted; false and 0 are sent.
15
+ def serialize(method, path_template, params)
16
+ raise SerializationError, "Request parameters must be a Hash." unless params.nil? || params.is_a?(Hash)
17
+
18
+ remaining = {}
19
+ (params || {}).each { |name, value| remaining[name.to_s] = value unless value.nil? }
20
+ path = path_template.gsub(/\{([^}]+)\}/) do
21
+ name = Regexp.last_match(1)
22
+ raise SerializationError.new("The \"#{name}\" path parameter is required.", name) unless remaining.key?(name)
23
+
24
+ URI.encode_www_form_component(wire_string(name, remaining.delete(name))).gsub("+", "%20")
25
+ end
26
+
27
+ if %w[POST PATCH].include?(method)
28
+ # The API reads wait from the query string on POST /products/{id}/check.
29
+ globals = QUERY_STRING_PARAMS.fetch(path_template, []).filter_map { |name| [name, wire_string(name, remaining.delete(name))] if remaining.key?(name) }
30
+ return [path, globals, remaining]
31
+ end
32
+
33
+ pairs = []
34
+ remaining.each do |name, value|
35
+ if value.is_a?(Array)
36
+ value.each { |item| pairs << [name, wire_string(name, item)] unless item.nil? }
37
+ else
38
+ pairs << [name, wire_string(name, value)]
39
+ end
40
+ end
41
+ [path, pairs, nil]
42
+ end
43
+
44
+ def wire_string(name, value)
45
+ case value
46
+ when String then value
47
+ when true, false, Integer, Symbol then value.to_s
48
+ when Float
49
+ raise SerializationError.new("The \"#{name}\" parameter must be a finite number.", name) unless value.finite?
50
+
51
+ value == value.floor ? value.to_i.to_s : value.to_s
52
+ when Hash, Array then JSON.generate(value)
53
+ else
54
+ raise SerializationError.new("The \"#{name}\" parameter has an unsupported type (#{value.class}).", name)
55
+ end
56
+ end
57
+
58
+ def encode_query(pairs)
59
+ pairs.map { |name, value| "#{escape(name)}=#{escape(value)}" }.join("&")
60
+ end
61
+
62
+ def escape(value)
63
+ URI.encode_www_form_component(value).gsub("+", "%20")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Asin
7
+ # Default transport on Net::HTTP. A custom transport is any object with
8
+ # +call(method:, url:, headers:, body:, timeout:)+ returning +[status, headers, body]+,
9
+ # where headers is a Hash of lower-cased names to arrays of values.
10
+ class NetHTTPTransport
11
+ def call(method:, url:, headers:, body:, timeout:)
12
+ uri = URI(url)
13
+ request = Net::HTTP.const_get(method.capitalize).new(uri)
14
+ headers.each { |name, value| request[name] = value }
15
+ request.body = body if body
16
+ http = Net::HTTP.new(uri.host, uri.port)
17
+ http.use_ssl = uri.scheme == "https"
18
+ if timeout
19
+ http.open_timeout = timeout
20
+ http.read_timeout = timeout
21
+ http.write_timeout = timeout
22
+ end
23
+ response = http.start { |connection| connection.request(request) }
24
+ [response.code.to_i, response.to_hash, response.body || ""]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asin
4
+ VERSION = "0.1.0"
5
+ end
data/lib/asin.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "asin/version"
4
+ require_relative "asin/errors"
5
+ require_relative "asin/responses"
6
+ require_relative "asin/serialization"
7
+ require_relative "asin/transport"
8
+ require_relative "asin/endpoints"
9
+ require_relative "asin/operations"
10
+ require_relative "asin/client"
11
+
12
+ # Official Ruby SDK for the Asin API (https://www.asin.dev).
13
+ module Asin
14
+ end
data/lib/asindotdev.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lets Bundler auto-require the gem by its name.
4
+ require_relative "asin"
data/sig/asin.rbs ADDED
@@ -0,0 +1,84 @@
1
+ module Asin
2
+ VERSION: String
3
+ ENDPOINTS: Hash[String, Hash[Symbol, untyped]]
4
+
5
+ class Error < StandardError
6
+ end
7
+ class ConfigurationError < Error
8
+ end
9
+ class SerializationError < Error
10
+ attr_reader param: String?
11
+ def initialize: (String message, ?String? param) -> void
12
+ end
13
+ class TransportError < Error
14
+ end
15
+ class APIError < Error
16
+ attr_reader status: Integer
17
+ attr_reader error_message: String?
18
+ attr_reader response_body: untyped
19
+ attr_reader raw_response: RawResponse
20
+ end
21
+ class ResponseDecodingError < Error
22
+ attr_reader raw_response: RawResponse
23
+ end
24
+
25
+ class RawResponse < Struct[untyped]
26
+ attr_accessor status: Integer
27
+ attr_accessor headers: Hash[String, Array[String]]
28
+ attr_accessor content_type: String?
29
+ attr_accessor body: String
30
+ end
31
+
32
+ class JsonResponse
33
+ attr_reader data: untyped
34
+ attr_reader raw_response: RawResponse
35
+ def kind: () -> :json
36
+ def json?: () -> true
37
+ def binary?: () -> false
38
+ end
39
+ class BinaryResponse
40
+ attr_reader bytes: String
41
+ attr_reader content_type: String?
42
+ attr_reader raw_response: RawResponse
43
+ def kind: () -> :binary
44
+ def json?: () -> false
45
+ def binary?: () -> true
46
+ def save: (String path) -> Integer
47
+ end
48
+
49
+ class BatchResult < Struct[untyped]
50
+ attr_accessor index: Integer
51
+ attr_accessor value: untyped
52
+ attr_accessor error: StandardError?
53
+ def ok?: () -> bool
54
+ end
55
+
56
+ type response = JsonResponse | BinaryResponse
57
+ type request_options = { ?method: String, ?timeout: Numeric?, ?headers: Hash[String, String], ?max_retries: Integer }
58
+
59
+ module Operations
60
+ def check_product: (id: String, ?wait: bool, ?request_options: request_options?) -> JsonResponse
61
+ def delete_product: (id: String, ?request_options: request_options?) -> JsonResponse
62
+ def fetch_offers: (asin: String, ?marketplace: String, ?refresh: bool, ?request_options: request_options?) -> JsonResponse
63
+ def get_account: (?request_options: request_options?) -> JsonResponse
64
+ def get_asin_history: (asin: String, ?marketplace: String, ?days: Integer, ?request_options: request_options?) -> JsonResponse
65
+ def get_offers: (asin: String, ?marketplace: String, ?request_options: request_options?) -> JsonResponse
66
+ def get_product: (id: String, ?days: Integer, ?request_options: request_options?) -> JsonResponse
67
+ def list_products: (?asin: String, ?marketplace: String, ?filter: String, ?page: Integer, ?limit: Integer, ?request_options: request_options?) -> JsonResponse
68
+ def search_amazon: (query: String, ?marketplace: String, ?request_options: request_options?) -> JsonResponse
69
+ def track_product: (product: String, ?marketplace: String, ?target_price: Numeric, ?interval_minutes: Integer, ?wait: bool, ?request_options: request_options?) -> JsonResponse
70
+ def update_product: (id: String, ?target_price: Numeric, ?interval_minutes: Integer, ?alert_on_drop: bool, ?paused: bool, ?request_options: request_options?) -> JsonResponse
71
+ end
72
+
73
+ class Client
74
+ include Operations
75
+ DEFAULT_CONCURRENCY: Integer
76
+ MAX_RETRIES: Integer
77
+ RETRY_BACKOFF_FACTOR: Float
78
+ DEFAULT_RETRY_DELAY: Float
79
+ DEFAULT_TIMEOUT: Integer
80
+ def initialize: (?api_key: String?, ?base_url: String, ?timeout: Numeric?, ?headers: Hash[untyped, untyped], ?max_retries: Integer, ?retry_delay: Numeric, ?transport: untyped) -> void
81
+ def request: (String | Symbol method, String path, ?Hash[untyped, untyped]? params, ?timeout: Numeric?, ?headers: Hash[untyped, untyped]?, ?max_retries: Integer?) -> response
82
+ def batch: [I, T] (Enumerable[I] items, ?concurrency: Integer) { (I item) -> T } -> Array[BatchResult]
83
+ end
84
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: asindotdev
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Asin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Track Amazon prices and products with a scheduled watchlist, price history,
14
+ offers and alerts.
15
+ email:
16
+ - contact@asin.dev
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - examples/README.md
24
+ - examples/json.rb
25
+ - examples/request.rb
26
+ - lib/asin.rb
27
+ - lib/asin/client.rb
28
+ - lib/asin/endpoints.rb
29
+ - lib/asin/errors.rb
30
+ - lib/asin/operations.rb
31
+ - lib/asin/responses.rb
32
+ - lib/asin/serialization.rb
33
+ - lib/asin/transport.rb
34
+ - lib/asin/version.rb
35
+ - lib/asindotdev.rb
36
+ - sig/asin.rbs
37
+ homepage: https://www.asin.dev
38
+ licenses:
39
+ - MIT
40
+ metadata:
41
+ bug_tracker_uri: https://github.com/asindotdev/asin-ruby/issues
42
+ documentation_uri: https://www.asin.dev/docs
43
+ homepage_uri: https://www.asin.dev
44
+ rubygems_mfa_required: 'true'
45
+ source_code_uri: https://github.com/asindotdev/asin-ruby
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.22
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: Official Ruby SDK for the Asin Amazon price tracking API.
65
+ test_files: []