ipregistry 1.0.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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ipregistry
4
+ # Caching backends used by {Client} to memoize IP lookups.
5
+ #
6
+ # Only successful single and batch IP lookups are cached. Origin lookups are
7
+ # never cached, because the requester IP is only known from the response.
8
+ #
9
+ # A cache is any object responding to +get(key)+, +set(key, value)+,
10
+ # +delete(key)+, and +clear+, and safe for concurrent use from multiple
11
+ # threads — bring your own backend (Redis, Rails.cache, ...) by satisfying
12
+ # that interface.
13
+ module Cache
14
+ # The default cache: stores nothing, so lookups always hit the API and
15
+ # data is never stale.
16
+ class None
17
+ def get(_key) = nil
18
+
19
+ def set(_key, _value) = nil
20
+
21
+ def delete(_key) = nil
22
+
23
+ def clear = nil
24
+ end
25
+
26
+ # A thread-safe, in-process cache with time-based expiration and a bounded
27
+ # size using least-recently-used eviction.
28
+ #
29
+ # cache = Ipregistry::Cache::Memory.new(max_size: 8192, ttl: 600)
30
+ # client = Ipregistry::Client.new("YOUR_API_KEY", cache: cache)
31
+ class Memory
32
+ DEFAULT_MAX_SIZE = 4096
33
+ DEFAULT_TTL = 600 # seconds
34
+
35
+ # @param max_size [Integer] maximum number of entries held before the
36
+ # least recently used entry is evicted (default 4096)
37
+ # @param ttl [Numeric] how long, in seconds, an entry stays valid after
38
+ # being written (default 600)
39
+ # @param clock [#call] overridable monotonic clock, for testing
40
+ def initialize(max_size: DEFAULT_MAX_SIZE, ttl: DEFAULT_TTL,
41
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
42
+ raise ArgumentError, "max_size must be positive" unless max_size.positive?
43
+ raise ArgumentError, "ttl must be positive" unless ttl.positive?
44
+
45
+ @max_size = max_size
46
+ @ttl = ttl
47
+ @clock = clock
48
+ @mutex = Mutex.new
49
+ @entries = {} # key => [value, expires_at]; insertion order tracks recency
50
+ end
51
+
52
+ # Returns the cached value for key, or nil when absent or expired.
53
+ def get(key)
54
+ @mutex.synchronize do
55
+ value, expires_at = @entries[key]
56
+ next nil unless expires_at
57
+
58
+ if @clock.call > expires_at
59
+ @entries.delete(key)
60
+ next nil
61
+ end
62
+
63
+ # Re-insert to mark the entry as most recently used.
64
+ @entries.delete(key)
65
+ @entries[key] = [value, expires_at]
66
+ value
67
+ end
68
+ end
69
+
70
+ # Stores value under key, refreshing its expiration and evicting the
71
+ # least recently used entry if the cache is full.
72
+ def set(key, value)
73
+ @mutex.synchronize do
74
+ @entries.delete(key)
75
+ @entries[key] = [value, @clock.call + @ttl]
76
+ @entries.delete(@entries.each_key.first) while @entries.size > @max_size
77
+ end
78
+ nil
79
+ end
80
+
81
+ # Removes the entry for key, if present.
82
+ def delete(key)
83
+ @mutex.synchronize { @entries.delete(key) }
84
+ nil
85
+ end
86
+
87
+ # Removes every entry.
88
+ def clear
89
+ @mutex.synchronize { @entries.clear }
90
+ nil
91
+ end
92
+
93
+ # Current number of entries, including possibly stale ones. Primarily
94
+ # useful in tests.
95
+ def size
96
+ @mutex.synchronize { @entries.size }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Ipregistry
9
+ # Client for the Ipregistry API. A Client is safe for concurrent use by
10
+ # multiple threads.
11
+ #
12
+ # client = Ipregistry::Client.new("YOUR_API_KEY")
13
+ # info = client.lookup("8.8.8.8")
14
+ # info.location.country.name # => "United States"
15
+ #
16
+ # By default the client uses a 15-second timeout, retries transient failures
17
+ # up to three times, and performs no caching. Behavior is customized with
18
+ # keyword arguments to {#initialize}.
19
+ class Client
20
+ # Base URL of the Ipregistry API used unless overridden with +base_url:+.
21
+ DEFAULT_BASE_URL = "https://api.ipregistry.co"
22
+
23
+ # Maximum number of IP addresses Ipregistry accepts in a single batch
24
+ # request. {#batch_lookup} transparently splits larger arrays into several
25
+ # requests so callers never have to.
26
+ MAX_BATCH_SIZE = 1024
27
+
28
+ DEFAULT_TIMEOUT = 15 # seconds
29
+ DEFAULT_MAX_RETRIES = 3
30
+ DEFAULT_RETRY_INTERVAL = 1.0 # seconds
31
+ DEFAULT_BATCH_CONCURRENCY = 4
32
+
33
+ # Default value of the User-Agent header sent with requests.
34
+ USER_AGENT = "IpregistryClient/Ruby/#{VERSION}".freeze
35
+
36
+ # Errors raised by Net::HTTP when a request does not complete in time.
37
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
38
+ private_constant :TIMEOUT_ERRORS
39
+
40
+ # Errors raised by Net::HTTP for connection-level failures.
41
+ CONNECTION_ERRORS = [SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError].freeze
42
+ private_constant :CONNECTION_ERRORS
43
+
44
+ # @return [Object] the cache used by the client
45
+ attr_reader :cache
46
+
47
+ # @return [String] the API base URL
48
+ attr_reader :base_url
49
+
50
+ # Creates a client authenticating with the given API key. You can obtain
51
+ # a key, along with a generous free tier, at https://ipregistry.co.
52
+ #
53
+ # @param api_key [String] your Ipregistry API key
54
+ # @param base_url [String] overrides the API base URL, mainly useful for
55
+ # testing or pointing at a private deployment
56
+ # @param timeout [Numeric] per-request timeout in seconds, applied to
57
+ # connection open, read, and write (default 15)
58
+ # @param max_retries [Integer] maximum number of automatic retries
59
+ # performed in addition to the initial attempt; 0 disables retries
60
+ # (default 3)
61
+ # @param retry_interval [Numeric] base backoff in seconds between retries;
62
+ # successive retries use an exponentially increasing delay
63
+ # (interval * 2^attempt), and a 429 response carrying a Retry-After
64
+ # header takes precedence (default 1)
65
+ # @param retry_on_server_error [Boolean] whether 5xx responses are retried
66
+ # (default true); transient network errors are always retried
67
+ # @param retry_on_too_many_requests [Boolean] whether 429 Too Many
68
+ # Requests responses are retried, honoring the Retry-After header when
69
+ # present. Ipregistry does not rate limit by default (it is opt-in per
70
+ # API key), so this defaults to false
71
+ # @param cache [Object] response cache; disabled by default so that data
72
+ # is never stale. Pass an {Cache::Memory} instance or any object with
73
+ # the same interface
74
+ # @param max_batch_size [Integer] maximum number of IP addresses sent in a
75
+ # single batch request, capped at {MAX_BATCH_SIZE} (the API limit)
76
+ # @param batch_concurrency [Integer] how many batch sub-requests
77
+ # {#batch_lookup} dispatches concurrently when an array is large enough
78
+ # to be split into chunks; 1 means strictly sequential dispatch, which
79
+ # is gentler on a rate-limited API key (default 4)
80
+ # @param user_agent [String] overrides the User-Agent header sent with
81
+ # requests
82
+ def initialize(api_key,
83
+ base_url: DEFAULT_BASE_URL,
84
+ timeout: DEFAULT_TIMEOUT,
85
+ max_retries: DEFAULT_MAX_RETRIES,
86
+ retry_interval: DEFAULT_RETRY_INTERVAL,
87
+ retry_on_server_error: true,
88
+ retry_on_too_many_requests: false,
89
+ cache: Cache::None.new,
90
+ max_batch_size: MAX_BATCH_SIZE,
91
+ batch_concurrency: DEFAULT_BATCH_CONCURRENCY,
92
+ user_agent: USER_AGENT)
93
+ raise ArgumentError, "api_key must not be nil or empty" if api_key.nil? || api_key.to_s.strip.empty?
94
+
95
+ @api_key = api_key.to_s
96
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
97
+ @timeout = timeout
98
+ @max_retries = [max_retries.to_i, 0].max
99
+ @retry_interval = retry_interval
100
+ @retry_on_server_error = retry_on_server_error
101
+ @retry_on_too_many_requests = retry_on_too_many_requests
102
+ @cache = cache || Cache::None.new
103
+ @max_batch_size = max_batch_size.to_i.clamp(1, MAX_BATCH_SIZE)
104
+ @batch_concurrency = [batch_concurrency.to_i, 1].max
105
+ @user_agent = user_agent
106
+ end
107
+
108
+ # Returns the data associated with the given IP address. The ip argument
109
+ # must be a non-empty IPv4 or IPv6 address (String or IPAddr); to look up
110
+ # the requester's own IP, use {#lookup_origin} instead.
111
+ #
112
+ # When a cache is configured, a hit is returned without contacting the
113
+ # API.
114
+ #
115
+ # @param ip [String, IPAddr] the IP address to look up
116
+ # @param fields [String, nil] restricts the response to the given fields,
117
+ # using Ipregistry's field selector syntax (for example
118
+ # "location.country.name,security"). This reduces payload size and, in
119
+ # some cases, credit usage. See
120
+ # https://ipregistry.co/docs/filtering-selecting-fields
121
+ # @param hostname [Boolean, nil] enables reverse-DNS hostname resolution
122
+ # (disabled by default)
123
+ # @param params [Hash] arbitrary extra query parameters not covered by a
124
+ # dedicated keyword
125
+ # @return [Models::IpInfo]
126
+ # @raise [ApiError, ClientError]
127
+ def lookup(ip, fields: nil, hostname: nil, **params)
128
+ ip = ip.to_s.strip
129
+ raise ArgumentError, "ip must not be empty; use #lookup_origin for the requester IP" if ip.empty?
130
+
131
+ query = build_query(fields: fields, hostname: hostname, **params)
132
+ key = cache_key(ip, query)
133
+ cached = @cache.get(key)
134
+ return cached if cached
135
+
136
+ info = Models::IpInfo.new(request_json(:get, url_for(ip, query)))
137
+ @cache.set(key, info)
138
+ info
139
+ end
140
+
141
+ # Returns the data associated with the IP address the request originates
142
+ # from, enriched with parsed User-Agent data. Origin lookups are never
143
+ # cached, because the requester IP is only known from the response.
144
+ #
145
+ # Accepts the same keywords as {#lookup}.
146
+ #
147
+ # @return [Models::RequesterIpInfo]
148
+ # @raise [ApiError, ClientError]
149
+ def lookup_origin(fields: nil, hostname: nil, **params)
150
+ query = build_query(fields: fields, hostname: hostname, **params)
151
+ Models::RequesterIpInfo.new(request_json(:get, url_for("", query)))
152
+ end
153
+
154
+ # Resolves several IP addresses at once. The returned {BatchResponse}
155
+ # preserves the order of ips, and each entry may independently succeed or
156
+ # fail; a raised error indicates the whole request failed (for example
157
+ # authentication or a network error), not the failure of an individual
158
+ # entry.
159
+ #
160
+ # The Ipregistry API accepts up to {MAX_BATCH_SIZE} addresses per request;
161
+ # larger arrays are transparently split into several requests dispatched
162
+ # with bounded concurrency (see +max_batch_size:+ and
163
+ # +batch_concurrency:+) and reassembled in input order.
164
+ #
165
+ # Entries already present in the cache are served locally; only the
166
+ # remainder are requested from the API, and freshly resolved entries are
167
+ # cached. Accepts the same keywords as {#lookup}.
168
+ #
169
+ # @param ips [Array<String, IPAddr>] the IP addresses to look up
170
+ # @return [BatchResponse]
171
+ # @raise [ApiError, ClientError]
172
+ def batch_lookup(ips, fields: nil, hostname: nil, **params)
173
+ ips = Array(ips).map { |ip| ip.to_s.strip }
174
+ query = build_query(fields: fields, hostname: hostname, **params)
175
+
176
+ cached = ips.map { |ip| @cache.get(cache_key(ip, query)) }
177
+ misses = ips.zip(cached).reject { |_, hit| hit }.map(&:first)
178
+ fresh = resolve_misses(misses, query)
179
+
180
+ BatchResponse.new(assemble_batch_results(ips, cached, fresh, query))
181
+ end
182
+
183
+ # Parses one or more raw User-Agent strings (such as the User-Agent
184
+ # header of an incoming HTTP request) into structured data. Results
185
+ # preserve the order of the input.
186
+ #
187
+ # response = client.parse_user_agents("Mozilla/5.0 ...")
188
+ # response[0].value!.name # => "Chrome"
189
+ #
190
+ # @param user_agents [Array<String>] the User-Agent strings to parse
191
+ # @return [BatchResponse]
192
+ # @raise [ApiError, ClientError]
193
+ def parse_user_agents(*user_agents)
194
+ body = JSON.generate(user_agents.flatten.map(&:to_s))
195
+ data = request_json(:post, "#{@base_url}/user_agent", body: body)
196
+ BatchResponse.new(parse_batch_results(data) { |entry| Models::UserAgent.new(entry) })
197
+ end
198
+
199
+ private
200
+
201
+ # Merges cached entries and freshly resolved results back into input
202
+ # order, caching newly successful entries along the way.
203
+ def assemble_batch_results(ips, cached, fresh, query)
204
+ next_fresh = -1
205
+ ips.each_with_index.map do |ip, i|
206
+ next BatchResponse::Result.success(cached[i]) if cached[i]
207
+
208
+ result = fresh[next_fresh += 1]
209
+ # Defensive: the API returned fewer results than requested.
210
+ result ||= BatchResponse::Result.failure(ApiError.new("missing result for requested IP address"))
211
+ @cache.set(cache_key(ip, query), result.value) if result.success?
212
+ result
213
+ end
214
+ end
215
+
216
+ # Fetches fresh data for the cache-missed IP addresses, splitting them
217
+ # into API-sized chunks dispatched with bounded concurrency when needed.
218
+ # The returned results preserve the order of misses.
219
+ def resolve_misses(misses, query)
220
+ return [] if misses.empty?
221
+
222
+ chunks = misses.each_slice(@max_batch_size).to_a
223
+ return batch_request(chunks.first, query) if chunks.size == 1
224
+
225
+ resolve_chunks(chunks, query).flatten(1)
226
+ end
227
+
228
+ # Dispatches batch chunks with at most batch_concurrency requests in
229
+ # flight and returns their results in order. If any chunk fails, the
230
+ # first error is raised once all in-flight requests have finished, and
231
+ # queued chunks are not started.
232
+ def resolve_chunks(chunks, query)
233
+ chunk_results = Array.new(chunks.size)
234
+ jobs = Queue.new
235
+ chunks.each_with_index { |chunk, index| jobs << [chunk, index] }
236
+ jobs.close
237
+
238
+ mutex = Mutex.new
239
+ first_error = nil
240
+
241
+ workers = Array.new([@batch_concurrency, chunks.size].min) do
242
+ Thread.new do
243
+ while (job = jobs.pop)
244
+ break if mutex.synchronize { first_error }
245
+
246
+ chunk, index = job
247
+ begin
248
+ chunk_results[index] = batch_request(chunk, query)
249
+ rescue Error => e
250
+ mutex.synchronize { first_error ||= e }
251
+ end
252
+ end
253
+ end
254
+ end
255
+ workers.each(&:join)
256
+
257
+ raise first_error if first_error
258
+
259
+ chunk_results
260
+ end
261
+
262
+ # Performs a single POST batch request and returns per-entry results.
263
+ def batch_request(ips, query)
264
+ data = request_json(:post, url_for("", query), body: JSON.generate(ips))
265
+ parse_batch_results(data) { |entry| Models::IpInfo.new(entry) }
266
+ end
267
+
268
+ # Maps the {"results" => [...]} envelope of a batch response to an array
269
+ # of Results, treating elements carrying an error code as failures.
270
+ def parse_batch_results(data)
271
+ entries = data.is_a?(Hash) && data["results"].is_a?(Array) ? data["results"] : []
272
+ entries.map do |entry|
273
+ if entry.is_a?(Hash) && entry["code"]
274
+ BatchResponse::Result.failure(ApiError.from_payload(entry))
275
+ else
276
+ BatchResponse::Result.success(yield(entry))
277
+ end
278
+ end
279
+ end
280
+
281
+ # Collapses lookup keywords into a deterministic, sorted query hash.
282
+ def build_query(fields:, hostname:, **params)
283
+ query = params.transform_keys(&:to_s)
284
+ query["fields"] = fields unless fields.nil?
285
+ query["hostname"] = hostname unless hostname.nil?
286
+ query.transform_values!(&:to_s)
287
+ query.sort.to_h
288
+ end
289
+
290
+ def url_for(ip, query)
291
+ url = "#{@base_url}/#{ip}"
292
+ url += "?#{URI.encode_www_form(query)}" unless query.empty?
293
+ url
294
+ end
295
+
296
+ # Derives a deterministic cache key from an IP address and its query
297
+ # parameters (already sorted by build_query).
298
+ def cache_key(ip, query)
299
+ return ip if query.empty?
300
+
301
+ "#{ip};#{URI.encode_www_form(query)}"
302
+ end
303
+
304
+ # Performs an HTTP request with automatic retries and returns the decoded
305
+ # 2xx response body. Non-2xx responses raise an {ApiError} subclass;
306
+ # transport and decoding failures raise a {ClientError} subclass.
307
+ def request_json(http_method, url, body: nil)
308
+ attempt = 0
309
+ loop do
310
+ response = attempt_request(http_method, url, body, attempt)
311
+
312
+ if response
313
+ status = response.code.to_i
314
+ return parse_json(response.body.to_s) if status.between?(200, 299)
315
+ raise api_error(response, status) unless retry_status?(status) && attempt < @max_retries
316
+
317
+ wait(backoff_delay(attempt, retry_after(response)))
318
+ else
319
+ # A transport error was swallowed by attempt_request: retry.
320
+ wait(backoff_delay(attempt, nil))
321
+ end
322
+
323
+ attempt += 1
324
+ end
325
+ end
326
+
327
+ # Performs one HTTP attempt. Transport errors are re-raised as typed
328
+ # ClientError subclasses once retries are exhausted, and otherwise
329
+ # swallowed (returning nil) so the caller backs off and retries;
330
+ # transient network errors are always retried, regardless of the
331
+ # retry-on-status flags.
332
+ def attempt_request(http_method, url, body, attempt)
333
+ perform(http_method, url, body)
334
+ rescue *TIMEOUT_ERRORS
335
+ raise Ipregistry::TimeoutError, "request timed out after #{@timeout}s" if attempt >= @max_retries
336
+
337
+ nil
338
+ rescue *CONNECTION_ERRORS => e
339
+ raise Ipregistry::ConnectionError, "request failed: #{e.message}" if attempt >= @max_retries
340
+
341
+ nil
342
+ end
343
+
344
+ def perform(http_method, url, body)
345
+ uri = URI.parse(url)
346
+ request = build_request(http_method, uri, body)
347
+
348
+ Net::HTTP.start(uri.host, uri.port,
349
+ use_ssl: uri.scheme == "https",
350
+ open_timeout: @timeout,
351
+ read_timeout: @timeout,
352
+ write_timeout: @timeout) do |http|
353
+ http.request(request)
354
+ end
355
+ end
356
+
357
+ def build_request(http_method, uri, body)
358
+ request = case http_method
359
+ when :get then Net::HTTP::Get.new(uri)
360
+ when :post then Net::HTTP::Post.new(uri)
361
+ else raise ArgumentError, "unsupported HTTP method: #{http_method}"
362
+ end
363
+ request["Authorization"] = "ApiKey #{@api_key}"
364
+ request["User-Agent"] = @user_agent
365
+ request["Accept"] = "application/json"
366
+ if body
367
+ request["Content-Type"] = "application/json"
368
+ request.body = body
369
+ end
370
+ request
371
+ end
372
+
373
+ def parse_json(body)
374
+ JSON.parse(body)
375
+ rescue JSON::ParserError
376
+ raise ParseError, "failed to decode response body as JSON"
377
+ end
378
+
379
+ # Converts a non-2xx response into the most specific ApiError, falling
380
+ # back to a generic message when the body is not a recognizable error
381
+ # payload.
382
+ def api_error(response, status)
383
+ payload = begin
384
+ JSON.parse(response.body.to_s)
385
+ rescue JSON::ParserError
386
+ nil
387
+ end
388
+
389
+ if payload.is_a?(Hash) && payload["code"]
390
+ ApiError.from_payload(payload, http_status: status)
391
+ else
392
+ ApiError.new("unexpected HTTP status #{status}", http_status: status)
393
+ end
394
+ end
395
+
396
+ def retry_status?(status)
397
+ return @retry_on_too_many_requests if status == 429
398
+
399
+ status.between?(500, 599) && @retry_on_server_error
400
+ end
401
+
402
+ # Delay before the next retry attempt, honoring an explicit Retry-After
403
+ # duration when positive and otherwise using exponential backoff.
404
+ def backoff_delay(attempt, retry_after)
405
+ return retry_after if retry_after&.positive?
406
+
407
+ @retry_interval * (2**[attempt, 30].min)
408
+ end
409
+
410
+ # Parses a Retry-After header expressed as an integer number of seconds.
411
+ # Returns nil when the header is absent or not a valid non-negative
412
+ # integer (the HTTP-date form is not supported).
413
+ def retry_after(response)
414
+ value = response["Retry-After"]
415
+ return nil unless value&.match?(/\A\d+\z/)
416
+
417
+ Integer(value, 10)
418
+ end
419
+
420
+ def wait(seconds)
421
+ sleep(seconds) if seconds.positive?
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ipregistry
4
+ # Base class for every error raised by this library. Rescue it to handle any
5
+ # Ipregistry failure with a single clause:
6
+ #
7
+ # begin
8
+ # client.lookup("8.8.8.8")
9
+ # rescue Ipregistry::Error => e
10
+ # # ...
11
+ # end
12
+ class Error < StandardError; end
13
+
14
+ # Raised for failures that occur on the client side rather than being
15
+ # reported by the API, such as network errors or a response that cannot be
16
+ # decoded. The underlying cause, when any, is available through #cause.
17
+ class ClientError < Error; end
18
+
19
+ # Raised when a connection to the API cannot be established or is
20
+ # interrupted (DNS failure, connection refused, TLS error, ...).
21
+ class ConnectionError < ClientError; end
22
+
23
+ # Raised when a request does not complete within the configured timeout.
24
+ class TimeoutError < ClientError; end
25
+
26
+ # Raised when a response body cannot be decoded as JSON.
27
+ class ParseError < ClientError; end
28
+
29
+ # Raised when the Ipregistry API reports a failure, such as an invalid IP
30
+ # address, an exhausted credit balance, or throttling.
31
+ #
32
+ # Each documented error code maps to a dedicated subclass (for example
33
+ # +INVALID_API_KEY+ raises {InvalidApiKeyError}), so callers can rescue the
34
+ # exact condition they care about. The raw code, the suggested resolution,
35
+ # and the HTTP status remain available on every instance.
36
+ #
37
+ # In batch lookups, an ApiError may also describe the failure of a single
38
+ # entry rather than the whole request (see {BatchResponse}); such per-entry
39
+ # errors are returned, not raised.
40
+ #
41
+ # The full list of error codes is documented at
42
+ # https://ipregistry.co/docs/errors
43
+ class ApiError < Error
44
+ # @return [String, nil] the raw error code returned by the API
45
+ attr_reader :code
46
+
47
+ # @return [String, nil] a suggestion on how to resolve the error
48
+ attr_reader :resolution
49
+
50
+ # @return [Integer, nil] the HTTP status of the response, when known
51
+ attr_reader :http_status
52
+
53
+ def initialize(message = "API error", code: nil, resolution: nil, http_status: nil)
54
+ @code = code
55
+ @resolution = resolution
56
+ @http_status = http_status
57
+ super(message)
58
+ end
59
+
60
+ # Builds the most specific error for a decoded API error payload.
61
+ #
62
+ # @param payload [Hash] the decoded error body ("code", "message", "resolution")
63
+ # @param http_status [Integer, nil] the HTTP status of the response
64
+ # @return [ApiError] an instance of the subclass matching the error code,
65
+ # or of ApiError itself when the code is not recognized
66
+ def self.from_payload(payload, http_status: nil)
67
+ payload = {} unless payload.is_a?(Hash)
68
+ code = payload["code"]
69
+ klass = ERRORS_BY_CODE.fetch(code, ApiError)
70
+ klass.new(
71
+ payload["message"] || "API error",
72
+ code: code,
73
+ resolution: payload["resolution"],
74
+ http_status: http_status
75
+ )
76
+ end
77
+ end
78
+
79
+ class BadRequestError < ApiError; end
80
+ class DisabledApiKeyError < ApiError; end
81
+ class ForbiddenIpError < ApiError; end
82
+ class ForbiddenOriginError < ApiError; end
83
+ class ForbiddenIpOriginError < ApiError; end
84
+ class InternalError < ApiError; end
85
+ class InsufficientCreditsError < ApiError; end
86
+ class InvalidApiKeyError < ApiError; end
87
+ class InvalidAsnError < ApiError; end
88
+ class InvalidFilterSyntaxError < ApiError; end
89
+ class InvalidIpAddressError < ApiError; end
90
+ class MissingApiKeyError < ApiError; end
91
+ class ReservedAsnError < ApiError; end
92
+ class ReservedIpAddressError < ApiError; end
93
+ class TooManyAsnsError < ApiError; end
94
+ class TooManyIpsError < ApiError; end
95
+ class TooManyRequestsError < ApiError; end
96
+ class TooManyUserAgentsError < ApiError; end
97
+ class UnknownAsnError < ApiError; end
98
+
99
+ # Maps raw Ipregistry API error codes to their dedicated error class. See
100
+ # https://ipregistry.co/docs/errors for the authoritative list.
101
+ ERRORS_BY_CODE = {
102
+ "BAD_REQUEST" => BadRequestError,
103
+ "DISABLED_API_KEY" => DisabledApiKeyError,
104
+ "FORBIDDEN_IP" => ForbiddenIpError,
105
+ "FORBIDDEN_ORIGIN" => ForbiddenOriginError,
106
+ "FORBIDDEN_IP_ORIGIN" => ForbiddenIpOriginError,
107
+ "INTERNAL" => InternalError,
108
+ "INSUFFICIENT_CREDITS" => InsufficientCreditsError,
109
+ "INVALID_API_KEY" => InvalidApiKeyError,
110
+ "INVALID_ASN" => InvalidAsnError,
111
+ "INVALID_FILTER_SYNTAX" => InvalidFilterSyntaxError,
112
+ "INVALID_IP_ADDRESS" => InvalidIpAddressError,
113
+ "MISSING_API_KEY" => MissingApiKeyError,
114
+ "RESERVED_ASN" => ReservedAsnError,
115
+ "RESERVED_IP_ADDRESS" => ReservedIpAddressError,
116
+ "TOO_MANY_ASNS" => TooManyAsnsError,
117
+ "TOO_MANY_IPS" => TooManyIpsError,
118
+ "TOO_MANY_REQUESTS" => TooManyRequestsError,
119
+ "TOO_MANY_USER_AGENTS" => TooManyUserAgentsError,
120
+ "UNKNOWN_ASN" => UnknownAsnError
121
+ }.freeze
122
+ end