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
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
|