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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e47cec1a0711d254f804e3ac7db1f3c48fd393783e8d6d9278be4b83de83bf24
4
+ data.tar.gz: 62d49ea6718b0225ea0cf97aabcec0c01b864997cfd8996d71b0086863cee8fb
5
+ SHA512:
6
+ metadata.gz: 5610d9f7837486b72df7ce4004d5094a57fe5e361cf063c1c515e6c2d35f541aaa7c33029f981d4617ed6bcb4cd50b67e36fb12bc3293d8b8d470066c0a9699a
7
+ data.tar.gz: d123e32f1c15b375633e50f7fa59a18ac638d2da72d7f943a40e09c19b744c40c90932e7e7fdddfd1a1f0beecda7ef2954ca7237180473c03b5505f9e92dd592
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Badele LLC (ipscope.io)
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,57 @@
1
+ # IPScope Official Ruby Server-Side SDK (`ipscope`)
2
+
3
+ Zero-dependency, thread-safe, memory-bounded Ruby Server-Side SDK for the **IPScope IP Intelligence Platform**.
4
+
5
+ Scoped strictly to **IP intelligence operations** (single IP lookup, caller IP lookup, batch lookup, chunked/streaming batch lookup, network CIDR prefix lookup, dataset metadata, geolocation/anonymity feedback submission, and service health status) with zero account, wallet, or billing dependencies.
6
+
7
+ ---
8
+
9
+ ## Key Features
10
+
11
+ - **Zero External Runtime Dependencies**: Built entirely on Ruby's standard library (`net/http`, `json`, `ipaddr`, `resolv`, `uri`).
12
+ - **Bounded `O(1)` LRU + TTL Cache**: Default `max_entries: 512` (`< 400 KB` memory footprint) using Ruby's ordered `Hash` protected by a `Mutex` and `Process::CLOCK_MONOTONIC`.
13
+ - **Singleflight Request Coalescing**: Concurrent threads looking up the same IP address simultaneously share a single in-flight HTTP request.
14
+ - **Hard `2 MiB` Streaming Response Ceiling & Bounded JSON Depth (`16`)**: Protects Ruby workers (Puma, Sidekiq, Unicorn, Pitchfork) against oversized upstream payloads and deeply nested JSON memory bombs.
15
+ - **Zero-DNS IPv4/IPv6 & CIDR Validation**: Validates and canonicalizes IP addresses and CIDR prefixes locally without triggering DNS queries.
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ Add to your `Gemfile`:
22
+
23
+ ```ruby
24
+ gem "ipscope"
25
+ ```
26
+
27
+ ---
28
+
29
+ ## Quick Start
30
+
31
+ ```ruby
32
+ require "ipscope"
33
+
34
+ client = IpScope::Client.new(api_key: "ips_live_your_api_key_here")
35
+
36
+ # 1. Single IPv4 or IPv6 lookup (cached automatically in bounded LRU cache)
37
+ ip_info = client.lookup_ip("8.8.8.8")
38
+ puts "#{ip_info.ip} #{ip_info.geo.country_code} AS#{ip_info.network.asn}"
39
+ puts "VPN: #{ip_info.security.is_vpn} Threat: #{ip_info.security.threat_level}"
40
+
41
+ # 2. Batch lookup (up to 100 IPs; deduplicates & reuses cached entries automatically)
42
+ batch = client.lookup_batch(["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"])
43
+ batch.results.each do |record|
44
+ puts "#{record.ip} -> #{record.network.org}"
45
+ end
46
+
47
+ # 3. Memory-bounded streaming batch lookup for 10,000+ IPs (constant O(chunk) RAM)
48
+ client.lookup_batch_stream(large_ip_list) do |chunk|
49
+ chunk.results.each do |record|
50
+ # Process each record with < 400 KB steady-state memory overhead
51
+ end
52
+ end
53
+
54
+ # 4. Network CIDR prefix lookup
55
+ prefix = client.lookup_network("8.8.8.0/24")
56
+ puts "#{prefix.requested_cidr} matches: #{prefix.total_matches}"
57
+ ```
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpScope
4
+ # Thread-safe bounded O(1) LRU + TTL in-memory cache for IP intelligence records.
5
+ #
6
+ # Uses Ruby's insertion-ordered Hash (`delete(key)` + `store[key] = entry`,
7
+ # `store.shift` for O(1) eviction of the least-recently-used key) protected by a `Mutex`.
8
+ class LruTtlCache
9
+ def initialize(options = nil)
10
+ opts = options || CacheOptions.new
11
+ @enabled = opts.enabled && opts.max_entries.positive? && opts.ttl_seconds.positive?
12
+ @max_entries = [opts.max_entries, 1].max
13
+ @ttl_seconds = [opts.ttl_seconds, 0.001].max
14
+ @mutex = Mutex.new
15
+ @store = {}
16
+ @hits = 0
17
+ @misses = 0
18
+ @evictions = 0
19
+ end
20
+
21
+ def enabled?
22
+ @enabled
23
+ end
24
+
25
+ def get(key, now_monotonic = nil)
26
+ return nil unless @enabled
27
+
28
+ now = now_monotonic || monotonic_now
29
+ @mutex.synchronize do
30
+ entry = @store[key]
31
+ unless entry
32
+ @misses += 1
33
+ return nil
34
+ end
35
+
36
+ if now >= entry[:expires_at]
37
+ @store.delete(key)
38
+ @misses += 1
39
+ return nil
40
+ end
41
+
42
+ # Promote to most-recently-used tail in O(1)
43
+ @store.delete(key)
44
+ @store[key] = entry
45
+ @hits += 1
46
+ entry[:value]
47
+ end
48
+ end
49
+
50
+ def set(key, value, now_monotonic = nil)
51
+ return unless @enabled
52
+
53
+ now = now_monotonic || monotonic_now
54
+ @mutex.synchronize do
55
+ if @store.key?(key)
56
+ @store.delete(key)
57
+ elsif @store.length >= @max_entries
58
+ @store.shift
59
+ @evictions += 1
60
+ end
61
+
62
+ @store[key] = {
63
+ value: value,
64
+ expires_at: now + @ttl_seconds
65
+ }
66
+ end
67
+ end
68
+
69
+ def clear
70
+ @mutex.synchronize do
71
+ @store.clear
72
+ @hits = 0
73
+ @misses = 0
74
+ @evictions = 0
75
+ end
76
+ end
77
+
78
+ def stats
79
+ @mutex.synchronize do
80
+ CacheStats.new(
81
+ enabled: @enabled,
82
+ size: @store.length,
83
+ max_entries: @max_entries,
84
+ hits: @hits,
85
+ misses: @misses,
86
+ evictions: @evictions
87
+ )
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def monotonic_now
94
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpScope
4
+ # Official Server-Side Ruby Client for the IPScope IP Intelligence Platform.
5
+ #
6
+ # Scoped strictly to IP intelligence operations (single IP lookup, caller IP lookup,
7
+ # batch lookup, chunked/streaming batch lookup, CIDR network prefix lookup, dataset metadata,
8
+ # geolocation/anonymity feedback submission, and service health status).
9
+ class Client
10
+ MAX_BATCH_SIZE = Validator::MAX_BATCH_SIZE
11
+ MAX_RESPONSE_BYTES = HttpTransport::MAX_RESPONSE_BYTES
12
+
13
+ def initialize(
14
+ api_key:,
15
+ base_url: HttpTransport::DEFAULT_BASE_URL,
16
+ timeout_seconds: HttpTransport::DEFAULT_TIMEOUT_SECONDS,
17
+ retry_options: nil,
18
+ cache_options: nil,
19
+ user_agent: nil,
20
+ custom_transport: nil
21
+ )
22
+ @transport = HttpTransport.new(
23
+ api_key: api_key,
24
+ base_url: base_url,
25
+ timeout_seconds: timeout_seconds,
26
+ retry_options: retry_options,
27
+ user_agent: user_agent,
28
+ custom_transport: custom_transport
29
+ )
30
+ @cache = LruTtlCache.new(cache_options)
31
+ @inflight_mutex = Mutex.new
32
+ @inflight = {}
33
+ end
34
+
35
+ # Closes persistent keep-alive connections and clears cached state.
36
+ def close
37
+ @cache.clear
38
+ @transport.close
39
+ end
40
+
41
+ # Looks up full IP intelligence for a single IPv4 or IPv6 address (`GET /v1/ip/{ip}`).
42
+ def lookup_ip(ip, options = nil)
43
+ lookup_ip_with_meta(ip, options).data
44
+ end
45
+
46
+ # Looks up full IP intelligence for a single IPv4 or IPv6 address along with
47
+ # rate-limit headers and cache hit metadata. Coalesces concurrent identical lookups
48
+ # across threads via singleflight.
49
+ def lookup_ip_with_meta(ip, options = nil)
50
+ canonical_ip = Validator.validate_ip_address(ip)
51
+ bypass_cache = options&.bypass_cache || false
52
+
53
+ unless bypass_cache
54
+ cached = @cache.get(canonical_ip)
55
+ unless cached.nil?
56
+ return IpScopeResultWithMeta.new(
57
+ data: cached,
58
+ rate_limit: @transport.last_rate_limit,
59
+ from_cache: true
60
+ )
61
+ end
62
+ end
63
+
64
+ return fetch_and_cache_single_ip(canonical_ip, bypass_cache, options) if bypass_cache
65
+
66
+ execute_singleflight(canonical_ip) do
67
+ fetch_and_cache_single_ip(canonical_ip, false, options)
68
+ end
69
+ end
70
+
71
+ # Resolves IP intelligence for the caller's own public IP address (`GET /v1/ip`).
72
+ def lookup_caller_ip(options = nil)
73
+ raw = @transport.request_json("GET", "/v1/ip", options: options)
74
+ record = IpLookupResponse.from_h(raw[:data])
75
+ @cache.set(record.ip, record) if !(options&.bypass_cache) && !record.ip.empty?
76
+ record
77
+ end
78
+
79
+ # Performs a batch IP lookup (`POST /v1/ip/batch`) for up to 100 IP addresses.
80
+ # Automatically deduplicates input IPs, reuses cached entries from the LRU cache,
81
+ # and backfills newly fetched records into the cache.
82
+ def lookup_batch(ips, options = nil)
83
+ unique_ips = Validator.validate_and_deduplicate_batch_ips(ips, MAX_BATCH_SIZE)
84
+ bypass_cache = options&.bypass_cache || false
85
+
86
+ resolved_map = {}
87
+ missing_ips = []
88
+
89
+ if !bypass_cache && @cache.enabled?
90
+ unique_ips.each do |ip|
91
+ cached = @cache.get(ip)
92
+ if cached
93
+ resolved_map[ip] = cached
94
+ else
95
+ missing_ips << ip
96
+ end
97
+ end
98
+ else
99
+ missing_ips = unique_ips
100
+ end
101
+
102
+ unless missing_ips.empty?
103
+ raw = @transport.request_json(
104
+ "POST",
105
+ "/v1/ip/batch",
106
+ json_body: { "ips" => missing_ips },
107
+ options: options
108
+ )
109
+ batch_resp = BatchLookupResponse.from_h(raw[:data])
110
+
111
+ batch_resp.results.each_with_index do |record, idx|
112
+ requested_key = missing_ips[idx] || record.ip
113
+ resolved_map[requested_key] = record
114
+ resolved_map[record.ip] = record unless record.ip.empty?
115
+ unless bypass_cache
116
+ @cache.set(requested_key, record)
117
+ @cache.set(record.ip, record) if !record.ip.empty? && record.ip != requested_key
118
+ end
119
+ end
120
+ end
121
+
122
+ ordered_results = unique_ips.each_with_object([]) do |ip, acc|
123
+ acc << resolved_map[ip] if resolved_map.key?(ip)
124
+ end
125
+
126
+ BatchLookupResponse.new(
127
+ count: ordered_results.length,
128
+ results: ordered_results
129
+ )
130
+ end
131
+
132
+ # Splits an arbitrarily large list of IP addresses into chunks of up to 100 unique IPs
133
+ # and merges the results into a single `BatchLookupResponse`.
134
+ def lookup_batch_chunked(ips, options = nil)
135
+ merged = []
136
+ lookup_batch_stream(ips, options) do |chunk_resp|
137
+ merged.concat(chunk_resp.results)
138
+ end
139
+ BatchLookupResponse.new(
140
+ count: merged.length,
141
+ results: merged
142
+ )
143
+ end
144
+
145
+ # Memory-bounded streaming enumerator that yields `BatchLookupResponse` chunks of up to
146
+ # 100 unique IP addresses at a time with constant `O(chunk)` RAM usage.
147
+ # Returns an `Enumerator` if no block is given.
148
+ def lookup_batch_stream(ips, options = nil)
149
+ return enum_for(:lookup_batch_stream, ips, options) unless block_given?
150
+
151
+ unless ips.respond_to?(:each)
152
+ raise IpScopeValidationError.new("Batch IP stream must be an Enumerable.", field: "ips")
153
+ end
154
+
155
+ chunk = []
156
+ seen_in_chunk = {}
157
+ yielded_any = false
158
+
159
+ ips.each do |raw_ip|
160
+ unless raw_ip.is_a?(String)
161
+ raise IpScopeValidationError.new("Expected String IP address in lookup_batch_stream.", field: "ips")
162
+ end
163
+
164
+ canonical = Validator.validate_ip_address(raw_ip, "ips")
165
+ next if seen_in_chunk.key?(canonical)
166
+
167
+ seen_in_chunk[canonical] = true
168
+ chunk << canonical
169
+
170
+ if chunk.length >= MAX_BATCH_SIZE
171
+ yielded_any = true
172
+ yield lookup_batch(chunk, options)
173
+ chunk = []
174
+ seen_in_chunk = {}
175
+ end
176
+ end
177
+
178
+ if !chunk.empty?
179
+ yield lookup_batch(chunk, options)
180
+ elsif !yielded_any
181
+ raise IpScopeValidationError.new("Batch IP stream must contain at least 1 IP address.", field: "ips")
182
+ end
183
+ end
184
+
185
+ # Looks up BGP/CIDR network prefix intelligence (`GET /v1/network/{address}/{prefixLength}`).
186
+ def lookup_network(cidr, options = nil)
187
+ parsed = Validator.validate_cidr_prefix(cidr)
188
+ encoded_addr = URI.encode_www_form_component(parsed[:address])
189
+ path = "/v1/network/#{encoded_addr}/#{parsed[:prefix_length]}"
190
+ raw = @transport.request_json("GET", path, options: options)
191
+ NetworkPrefixLookupResponse.from_h(raw[:data])
192
+ end
193
+
194
+ # Retrieves active IP intelligence dataset snapshots metadata (`GET /v1/datasets/metadata`).
195
+ def get_datasets_metadata(options = nil)
196
+ raw = @transport.request_json("GET", "/v1/datasets/metadata", options: options)
197
+ DatasetsMetadataResponse.from_h(raw[:data])
198
+ end
199
+
200
+ # Submits an IP geolocation or anonymity correction (`POST /v1/feedback`).
201
+ def submit_feedback(feedback, options = nil)
202
+ unless feedback.is_a?(FeedbackSubmissionInput)
203
+ raise IpScopeValidationError.new("Expected FeedbackSubmissionInput instance.", field: "feedback")
204
+ end
205
+
206
+ trimmed = feedback.ip_or_cidr.strip
207
+ if trimmed.empty?
208
+ raise IpScopeValidationError.new("ip_or_cidr must not be empty.", field: "ip_or_cidr")
209
+ end
210
+
211
+ normalized = if trimmed.include?("/")
212
+ Validator.validate_cidr_prefix(trimmed)[:canonical_cidr]
213
+ else
214
+ Validator.validate_ip_address(trimmed, "ip_or_cidr")
215
+ end
216
+
217
+ payload = feedback.to_h
218
+ payload["ip_or_cidr"] = normalized
219
+
220
+ raw = @transport.request_json("POST", "/v1/feedback", json_body: payload, options: options)
221
+ FeedbackSubmissionResponse.from_h(raw[:data])
222
+ end
223
+
224
+ # Queries the IPScope API health status (`GET /v1/status`).
225
+ def get_status(options = nil)
226
+ raw = @transport.request_json("GET", "/v1/status", options: options)
227
+ ServiceStatusResponse.from_h(raw[:data])
228
+ end
229
+
230
+ # Returns the most recent rate-limit metadata observed from `x-ratelimit-*` headers.
231
+ def last_rate_limit
232
+ @transport.last_rate_limit
233
+ end
234
+
235
+ # Returns current bounded LRU cache statistics.
236
+ def cache_stats
237
+ @cache.stats
238
+ end
239
+
240
+ # Clears all entries and resets statistics in the in-memory LRU cache.
241
+ def clear_cache
242
+ @cache.clear
243
+ end
244
+
245
+ private
246
+
247
+ def fetch_and_cache_single_ip(canonical_ip, bypass_cache, options)
248
+ encoded = URI.encode_www_form_component(canonical_ip)
249
+ raw = @transport.request_json("GET", "/v1/ip/#{encoded}", options: options)
250
+ record = IpLookupResponse.from_h(raw[:data])
251
+
252
+ unless bypass_cache
253
+ @cache.set(canonical_ip, record)
254
+ @cache.set(record.ip, record) if !record.ip.empty? && record.ip != canonical_ip
255
+ end
256
+
257
+ IpScopeResultWithMeta.new(
258
+ data: record,
259
+ rate_limit: raw[:rate_limit],
260
+ from_cache: false
261
+ )
262
+ end
263
+
264
+ def execute_singleflight(key)
265
+ leader = false
266
+ state = nil
267
+
268
+ @inflight_mutex.synchronize do
269
+ if @inflight.key?(key)
270
+ state = @inflight[key]
271
+ else
272
+ leader = true
273
+ state = { done: false, result: nil, error: nil, cond: ConditionVariable.new }
274
+ @inflight[key] = state
275
+ end
276
+ end
277
+
278
+ if leader
279
+ begin
280
+ res = yield
281
+ @inflight_mutex.synchronize do
282
+ state[:result] = res
283
+ state[:done] = true
284
+ @inflight.delete(key)
285
+ state[:cond].broadcast
286
+ end
287
+ res
288
+ rescue StandardError => e
289
+ @inflight_mutex.synchronize do
290
+ state[:error] = e
291
+ state[:done] = true
292
+ @inflight.delete(key)
293
+ state[:cond].broadcast
294
+ end
295
+ raise e
296
+ end
297
+ else
298
+ @inflight_mutex.synchronize do
299
+ state[:cond].wait(@inflight_mutex) until state[:done]
300
+ end
301
+ raise state[:error] if state[:error]
302
+
303
+ state[:result]
304
+ end
305
+ end
306
+ end
307
+
308
+ # Alias for consistency with other IPScope SDKs (`IpScopeClient`).
309
+ IpScopeClient = Client
310
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpScope
4
+ # Base error class for all exceptions raised by the IPScope Ruby SDK.
5
+ class IpScopeError < StandardError
6
+ attr_reader :error_code
7
+
8
+ def initialize(message, error_code: "IPSCOPE_ERROR")
9
+ super(message)
10
+ @error_code = error_code.to_s.freeze
11
+ end
12
+ end
13
+
14
+ # Raised when client-side validation fails before sending an HTTP request.
15
+ class IpScopeValidationError < IpScopeError
16
+ attr_reader :field
17
+
18
+ def initialize(message, field: nil)
19
+ super(message, error_code: "IPSCOPE_VALIDATION_ERROR")
20
+ @field = field ? field.to_s.freeze : nil
21
+ end
22
+ end
23
+
24
+ # Raised when an HTTP request exceeds the configured timeout.
25
+ class IpScopeTimeoutError < IpScopeError
26
+ attr_reader :path, :timeout_seconds
27
+
28
+ def initialize(path, timeout_seconds)
29
+ @path = path.to_s.freeze
30
+ @timeout_seconds = timeout_seconds.to_f
31
+ super(
32
+ format('IPScope request to "%s" timed out after %.2fs', @path, @timeout_seconds),
33
+ error_code: "IPSCOPE_TIMEOUT_ERROR"
34
+ )
35
+ end
36
+ end
37
+
38
+ # Raised when the IPScope API returns a non-2xx HTTP status code or network failure.
39
+ class IpScopeApiError < IpScopeError
40
+ attr_reader :status, :path, :rate_limit
41
+
42
+ def initialize(message, status:, path:, error_code: nil, rate_limit: nil)
43
+ @status = status.to_i
44
+ @path = path.to_s.freeze
45
+ @rate_limit = rate_limit
46
+ super(message, error_code: error_code || "IPSCOPE_HTTP_#{@status}")
47
+ end
48
+ end
49
+
50
+ # Raised on HTTP 401 Unauthorized or 403 Forbidden.
51
+ class IpScopeAuthenticationError < IpScopeApiError
52
+ def initialize(message, status:, path:, rate_limit: nil)
53
+ code = status.to_i == 401 ? "IPSCOPE_UNAUTHORIZED" : "IPSCOPE_FORBIDDEN_SCOPE"
54
+ super(message, status: status, path: path, error_code: code, rate_limit: rate_limit)
55
+ end
56
+ end
57
+
58
+ # Raised on HTTP 429 Too Many Requests when retries are exhausted or disabled.
59
+ class IpScopeRateLimitError < IpScopeApiError
60
+ attr_reader :retry_after_seconds
61
+
62
+ def initialize(message, path:, retry_after_seconds: nil, rate_limit: nil)
63
+ @retry_after_seconds = retry_after_seconds ? retry_after_seconds.to_f : nil
64
+ super(
65
+ message,
66
+ status: 429,
67
+ path: path,
68
+ error_code: "IPSCOPE_RATE_LIMITED",
69
+ rate_limit: rate_limit
70
+ )
71
+ end
72
+ end
73
+ end