piloterr 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.
@@ -0,0 +1,12 @@
1
+ # Examples
2
+
3
+ ```sh
4
+ gem install piloterr
5
+ export PILOTERR_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
+ - `binary.rb` — save a binary response to a file.
11
+ - `request.rb` — call any path with `request` and handle API errors.
12
+ - `wait_for_job.rb` — start an async job and wait for its result.
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "piloterr"
4
+
5
+ client = Piloterr::Client.new # reads PILOTERR_API_KEY
6
+ response = client.website_screenshot(url: "https://example.com")
7
+ if response.binary?
8
+ response.save("screenshot.png")
9
+ puts "Saved screenshot.png (#{response.content_type}, #{response.bytes.bytesize} bytes)"
10
+ else
11
+ puts response.data
12
+ end
data/examples/json.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "piloterr"
5
+
6
+ client = Piloterr::Client.new # reads PILOTERR_API_KEY
7
+ response = client.amazon_product(query: "B09DFCB66S")
8
+ puts JSON.pretty_generate(response.data)
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "piloterr"
4
+
5
+ client = Piloterr::Client.new # reads PILOTERR_API_KEY
6
+ begin
7
+ response = client.request("GET", "/v1/usage")
8
+ puts response.data if response.json?
9
+ rescue Piloterr::APIError => e
10
+ warn "HTTP #{e.status}: #{e.message}"
11
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "piloterr"
4
+
5
+ client = Piloterr::Client.new # reads PILOTERR_API_KEY
6
+ submitted = client.amazon_product(query: "B09DFCB66S", is_async: true)
7
+ job = client.wait_for_job(submitted.data["job_id"]) { |j| puts "#{j['status']} #{j['progress']}%" }
8
+ puts job.data
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Piloterr
6
+ # Official Piloterr API client. Create one instance and reuse it.
7
+ class Client
8
+ include Operations
9
+
10
+ DEFAULT_BASE_URL = "https://api.piloterr.com"
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, 500, 502, 503, 504].freeze
22
+ API_KEY_ENVIRONMENT_VARIABLE = "PILOTERR_API_KEY"
23
+ JOB_TERMINAL_STATUSES = %w[CompletedJob Error NotFound BadRequest].freeze
24
+ ERROR_MESSAGE_KEYS = %w[error message error_message detail].freeze
25
+
26
+ # @param api_key [String, nil] defaults to ENV["PILOTERR_API_KEY"]
27
+ # @param base_url [String] API origin
28
+ # @param timeout [Numeric, nil] seconds per request; nil disables it
29
+ # @param headers [Hash] extra headers for every request
30
+ # @param max_retries [Integer] retries after a network error or a 408/429/5xx response; 0 disables them
31
+ # @param retry_delay [Numeric] seconds before the first retry, growing 1.5x per retry
32
+ # @param transport [#call] custom transport, see {NetHTTPTransport}
33
+ def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, headers: {},
34
+ max_retries: MAX_RETRIES, retry_delay: DEFAULT_RETRY_DELAY, transport: nil)
35
+ api_key = ENV.fetch(API_KEY_ENVIRONMENT_VARIABLE, nil) if api_key.nil?
36
+ if !api_key.is_a?(String) || api_key.strip.empty?
37
+ raise ConfigurationError,
38
+ "A non-blank Piloterr API key is required. Pass api_key: or set #{API_KEY_ENVIRONMENT_VARIABLE}."
39
+ end
40
+ raise ConfigurationError, "The Piloterr API key cannot be used in an HTTP header." if api_key.match?(/[\r\n]/)
41
+
42
+ uri = URI.parse(base_url.to_s)
43
+ unless %w[http https].include?(uri.scheme) && uri.host
44
+ raise ConfigurationError, "base_url must be an absolute http or https URL."
45
+ end
46
+ raise ConfigurationError, "timeout must be a positive number or nil." if timeout && !(timeout.is_a?(Numeric) && timeout.positive?)
47
+ raise ConfigurationError, "transport must respond to #call." if transport && !transport.respond_to?(:call)
48
+
49
+ @api_key = api_key
50
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
51
+ @timeout = timeout
52
+ @headers = headers.to_h { |name, value| [name.to_s, value.to_s] }.freeze
53
+ raise ConfigurationError, "max_retries must be a non-negative Integer." unless max_retries.is_a?(Integer) && max_retries >= 0
54
+ raise ConfigurationError, "retry_delay must be a non-negative number." unless retry_delay.is_a?(Numeric) && retry_delay >= 0
55
+
56
+ @max_retries = max_retries
57
+ @retry_delay = retry_delay
58
+ @transport = transport || NetHTTPTransport.new
59
+ end
60
+
61
+ def inspect
62
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
63
+ end
64
+
65
+ # Sends any request, including endpoints released after this SDK version.
66
+ # GET parameters travel in the query string; POST parameters travel in a JSON body.
67
+ # @return [JsonResponse, BinaryResponse]
68
+ def request(method, path, params = nil, timeout: nil, headers: nil, max_retries: nil)
69
+ send_request(method.to_s.upcase, path, params, { timeout: timeout, headers: headers, max_retries: max_retries }, :any)
70
+ end
71
+
72
+ # Runs the block for every item on +concurrency+ threads (default 10) and returns one {BatchResult} per item,
73
+ # in input order. A failing item never stops the others; the client is thread-safe.
74
+ #
75
+ # results = client.batch(ids, concurrency: 5) { |id| client.some_endpoint(query: id) }
76
+ # @return [Array<BatchResult>]
77
+ def batch(items, concurrency: DEFAULT_CONCURRENCY)
78
+ raise ConfigurationError, "concurrency must be a positive Integer." unless concurrency.is_a?(Integer) && concurrency.positive?
79
+ raise ArgumentError, "batch needs a block" unless block_given?
80
+
81
+ list = items.to_a
82
+ results = Array.new(list.size)
83
+ queue = Queue.new
84
+ list.each_with_index { |item, index| queue << [item, index] }
85
+ queue.close
86
+ workers = Array.new([concurrency, list.size].min) do
87
+ Thread.new do
88
+ while (job = queue.pop)
89
+ item, index = job
90
+ results[index] = begin
91
+ BatchResult.new(index: index, value: yield(item), error: nil)
92
+ rescue StandardError => e
93
+ BatchResult.new(index: index, value: nil, error: e)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ workers.each(&:join)
99
+ results
100
+ end
101
+
102
+ # Polls GET /v1/jobs/{id} until the job reaches a terminal status. The delay starts at +interval+ seconds
103
+ # and grows 1.5x per poll up to 60 s; +timeout+ defaults to 12 hours.
104
+ # @yieldparam job [Hash] the latest job document after every poll
105
+ # @return [JsonResponse]
106
+ def wait_for_job(job_id, interval: 3, timeout: 43_200)
107
+ raise SerializationError.new("A non-blank job id is required.", "id") if job_id.to_s.strip.empty?
108
+
109
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
110
+ loop do
111
+ last = request("GET", "/v1/jobs/{id}", { "id" => job_id })
112
+ yield last.data if block_given?
113
+ return last if last.data.is_a?(Hash) && JOB_TERMINAL_STATUSES.include?(last.data["status"])
114
+
115
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
116
+ raise JobTimeoutError.new(job_id, last) if elapsed + interval > timeout
117
+
118
+ sleep(interval)
119
+ interval = (interval * 1.5).clamp(interval, [interval, 60].max)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def call_operation(name, params, options)
126
+ endpoint = ENDPOINTS.fetch(name)
127
+ options = (options || {}).transform_keys(&:to_sym)
128
+ method = (options[:method] || endpoint[:methods].first).to_s.upcase
129
+ unless endpoint[:methods].include?(method)
130
+ raise ConfigurationError, "#{endpoint[:path]} supports #{endpoint[:methods].join(', ')}, not #{method}."
131
+ end
132
+
133
+ params = params.transform_keys(&:to_s)
134
+ unknown = params.keys - endpoint[:params]
135
+ unless unknown.empty?
136
+ raise SerializationError.new(
137
+ "Unknown parameter(s) for #{name}: #{unknown.join(', ')}. Use #request for undocumented parameters.",
138
+ unknown.first
139
+ )
140
+ end
141
+ endpoint[:required].each do |required|
142
+ raise SerializationError.new("The \"#{required}\" parameter is required.", required) if params[required].nil?
143
+ end
144
+ send_request(method, endpoint[:path], params, options, endpoint[:response])
145
+ end
146
+
147
+ def send_request(method, path, params, options, expected)
148
+ raise ConfigurationError, "Unsupported HTTP method: #{method.inspect}." unless %w[GET POST].include?(method)
149
+ raise ConfigurationError, 'The request path must start with "/".' unless path.is_a?(String) && path.start_with?("/")
150
+
151
+ path, pairs, body = Serialization.serialize(method, path, params)
152
+ url = @base_url + path
153
+ url += "?#{Serialization.encode_query(pairs)}" unless pairs.empty?
154
+ headers = {
155
+ "Accept" => "application/json, */*;q=0.8",
156
+ "User-Agent" => "piloterr-ruby/#{VERSION}"
157
+ }.merge(@headers).merge((options[:headers] || {}).to_h { |k, v| [k.to_s, v.to_s] })
158
+ headers["x-api-key"] = @api_key
159
+ payload = nil
160
+ if body
161
+ headers["Content-Type"] = "application/json"
162
+ payload = JSON.generate(body)
163
+ end
164
+
165
+ max_retries = options[:max_retries] || @max_retries
166
+ attempt = 0
167
+ loop do
168
+ begin
169
+ status, response_headers, response_body = @transport.call(
170
+ method: method, url: url, headers: headers, body: payload, timeout: options[:timeout] || @timeout
171
+ )
172
+ rescue Timeout::Error => e
173
+ raise TransportError, "Piloterr request timed out: #{e.message}"
174
+ rescue StandardError => e
175
+ raise TransportError, "Piloterr request failed before an HTTP response was received: #{e.message}" if attempt >= max_retries
176
+
177
+ sleep(retry_delay(attempt, nil))
178
+ attempt += 1
179
+ next
180
+ end
181
+ raw = build_raw_response(status, response_headers, response_body)
182
+ return decode(raw, expected) if (200..299).cover?(raw.status)
183
+ raise api_error(raw) unless retryable?(raw) && attempt < max_retries
184
+
185
+ sleep(retry_delay(attempt, raw.headers["retry-after"]&.first))
186
+ attempt += 1
187
+ end
188
+ end
189
+
190
+ # Server errors and plan rate limits (401/429 "Rate limit exceeded") are retried; per-key quotas are not.
191
+ def retryable?(raw)
192
+ return true if RETRYABLE_STATUSES.include?(raw.status)
193
+ return false unless [401, 429].include?(raw.status)
194
+
195
+ message = api_error(raw).error_message.to_s.downcase
196
+ return false if message.include?("quota")
197
+
198
+ message.include?("rate limit") || (raw.status == 429 && message.empty?)
199
+ end
200
+
201
+ def retry_delay(attempt, retry_after)
202
+ seconds = Float(retry_after, exception: false) if retry_after
203
+ return [seconds, 60].min if seconds && seconds >= 0
204
+
205
+ [@retry_delay * (RETRY_BACKOFF_FACTOR**attempt), MAX_RETRY_DELAY].min
206
+ end
207
+
208
+ def build_raw_response(status, headers, body)
209
+ normalized = {}
210
+ (headers || {}).each { |name, values| normalized[name.to_s.downcase] = Array(values).map(&:to_s).freeze }
211
+ RawResponse.new(
212
+ status: status.to_i,
213
+ headers: normalized.freeze,
214
+ content_type: normalized["content-type"]&.first,
215
+ body: body.to_s.b
216
+ )
217
+ end
218
+
219
+ def api_error(raw)
220
+ parsed = parse_json(raw.body)
221
+ message = nil
222
+ if parsed.is_a?(Hash)
223
+ ERROR_MESSAGE_KEYS.each do |key|
224
+ value = parsed[key]
225
+ if value.is_a?(String) && !value.empty?
226
+ message = value
227
+ break
228
+ end
229
+ end
230
+ end
231
+ APIError.new(raw_response: raw, response_body: parsed == NOT_JSON ? nil : parsed, error_message: message)
232
+ end
233
+
234
+ def decode(raw, expected)
235
+ unless json_media_type?(raw.content_type)
236
+ if expected == :json
237
+ raise ResponseDecodingError.new("Piloterr returned a successful non-JSON response for a JSON operation.", raw)
238
+ end
239
+
240
+ return BinaryResponse.new(bytes: raw.body, content_type: raw.content_type, raw_response: raw)
241
+ end
242
+ data = parse_json(raw.body)
243
+ if data == NOT_JSON
244
+ raise ResponseDecodingError.new("Piloterr returned a successful JSON response that could not be decoded.", raw)
245
+ end
246
+
247
+ JsonResponse.new(data: data, raw_response: raw)
248
+ end
249
+
250
+ NOT_JSON = Object.new.freeze
251
+ private_constant :NOT_JSON
252
+
253
+ def parse_json(body)
254
+ JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
255
+ rescue JSON::ParserError, EncodingError
256
+ NOT_JSON
257
+ end
258
+
259
+ def json_media_type?(content_type)
260
+ return false if content_type.nil?
261
+
262
+ media_type = content_type.split(";", 2).first.strip.downcase
263
+ media_type == "application/json" || media_type.end_with?("+json")
264
+ end
265
+ end
266
+ end