diff --git a/README.md b/README.md index c85141b..145a87c 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,32 @@ client.lookup("8.8.8.8") # served from cache ### Custom Timeout ```ruby -client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds +client = IPData::Client.new("YOUR_API_KEY", timeout: 10) # 10 seconds to connect, read and write ``` +## Connection reuse + +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. + +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. + +```ruby +client = IPData::Client.new("YOUR_API_KEY") + +client.lookup("8.8.8.8") # opens the connection (TCP + TLS handshake) +client.lookup("1.1.1.1") # reuses it + +at_exit { client.close } +``` + +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. + +```ruby +client = IPData::Client.new("YOUR_API_KEY", keep_alive_timeout: 30) +``` + +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. + ## Response Fields All responses are wrapped in `IPData::Response` objects that support both dot-notation and hash-style access: @@ -138,6 +161,8 @@ response.continent_name response.latitude response.longitude response.is_eu +response.is_satellite # satellite ISP; sent only when true, reads false otherwise +response.is_anycast # anycast address; sent only when true, reads false otherwise response.postal response.calling_code response.flag @@ -182,7 +207,10 @@ response.threat.is_known_attacker response.threat.is_known_abuser response.threat.is_threat response.threat.is_bogon -response.threat.blocklists +response.threat.blocklists # array of entries: name, site, type and, when the feed reports them: +response.threat.blocklists.first.first_seen # first day listed, "YYYY-MM-DD" (UTC); absent for some feeds +response.threat.blocklists.first.last_seen # last day listed, "YYYY-MM-DD" (UTC); absent for some feeds +response.threat.blocklists.first.days_seen # number of days listed (integer); absent for some feeds response.threat.scores # Languages (array) @@ -198,6 +226,8 @@ response[:country_code] response.to_h ``` +`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. + ## Error Handling ```ruby diff --git a/lib/ipdata/client.rb b/lib/ipdata/client.rb index 0f636bd..82af347 100644 --- a/lib/ipdata/client.rb +++ b/lib/ipdata/client.rb @@ -1,12 +1,25 @@ # frozen_string_literal: true require "net/http" +require "openssl" require "json" require "uri" module IPData # HTTP client for the ipdata.co API. # + # A client keeps one persistent HTTPS connection (HTTP/1.1 keep-alive) for + # as long as it lives: the connection is opened on the first request and + # reused for every later one, so only the first lookup pays the TCP and TLS + # handshake. Create one client per process (or per API key or endpoint), + # keep it, and call {#close} at shutdown. + # + # A client is safe to share between threads; requests are serialised on its + # single connection, so use one client per thread for parallel lookups. + # Create clients after forking: a client inherited from a parent process + # notices the new process id and opens its own connection instead of + # reusing (or closing) the parent's socket. + # # @example Basic usage # client = IPData::Client.new("YOUR_API_KEY") # response = client.lookup("8.8.8.8") @@ -22,17 +35,27 @@ class Client BASE_URL = "https://api.ipdata.co" EU_BASE_URL = "https://eu-api.ipdata.co" + # Failures that mean a reused connection died before the server answered. + RETRYABLE_ERRORS = [EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, OpenSSL::SSL::SSLError].freeze + private_constant :RETRYABLE_ERRORS + # @param api_key [String] Your ipdata API key # @param cache [IPData::Cache, nil] Optional LRU cache instance - # @param timeout [Integer] HTTP timeout in seconds (default: 30) + # @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) - def initialize(api_key, cache: nil, timeout: 30, base_url: nil) + # @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) raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty? @api_key = api_key @cache = cache @timeout = timeout + @keep_alive_timeout = keep_alive_timeout @base_uri = URI.parse(base_url || BASE_URL) + @mutex = Mutex.new + @connection = nil + @connection_pid = nil end # Create a client configured for the EU endpoint. @@ -91,6 +114,16 @@ def bulk_lookup(ips, fields: nil) data.map { |entry| Response.new(entry) } end + # Close the persistent connection, if one is open. + # + # The client stays usable: the next request opens a new connection. + # + # @return [nil] + def close + @mutex.synchronize { discard_connection } + nil + end + private def user_agent @@ -118,7 +151,7 @@ def get(path, params = {}) request["Accept"] = "application/json" request["User-Agent"] = user_agent - execute(uri, request) + execute(request) end def post(path, body, params = {}) @@ -130,16 +163,11 @@ def post(path, body, params = {}) request["User-Agent"] = user_agent request.body = JSON.generate(body) - execute(uri, request) + execute(request) end - def execute(uri, request) - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.open_timeout = @timeout - http.read_timeout = @timeout - - response = http.request(request) + def execute(request) + response = @mutex.synchronize { perform(request) } unless response.is_a?(Net::HTTPSuccess) raise Error.from_response(response.code.to_i, response.body) @@ -147,5 +175,61 @@ def execute(uri, request) JSON.parse(response.body) end + + # Send +request+ on the pooled connection. Must be called with @mutex held. + # + # The server may drop an idle connection without notice. Net::HTTP retries + # idempotent requests (GET) on such a failure itself (max_retries); POST + # /bulk is not idempotent, so Net::HTTP leaves it alone and it is retried + # here instead: exactly once, on a fresh connection, and only when the + # failure happened before any response byte arrived. An HTTP error status + # is a response and is never retried. + def perform(request) + retried = false + loop do + http = connection + responded = false + begin + return http.request(request) { responded = true } + rescue *RETRYABLE_ERRORS + discard_connection + raise if retried || responded || request.method != "POST" + + retried = true + end + end + end + + # The pooled Net::HTTP, opened on first use. A connection inherited from a + # parent process is dropped without finish (the socket is shared with the + # parent, so no TLS close_notify may be sent on it) and a new one opened. + def connection + discard_connection if @connection && @connection_pid != Process.pid + @connection ||= connect + end + + def connect + http = Net::HTTP.new(@base_uri.host, @base_uri.port) + http.use_ssl = @base_uri.scheme == "https" + http.open_timeout = @timeout + http.read_timeout = @timeout + http.write_timeout = @timeout + http.keep_alive_timeout = @keep_alive_timeout + http.max_retries = 1 + http.start + @connection_pid = Process.pid + http + end + + # Forget the pooled connection, finishing it only if this process opened it. + def discard_connection + http = @connection + @connection = nil + return unless http && @connection_pid == Process.pid + + http.finish if http.started? + rescue IOError, SystemCallError + nil + end end end diff --git a/lib/ipdata/response.rb b/lib/ipdata/response.rb index 78f1006..be2e6e6 100644 --- a/lib/ipdata/response.rb +++ b/lib/ipdata/response.rb @@ -14,6 +14,9 @@ module IPData # # Nested hashes are recursively wrapped as Response objects. # Arrays of hashes (e.g. languages, blocklists) are mapped to Response arrays. + # + # Keys the API sends only when they are true (+is_satellite+, +is_anycast+) + # have explicit readers that return false when the key is absent. class Response def initialize(data) @data = data || {} @@ -24,6 +27,23 @@ def [](key) wrap(@data[key.to_s]) end + # Whether the address belongs to a satellite internet provider. + # + # The API includes the +is_satellite+ key only when it is true, so this + # reads +false+ when the key is absent instead of raising NoMethodError. + # The raw hash (+to_h+, +[]+, +key?+) is left untouched. + def is_satellite + @data.fetch("is_satellite", false) + end + + # Whether the address is anycast (announced from several locations at once). + # + # The API includes the +is_anycast+ key only when it is true, so this + # reads +false+ when the key is absent. The raw hash is left untouched. + def is_anycast + @data.fetch("is_anycast", false) + end + # Returns the underlying raw Hash. def to_h @data diff --git a/test/test_client.rb b/test/test_client.rb index 295e686..f86c068 100644 --- a/test/test_client.rb +++ b/test/test_client.rb @@ -31,6 +31,11 @@ def test_custom_base_url assert_instance_of IPData::Client, client end + def test_keep_alive_timeout_option + client = IPData::Client.new(API_KEY, keep_alive_timeout: 30) + assert_instance_of IPData::Client, client + end + # --- lookup --- def test_lookup_with_ip @@ -148,49 +153,40 @@ def test_raises_generic_error_on_500 def test_lookup_uses_cache cache = IPData::Cache.new client = IPData::Client.new(API_KEY, cache: cache) - call_count = 0 + mock = mock_http(stub_http_response) - Net::HTTP.stub(:new, lambda { |_host, _port| - call_count += 1 - mock_http(stub_http_response) - }) do + Net::HTTP.stub(:new, ->(_h, _p) { mock }) do # First call hits the API client.lookup("8.8.8.8") - assert_equal 1, call_count + assert_equal 1, mock.requests.size # Second call served from cache client.lookup("8.8.8.8") - assert_equal 1, call_count + assert_equal 1, mock.requests.size end end def test_select_field_does_not_use_cache cache = IPData::Cache.new client = IPData::Client.new(API_KEY, cache: cache) - call_count = 0 + mock = mock_http(stub_http_response(body: '"Mountain View"')) - Net::HTTP.stub(:new, lambda { |_host, _port| - call_count += 1 - mock_http(stub_http_response(body: '"Mountain View"')) - }) do + Net::HTTP.stub(: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, call_count + assert_equal 2, mock.requests.size end end def test_different_fields_have_different_cache_keys cache = IPData::Cache.new client = IPData::Client.new(API_KEY, cache: cache) - call_count = 0 + mock = mock_http(stub_http_response) - Net::HTTP.stub(:new, lambda { |_host, _port| - call_count += 1 - mock_http(stub_http_response) - }) do + Net::HTTP.stub(: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, call_count + assert_equal 2, mock.requests.size end end @@ -227,6 +223,300 @@ def test_accept_json_header assert_equal "application/json", captured_request["Accept"] end + # --- Connection reuse --- + + def test_consecutive_calls_reuse_one_connection + answer = lambda do |req, &on_response| + body = req.method == "POST" ? JSON.generate(SAMPLE_BULK_RESPONSE) : JSON.generate(SAMPLE_RESPONSE) + response = stub_http_response(body: body) + on_response&.call(response) + response + end + + connections = stub_connections(answer) do + @client.lookup("8.8.8.8") + @client.lookup("1.1.1.1") + @client.bulk_lookup(["8.8.8.8", "1.1.1.1"]) + end + + assert_equal 1, connections.size, "expected one Net::HTTP object for the lifetime of the client" + http = connections.first + assert_equal %w[GET GET POST], http.requests.map(&:method) + assert_equal 1, http.start_count + assert http.started?, "the connection stays open between requests" + end + + def test_connection_is_configured_from_options_and_started_before_use + client = IPData::Client.new(API_KEY, timeout: 7, keep_alive_timeout: 12) + connections = stub_connections { client.lookup("8.8.8.8") } + + http = connections.first + assert_equal "api.ipdata.co", http.address + assert_equal 443, http.port + assert_equal true, http.settings[:use_ssl] + assert_equal 7, http.settings[:open_timeout] + assert_equal 7, http.settings[:read_timeout] + assert_equal 7, http.settings[:write_timeout] + assert_equal 12, http.settings[:keep_alive_timeout] + assert_equal 1, http.settings[:max_retries] + assert_equal 1, http.start_count + end + + def test_keep_alive_timeout_defaults_to_60_seconds + connections = stub_connections { @client.lookup("8.8.8.8") } + assert_equal 60, connections.first.settings[:keep_alive_timeout] + end + + def test_use_ssl_follows_the_base_url_scheme + client = IPData::Client.new(API_KEY, base_url: "http://localhost:8080") + connections = stub_connections { client.lookup("8.8.8.8") } + + http = connections.first + assert_equal "localhost", http.address + assert_equal 8080, http.port + assert_equal false, http.settings[:use_ssl] + end + + def test_close_finishes_the_connection + connections = stub_connections { @client.lookup("8.8.8.8") } + http = connections.first + assert http.started? + + assert_nil @client.close + + refute http.started? + assert_equal 1, http.finish_count + end + + def test_close_without_a_connection_is_a_no_op + assert_nil @client.close + assert_nil @client.close + end + + def test_lookup_after_close_opens_a_new_connection + connections = stub_connections do + @client.lookup("8.8.8.8") + @client.close + @client.lookup("8.8.8.8") + end + + assert_equal 2, connections.size + refute connections[0].started? + assert connections[1].started? + assert_equal [1, 1], connections.map { |http| http.requests.size } + end + + 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") } + end + + assert_equal 2, connections.size + inherited, own = connections + assert inherited.started?, "the parent's connection must not be finished from the child" + assert_equal 0, inherited.finish_count + assert_equal 1, inherited.requests.size + assert_equal 1, own.requests.size + end + + 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 } + + assert connections.first.started? + assert_equal 0, connections.first.finish_count + end + + def test_bulk_lookup_is_retried_once_on_a_dead_connection + bulk = stub_http_response(body: JSON.generate(SAMPLE_BULK_RESPONSE)) + connections = stub_connections(EOFError.new("end of file reached"), bulk) do + responses = @client.bulk_lookup(["8.8.8.8", "1.1.1.1"]) + assert_equal ["8.8.8.8", "1.1.1.1"], responses.map(&:ip) + end + + assert_equal 2, connections.size, "the dead connection is discarded and a new one opened" + dead, fresh = connections + assert_equal 1, dead.requests.size + assert_equal 1, dead.finish_count + assert_equal 1, fresh.requests.size + assert_equal "POST", fresh.requests.first.method + assert_equal JSON.generate(["8.8.8.8", "1.1.1.1"]), fresh.requests.first.body + end + + def test_bulk_lookup_is_retried_on_each_dead_connection_error + errors = [ + IOError.new("closed stream"), + Errno::ECONNRESET.new, + Errno::EPIPE.new, + OpenSSL::SSL::SSLError.new("SSL_read: unexpected eof while reading") + ] + + errors.each do |error| + client = IPData::Client.new(API_KEY) + bulk = stub_http_response(body: JSON.generate([SAMPLE_RESPONSE])) + connections = stub_connections(error, bulk) do + assert_equal 1, client.bulk_lookup(["8.8.8.8"]).size, "expected a retry after #{error.class}" + end + assert_equal 2, connections.size, "expected a new connection after #{error.class}" + end + end + + def test_bulk_lookup_is_retried_only_once + connections = stub_connections(EOFError.new("end of file reached")) do + assert_raises(EOFError) { @client.bulk_lookup(["8.8.8.8"]) } + end + + assert_equal 2, connections.size + assert_equal [1, 1], connections.map { |http| http.requests.size } + end + + def test_bulk_lookup_is_not_retried_after_response_bytes_arrived + dies_mid_body = lambda do |_req, &on_response| + on_response&.call(stub_http_response) + raise EOFError, "end of file reached" + end + + connections = stub_connections(dies_mid_body) do + assert_raises(EOFError) { @client.bulk_lookup(["8.8.8.8"]) } + end + + assert_equal 1, connections.size + assert_equal 1, connections.first.requests.size + end + + def test_bulk_lookup_is_not_retried_on_an_http_error_status + failure = stub_http_response(status: "500", body: '{"message": "Server error"}') + connections = stub_connections(failure) do + assert_raises(IPData::Error) { @client.bulk_lookup(["8.8.8.8"]) } + end + + assert_equal 1, connections.size + assert_equal 1, connections.first.requests.size + end + + def test_bulk_lookup_is_not_retried_on_a_timeout + connections = stub_connections(Net::ReadTimeout.new) do + assert_raises(Net::ReadTimeout) { @client.bulk_lookup(["8.8.8.8"]) } + end + + assert_equal 1, connections.size + assert_equal 1, connections.first.requests.size + end + + def test_get_is_not_retried_by_the_client + # Net::HTTP retries idempotent requests itself; the client must not add a second attempt. + connections = stub_connections(EOFError.new("end of file reached")) do + assert_raises(EOFError) { @client.lookup("8.8.8.8") } + end + + assert_equal 1, connections.size + assert_equal 1, connections.first.requests.size + end + + def test_dead_connection_is_replaced_on_the_next_call + connections = stub_connections(EOFError.new("end of file reached"), stub_http_response) do + assert_raises(EOFError) { @client.lookup("8.8.8.8") } + @client.lookup("8.8.8.8") + end + + assert_equal 2, connections.size + assert_equal 1, connections[0].finish_count + assert connections[1].started? + end + + def test_requests_from_several_threads_are_serialised_on_the_one_connection + in_flight = 0 + max_in_flight = 0 + counter = Mutex.new + one_at_a_time = lambda do |_req, &on_response| + counter.synchronize do + in_flight += 1 + max_in_flight = [max_in_flight, in_flight].max + end + Thread.pass + response = stub_http_response + on_response&.call(response) + counter.synchronize { in_flight -= 1 } + response + end + + connections = stub_connections(one_at_a_time) do + threads = Array.new(4) { Thread.new { 25.times { @client.lookup("8.8.8.8") } } } + threads.each(&:join) + end + + assert_equal 1, connections.size + assert_equal 100, connections.first.requests.size + assert_equal 1, max_in_flight, "requests must not overlap on the shared connection" + end + + # --- is_satellite / is_anycast and blocklist dates --- + + def test_lookup_exposes_is_satellite + body = JSON.generate(SAMPLE_SATELLITE_RESPONSE) + assert_request(:get, "/98.97.34.0", query: { "api-key" => API_KEY }, body: body) do + response = @client.lookup("98.97.34.0") + assert_equal true, response.is_satellite + assert_equal false, response.is_anycast + assert_equal "AS14593", response.asn.asn + end + end + + def test_lookup_exposes_is_anycast_and_dated_blocklist_entries + body = JSON.generate(SAMPLE_ANYCAST_RESPONSE) + assert_request(:get, "/8.8.8.8", query: { "api-key" => API_KEY }, body: body) do + response = @client.lookup("8.8.8.8") + assert_equal true, response.is_anycast + assert_equal false, response.is_satellite + + entry = response.threat.blocklists.first + assert_equal "2026-11-02", entry.first_seen + assert_equal "2026-11-02", entry.last_seen + assert_equal 1, entry.days_seen + end + end + + def test_fields_accept_the_new_flag_names + query = { "api-key" => API_KEY, "fields" => "ip,is_satellite,is_anycast" } + assert_request(:get, "/8.8.8.8", query: query, body: JSON.generate("ip" => "8.8.8.8")) do + response = @client.lookup("8.8.8.8", fields: ["ip", "is_satellite", "is_anycast"]) + assert_equal "8.8.8.8", response.ip + assert_equal false, response.is_satellite + assert_equal false, response.is_anycast + end + end + + def test_select_field_accepts_the_new_flag_names + assert_request(:get, "/98.97.34.0/is_satellite", query: { "api-key" => API_KEY }, body: "true") do + assert_equal true, @client.lookup("98.97.34.0", select_field: "is_satellite") + end + end + + def test_bulk_lookup_items_carry_the_new_fields + ips = ["8.8.8.8", "187.13.250.1"] + body = JSON.generate([SAMPLE_ANYCAST_RESPONSE, SAMPLE_VPN_RESPONSE]) + + assert_request(:post, "/bulk", query: { "api-key" => API_KEY }, body: body, request_body: JSON.generate(ips)) do + anycast, vpn = @client.bulk_lookup(ips) + + assert_equal true, anycast.is_anycast + assert_equal false, anycast.is_satellite + assert_equal "2026-11-02", anycast.threat.blocklists.first.first_seen + + assert_equal false, vpn.is_anycast + assert_equal false, vpn.is_satellite + assert_equal true, vpn.threat.is_vpn + assert_equal 6, vpn.threat.blocklists.size + assert_equal "2026-10-31", vpn.threat.blocklists.first.first_seen + assert_equal 2, vpn.threat.blocklists.first.days_seen + refute vpn.threat.blocklists[2].key?("first_seen") + assert_nil vpn.threat.blocklists[2]["days_seen"] + end + end + private # Helper to assert a request was made with expected parameters. @@ -272,18 +562,88 @@ def assert_error_response(status, body, error_class) end end - # Create a mock Net::HTTP object that returns the given response. - def mock_http(response, &on_request) - http = Object.new + # Create a fake Net::HTTP that answers every request with +outcome+ (see FakeHTTP). + def mock_http(outcome, &on_request) + FakeHTTP.new(outcome, &on_request) + end + + # Stub Net::HTTP.new to hand out a new FakeHTTP per call and return every + # connection the client opened. Connection i answers with outcomes[i] (the + # last outcome repeats); with no outcomes every connection returns the + # sample response. + def stub_connections(*outcomes) + outcomes = [stub_http_response] if outcomes.empty? + connections = [] - http.define_singleton_method(:use_ssl=) { |_v| } - http.define_singleton_method(:open_timeout=) { |_v| } - http.define_singleton_method(:read_timeout=) { |_v| } - http.define_singleton_method(:request) do |req| - on_request&.call(req) - response + Net::HTTP.stub(:new, lambda { |host, port| + http = mock_http(outcomes.fetch(connections.size, outcomes.last)) + http.address = host + http.port = port + connections << http + http + }) do + yield end - http + connections + end + + # Stand-in for Net::HTTP that records how the client configures and drives it. + # + # +outcome+ is the response to return, an exception to raise before any + # response byte arrives (a dead connection), or a Proc called with the + # request and the response block for finer control. + class FakeHTTP + attr_accessor :address, :port + attr_reader :settings, :requests, :start_count, :finish_count + + def initialize(outcome, &on_request) + @outcome = outcome + @on_request = on_request + @settings = {} + @requests = [] + @started = false + @start_count = 0 + @finish_count = 0 + end + + %i[use_ssl open_timeout read_timeout write_timeout keep_alive_timeout max_retries].each do |setting| + define_method(:"#{setting}=") { |value| @settings[setting] = value } + end + + def start + raise IOError, "HTTP session already opened" if @started + + @started = true + @start_count += 1 + self + end + + def started? + @started + end + + def finish + raise IOError, "HTTP session not yet started" unless @started + + @started = false + @finish_count += 1 + end + + # Like Net::HTTP#request: yields the response once its headers arrived, then returns it. + def request(req, &on_response) + raise Minitest::Assertion, "request sent on a connection that was not started" unless @started + + @requests << req + @on_request&.call(req) + + case @outcome + when Proc then @outcome.call(req, &on_response) + when Exception then raise @outcome + else + on_response&.call(@outcome) + @outcome + end + end end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 493efb5..26c2963 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -86,6 +86,143 @@ module IPDataFixtures SAMPLE_RESPONSE.merge("ip" => "1.1.1.1", "city" => "Los Angeles", "country_name" => "Australia") ].freeze + # Sample A: satellite ISP. The API includes is_satellite only when it is true. + SAMPLE_SATELLITE_RESPONSE = { + "ip" => "98.97.34.0", + "is_satellite" => true, + "is_eu" => false, + "city" => "Seattle", + "country_code" => "US", + "asn" => { + "asn" => "AS14593", + "name" => "Space Exploration Technologies Corporation", + "domain" => "spacex.com", + "route" => "98.97.32.0/21", + "type" => "business" + }, + "threat" => { + "is_tor" => false, + "is_vpn" => false, + "is_icloud_relay" => false, + "is_proxy" => false, + "is_datacenter" => false, + "is_anonymous" => false, + "is_known_attacker" => false, + "is_known_abuser" => false, + "is_threat" => false, + "is_bogon" => false, + "blocklists" => [], + "scores" => { "vpn_score" => 0, "proxy_score" => 1, "threat_score" => 8, "trust_score" => 97 } + }, + "count" => "0" + }.freeze + + # Sample B: anycast address with dated blocklist entries (complete response). + SAMPLE_ANYCAST_RESPONSE = { + "ip" => "8.8.8.8", + "is_anycast" => true, + "is_eu" => false, + "city" => nil, + "region" => nil, + "region_code" => nil, + "region_type" => nil, + "country_name" => "United States", + "country_code" => "US", + "continent_name" => "North America", + "continent_code" => "NA", + "latitude" => 39.7599983215332, + "longitude" => -98.5, + "postal" => nil, + "calling_code" => "1", + "flag" => "https://ipdata.co/flags/us.png", + "emoji_flag" => "\u{1F1FA}\u{1F1F8}", + "emoji_unicode" => "U+1F1FA U+1F1F8", + "asn" => { + "asn" => "AS15169", + "name" => "Google LLC", + "domain" => "about.google", + "route" => "8.8.8.0/24", + "type" => "hosting" + }, + "company" => { + "name" => "Google LLC", + "domain" => "about.google", + "network" => "8.8.8.0/24", + "type" => "hosting" + }, + "languages" => [ + { "name" => "English", "native" => "English", "code" => "en" } + ], + "currency" => { + "name" => "US Dollar", + "code" => "USD", + "symbol" => "$", + "native" => "$", + "plural" => "US dollars" + }, + "time_zone" => { + "name" => nil, + "abbr" => nil, + "offset" => nil, + "is_dst" => nil, + "current_time" => nil + }, + "threat" => { + "is_tor" => false, + "is_vpn" => false, + "is_icloud_relay" => false, + "is_proxy" => false, + "is_datacenter" => true, + "is_anonymous" => false, + "is_known_attacker" => false, + "is_known_abuser" => false, + "is_threat" => false, + "is_bogon" => false, + "blocklists" => [ + { "days_seen" => 1, "first_seen" => "2026-11-02", "last_seen" => "2026-11-02", + "name" => "ipdata", "site" => "https://ipdata.co", "type" => "cloud" }, + { "days_seen" => 1, "first_seen" => "2026-11-02", "last_seen" => "2026-11-02", + "name" => "ipdata", "site" => "https://ipdata.co", "type" => "open_resolvers" } + ], + "scores" => { "vpn_score" => 100, "proxy_score" => 93, "threat_score" => 100, "trust_score" => 2 } + }, + "count" => "0" + }.freeze + + # Sample C: VPN exit (excerpt). Neither top-level flag is present, and the + # third blocklist entry (ipdata's own vpn feed) carries no dates. + SAMPLE_VPN_RESPONSE = { + "ip" => "187.13.250.1", + "is_eu" => false, + "threat" => { + "is_tor" => false, + "is_vpn" => true, + "is_icloud_relay" => false, + "is_proxy" => false, + "is_datacenter" => true, + "is_anonymous" => true, + "is_known_attacker" => true, + "is_known_abuser" => true, + "is_threat" => true, + "is_bogon" => false, + "blocklists" => [ + { "days_seen" => 2, "first_seen" => "2026-10-31", "last_seen" => "2026-11-02", + "name" => "Gluetun", "site" => "https://github.com/qdm12/gluetun", "type" => "vpn" }, + { "days_seen" => 1, "first_seen" => "2026-11-02", "last_seen" => "2026-11-02", + "name" => "ipdata", "site" => "https://ipdata.co", "type" => "cloud" }, + { "name" => "ipdata", "site" => "https://ipdata.co", "type" => "vpn" }, + { "days_seen" => 2, "first_seen" => "2026-10-31", "last_seen" => "2026-11-02", + "name" => "NordVPN", "site" => "https://nordvpn.com", "type" => "vpn" }, + { "days_seen" => 2, "first_seen" => "2026-10-31", "last_seen" => "2026-11-02", + "name" => "Wikimedia Global Blocklist", "site" => "https://wikimedia.org", "type" => "general" }, + { "days_seen" => 2, "first_seen" => "2026-10-31", "last_seen" => "2026-11-02", + "name" => "X4BNet", "site" => "https://github.com/X4BNet/lists_vpn", "type" => "vpn" } + ], + "scores" => { "vpn_score" => 100, "proxy_score" => 99, "threat_score" => 100, "trust_score" => 0 } + }, + "count" => "0" + }.freeze + # Helper to create a stubbed Net::HTTP response. def stub_http_response(status: "200", body: nil) response = Net::HTTPResponse.allocate diff --git a/test/test_response.rb b/test/test_response.rb index 7b0db2f..2065100 100644 --- a/test/test_response.rb +++ b/test/test_response.rb @@ -224,6 +224,141 @@ def test_method_missing_raises_for_unknown_key assert_raises(NoMethodError) { @response.nonexistent_field } end + # --- is_satellite / is_anycast (sent by the API only when true) --- + + def test_is_satellite_reads_true_when_present + response = IPData::Response.new(SAMPLE_SATELLITE_RESPONSE) + assert_equal true, response.is_satellite + assert_equal true, response["is_satellite"] + assert response.key?("is_satellite") + end + + def test_is_anycast_reads_true_when_present + response = IPData::Response.new(SAMPLE_ANYCAST_RESPONSE) + assert_equal true, response.is_anycast + assert_equal true, response[:is_anycast] + assert response.key?(:is_anycast) + end + + def test_new_flags_read_false_when_absent + assert_equal false, @response.is_satellite + assert_equal false, @response.is_anycast + + assert_equal false, IPData::Response.new(SAMPLE_SATELLITE_RESPONSE).is_anycast + assert_equal false, IPData::Response.new(SAMPLE_ANYCAST_RESPONSE).is_satellite + + vpn = IPData::Response.new(SAMPLE_VPN_RESPONSE) + assert_equal false, vpn.is_satellite + assert_equal false, vpn.is_anycast + end + + def test_new_flags_leave_the_raw_hash_untouched + refute @response.key?("is_satellite") + refute @response.key?("is_anycast") + assert_nil @response["is_satellite"] + assert_nil @response[:is_anycast] + assert_equal SAMPLE_RESPONSE, @response.to_h + + keys = [] + @response.each { |k, _v| keys << k } + refute_includes keys, "is_satellite" + refute_includes keys, "is_anycast" + refute_includes JSON.parse(@response.to_json).keys, "is_satellite" + end + + def test_new_flags_are_always_responded_to + assert_respond_to @response, :is_satellite + assert_respond_to @response, :is_anycast + assert_respond_to IPData::Response.new(SAMPLE_SATELLITE_RESPONSE), :is_satellite + end + + def test_satellite_sample_other_fields + response = IPData::Response.new(SAMPLE_SATELLITE_RESPONSE) + assert_equal "98.97.34.0", response.ip + assert_equal false, response.is_eu + assert_equal "Seattle", response.city + assert_equal "AS14593", response.asn.asn + assert_equal "spacex.com", response.asn.domain + assert_empty response.threat.blocklists + assert_equal 97, response.threat.scores.trust_score + end + + def test_anycast_sample_null_fields_pass_through + response = IPData::Response.new(SAMPLE_ANYCAST_RESPONSE) + assert_nil response.city + assert_nil response.region_type + assert_nil response.time_zone.name + assert_equal "hosting", response.asn.type + assert_equal "about.google", response.company.domain + assert_equal true, response.threat.is_datacenter + end + + # --- Blocklist entries: first_seen, last_seen, days_seen --- + + def test_blocklist_entries_carry_dates_and_days_seen + blocklists = IPData::Response.new(SAMPLE_ANYCAST_RESPONSE).threat.blocklists + assert_equal 2, blocklists.size + + entry = blocklists.first + assert_instance_of IPData::Response, entry + assert_equal "ipdata", entry.name + assert_equal "https://ipdata.co", entry.site + assert_equal "cloud", entry.type + assert_equal 1, entry.days_seen + assert_equal "2026-11-02", entry.first_seen + assert_equal "2026-11-02", entry.last_seen + assert_equal "open_resolvers", blocklists.last.type + end + + def test_blocklist_dates_stay_strings + entry = IPData::Response.new(SAMPLE_VPN_RESPONSE).threat.blocklists.first + assert_equal "Gluetun", entry.name + assert_instance_of String, entry.first_seen + assert_instance_of String, entry.last_seen + assert_equal "2026-10-31", entry.first_seen + assert_equal "2026-11-02", entry.last_seen + assert_equal "2026-10-31", entry["first_seen"] + assert_equal 2, entry.days_seen + assert_kind_of Integer, entry.days_seen + end + + def test_blocklist_entry_without_dates + blocklists = IPData::Response.new(SAMPLE_VPN_RESPONSE).threat.blocklists + assert_equal 6, blocklists.size + assert_equal 5, blocklists.count { |entry| entry.key?("first_seen") } + + undated = blocklists[2] + assert_equal "ipdata", undated.name + assert_equal "https://ipdata.co", undated.site + assert_equal "vpn", undated.type + refute undated.key?("first_seen") + refute undated.key?("last_seen") + refute undated.key?("days_seen") + assert_nil undated["first_seen"] + assert_nil undated["last_seen"] + assert_nil undated[:days_seen] + refute_respond_to undated, :first_seen + assert_equal({ "name" => "ipdata", "site" => "https://ipdata.co", "type" => "vpn" }, undated.to_h) + end + + def test_vpn_sample_threat_flags + threat = IPData::Response.new(SAMPLE_VPN_RESPONSE).threat + assert_equal true, threat.is_vpn + assert_equal true, threat.is_anonymous + assert_equal true, threat.is_threat + assert_equal false, threat.is_tor + assert_equal 0, threat.scores.trust_score + end + + # --- Unknown keys --- + + def test_unknown_keys_pass_through + response = IPData::Response.new(SAMPLE_RESPONSE.merge("some_future_field" => { "x" => 1 })) + assert_equal 1, response.some_future_field.x + assert_equal({ "x" => 1 }, response["some_future_field"].to_h) + assert_equal "8.8.8.8", response.ip + end + # --- Edge cases --- def test_nil_data