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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpScope
4
+ VERSION = "1.0.0"
5
+ 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: []