ipdata 0.1.5 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecd1e1860fb3d422c8aea50274e10528dd45d12c4555de9585e7af64f06de450
4
- data.tar.gz: f7ad0dcf1f3fd4a526e5ebe64b901c848f4c5cab432ebe33d0a897f72f9c500d
3
+ metadata.gz: e7127035fac487a80213a19c0076dd279854a26ed9409ebd33cfd99939472a3e
4
+ data.tar.gz: 61e8191443dc04b4d4cc5367c110d6afeeb2d1c3e121b2659078793978338d98
5
5
  SHA512:
6
- metadata.gz: 5c3e379a60cd06fc5a5e38aca4680d2014c6b2324bd2587cb108310c0c1356cfc294bd2082ed1d3278e4b7950e5df1c0ff0d7da89be6f40d2b307e63eebdf0e9
7
- data.tar.gz: 18f91231a4c26f1c3ba5f2e86436eff0ca22a2b8078f9749af994ace74de4707c4c95d0416344ed658ca186504bdbb3ebfbdec669466ce00d8c6c1a095ad8377
6
+ metadata.gz: 7d3b43d466e48c1808adb3faf419ebc2e4dd3a0dd8f0074e2989ab3c74df264630c5fe5d6cf429c84e09ddc577b48e37f05580957c678e5a3ba92a53735d7485
7
+ data.tar.gz: 26db682141596dd33486a38c392a8e8cf408eb0346593c75cd44c3ccfca843993ac0f740cc089cb987dc9541155d93f76c165a67c898b970779119fc2f1a35f8
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Change Log
2
+
3
+ ## 0.2.0
4
+
5
+ ### Added
6
+
7
+ - Connection reuse: an `IPData::Client` keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for its lifetime instead of opening a new TLS connection for every request. Create one client and reuse it. New option `keep_alive_timeout:` (seconds, default 60) for the client-side idle timeout, and new method `Client#close` to finish the connection at shutdown; the client reconnects on demand. A client is safe to share between threads (requests are serialised on its connection) and reconnects in a forked child without closing the parent's socket.
8
+ - `Response#is_satellite` and `Response#is_anycast`: the API sends these top-level booleans only when they are true; the readers return `false` when the key is absent, while `response["is_satellite"]`, `key?` and `to_h` reflect the raw response. Both names can be requested through `fields:` and `select_field:`.
9
+ - `threat.blocklists` entries carry `first_seen`, `last_seen` (`"YYYY-MM-DD"`, UTC) and `days_seen` when the feed reports them; some feeds omit all three.
10
+
11
+ ### Changed
12
+
13
+ - `timeout:` now also applies to writes (previously only to connecting and reading).
14
+ - `bulk_lookup` (`POST /bulk`) is retried once, on a fresh connection, when a reused connection died before any response byte arrived; `lookup` (`GET`) was already retried once by `Net::HTTP`. Nothing is retried on an HTTP error status.
15
+ - The `User-Agent` header is now `ipdata-ruby/<version> Ruby/<ruby-version> (<platform>)` instead of `ruby-ipdata/...`.
16
+ - Gem metadata: the homepage is now https://ipdata.co; `source_code_uri` keeps pointing at this repository.
17
+
18
+ ### Fixed
19
+
20
+ - `use_ssl` follows the scheme of `base_url:`, so plain `http://` base URLs work; it used to be always on.
21
+ - `lookup(select_field: "city")` without an IP address requests `/city` instead of `//city`.
22
+ - A `base_url:` with a path prefix (for example `https://example.com/ipdata`) keeps that prefix in front of every request path instead of losing it.
23
+
24
+ ## 0.1.5
25
+
26
+ Initial release on RubyGems: `lookup` for the caller's own IP or a given IP with `fields:` and `select_field:`, `bulk_lookup` for up to 100 addresses, `Client.eu` for the EU endpoint, an in-memory LRU cache with TTL, typed errors (`AuthenticationError`, `ForbiddenError`, `BadRequestError`) and no runtime dependencies.
data/README.md CHANGED
@@ -118,9 +118,32 @@ client.lookup("8.8.8.8") # served from cache
118
118
  ### Custom Timeout
