ipscope 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +57 -0
- data/lib/ipscope/cache.rb +97 -0
- data/lib/ipscope/client.rb +310 -0
- data/lib/ipscope/errors.rb +73 -0
- data/lib/ipscope/models.rb +572 -0
- data/lib/ipscope/transport.rb +348 -0
- data/lib/ipscope/validator.rb +152 -0
- data/lib/ipscope/version.rb +5 -0
- data/lib/ipscope.rb +9 -0
- metadata +59 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module IpScope
|
|
8
|
+
# Thread-safe HTTP transport with persistent keep-alive Net::HTTP pooling,
|
|
9
|
+
# streaming 2 MiB response ceiling, bounded JSON nesting depth (16),
|
|
10
|
+
# and automatic exponential backoff with jitter.
|
|
11
|
+
class HttpTransport
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.ipscope.io"
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS = 5.0
|
|
14
|
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB hard ceiling
|
|
15
|
+
MAX_JSON_DEPTH = 16
|
|
16
|
+
|
|
17
|
+
attr_reader :last_rate_limit
|
|
18
|
+
|
|
19
|
+
def initialize(
|
|
20
|
+
api_key:,
|
|
21
|
+
base_url: DEFAULT_BASE_URL,
|
|
22
|
+
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
|
23
|
+
retry_options: nil,
|
|
24
|
+
user_agent: nil,
|
|
25
|
+
custom_transport: nil
|
|
26
|
+
)
|
|
27
|
+
@api_key = Validator.validate_api_key(api_key)
|
|
28
|
+
cleaned_base = base_url.to_s.strip
|
|
29
|
+
cleaned_base = DEFAULT_BASE_URL if cleaned_base.empty?
|
|
30
|
+
@base_url = cleaned_base.sub(%r{/+\z}, "")
|
|
31
|
+
@base_uri = URI.parse(@base_url)
|
|
32
|
+
@timeout_seconds = timeout_seconds.to_f.positive? ? timeout_seconds.to_f : DEFAULT_TIMEOUT_SECONDS
|
|
33
|
+
@retry = retry_options || RetryOptions.new
|
|
34
|
+
@user_agent = if user_agent && !user_agent.to_s.strip.empty?
|
|
35
|
+
user_agent.to_s.strip.freeze
|
|
36
|
+
else
|
|
37
|
+
"ipscope-sdk-ruby/#{VERSION} (Ruby/#{RUBY_VERSION})".freeze
|
|
38
|
+
end
|
|
39
|
+
@custom_transport = custom_transport
|
|
40
|
+
@http_mutex = Mutex.new
|
|
41
|
+
@http_conn = nil
|
|
42
|
+
@last_rate_limit = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close
|
|
46
|
+
@http_mutex.synchronize do
|
|
47
|
+
if @http_conn && @http_conn.started?
|
|
48
|
+
begin
|
|
49
|
+
@http_conn.finish
|
|
50
|
+
rescue StandardError
|
|
51
|
+
# Ignore socket close errors on shutdown
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
@http_conn = nil
|
|
55
|
+
@last_rate_limit = nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Executes an HTTP request and returns `{ data: Hash, rate_limit: RateLimitMetadata|nil }`.
|
|
60
|
+
def request_json(method, path, json_body: nil, options: nil)
|
|
61
|
+
normalized_path = path.to_s.start_with?("/") ? path.to_s : "/#{path}"
|
|
62
|
+
url = "#{@base_url}#{normalized_path}"
|
|
63
|
+
timeout = options&.timeout_seconds || @timeout_seconds
|
|
64
|
+
|
|
65
|
+
headers = {
|
|
66
|
+
"Accept" => "application/json",
|
|
67
|
+
"X-API-Key" => @api_key,
|
|
68
|
+
"User-Agent" => @user_agent
|
|
69
|
+
}
|
|
70
|
+
options&.headers&.each { |k, v| headers[k.to_s] = v.to_s }
|
|
71
|
+
|
|
72
|
+
encoded_body = nil
|
|
73
|
+
unless json_body.nil?
|
|
74
|
+
headers["Content-Type"] = "application/json"
|
|
75
|
+
encoded_body = JSON.generate(json_body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
max_attempts = [@retry.max_retries + 1, 1].max
|
|
79
|
+
attempt = 0
|
|
80
|
+
|
|
81
|
+
while attempt < max_attempts
|
|
82
|
+
raw_resp = execute_raw(method.to_s.upcase, normalized_path, url, headers, encoded_body, timeout)
|
|
83
|
+
status = raw_resp[:status]
|
|
84
|
+
resp_headers = raw_resp[:headers]
|
|
85
|
+
raw_body = raw_resp[:body]
|
|
86
|
+
|
|
87
|
+
rate_limit = self.class.extract_rate_limit_metadata(resp_headers)
|
|
88
|
+
@last_rate_limit = rate_limit unless rate_limit.nil?
|
|
89
|
+
|
|
90
|
+
if status >= 200 && status < 300
|
|
91
|
+
decoded = decode_bounded_json(raw_body, normalized_path, status, rate_limit)
|
|
92
|
+
return {
|
|
93
|
+
data: decoded,
|
|
94
|
+
rate_limit: rate_limit
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
retryable = [429, 502, 503, 504].include?(status)
|
|
99
|
+
retry_after_sec = self.class.parse_retry_after_seconds(resp_headers["retry-after"])
|
|
100
|
+
|
|
101
|
+
if retryable && (attempt + 1) < max_attempts
|
|
102
|
+
delay_sec = compute_backoff_seconds(attempt, retry_after_sec)
|
|
103
|
+
sleep(delay_sec) if delay_sec.positive?
|
|
104
|
+
attempt += 1
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise_mapped_api_error(status, normalized_path, raw_body, rate_limit, retry_after_sec)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise IpScopeApiError.new("Exhausted retry attempts", status: 503, path: normalized_path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.extract_rate_limit_metadata(headers)
|
|
115
|
+
limit = parse_header_int(headers["x-ratelimit-limit"])
|
|
116
|
+
remaining = parse_header_int(headers["x-ratelimit-remaining"])
|
|
117
|
+
reset = parse_header_int(headers["x-ratelimit-reset"])
|
|
118
|
+
credits = parse_header_int(headers["x-ratelimit-credits-remaining"])
|
|
119
|
+
tier_raw = headers["x-ratelimit-tier"].to_s.strip
|
|
120
|
+
tier = tier_raw.empty? ? nil : tier_raw
|
|
121
|
+
|
|
122
|
+
return nil if limit.nil? && remaining.nil? && reset.nil? && credits.nil? && tier.nil?
|
|
123
|
+
|
|
124
|
+
RateLimitMetadata.new(
|
|
125
|
+
limit: limit,
|
|
126
|
+
remaining: remaining,
|
|
127
|
+
reset_epoch_seconds: reset,
|
|
128
|
+
credits_remaining: credits,
|
|
129
|
+
plan_tier: tier
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.parse_header_int(raw)
|
|
134
|
+
return nil if raw.nil?
|
|
135
|
+
|
|
136
|
+
trimmed = raw.to_s.strip
|
|
137
|
+
return nil if trimmed.empty? || !/\A-?\d+\z/.match?(trimmed)
|
|
138
|
+
|
|
139
|
+
trimmed.to_i
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.parse_retry_after_seconds(raw)
|
|
143
|
+
return nil if raw.nil?
|
|
144
|
+
|
|
145
|
+
trimmed = raw.to_s.strip
|
|
146
|
+
return nil if trimmed.empty? || !/\A\d+(\.\d+)?\z/.match?(trimmed)
|
|
147
|
+
|
|
148
|
+
val = trimmed.to_f
|
|
149
|
+
val >= 0.0 ? val : nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def execute_raw(method, path, url, headers, body, timeout_seconds)
|
|
155
|
+
if @custom_transport
|
|
156
|
+
res = @custom_transport.call(method, url, headers, body, timeout_seconds)
|
|
157
|
+
raw_body = (res[:body] || res["body"] || "").to_s
|
|
158
|
+
if raw_body.bytesize > MAX_RESPONSE_BYTES
|
|
159
|
+
raise IpScopeApiError.new(
|
|
160
|
+
format("IPScope response exceeded maximum allowed size of %d bytes", MAX_RESPONSE_BYTES),
|
|
161
|
+
status: 502,
|
|
162
|
+
path: path,
|
|
163
|
+
error_code: "IPSCOPE_RESPONSE_TOO_LARGE"
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
norm_headers = {}
|
|
167
|
+
(res[:headers] || res["headers"] || {}).each do |k, v|
|
|
168
|
+
norm_headers[k.to_s.downcase] = v.to_s
|
|
169
|
+
end
|
|
170
|
+
return {
|
|
171
|
+
status: (res[:status] || res["status"] || 500).to_i,
|
|
172
|
+
headers: norm_headers,
|
|
173
|
+
body: raw_body
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
uri = URI.parse(url)
|
|
178
|
+
req = build_net_http_request(method, uri, headers, body)
|
|
179
|
+
|
|
180
|
+
@http_mutex.synchronize do
|
|
181
|
+
conn = ensure_http_connection(timeout_seconds)
|
|
182
|
+
conn.open_timeout = [timeout_seconds, 3.0].min
|
|
183
|
+
conn.read_timeout = timeout_seconds
|
|
184
|
+
conn.write_timeout = timeout_seconds if conn.respond_to?(:write_timeout=)
|
|
185
|
+
|
|
186
|
+
chunks = []
|
|
187
|
+
received_bytes = 0
|
|
188
|
+
resp_headers = {}
|
|
189
|
+
status_code = 500
|
|
190
|
+
|
|
191
|
+
begin
|
|
192
|
+
conn.request(req) do |response|
|
|
193
|
+
status_code = response.code.to_i
|
|
194
|
+
response.each_header do |k, v|
|
|
195
|
+
resp_headers[k.to_s.downcase] = v.to_s
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
response.read_body do |chunk|
|
|
199
|
+
received_bytes += chunk.bytesize
|
|
200
|
+
if received_bytes > MAX_RESPONSE_BYTES
|
|
201
|
+
raise IpScopeApiError.new(
|
|
202
|
+
format("IPScope response exceeded maximum allowed size of %d bytes", MAX_RESPONSE_BYTES),
|
|
203
|
+
status: 502,
|
|
204
|
+
path: path,
|
|
205
|
+
error_code: "IPSCOPE_RESPONSE_TOO_LARGE"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
chunks << chunk
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
|
|
212
|
+
reset_http_connection_unlocked
|
|
213
|
+
raise IpScopeTimeoutError.new(path, timeout_seconds)
|
|
214
|
+
rescue IpScopeError
|
|
215
|
+
reset_http_connection_unlocked
|
|
216
|
+
raise
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
reset_http_connection_unlocked
|
|
219
|
+
raise IpScopeApiError.new(
|
|
220
|
+
format("Network error communicating with IPScope API (%s): %s", path, e.message),
|
|
221
|
+
status: 503,
|
|
222
|
+
path: path,
|
|
223
|
+
error_code: "IPSCOPE_NETWORK_ERROR"
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
{
|
|
228
|
+
status: status_code,
|
|
229
|
+
headers: resp_headers,
|
|
230
|
+
body: chunks.join
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def ensure_http_connection(timeout_seconds)
|
|
236
|
+
if @http_conn.nil? || !@http_conn.started?
|
|
237
|
+
http = Net::HTTP.new(@base_uri.host, @base_uri.port)
|
|
238
|
+
http.use_ssl = (@base_uri.scheme == "https")
|
|
239
|
+
http.keep_alive_timeout = 30
|
|
240
|
+
http.open_timeout = [timeout_seconds, 3.0].min
|
|
241
|
+
http.read_timeout = timeout_seconds
|
|
242
|
+
http.start
|
|
243
|
+
@http_conn = http
|
|
244
|
+
end
|
|
245
|
+
@http_conn
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def reset_http_connection_unlocked
|
|
249
|
+
if @http_conn && @http_conn.started?
|
|
250
|
+
begin
|
|
251
|
+
@http_conn.finish
|
|
252
|
+
rescue StandardError
|
|
253
|
+
# Ignore close errors
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
@http_conn = nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def build_net_http_request(method, uri, headers, body)
|
|
260
|
+
request_path = uri.request_uri
|
|
261
|
+
klass = case method
|
|
262
|
+
when "GET" then Net::HTTP::Get
|
|
263
|
+
when "POST" then Net::HTTP::Post
|
|
264
|
+
when "PUT" then Net::HTTP::Put
|
|
265
|
+
when "DELETE" then Net::HTTP::Delete
|
|
266
|
+
else Net::HTTP::Get
|
|
267
|
+
end
|
|
268
|
+
req = klass.new(request_path)
|
|
269
|
+
headers.each { |k, v| req[k] = v }
|
|
270
|
+
req.body = body unless body.nil?
|
|
271
|
+
req
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def decode_bounded_json(raw_body, path, status, rate_limit)
|
|
275
|
+
parsed = JSON.parse(raw_body, max_nesting: MAX_JSON_DEPTH)
|
|
276
|
+
unless parsed.is_a?(Hash)
|
|
277
|
+
raise IpScopeApiError.new(
|
|
278
|
+
"Expected JSON object response from IPScope API.",
|
|
279
|
+
status: status,
|
|
280
|
+
path: path,
|
|
281
|
+
error_code: "IPSCOPE_INVALID_JSON",
|
|
282
|
+
rate_limit: rate_limit
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
parsed
|
|
286
|
+
rescue JSON::JSONError => e
|
|
287
|
+
raise IpScopeApiError.new(
|
|
288
|
+
"Invalid or excessively nested JSON received from IPScope API: #{e.message}",
|
|
289
|
+
status: status,
|
|
290
|
+
path: path,
|
|
291
|
+
error_code: "IPSCOPE_INVALID_JSON",
|
|
292
|
+
rate_limit: rate_limit
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def compute_backoff_seconds(attempt, retry_after_seconds)
|
|
297
|
+
if retry_after_seconds && retry_after_seconds.positive?
|
|
298
|
+
return [retry_after_seconds, @retry.max_delay_seconds].min
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
exp = @retry.initial_delay_seconds * (2**attempt)
|
|
302
|
+
capped = [exp, @retry.max_delay_seconds].min
|
|
303
|
+
jitter = 0.75 + (rand * 0.25)
|
|
304
|
+
capped * jitter
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def raise_mapped_api_error(status, path, raw_body, rate_limit, retry_after_seconds)
|
|
308
|
+
message = format('IPScope API request to "%s" failed with HTTP %d', path, status)
|
|
309
|
+
error_code = "IPSCOPE_HTTP_#{status}"
|
|
310
|
+
|
|
311
|
+
if !raw_body.to_s.empty? && raw_body.bytesize <= 65_536
|
|
312
|
+
begin
|
|
313
|
+
parsed = JSON.parse(raw_body, max_nesting: 8)
|
|
314
|
+
if parsed.is_a?(Hash)
|
|
315
|
+
if parsed["message"].is_a?(String) && !parsed["message"].strip.empty?
|
|
316
|
+
message = parsed["message"]
|
|
317
|
+
elsif parsed["error"].is_a?(String) && !parsed["error"].strip.empty?
|
|
318
|
+
message = parsed["error"]
|
|
319
|
+
end
|
|
320
|
+
error_code = parsed["code"] if parsed["code"].is_a?(String)
|
|
321
|
+
end
|
|
322
|
+
rescue JSON::JSONError
|
|
323
|
+
# Keep default message
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if status == 401 || status == 403
|
|
328
|
+
raise IpScopeAuthenticationError.new(message, status: status, path: path, rate_limit: rate_limit)
|
|
329
|
+
end
|
|
330
|
+
if status == 429
|
|
331
|
+
raise IpScopeRateLimitError.new(
|
|
332
|
+
message,
|
|
333
|
+
path: path,
|
|
334
|
+
retry_after_seconds: retry_after_seconds,
|
|
335
|
+
rate_limit: rate_limit
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
raise IpScopeApiError.new(
|
|
340
|
+
message,
|
|
341
|
+
status: status,
|
|
342
|
+
path: path,
|
|
343
|
+
error_code: error_code,
|
|
344
|
+
rate_limit: rate_limit
|
|
345
|
+
)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "resolv"
|
|
5
|
+
|
|
6
|
+
module IpScope
|
|
7
|
+
# Zero-DNS client-side validator for IPv4/IPv6 addresses, CIDR prefixes, and API keys.
|
|
8
|
+
module Validator
|
|
9
|
+
MAX_BATCH_SIZE = 100
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def validate_api_key(api_key)
|
|
14
|
+
trimmed = api_key.to_s.strip
|
|
15
|
+
if trimmed.empty? || trimmed.length < 8
|
|
16
|
+
raise IpScopeValidationError.new(
|
|
17
|
+
'A valid IPScope API key ("ips_live_..." or "ips_test_...") is required.',
|
|
18
|
+
field: "api_key"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
trimmed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Validates and canonicalizes an IPv4 or IPv6 literal without performing DNS resolution.
|
|
25
|
+
def validate_ip_address(ip, field_name = "ip")
|
|
26
|
+
unless ip.is_a?(String)
|
|
27
|
+
raise IpScopeValidationError.new("IP address must be a non-empty string.", field: field_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
trimmed = ip.strip
|
|
31
|
+
if trimmed.empty?
|
|
32
|
+
raise IpScopeValidationError.new("IP address must be a non-empty string.", field: field_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strip optional IPv6 zone identifier (%eth0)
|
|
36
|
+
clean = trimmed.split("%", 2).first.to_s
|
|
37
|
+
if clean.include?("/") || !(Resolv::IPv4::Regex.match?(clean) || Resolv::IPv6::Regex.match?(clean))
|
|
38
|
+
raise IpScopeValidationError.new(
|
|
39
|
+
format('Invalid IPv4 or IPv6 address: "%s".', trimmed),
|
|
40
|
+
field: field_name
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
parsed = IPAddr.new(clean)
|
|
46
|
+
parsed.to_s
|
|
47
|
+
rescue IPAddr::Error
|
|
48
|
+
raise IpScopeValidationError.new(
|
|
49
|
+
format('Invalid IPv4 or IPv6 address: "%s".', trimmed),
|
|
50
|
+
field: field_name
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validates and normalizes a CIDR prefix (`8.8.8.0/24` or `2001:4860::/32`).
|
|
56
|
+
# Returns `{ address: String, prefix_length: Integer, canonical_cidr: String }`.
|
|
57
|
+
def validate_cidr_prefix(cidr)
|
|
58
|
+
trimmed = cidr.to_s.strip
|
|
59
|
+
parts = trimmed.split("/", -1)
|
|
60
|
+
if parts.length != 2
|
|
61
|
+
raise IpScopeValidationError.new(
|
|
62
|
+
format('Invalid CIDR notation "%s". Expected "<ip>/<prefixLength>" (e.g. "8.8.8.0/24").', cidr),
|
|
63
|
+
field: "cidr"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
raw_address = parts[0].strip
|
|
68
|
+
raw_prefix = parts[1].strip
|
|
69
|
+
|
|
70
|
+
if raw_prefix.empty? || !/\A\d+\z/.match?(raw_prefix)
|
|
71
|
+
raise IpScopeValidationError.new(
|
|
72
|
+
format('Invalid prefix length in CIDR "%s".', cidr),
|
|
73
|
+
field: "cidr"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
unless Resolv::IPv4::Regex.match?(raw_address) || Resolv::IPv6::Regex.match?(raw_address)
|
|
78
|
+
raise IpScopeValidationError.new(
|
|
79
|
+
format('Invalid network IP address in CIDR "%s".', cidr),
|
|
80
|
+
field: "cidr"
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
base_ip = IPAddr.new(raw_address)
|
|
86
|
+
rescue IPAddr::Error
|
|
87
|
+
raise IpScopeValidationError.new(
|
|
88
|
+
format('Invalid network IP address in CIDR "%s".', cidr),
|
|
89
|
+
field: "cidr"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
prefix_len = raw_prefix.to_i
|
|
94
|
+
max_bits = base_ip.ipv4? ? 32 : 128
|
|
95
|
+
if prefix_len.negative? || prefix_len > max_bits
|
|
96
|
+
raise IpScopeValidationError.new(
|
|
97
|
+
format('Prefix length %d is out of range (0..%d) for "%s".', prefix_len, max_bits, cidr),
|
|
98
|
+
field: "cidr"
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
masked_ip = base_ip.mask(prefix_len)
|
|
103
|
+
network_addr = masked_ip.to_s
|
|
104
|
+
{
|
|
105
|
+
address: network_addr,
|
|
106
|
+
prefix_length: prefix_len,
|
|
107
|
+
canonical_cidr: "#{network_addr}/#{prefix_len}"
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Validates and deduplicates a batch of IP addresses (`1..100` items by default).
|
|
112
|
+
def validate_and_deduplicate_batch_ips(ips, max_size = MAX_BATCH_SIZE)
|
|
113
|
+
unless ips.is_a?(Array) && !ips.empty?
|
|
114
|
+
raise IpScopeValidationError.new(
|
|
115
|
+
"Batch IP list must contain at least 1 IP address.",
|
|
116
|
+
field: "ips"
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
unique = []
|
|
121
|
+
seen = {}
|
|
122
|
+
|
|
123
|
+
ips.each_with_index do |raw, idx|
|
|
124
|
+
unless raw.is_a?(String)
|
|
125
|
+
raise IpScopeValidationError.new(
|
|
126
|
+
format("Expected String at ips[%d].", idx),
|
|
127
|
+
field: format("ips[%d]", idx)
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
canonical = validate_ip_address(raw, format("ips[%d]", idx))
|
|
132
|
+
next if seen.key?(canonical)
|
|
133
|
+
|
|
134
|
+
seen[canonical] = true
|
|
135
|
+
unique << canonical
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if unique.length > max_size
|
|
139
|
+
raise IpScopeValidationError.new(
|
|
140
|
+
format(
|
|
141
|
+
"Batch IP list exceeds maximum of %d unique IPs (%d provided). Use lookup_batch_chunked or lookup_batch_stream for larger lists.",
|
|
142
|
+
max_size,
|
|
143
|
+
unique.length
|
|
144
|
+
),
|
|
145
|
+
field: "ips"
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
unique
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/ipscope.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ipscope/version"
|
|
4
|
+
require_relative "ipscope/errors"
|
|
5
|
+
require_relative "ipscope/models"
|
|
6
|
+
require_relative "ipscope/validator"
|
|
7
|
+
require_relative "ipscope/cache"
|
|
8
|
+
require_relative "ipscope/transport"
|
|
9
|
+
require_relative "ipscope/client"
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ipscope
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- IPScope Engineering
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Server-side Ruby SDK for IPScope providing single IP lookup, batch lookup,
|
|
14
|
+
constant-RAM streaming batch lookup, CIDR network prefix lookup, dataset metadata,
|
|
15
|
+
feedback submission, bounded O(1) LRU caching, and singleflight request coalescing.
|
|
16
|
+
email:
|
|
17
|
+
- sdk@ipscope.io
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/ipscope.rb
|
|
25
|
+
- lib/ipscope/cache.rb
|
|
26
|
+
- lib/ipscope/client.rb
|
|
27
|
+
- lib/ipscope/errors.rb
|
|
28
|
+
- lib/ipscope/models.rb
|
|
29
|
+
- lib/ipscope/transport.rb
|
|
30
|
+
- lib/ipscope/validator.rb
|
|
31
|
+
- lib/ipscope/version.rb
|
|
32
|
+
homepage: https://ipscope.io
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://ipscope.io
|
|
37
|
+
source_code_uri: https://github.com/ipscope-io/ipscope-ruby
|
|
38
|
+
documentation_uri: https://ipscope.io/docs/sdks/ruby
|
|
39
|
+
post_install_message:
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: 2.6.0
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 3.5.22
|
|
55
|
+
signing_key:
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Official zero-dependency, memory-safe Ruby Server-Side SDK for the IPScope
|
|
58
|
+
IP Intelligence Platform.
|
|
59
|
+
test_files: []
|