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 +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/examples/README.md +10 -0
- data/examples/json.rb +8 -0
- data/examples/request.rb +11 -0
- data/lib/asin/client.rb +239 -0
- data/lib/asin/endpoints.rb +86 -0
- data/lib/asin/errors.rb +45 -0
- data/lib/asin/operations.rb +148 -0
- data/lib/asin/responses.rb +45 -0
- data/lib/asin/serialization.rb +66 -0
- data/lib/asin/transport.rb +27 -0
- data/lib/asin/version.rb +5 -0
- data/lib/asin.rb +14 -0
- data/lib/asindotdev.rb +4 -0
- data/sig/asin.rbs +84 -0
- metadata +65 -0
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).
|
data/examples/README.md
ADDED
data/examples/json.rb
ADDED
data/examples/request.rb
ADDED
|
@@ -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
|
data/lib/asin/client.rb
ADDED
|
@@ -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
|
data/lib/asin/errors.rb
ADDED
|
@@ -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
|
data/lib/asin/version.rb
ADDED
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
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: []
|