119
119
 
120
120
  ```ruby
121
- client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds
121
+ client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds to connect, read and write
122
122
  ```
123
123
 
124
+ ## Connection reuse
125
+
126
+ An `IPData::Client` keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for as long as it lives. Create one client per process (or one per API key or endpoint), keep it, and reuse it for every lookup: each new client pays a fresh TLS handshake. HTTP/2 is not available with Ruby's `Net::HTTP`, so the saving is the TLS handshake, not multiplexing.
127
+
128
+ A client is safe to share between threads, but it serialises requests on its single connection, so use one client per thread when you need lookups to run in parallel. Create clients after forking (for example in your web server's after-fork hook): a client inherited from the parent process notices the new process id and opens its own connection instead of reusing the parent's socket, which it never closes. Call `close` at shutdown to finish the connection; the client stays usable and reconnects on the next request.
129
+
130
+ ```ruby
131
+ client = IPData::Client.new("YOUR_API_KEY")
132
+
133
+ client.lookup("8.8.8.8") # opens the connection (TCP + TLS handshake)
134
+ client.lookup("1.1.1.1") # reuses it
135
+
136
+ at_exit { client.close }
137
+ ```
138
+
139
+ The connection is opened on the first request. An idle connection is kept for `keep_alive_timeout:` seconds (default 60); after that the next request opens a new one. `timeout:` applies to connecting, reading and writing.
140
+
141
+ ```ruby
142
+ client = IPData::Client.new("YOUR_API_KEY", keep_alive_timeout: 30)
143
+ ```
144
+
145
+ If the server has silently dropped an idle connection, `lookup` (a `GET`) is retried once by `Net::HTTP` and `bulk_lookup` (a `POST`) once by the client, in both cases on a fresh connection and only when the failure happened before any response byte arrived. Nothing is retried on an HTTP error status.
146
+
124
147
  ## Response Fields
125
148
 
126
149
  All responses are wrapped in `IPData::Response` objects that support both dot-notation and hash-style access:
@@ -138,6 +161,8 @@ response.continent_name
138
161
  response.latitude
139
162
  response.longitude
140
163
  response.is_eu
164
+ response.is_satellite # satellite ISP; sent only when true, reads false otherwise
165
+ response.is_anycast # anycast address; sent only when true, reads false otherwise
141
166
  response.postal
142
167
  response.calling_code
143
168
  response.flag
@@ -182,7 +207,10 @@ response.threat.is_known_attacker
182
207
  response.threat.is_known_abuser
183
208
  response.threat.is_threat
184
209
  response.threat.is_bogon
185
- response.threat.blocklists
210
+ response.threat.blocklists # array of entries: name, site, type and, when the feed reports them:
211
+ response.threat.blocklists.first.first_seen # first day listed, "YYYY-MM-DD" (UTC); absent for some feeds
212
+ response.threat.blocklists.first.last_seen # last day listed, "YYYY-MM-DD" (UTC); absent for some feeds
213
+ response.threat.blocklists.first.days_seen # number of days listed (integer); absent for some feeds
186
214
  response.threat.scores
187
215
 
188
216
  # Languages (array)
@@ -198,6 +226,8 @@ response[:country_code]
198
226
  response.to_h
199
227
  ```
200
228
 
229
+ `is_satellite` and `is_anycast` are included in the API response only when they are true. `response.is_satellite` and `response.is_anycast` read `false` when the key is absent, while `response["is_satellite"]`, `response.key?("is_satellite")` and `response.to_h` reflect the raw response (`nil`, `false` and no key). Each `threat.blocklists` entry carries `first_seen` and `last_seen` (calendar dates as `"YYYY-MM-DD"` strings, UTC) and `days_seen` (an integer) when the feed reports them; some feeds, for example ipdata's own vpn feed, omit all three, so read them with `entry["first_seen"]` (`nil` when absent) or check `entry.key?("first_seen")`. The dates are kept as strings; use `Date.iso8601(entry.first_seen)` if you need `Date` objects.
230
+
201
231
  ## Error Handling
202
232
 
203
233
  ```ruby
data/lib/ipdata/client.rb CHANGED
@@ -1,12 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "json"
5
6
  require "uri"
6
7
 
7
8
  module IPData
8
9
  # HTTP client for the ipdata.co API.
9
10
  #
11
+ # A client keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for
12
+ # as long as it lives: the connection is opened on the first request and
13
+ # reused for every later one, so only the first lookup pays the TCP and TLS
14
+ # handshake. Create one client per process (or per API key or endpoint),
15
+ # keep it, and call {#close} at shutdown.
16
+ #
17
+ # A client is safe to share between threads; requests are serialised on its
18
+ # single connection, so use one client per thread for parallel lookups.
19
+ # Create clients after forking: a client inherited from a parent process
20
+ # notices the new process id and opens its own connection instead of
21
+ # reusing (or closing) the parent's socket.
22
+ #
10
23
  # @example Basic usage
11
24
  # client = IPData::Client.new("YOUR_API_KEY")
12
25
  # response = client.lookup("8.8.8.8")
@@ -22,17 +35,29 @@ module IPData
22
35
  BASE_URL = "https://api.ipdata.co"
23
36
  EU_BASE_URL = "https://eu-api.ipdata.co"
24
37
 
38
+ # Failures that mean a reused connection died before the server answered.
39
+ RETRYABLE_ERRORS = [EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError].freeze
40
+ private_constant :RETRYABLE_ERRORS
41
+
25
42
  # @param api_key [String] Your ipdata API key
26
43
  # @param cache [IPData::Cache, nil] Optional LRU cache instance
27
- # @param timeout [Integer] HTTP timeout in seconds (default: 30)
28
- # @param base_url [String, nil] Override the base URL (default: global endpoint)
29
- def initialize(api_key, cache: nil, timeout: 30, base_url: nil)
44
+ # @param timeout [Integer] HTTP open, read and write timeout in seconds (default: 30)
45
+ # @param base_url [String, nil] Override the base URL (default: global endpoint).
46
+ # A path prefix, if the URL has one, is kept in front of every request path
47
+ # @param keep_alive_timeout [Integer] Seconds an idle connection is kept before
48
+ # the next request opens a new one (default: 60)
49
+ def initialize(api_key, cache: nil, timeout: 30, base_url: nil, keep_alive_timeout: 60)
30
50
  raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
31
51
 
32
52
  @api_key = api_key
33
53
  @cache = cache
34
54
  @timeout = timeout
55
+ @keep_alive_timeout = keep_alive_timeout
35
56
  @base_uri = URI.parse(base_url || BASE_URL)
57
+ @base_path = @base_uri.path.chomp("/")
58
+ @mutex = Mutex.new
59
+ @connection = nil
60
+ @connection_pid = nil
36
61
  end
37
62
 
38
63
  # Create a client configured for the EU endpoint.
@@ -51,11 +76,7 @@ module IPData
51
76
  # @param select_field [String, nil] A single field to extract (returns raw value)
52
77
  # @return [Response, Object] Response object, or raw value if select_field is used
53
78
  def lookup(ip = nil, fields: nil, select_field: nil)
54
- if select_field
55
- path = "/#{ip}/#{select_field}"
56
- data = get(path)
57
- return data
58
- end
79
+ return get(request_path(ip, select_field)) if select_field
59
80
 
60
81
  cache_key = build_cache_key(ip, fields)
61
82
  if @cache
@@ -63,11 +84,10 @@ module IPData
63
84
  return cached if cached
64
85
  end
65
86
 
66
- path = ip ? "/#{ip}" : "/"
67
87
  params = {}
68
88
  params["fields"] = fields.join(",") if fields && !fields.empty?
69
89
 
70
- data = get(path, params)
90
+ data = get(request_path(ip), params)
71
91
  response = Response.new(data)
72
92
 
73
93
  @cache&.set(cache_key, response)
@@ -87,14 +107,24 @@ module IPData
87
107
  params = {}
88
108
  params["fields"] = fields.join(",") if fields && !fields.empty?
89
109
 
90
- data = post("/bulk", ips, params)
110
+ data = post(request_path("bulk"), ips, params)
91
111
  data.map { |entry| Response.new(entry) }
92
112
  end
93
113
 
114
+ # Close the persistent connection, if one is open.
115
+ #
116
+ # The client stays usable: the next request opens a new connection.
117
+ #
118
+ # @return [nil]
119
+ def close
120
+ @mutex.synchronize { discard_connection }
121
+ nil
122
+ end
123
+
94
124
  private
95
125
 
96
126
  def user_agent
97
- @user_agent ||= "ruby-ipdata/#{VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
127
+ @user_agent ||= "ipdata-ruby/#{VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})"
98
128
  end
99
129
 
100
130
  def build_cache_key(ip, fields)
@@ -103,6 +133,13 @@ module IPData
103
133
  key
104
134
  end
105
135
 
136
+ # Path of a request made of +segments+, under the path prefix of the base
137
+ # URL if it has one. Nil segments are skipped, so a lookup of the caller's
138
+ # own IP uses "/" and its single-field form "/<field>", not "//<field>".
139
+ def request_path(*segments)
140
+ "#{@base_path}/#{segments.compact.join('/')}"
141
+ end
142
+
106
143
  def build_uri(path, params = {})
107
144
  uri = @base_uri.dup
108
145
  uri.path = path
@@ -118,7 +155,7 @@ module IPData
118
155
  request["Accept"] = "application/json"
119
156
  request["User-Agent"] = user_agent
120
157
 
121
- execute(uri, request)
158
+ execute(request)
122
159
  end
123
160
 
124
161
  def post(path, body, params = {})
@@ -130,16 +167,11 @@ module IPData
130
167
  request["User-Agent"] = user_agent
131
168
  request.body = JSON.generate(body)
132
169
 
133
- execute(uri, request)
170
+ execute(request)
134
171
  end
135
172
 
136
- def execute(uri, request)
137
- http = Net::HTTP.new(uri.host, uri.port)
138
- http.use_ssl = true
139
- http.open_timeout = @timeout
140
- http.read_timeout = @timeout
141
-
142
- response = http.request(request)
173
+ def execute(request)
174
+ response = @mutex.synchronize { perform(request) }
143
175
 
144
176
  unless response.is_a?(Net::HTTPSuccess)
145
177
  raise Error.from_response(response.code.to_i, response.body)
@@ -147,5 +179,61 @@ module IPData
147
179
 
148
180
  JSON.parse(response.body)
149
181
  end
182
+
183
+ # Send +request+ on the pooled connection. Must be called with @mutex held.
184
+ #
185
+ # The server may drop an idle connection without notice. Net::HTTP retries
186
+ # idempotent requests (GET) on such a failure itself (max_retries); POST
187
+ # /bulk is not idempotent, so Net::HTTP leaves it alone and it is retried
188
+ # here instead: exactly once, on a fresh connection, and only when the
189
+ # failure happened before any response byte arrived. An HTTP error status
190
+ # is a response and is never retried.
191
+ def perform(request)
192
+ retried = false
193
+ loop do
194
+ http = connection
195
+ responded = false
196
+ begin
197
+ return http.request(request) { responded = true }
198
+ rescue *RETRYABLE_ERRORS
199
+ discard_connection
200
+ raise if retried || responded || request.method != "POST"
201
+
202
+ retried = true
203
+ end
204
+ end
205
+ end
206
+
207
+ # The pooled Net::HTTP, opened on first use. A connection inherited from a
208
+ # parent process is dropped without finish (the socket is shared with the
209
+ # parent, so no TLS close_notify may be sent on it) and a new one opened.
210
+ def connection
211
+ discard_connection if @connection && @connection_pid != Process.pid
212
+ @connection ||= connect
213
+ end
214
+
215
+ def connect
216
+ http = Net::HTTP.new(@base_uri.host, @base_uri.port)
217
+ http.use_ssl = @base_uri.scheme == "https"
218
+ http.open_timeout = @timeout
219
+ http.read_timeout = @timeout
220
+ http.write_timeout = @timeout
221
+ http.keep_alive_timeout = @keep_alive_timeout
222
+ http.max_retries = 1
223
+ http.start
224
+ @connection_pid = Process.pid
225
+ http
226
+ end
227
+
228
+ # Forget the pooled connection, finishing it only if this process opened it.
229
+ def discard_connection
230
+ http = @connection
231
+ @connection = nil
232
+ return unless http && @connection_pid == Process.pid
233
+
234
+ http.finish if http.started?
235
+ rescue IOError, SystemCallError
236
+ nil
237
+ end
150
238
  end
151
239
  end
@@ -14,6 +14,9 @@ module IPData
14
14
  #
15
15
  # Nested hashes are recursively wrapped as Response objects.
16
16
  # Arrays of hashes (e.g. languages, blocklists) are mapped to Response arrays.
17
+ #
18
+ # Keys the API sends only when they are true (+is_satellite+, +is_anycast+)
19
+ # have explicit readers that return false when the key is absent.
17
20
  class Response
18
21
  def initialize(data)
19
22
  @data = data || {}
@@ -24,6 +27,23 @@ module IPData
24
27
  wrap(@data[key.to_s])
25
28
  end
26
29
 
30
+ # Whether the address belongs to a satellite internet provider.
31
+ #
32
+ # The API includes the +is_satellite+ key only when it is true, so this
33
+ # reads +false+ when the key is absent instead of raising NoMethodError.
34
+ # The raw hash (+to_h+, +[]+, +key?+) is left untouched.
35
+ def is_satellite
36
+ @data.fetch("is_satellite", false)
37
+ end
38
+
39
+ # Whether the address is anycast (announced from several locations at once).
40
+ #
41
+ # The API includes the +is_anycast+ key only when it is true, so this
42
+ # reads +false+ when the key is absent. The raw hash is left untouched.
43
+ def is_anycast
44
+ @data.fetch("is_anycast", false)
45
+ end
46
+
27
47
  # Returns the underlying raw Hash.
28
48
  def to_h
29
49
  @data
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IPData
4
- VERSION = "0.1.5"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ipdata
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - IPData
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-23 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Official Ruby SDK for the ipdata.co API. Look up geolocation, threat
14
14
  intelligence, ASN, company, carrier, currency, timezone and language data for any
@@ -19,6 +19,7 @@ executables: []
19
19
  extensions: []
20
20
  extra_rdoc_files: []
21
21
  files:
22
+ - CHANGELOG.md
22
23
  - LICENSE
23
24
  - README.md
24
25
  - lib/ipdata.rb
@@ -27,11 +28,11 @@ files:
27
28
  - lib/ipdata/errors.rb
28
29
  - lib/ipdata/response.rb
29
30
  - lib/ipdata/version.rb
30
- homepage: https://github.com/ipdata/ruby
31
+ homepage: https://ipdata.co
31
32
  licenses:
32
33
  - MIT
33
34
  metadata:
34
- homepage_uri: https://github.com/ipdata/ruby
35
+ homepage_uri: https://ipdata.co
35
36
  source_code_uri: https://github.com/ipdata/ruby
36
37
  changelog_uri: https://github.com/ipdata/ruby/blob/main/CHANGELOG.md
37
38
  bug_tracker_uri: https://github.com/ipdata/ruby/issues