From b65bed8e465486c4350d81ba93e5ab7667afc130 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:00 +0000 Subject: [PATCH 1/8] Clean up the CI workflow and test on Ruby 3.4 and 4.0 ci.yml started with "# frozen_string_literal: true", a Ruby directive that means nothing in YAML. The matrix now also covers Ruby 3.4 and 4.0 next to 3.0 to 3.3, and actions/checkout moves to v5, which runs on Node 24; v4 is a Node 20 action that the runners only run with a deprecation warning. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d40aa58..9506533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,3 @@ -# frozen_string_literal: true - name: CI on: @@ -13,10 +11,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ["3.0", "3.1", "3.2", "3.3"] + ruby-version: ["3.0", "3.1", "3.2", "3.3", "3.4", "4.0"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby ${{ matrix.ruby-version }} uses: ruby/setup-ruby@v1 From 4d73c72e3f230fc38e0a09774f8417c952b79e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:00 +0000 Subject: [PATCH 2/8] Publish to RubyGems only for version tags The push trigger of the release workflow listed branches: [main] next to tags: ["v*"], so every push to main, including a plain merge, ran the release job and tried to publish the current version again, which RubyGems rejects. The push trigger is now limited to v* tags. The manual workflow_dispatch path (bump, commit, tag, publish) is unchanged apart from actions/checkout moving to v5 (Node 24). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/release.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f348a91..7817705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,6 @@ on: push: tags: - "v*" - branches: - - main workflow_dispatch: inputs: bump: @@ -25,7 +23,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -67,7 +65,7 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref }} From 9d0b1295723463079577a1f6b34b0f018fd7c87a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:00 +0000 Subject: [PATCH 3/8] Build request paths from segments under the base URL path lookup(select_field: "city") without an IP built the path "//city", because the nil IP was interpolated as an empty segment, and build_uri replaced the whole path of base_url:, so a base URL with a path prefix (for example https://example.com/ipdata) lost that prefix. Request paths are now assembled from the non-nil segments under the base URL's own path, and a trailing slash on the base URL is tolerated. Tests cover the single-field lookup of the caller's own IP and prefixed base URLs for lookup, select_field and bulk_lookup. Co-Authored-By: Claude Fable 5.1 --- lib/ipdata/client.rb | 22 +++++++++++++--------- test/test_client.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/lib/ipdata/client.rb b/lib/ipdata/client.rb index 82af347..9631e7c 100644 --- a/lib/ipdata/client.rb +++ b/lib/ipdata/client.rb @@ -42,7 +42,8 @@ class Client # @param api_key [String] Your ipdata API key # @param cache [IPData::Cache, nil] Optional LRU cache instance # @param timeout [Integer] HTTP open, read and write timeout in seconds (default: 30) - # @param base_url [String, nil] Override the base URL (default: global endpoint) + # @param base_url [String, nil] Override the base URL (default: global endpoint). + # A path prefix, if the URL has one, is kept in front of every request path # @param keep_alive_timeout [Integer] Seconds an idle connection is kept before # the next request opens a new one (default: 60) def initialize(api_key, cache: nil, timeout: 30, base_url: nil, keep_alive_timeout: 60) @@ -53,6 +54,7 @@ def initialize(api_key, cache: nil, timeout: 30, base_url: nil, keep_alive_timeo @timeout = timeout @keep_alive_timeout = keep_alive_timeout @base_uri = URI.parse(base_url || BASE_URL) + @base_path = @base_uri.path.chomp("/") @mutex = Mutex.new @connection = nil @connection_pid = nil @@ -74,11 +76,7 @@ def self.eu(api_key, **opts) # @param select_field [String, nil] A single field to extract (returns raw value) # @return [Response, Object] Response object, or raw value if select_field is used def lookup(ip = nil, fields: nil, select_field: nil) - if select_field - path = "/#{ip}/#{select_field}" - data = get(path) - return data - end + return get(request_path(ip, select_field)) if select_field cache_key = build_cache_key(ip, fields) if @cache @@ -86,11 +84,10 @@ def lookup(ip = nil, fields: nil, select_field: nil) return cached if cached end - path = ip ? "/#{ip}" : "/" params = {} params["fields"] = fields.join(",") if fields && !fields.empty? - data = get(path, params) + data = get(request_path(ip), params) response = Response.new(data) @cache&.set(cache_key, response) @@ -110,7 +107,7 @@ def bulk_lookup(ips, fields: nil) params = {} params["fields"] = fields.join(",") if fields && !fields.empty? - data = post("/bulk", ips, params) + data = post(request_path("bulk"), ips, params) data.map { |entry| Response.new(entry) } end @@ -136,6 +133,13 @@ def build_cache_key(ip, fields) key end + # Path of a request made of +segments+, under the path prefix of the base + # URL if it has one. Nil segments are skipped, so a lookup of the caller's + # own IP uses "/" and its single-field form "/", not "//". + def request_path(*segments) + "#{@base_path}/#{segments.compact.join('/')}" + end + def build_uri(path, params = {}) uri = @base_uri.dup uri.path = path diff --git a/test/test_client.rb b/test/test_client.rb index f86c068..ca3087b 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -31,6 +31,27 @@ def test_custom_base_url assert_instance_of IPData::Client, client end + def test_base_url_path_prefix_is_kept_in_request_paths + prefixed = -> { IPData::Client.new(API_KEY, base_url: "https://gateway.example.com/ipdata") } + + assert_request(:get, "/ipdata/8.8.8.8", query: { "api-key" => API_KEY }) { prefixed.call.lookup("8.8.8.8") } + assert_request(:get, "/ipdata/", query: { "api-key" => API_KEY }) { prefixed.call.lookup } + assert_request(:get, "/ipdata/8.8.8.8/city", query: { "api-key" => API_KEY }, body: '"Mountain View"') do + assert_equal "Mountain View", prefixed.call.lookup("8.8.8.8", select_field: "city") + end + assert_request(:post, "/ipdata/bulk", query: { "api-key" => API_KEY }, body: JSON.generate(SAMPLE_BULK_RESPONSE)) do + assert_equal 2, prefixed.call.bulk_lookup(["8.8.8.8", "1.1.1.1"]).size + end + end + + def test_base_url_trailing_slash_does_not_double_the_path_separator + client = IPData::Client.new(API_KEY, base_url: "https://gateway.example.com/ipdata/") + assert_request(:get, "/ipdata/8.8.8.8", query: { "api-key" => API_KEY }) { client.lookup("8.8.8.8") } + + client = IPData::Client.new(API_KEY, base_url: "https://api.ipdata.co/") + assert_request(:get, "/8.8.8.8", query: { "api-key" => API_KEY }) { client.lookup("8.8.8.8") } + end + def test_keep_alive_timeout_option client = IPData::Client.new(API_KEY, keep_alive_timeout: 30) assert_instance_of IPData::Client, client @@ -68,6 +89,12 @@ def test_lookup_with_select_field end end + def test_lookup_with_select_field_and_no_ip_uses_a_single_segment_path + assert_request(:get, "/city", query: { "api-key" => API_KEY }, body: '"Mountain View"') do + assert_equal "Mountain View", @client.lookup(select_field: "city") + end + end + # --- bulk_lookup --- def test_bulk_lookup From 46cc3034e8ed3a3b5fae26fbb9aaa9ff4bed4bfa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:01 +0000 Subject: [PATCH 4/8] Use ipdata-ruby/ as the User-Agent product token The header read "ruby-ipdata/ Ruby/ ()". The product token now has the ipdata-/ form that the ipdata Python client also uses; the Ruby version and platform suffix is unchanged. Co-Authored-By: Claude Fable 5.1 --- lib/ipdata/client.rb | 2 +- test/test_client.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ipdata/client.rb b/lib/ipdata/client.rb index 9631e7c..fc641a4 100644 --- a/lib/ipdata/client.rb +++ b/lib/ipdata/client.rb @@ -124,7 +124,7 @@ def close private def user_agent - @user_agent ||= "ruby-ipdata/#{VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})" + @user_agent ||= "ipdata-ruby/#{VERSION} Ruby/#{RUBY_VERSION} (#{RUBY_PLATFORM})" end def build_cache_key(ip, fields) diff --git a/test/test_client.rb b/test/test_client.rb index ca3087b..a53a9af 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -230,7 +230,7 @@ def test_user_agent_header @client.lookup("8.8.8.8") end - assert_match(/ruby-ipdata\/#{IPData::VERSION}/, captured_request["User-Agent"]) + assert_match(%r{\Aipdata-ruby/#{Regexp.escape(IPData::VERSION)} }, captured_request["User-Agent"]) assert_match(/Ruby\/#{RUBY_VERSION}/, captured_request["User-Agent"]) end From 9d514a67965cbff6a00d8838facd23449801ec70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:01 +0000 Subject: [PATCH 5/8] Remove an unused local from the assert_request test helper The helper parsed the captured request path into a local that nothing read, which ruby -w reported as an assigned but unused variable. Co-Authored-By: Claude Fable 5.1 --- test/test_client.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_client.rb b/test/test_client.rb index a53a9af..2b8670f 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -561,7 +561,6 @@ def assert_request(method, path, query: {}, body: nil, request_body: nil) end assert captured_request, "Expected HTTP request to be made" - uri = URI.parse(captured_request.path.to_s + "?" + (captured_request.instance_variable_get(:@query) || "")) # Check path request_uri = URI.parse(captured_request.uri.to_s) From f4a5cc283eef8480734481b08ae1bd4d81768093 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:02 +0000 Subject: [PATCH 6/8] Fix gem metadata and add a changelog The gemspec pointed changelog_uri at a CHANGELOG.md that did not exist. The file now exists, lists the changes since the 0.1.5 release under "Unreleased" and is packaged with the gem. homepage_uri and source_code_uri were the same URL, which gem build warned about since rubygems.org shows only one of them; the homepage is now https://ipdata.co while source_code_uri keeps pointing at the repository. The stdlib comment also names openssl, which the client requires. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ ipdata.gemspec | 6 +++--- 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..af633e0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Change Log + +## Unreleased + +### Added + +- 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. +- `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:`. +- `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. + +### Changed + +- `timeout:` now also applies to writes (previously only to connecting and reading). +- `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. +- The `User-Agent` header is now `ipdata-ruby/ Ruby/ ()` instead of `ruby-ipdata/...`. +- Gem metadata: the homepage is now https://ipdata.co; `source_code_uri` keeps pointing at this repository. + +### Fixed + +- `use_ssl` follows the scheme of `base_url:`, so plain `http://` base URLs work; it used to be always on. +- `lookup(select_field: "city")` without an IP address requests `/city` instead of `//city`. +- 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. + +## 0.1.5 + +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. diff --git a/ipdata.gemspec b/ipdata.gemspec index cee4931..834ef0e 100644 --- a/ipdata.gemspec +++ b/ipdata.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "Official Ruby SDK for the ipdata.co API. " \ "Look up geolocation, threat intelligence, ASN, company, " \ "carrier, currency, timezone and language data for any IP address." - spec.homepage = "https://github.com/ipdata/ruby" + spec.homepage = "https://ipdata.co" spec.license = "MIT" spec.required_ruby_version = ">= 3.0" @@ -25,8 +25,8 @@ Gem::Specification.new do |spec| "documentation_uri" => "https://docs.ipdata.co" } - spec.files = Dir["lib/**/*.rb", "LICENSE", "README.md"] + spec.files = Dir["lib/**/*.rb", "CHANGELOG.md", "LICENSE", "README.md"] spec.require_paths = ["lib"] - # Zero runtime dependencies — uses only Ruby stdlib (net/http, json, uri, monitor) + # Zero runtime dependencies — uses only Ruby stdlib (net/http, openssl, json, uri, monitor) end From 8c133d7d5a84fc38472976971a61f8ac09e1e99b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:59:38 +0000 Subject: [PATCH 7/8] Make the test suite independent of minitest/mock The suite stubbed Net::HTTP.new and Process.pid with Object#stub from minitest/mock, which it never required: Minitest 5's minitest/autorun loaded it implicitly. Minitest 6, which Ruby 4.0 bundles, no longer ships minitest/mock (it moved to the separate minitest-mock gem), so every stubbing test failed there with NoMethodError. A small with_stub helper in test_helper.rb now replaces the singleton method for the duration of a block and restores it afterwards; the call sites change only in the helper name. Verified with Minitest 5.26 and 6.0. Co-Authored-By: Claude Fable 5.1 --- test/test_client.rb | 22 +++++++++++----------- test/test_helper.rb | 24 +++++++++++++++++++++++- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/test/test_client.rb b/test/test_client.rb index 2b8670f..3944e3b 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -146,7 +146,7 @@ def test_eu_client_uses_eu_base_url client = IPData::Client.eu(API_KEY) called = false - Net::HTTP.stub(:new, lambda { |host, port| + with_stub(Net::HTTP, :new, lambda { |host, port| assert_equal "eu-api.ipdata.co", host assert_equal 443, port called = true @@ -182,7 +182,7 @@ def test_lookup_uses_cache client = IPData::Client.new(API_KEY, cache: cache) mock = mock_http(stub_http_response) - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do # First call hits the API client.lookup("8.8.8.8") assert_equal 1, mock.requests.size @@ -198,7 +198,7 @@ def test_select_field_does_not_use_cache client = IPData::Client.new(API_KEY, cache: cache) mock = mock_http(stub_http_response(body: '"Mountain View"')) - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do client.lookup("8.8.8.8", select_field: "city") client.lookup("8.8.8.8", select_field: "city") assert_equal 2, mock.requests.size @@ -210,7 +210,7 @@ def test_different_fields_have_different_cache_keys client = IPData::Client.new(API_KEY, cache: cache) mock = mock_http(stub_http_response) - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do client.lookup("8.8.8.8", fields: ["ip"]) client.lookup("8.8.8.8", fields: ["ip", "city"]) assert_equal 2, mock.requests.size @@ -226,7 +226,7 @@ def test_user_agent_header captured_request = req end - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do @client.lookup("8.8.8.8") end @@ -243,7 +243,7 @@ def test_accept_json_header captured_request = req end - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do @client.lookup("8.8.8.8") end @@ -337,7 +337,7 @@ def test_pid_change_reconnects_without_finishing_the_inherited_connection connections = stub_connections do @client.lookup("8.8.8.8") # Pretend the client was inherited by a forked child process. - Process.stub(:pid, Process.pid + 1) { @client.lookup("8.8.8.8") } + with_stub(Process, :pid, Process.pid + 1) { @client.lookup("8.8.8.8") } end assert_equal 2, connections.size @@ -351,7 +351,7 @@ def test_pid_change_reconnects_without_finishing_the_inherited_connection def test_close_in_a_forked_child_leaves_the_inherited_connection_alone connections = stub_connections { @client.lookup("8.8.8.8") } - Process.stub(:pid, Process.pid + 1) { @client.close } + with_stub(Process, :pid, Process.pid + 1) { @client.close } assert connections.first.started? assert_equal 0, connections.first.finish_count @@ -556,7 +556,7 @@ def assert_request(method, path, query: {}, body: nil, request_body: nil) captured_request = req end - Net::HTTP.stub(:new, ->(_h, _p) { mock }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock }) do yield end @@ -582,7 +582,7 @@ def assert_request(method, path, query: {}, body: nil, request_body: nil) def assert_error_response(status, body, error_class) http_response = stub_http_response(status: status.to_s, body: body) - Net::HTTP.stub(:new, ->(_h, _p) { mock_http(http_response) }) do + with_stub(Net::HTTP, :new, ->(_h, _p) { mock_http(http_response) }) do error = assert_raises(error_class) { @client.lookup("8.8.8.8") } assert_equal status, error.status_code end @@ -601,7 +601,7 @@ def stub_connections(*outcomes) outcomes = [stub_http_response] if outcomes.empty? connections = [] - Net::HTTP.stub(:new, lambda { |host, port| + with_stub(Net::HTTP, :new, lambda { |host, port| http = mock_http(outcomes.fetch(connections.size, outcomes.last)) http.address = host http.port = port diff --git a/test/test_helper.rb b/test/test_helper.rb index 26c2963..de0a443 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,8 +4,30 @@ require "ipdata" require "minitest/autorun" -# Shared test fixtures for the ipdata test suite. +# Shared test fixtures and helpers for the ipdata test suite. module IPDataFixtures + # Replace the singleton method +name+ of +receiver+ for the duration of the + # block, then restore the original. A +replacement+ that responds to +call+ + # is invoked with the arguments of each call; any other value is returned + # as is. This mirrors Object#stub from minitest/mock, which Minitest 6 no + # longer ships (Ruby 4.0 bundles Minitest 6), so the suite does not depend + # on it. + def with_stub(receiver, name, replacement) + singleton = receiver.singleton_class + original = :"__original_#{name}" + singleton.alias_method(original, name) + singleton.define_method(name) do |*args, **kwargs, &block| + replacement.respond_to?(:call) ? replacement.call(*args, **kwargs, &block) : replacement + end + yield + ensure + if singleton.method_defined?(original) || singleton.private_method_defined?(original) + singleton.remove_method(name) + singleton.alias_method(name, original) + singleton.remove_method(original) + end + end + SAMPLE_RESPONSE = { "ip" => "8.8.8.8", "is_eu" => false, From b037c1496ed826c0643cc69a1eea51ec8c720fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:59:38 +0000 Subject: [PATCH 8/8] Keep the other CI jobs running when one Ruby version fails With the default fail-fast strategy one failing matrix job cancels the rest, which hides whether a failure is specific to that Ruby version. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9506533..5441ed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: ruby-version: ["3.0", "3.1", "3.2", "3.3", "3.4", "4.0"]