From fe5a4c843e6c4630f66ce62919d9779b1e39f8ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:24 +0000 Subject: [PATCH 1/9] Verify the server certificate on every HTTP::Tiny version HTTP::Tiny verifies TLS certificates by default only since 0.083, and even then the default can be switched off through an environment variable, so the connection to the API went unverified on older HTTP::Tiny. Pass verify_SSL => 1 explicitly and add the constructor option verify_ssl (default 1; 0 disables the check, for example for a local test endpoint) with a matching accessor. Document that verification needs a CA bundle (Mozilla::CA or the operating system's). Co-Authored-By: Claude Fable 5.1 --- Changes | 7 +++++++ README.md | 10 ++++++++-- lib/Net/IPData.pm | 39 +++++++++++++++++++++++++++++++++++---- t/01-constructor.t | 17 ++++++++++++++++- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Changes b/Changes index 59848bf..ece0caf 100644 --- a/Changes +++ b/Changes @@ -1,5 +1,12 @@ Revision history for Net-IPData +Unreleased + - The API server's TLS certificate is now verified with every HTTP::Tiny + version: verify_SSL => 1 is passed explicitly (HTTP::Tiny before 0.083 + verified only when asked to, and 0.083+ can be told not to through an + environment variable), with a new constructor option verify_ssl + (default 1; 0 disables the check) and a matching accessor + 1.1.0 2026-11-03 - Connection reuse: the HTTP::Tiny user agent keeps one HTTPS connection alive across requests (keep_alive => 1, now explicit and configurable); diff --git a/README.md b/README.md index 6d0163c..984f923 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Perl client for the [ipdata.co](https://ipdata.co/) IP geolocation and threat in - **EU endpoint** -- route requests through EU-only servers for GDPR compliance - **Zero non-core dependencies** -- uses only `HTTP::Tiny` and `JSON::PP` (core since Perl 5.14) - **Connection reuse** -- one persistent HTTPS connection per client object (HTTP/1.1 keep-alive), so only the first request pays the TLS handshake +- **Certificate verification** -- the API server's TLS certificate is verified with every `HTTP::Tiny` version (`verify_ssl => 0` turns it off) - **Production ready** -- input validation, structured error handling, configurable timeouts ## Installation @@ -193,7 +194,11 @@ A `Net::IPData` object is not shared between threads: use one per thread. It is Pass `keep_alive => 0` to open a new connection per request, or supply your own user agent with `ua => $obj` (any object with `HTTP::Tiny`-compatible `get`/`post` methods returning `{status, content, headers, success}`), for example a pre-configured `HTTP::Tiny` or a mock in tests: ```perl -my $ua = HTTP::Tiny->new(verify_SSL => 1, keep_alive => 1, timeout => 10); +my $ua = HTTP::Tiny->new( + keep_alive => 1, + timeout => 10, + SSL_options => { SSL_ca_file => '/etc/ssl/private-ca.pem' }, +); my $ipdata = Net::IPData->new(api_key => 'YOUR_API_KEY', ua => $ua); ``` @@ -207,6 +212,7 @@ my $ipdata = Net::IPData->new(api_key => 'YOUR_API_KEY', ua => $ua); | `timeout` | int | `30` | HTTP timeout in seconds | | `keep_alive` | bool | `1` | Keep the HTTPS connection open between requests (HTTP/1.1 keep-alive); `0` opens a new connection per request | | `keep_alive_timeout` | int | *unset* | Seconds a kept-alive connection may sit idle before the next request opens a fresh one; honoured by `HTTP::Tiny` 0.092+ (older versions ignore it) and forwarded only when set; `60` suits api.ipdata.co | +| `verify_ssl` | bool | `1` | Verify the API server's TLS certificate. Passed to `HTTP::Tiny` as `verify_SSL`, so the check applies with every `HTTP::Tiny` version (not only 0.083+, which verify by default) and needs a CA bundle (`Mozilla::CA` or the operating system's); `0` connects without checking the server's identity | | `ua` | object | new `HTTP::Tiny` | Your own user agent: any object with `HTTP::Tiny`-compatible `get`/`post` returning `{status, content, headers, success}` | ## Methods @@ -284,7 +290,7 @@ Some feeds, for example ipdata's own vpn feed, omit all three blocklist fields, - Perl 5.10.1 or later - Core modules only: [HTTP::Tiny](https://metacpan.org/pod/HTTP::Tiny) 0.049 or later (ships with Perl 5.22+; older Perls install it from CPAN), [JSON::PP](https://metacpan.org/pod/JSON::PP), [Carp](https://metacpan.org/pod/Carp), [Scalar::Util](https://metacpan.org/pod/Scalar::Util) -- Recommended: [IO::Socket::SSL](https://metacpan.org/pod/IO::Socket::SSL) and [Net::SSLeay](https://metacpan.org/pod/Net::SSLeay) for HTTPS support +- Recommended: [IO::Socket::SSL](https://metacpan.org/pod/IO::Socket::SSL) 1.56+ and [Net::SSLeay](https://metacpan.org/pod/Net::SSLeay) 1.49+ for HTTPS, plus a CA bundle ([Mozilla::CA](https://metacpan.org/pod/Mozilla::CA) or the operating system's) for the certificate verification that is on by default (`verify_ssl => 1`) ## Testing diff --git a/lib/Net/IPData.pm b/lib/Net/IPData.pm index 34780bb..ac9db65 100644 --- a/lib/Net/IPData.pm +++ b/lib/Net/IPData.pm @@ -52,6 +52,7 @@ sub new { my $timeout = $args{timeout} // 30; my $keep_alive = $args{keep_alive} // 1; my $keep_alive_timeout = $args{keep_alive_timeout}; + my $verify_ssl = ($args{verify_ssl} // 1) ? 1 : 0; # One user agent per Net::IPData object: it owns the kept-alive HTTPS # connection, so building it once here (and never per request) is what @@ -66,6 +67,10 @@ sub new { agent => "Net-IPData-Perl/$VERSION", timeout => $timeout, keep_alive => $keep_alive, + # Always passed explicitly: HTTP::Tiny before 0.083 verifies the + # server certificate only when asked to, and from 0.083 on its + # default can be switched off by an environment variable. + verify_SSL => $verify_ssl, # Forwarded only when the caller asked for one, so HTTP::Tiny's # own default (no idle limit) applies otherwise; HTTP::Tiny # before 0.092 has no such setting and ignores the key. @@ -93,6 +98,7 @@ sub new { timeout => $timeout, keep_alive => $keep_alive, keep_alive_timeout => $keep_alive_timeout, + verify_ssl => $verify_ssl, }, $class; } @@ -213,6 +219,7 @@ sub base_url { return $_[0]->{base_url} } sub timeout { return $_[0]->{timeout} } sub keep_alive { return $_[0]->{keep_alive} } sub keep_alive_timeout { return $_[0]->{keep_alive_timeout} } +sub verify_ssl { return $_[0]->{verify_ssl} } sub ua { return $_[0]->{ua} } # --- Private Methods --- @@ -365,6 +372,7 @@ has no non-core dependencies. timeout => 30, # HTTP timeout in seconds (default: 30) keep_alive => 1, # reuse the HTTPS connection (default: 1) keep_alive_timeout => 60, # idle timeout in seconds (default: none) + verify_ssl => 1, # verify the server certificate (default: 1) ua => $user_agent, # your own HTTP::Tiny-compatible object ); @@ -386,15 +394,30 @@ such setting and ignores it, in which case the value is merely stored (see L). api.ipdata.co keeps idle connections open for roughly two minutes, so 60 is a sensible value. Not applied when C is supplied. +=item verify_ssl + +Whether the API server's TLS certificate is verified (chain and host name), +default 1. The value is passed to L as C, so +certificates are checked with every HTTP::Tiny version, not only with 0.083 +and later (which verify by default), and independently of HTTP::Tiny's +C environment variable. +Verification needs a CA bundle: HTTP::Tiny uses L when it is +installed and otherwise the operating system's bundle (see "SSL SUPPORT" in +L); without one every request fails with a C. +Set it to 0 only when you must talk to a server whose certificate cannot be +verified, for example a local test endpoint given as C: the +connection is still encrypted but the server's identity is not checked. Not +applied when C is supplied. + =item ua An object to use instead of a new L: anything with HTTP::Tiny-compatible C and C methods that return a hashref with C, C, C and C. Use -it to share one pre-configured HTTP::Tiny (proxy, C, -C) or to inject a mock in tests. C, C and -C are not applied to a supplied object; configure it -yourself. +it to share one pre-configured HTTP::Tiny (proxy, C such as a +private CA file) or to inject a mock in tests. C, C, +C and C are not applied to a supplied +object; configure it yourself. =back @@ -516,6 +539,8 @@ The following methods call C for common fields: =item keep_alive_timeout - The idle timeout given to the constructor, or undef +=item verify_ssl - Whether the server certificate is verified (default 1) + =item ua - The user agent object (an L unless one was injected) =back @@ -607,6 +632,12 @@ with Perl 5.22 and later, older Perls install it from CPAN) =back +HTTPS itself needs L 1.56 and L 1.49 or later, +plus a CA bundle (L or the operating system's) for the +certificate verification that is on by default (see L). They +are recommended rather than required prerequisites so that the module can be +installed where they are not available. + =head1 SEE ALSO L - ipdata API documentation diff --git a/t/01-constructor.t b/t/01-constructor.t index bdb3b7f..e765132 100644 --- a/t/01-constructor.t +++ b/t/01-constructor.t @@ -1,7 +1,7 @@ #!/usr/bin/env perl use strict; use warnings; -use Test::More tests => 35; +use Test::More tests => 41; use_ok('Net::IPData'); @@ -59,6 +59,20 @@ is($ipd->ua->agent, "Net-IPData-Perl/$Net::IPData::VERSION", 'HTTP::Tiny carries my $ssl_options = $ipd->ua->SSL_options || {}; ok(!$ssl_options->{SSL_alpn_protocols}, 'no ALPN protocols (h2) are advertised'); +# --- TLS certificate verification --- +is($ipd->verify_ssl, 1, 'verify_ssl defaults to 1'); +is($ipd->ua->verify_SSL, 1, 'HTTP::Tiny is built with verify_SSL => 1 whatever its version'); +{ + # HTTP::Tiny 0.083+ consults this variable only when verify_SSL is not + # passed; the module always passes it, so the check stays on. + local $ENV{PERL_HTTP_TINY_SSL_INSECURE_BY_DEFAULT} = 1; + my $env = Net::IPData->new(api_key => 'test_key'); + is($env->ua->verify_SSL, 1, 'verify_SSL stays on regardless of PERL_HTTP_TINY_SSL_INSECURE_BY_DEFAULT'); +} +my $insecure = Net::IPData->new(api_key => 'test_key', verify_ssl => 0); +is($insecure->verify_ssl, 0, 'verify_ssl => 0 is stored'); +is($insecure->ua->verify_SSL, 0, 'HTTP::Tiny is built with verify_SSL => 0 when asked'); + # --- keep_alive => 0 --- my $no_keep_alive = Net::IPData->new(api_key => 'test_key', keep_alive => 0); is($no_keep_alive->keep_alive, 0, 'keep_alive => 0 is stored'); @@ -109,6 +123,7 @@ my $mock_ua = My::Test::UA->new; my $injected = Net::IPData->new(api_key => 'test_key', ua => $mock_ua); is($injected->ua, $mock_ua, 'injected ua is used as-is'); is($injected->keep_alive, 1, 'keep_alive is still recorded with an injected ua'); +is($injected->verify_ssl, 1, 'verify_ssl is still recorded with an injected ua'); my $result = $injected->lookup('8.8.8.8'); is($result->{city}, 'Mountain View', 'lookup goes through the injected ua'); From 09e0d258a3ef7ed94c9642f93f3f32ee06439189 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:51:26 +0000 Subject: [PATCH 2/9] Send Content-Type only with the bulk POST, use ipdata-perl user agent Content-Type: application/json was a default header of the HTTP::Tiny object, so every GET request announced a body type it does not have. The header now travels only with the bulk POST, passed in that request's options (so a supplied ua receives it too), while GET requests send Accept alone. The User-Agent header becomes ipdata-perl/, the module version being available at runtime. Co-Authored-By: Claude Fable 5.1 --- Changes | 4 ++++ README.md | 4 ++++ lib/Net/IPData.pm | 30 +++++++++++++++++++++++------- t/01-constructor.t | 11 +++++++---- t/03-mock-responses.t | 5 ++++- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/Changes b/Changes index ece0caf..87b7b7d 100644 --- a/Changes +++ b/Changes @@ -6,6 +6,10 @@ Unreleased verified only when asked to, and 0.083+ can be told not to through an environment variable), with a new constructor option verify_ssl (default 1; 0 disables the check) and a matching accessor + - The User-Agent header is now ipdata-perl/ (was + Net-IPData-Perl/) + - Content-Type: application/json is sent only with the bulk POST, no + longer as a default header on GET requests too 1.1.0 2026-11-03 - Connection reuse: the HTTP::Tiny user agent keeps one HTTPS connection diff --git a/README.md b/README.md index 984f923..5563ec8 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,10 @@ Error messages include the HTTP status code and the API's error message: | 429 | Rate limit exceeded | | 599 | Network/connection failure | +### Request Headers + +Every request carries `Accept: application/json` and the user agent `ipdata-perl/`. The bulk `POST` -- the only request with a body -- also sends `Content-Type: application/json`; `GET` requests send no `Content-Type`. + ## Connection reuse A `Net::IPData` object 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 `HTTP::Tiny`, so the saving is the TLS handshake, not multiplexing. diff --git a/lib/Net/IPData.pm b/lib/Net/IPData.pm index ac9db65..0e7eaf9 100644 --- a/lib/Net/IPData.pm +++ b/lib/Net/IPData.pm @@ -64,7 +64,7 @@ sub new { } else { $ua = HTTP::Tiny->new( - agent => "Net-IPData-Perl/$VERSION", + agent => "ipdata-perl/$VERSION", timeout => $timeout, keep_alive => $keep_alive, # Always passed explicitly: HTTP::Tiny before 0.083 verifies the @@ -77,10 +77,9 @@ sub new { (defined $keep_alive_timeout ? (keep_alive_timeout => $keep_alive_timeout) : ()), - default_headers => { - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - }, + # Content-Type belongs to the one request with a body, the bulk + # POST, which adds it itself (see _post_options). + default_headers => { Accept => 'application/json' }, ); # HTTP::Tiny 0.092 and later document keep_alive_timeout as a # constructor attribute, but their constructor (checked up to 0.096) @@ -244,7 +243,7 @@ sub _post { my ($self, $url, $data) = @_; my $body = encode_json($data); - my $response = $self->{ua}->post($url, { content => $body }); + my $response = $self->{ua}->post($url, _post_options($body)); # A kept-alive connection can be closed by the server while idle. # HTTP::Tiny retries idempotent methods on such a socket itself but never @@ -252,12 +251,20 @@ sub _post { # (status 599) rather than an HTTP status. HTTP::Tiny has already dropped # the dead connection by then, so the retry opens a fresh one. if (_is_stale_connection_error($response)) { - $response = $self->{ua}->post($url, { content => $body }); + $response = $self->{ua}->post($url, _post_options($body)); } return $self->_handle_response($response); } +# Options for the bulk POST: the JSON body and its Content-Type, which only +# a request with a body sends. A fresh hashref per attempt, as HTTP::Tiny +# adds bookkeeping keys to the one it is given. +sub _post_options { + my ($body) = @_; + return { content => $body, headers => { 'Content-Type' => 'application/json' } }; +} + # True for the status-599 pseudo-responses HTTP::Tiny returns when a reused # socket turned out to be dead; never true for a real HTTP status. sub _is_stale_connection_error { @@ -454,6 +461,15 @@ shutdown is all the cleanup needed. Pass C<< keep_alive => 0 >> to open a new connection per request, or your own C to control the transport completely. +=head1 REQUEST HEADERS + +Every request carries C and the user agent +C, VERSION being the module version. The bulk POST, +the only request with a body, also sends C; +GET requests send no Content-Type. A supplied C uses its own default +headers and user agent, but still receives the bulk Content-Type, which the +module passes with each POST. + =head1 METHODS =head2 lookup($ip, %opts) diff --git a/t/01-constructor.t b/t/01-constructor.t index e765132..a88ead7 100644 --- a/t/01-constructor.t +++ b/t/01-constructor.t @@ -1,7 +1,7 @@ #!/usr/bin/env perl use strict; use warnings; -use Test::More tests => 41; +use Test::More tests => 44; use_ok('Net::IPData'); @@ -55,7 +55,8 @@ ok(!defined $ipd->keep_alive_timeout, 'keep_alive_timeout is unset by default'); isa_ok($ipd->ua, 'HTTP::Tiny', 'default user agent'); is($ipd->ua->keep_alive, 1, 'HTTP::Tiny is built with keep_alive => 1'); is($ipd->ua->timeout, 30, 'HTTP::Tiny is built with the timeout'); -is($ipd->ua->agent, "Net-IPData-Perl/$Net::IPData::VERSION", 'HTTP::Tiny carries the Net-IPData-Perl user agent'); +is($ipd->ua->agent, "ipdata-perl/$Net::IPData::VERSION", 'HTTP::Tiny carries the ipdata-perl/ user agent'); +is_deeply($ipd->ua->default_headers, { Accept => 'application/json' }, 'default headers are Accept only: no Content-Type on GET requests'); my $ssl_options = $ipd->ua->SSL_options || {}; ok(!$ssl_options->{SSL_alpn_protocols}, 'no ALPN protocols (h2) are advertised'); @@ -97,8 +98,8 @@ SKIP: { sub new { return bless { calls => [] }, shift } sub get { - my ($self, $url) = @_; - push @{ $self->{calls} }, ['get', $url]; + my ($self, $url, $opts) = @_; + push @{ $self->{calls} }, ['get', $url, $opts]; return { status => 200, success => 1, @@ -129,10 +130,12 @@ my $result = $injected->lookup('8.8.8.8'); is($result->{city}, 'Mountain View', 'lookup goes through the injected ua'); is($mock_ua->{calls}[0][0], 'get', 'injected ua received a GET'); is($mock_ua->{calls}[0][1], 'https://api.ipdata.co/8.8.8.8?api-key=test_key', 'injected ua received the lookup URL'); +ok(!defined $mock_ua->{calls}[0][2], 'GET passes no per-request options, so no Content-Type'); $injected->bulk(['8.8.8.8']); is($mock_ua->{calls}[1][0], 'post', 'bulk goes through the injected ua as a POST'); is($mock_ua->{calls}[1][2]{content}, '["8.8.8.8"]', 'bulk body is the JSON array of IPs'); +is($mock_ua->{calls}[1][2]{headers}{'Content-Type'}, 'application/json', 'bulk POST passes Content-Type: application/json with the request'); eval { Net::IPData->new(api_key => 'test_key', ua => 'not an object') }; like($@, qr/"ua"/, 'rejects a ua that is not an object'); diff --git a/t/03-mock-responses.t b/t/03-mock-responses.t index 4a3ca36..c2e7e90 100644 --- a/t/03-mock-responses.t +++ b/t/03-mock-responses.t @@ -1,7 +1,7 @@ #!/usr/bin/env perl use strict; use warnings; -use Test::More tests => 50; +use Test::More tests => 53; use JSON::PP qw(encode_json decode_json); use Scalar::Util qw(refaddr); @@ -177,6 +177,7 @@ my $ipd = Net::IPData->new(api_key => 'test_key_123'); mock_json_response(200, $bulk_data); my $results = $ipd->bulk(['8.8.8.8', '1.1.1.1']); + is($mock_calls[-1][3]{headers}{'Content-Type'}, 'application/json', 'bulk POST carries Content-Type: application/json'); is(ref $results, 'ARRAY', 'bulk returns array ref'); is(scalar @$results, 2, 'bulk returns 2 results'); is($results->[0]{ip}, '8.8.8.8', 'bulk result 0 IP'); @@ -247,6 +248,7 @@ sub mock_network_error { is((keys %seen)[0], refaddr($ipd->ua), 'that object is the one the ua accessor returns'); isa_ok($ipd->ua, 'HTTP::Tiny', 'ua'); ok($ipd->ua->keep_alive, 'the HTTP::Tiny object has keep_alive enabled'); + is_deeply($ipd->ua->default_headers, { Accept => 'application/json' }, 'GET requests send Accept only: Content-Type is not a default header'); } # --- Test: bulk (POST) is retried once when the kept-alive socket was dead --- @@ -261,6 +263,7 @@ sub mock_network_error { is($mock_calls[1][0], 'post', 'retry was a POST'); is($mock_calls[1][2], $mock_calls[0][2], 'retry used the same URL'); is($mock_calls[1][3]{content}, $mock_calls[0][3]{content}, 'retry sent the same body'); + is($mock_calls[1][3]{headers}{'Content-Type'}, 'application/json', 'retry sent the Content-Type too'); is($results->[0]{ip}, '8.8.8.8', 'result of the retry is returned'); } From 15b68b0b3b2d5ed6884b9b8bf09e42abce1e7ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:53:43 +0000 Subject: [PATCH 3/9] Decode bare JSON values with every JSON::PP version Single-field lookups (/{ip}/{field}) answer with a bare JSON value. JSON::PP before 4.0 (core before Perl 5.30) rejects a top-level string, number or null unless allow_nonref is set, so the module fell back to returning the raw text: null came back as the string "null", which is true in Perl, and escape sequences in strings were left undecoded. Decode with a JSON::PP object that has allow_nonref set; the fallback remains for bodies that are not JSON at all. Tests cover false, true, null, escaped strings, numbers and non-JSON bodies. Co-Authored-By: Claude Fable 5.1 --- Changes | 6 ++++-- lib/Net/IPData.pm | 16 +++++++++++----- t/03-mock-responses.t | 32 +++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Changes b/Changes index 87b7b7d..8704e11 100644 --- a/Changes +++ b/Changes @@ -10,8 +10,10 @@ Unreleased Net-IPData-Perl/) - Content-Type: application/json is sent only with the bulk POST, no longer as a default header on GET requests too - -1.1.0 2026-11-03 + - Single-field lookups (/{ip}/{field}) decode their bare JSON value with + every JSON::PP version: JSON::PP before 4.0 (core before Perl 5.30) + rejects a top-level string, number or null, so null came back as the + string "null" and escape sequences in strings stayed undecoded - Connection reuse: the HTTP::Tiny user agent keeps one HTTPS connection alive across requests (keep_alive => 1, now explicit and configurable); HTTP/2 is not available with HTTP::Tiny, so the saving is the TLS diff --git a/lib/Net/IPData.pm b/lib/Net/IPData.pm index 0e7eaf9..e628eb8 100644 --- a/lib/Net/IPData.pm +++ b/lib/Net/IPData.pm @@ -7,7 +7,7 @@ use 5.010001; our $VERSION = '1.1.0'; use HTTP::Tiny; -use JSON::PP qw(encode_json decode_json); +use JSON::PP qw(encode_json); use Carp qw(croak); use Scalar::Util qw(blessed); @@ -18,6 +18,12 @@ use constant { MAX_BULK => 100, }; +# Single-field lookups (/{ip}/{field}) answer with a bare JSON value: a +# string, a number, true, false or null. JSON::PP before 4.0 rejects such a +# top-level scalar unless allow_nonref is set, hence one configured decoder +# instead of decode_json. +my $JSON = JSON::PP->new->utf8->allow_nonref; + # Valid top-level fields for filtering my %VALID_FIELDS = map { $_ => 1 } qw( ip is_eu is_satellite is_anycast city region region_code region_type @@ -286,12 +292,12 @@ sub _handle_response { croak "Network error: $content"; } - # Try to decode JSON + # Decode the JSON, including the bare scalar a single-field lookup returns my $data; - eval { $data = decode_json($content); }; + eval { $data = $JSON->decode($content); }; if ($@) { - # Non-JSON response (e.g., single field value like country_name) - # The API returns bare strings for single-field path lookups + # Not JSON at all (for example an HTML error page): return or report + # the trimmed text $content =~ s/^\s+|\s+$//g; # trim whitespace $content =~ s/^"|"$//g; # strip surrounding quotes diff --git a/t/03-mock-responses.t b/t/03-mock-responses.t index c2e7e90..1d36f7b 100644 --- a/t/03-mock-responses.t +++ b/t/03-mock-responses.t @@ -1,7 +1,7 @@ #!/usr/bin/env perl use strict; use warnings; -use Test::More tests => 53; +use Test::More tests => 61; use JSON::PP qw(encode_json decode_json); use Scalar::Util qw(refaddr); @@ -168,6 +168,36 @@ my $ipd = Net::IPData->new(api_key => 'test_key_123'); is($result->{name}, 'Google LLC', 'lookup_field returns parsed JSON for asn'); } +# --- Test: single-field lookups answer with a bare JSON scalar, which every +# JSON::PP version must decode (before 4.0 only allow_nonref permits it) --- +{ + mock_text_response(200, 'false'); + my $eu = $ipd->is_eu('8.8.8.8'); + ok(defined $eu && !$eu, 'is_eu: bare false decodes to a defined false value'); + + mock_text_response(200, 'true'); + ok($ipd->is_eu('185.1.1.1'), 'is_eu: bare true decodes to a true value'); + + mock_text_response(200, 'null'); + ok(!defined $ipd->lookup_field('8.8.8.8', 'city'), 'lookup_field: bare null decodes to undef, not the string "null"'); + + mock_text_response(200, '"Zürich"'); + is($ipd->lookup_field('8.8.8.8', 'city'), "Z\x{fc}rich", 'lookup_field: a \u escape in a bare string is decoded'); + + mock_text_response(200, '"Mountain \"View\""'); + is($ipd->lookup_field('8.8.8.8', 'city'), 'Mountain "View"', 'lookup_field: escaped quotes in a bare string are decoded'); + + mock_text_response(200, '37.386'); + is($ipd->lookup_field('8.8.8.8', 'latitude'), 37.386, 'lookup_field: a bare number is returned as a number'); + + mock_text_response(200, 'not json at all'); + is($ipd->lookup_field('8.8.8.8', 'city'), 'not json at all', 'a 2xx body that is not JSON is returned as trimmed text'); + + mock_text_response(502, 'Bad Gateway'); + eval { $ipd->lookup('8.8.8.8') }; + like($@, qr{^HTTP 502: Bad Gateway}, 'an error body that is not JSON is reported with its status'); +} + # --- Test: bulk lookup --- { my $bulk_data = [ From 6701dfbc2d3b1f7c9c83ac93f3dd9ca6511f6794 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:53:43 +0000 Subject: [PATCH 4/9] Run the validation tests without network access t/02-validation.t sent real requests to api.ipdata.co with a placeholder key and accepted any network or HTTP error as proof that validation had passed, so "make test" needed network access although the README says the unit tests need no API key. The requests now go to a stand-in user agent injected through ua, and the tests check the URL that was built instead of the error that came back. Also drop two unused variables from t/03-mock-responses.t. Co-Authored-By: Claude Fable 5.1 --- Changes | 2 ++ README.md | 2 +- t/02-validation.t | 54 +++++++++++++++++++++++++++++++++++++------ t/03-mock-responses.t | 3 --- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Changes b/Changes index 8704e11..06844ee 100644 --- a/Changes +++ b/Changes @@ -14,6 +14,8 @@ Unreleased every JSON::PP version: JSON::PP before 4.0 (core before Perl 5.30) rejects a top-level string, number or null, so null came back as the string "null" and escape sequences in strings stayed undecoded + - The test suite no longer needs network access (t/02-validation.t used + to send requests to api.ipdata.co with a placeholder key) - Connection reuse: the HTTP::Tiny user agent keeps one HTTPS connection alive across requests (keep_alive => 1, now explicit and configurable); HTTP/2 is not available with HTTP::Tiny, so the saving is the TLS diff --git a/README.md b/README.md index 5563ec8..5990df9 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ Some feeds, for example ipdata's own vpn feed, omit all three blocklist fields, ## Testing -Run the unit tests (no API key needed): +Run the unit tests (no API key and no network access needed): ```sh make test diff --git a/t/02-validation.t b/t/02-validation.t index 9548069..6595480 100644 --- a/t/02-validation.t +++ b/t/02-validation.t @@ -1,11 +1,47 @@ #!/usr/bin/env perl use strict; use warnings; -use Test::More tests => 11; +use Test::More tests => 15; use_ok('Net::IPData'); -my $ipd = Net::IPData->new(api_key => 'test'); +# --- A stand-in user agent injected through ua =>: validation happens before +# any request is made, so a request reaching it means the arguments passed +# validation. It records each URL and answers like the API would, so the +# test needs no network access. --- +{ + package Validation::UA; + + sub new { return bless { urls => [] }, shift } + + sub get { + my ($self, $url) = @_; + push @{ $self->{urls} }, $url; + return { + status => 200, + success => 1, + headers => { 'content-type' => 'application/json' }, + content => '{"ip":"8.8.8.8"}', + }; + } + + sub post { + my ($self, $url, $opts) = @_; + push @{ $self->{urls} }, $url; + return { + status => 200, + success => 1, + headers => { 'content-type' => 'application/json' }, + content => '[{"ip":"8.8.8.8"}]', + }; + } + + sub last_url { return $_[0]{urls}[-1] } + sub requests { return scalar @{ $_[0]{urls} } } +} + +my $ua = Validation::UA->new; +my $ipd = Net::IPData->new(api_key => 'test', ua => $ua); # --- bulk validation --- eval { $ipd->bulk('not_an_array') }; @@ -25,14 +61,17 @@ like($@, qr/Invalid field.*bogus_field/i, 'lookup rejects invalid fields'); eval { $ipd->bulk(['8.8.8.8'], fields => ['not_real']) }; like($@, qr/Invalid field/i, 'bulk rejects invalid fields'); -# --- valid fields accepted (these will fail at network level, not validation) --- +is($ua->requests, 0, 'nothing that failed validation was sent'); + +# --- valid fields accepted --- eval { $ipd->lookup('8.8.8.8', fields => [qw(ip country_name asn)]) }; -# Should die from network error, not field validation -like($@, qr/(?:Network|error|connect)/i, 'valid fields pass validation'); +is($@, '', 'valid fields pass validation'); +like($ua->last_url, qr/[?&]fields=ip,country_name,asn(?:&|$)/, 'valid fields are sent as the fields parameter'); # --- fields as comma-separated string --- eval { $ipd->lookup('8.8.8.8', fields => 'ip,country_name') }; -like($@, qr/(?:Network|error|connect)/i, 'comma-separated fields pass validation'); +is($@, '', 'comma-separated fields pass validation'); +like($ua->last_url, qr/[?&]fields=ip,country_name(?:&|$)/, 'comma-separated fields are sent as the fields parameter'); # --- lookup_field rejects fields not available via path --- eval { $ipd->lookup_field('8.8.8.8', 'company') }; @@ -43,4 +82,5 @@ like($@, qr/Invalid field/, 'lookup_field rejects non-path field: count'); # --- lookup_field accepts valid path fields --- eval { $ipd->lookup_field('8.8.8.8', 'country_name') }; -like($@, qr/(?:Network|error|connect)/i, 'valid path field passes validation'); +is($@, '', 'valid path field passes validation'); +like($ua->last_url, qr{/8\.8\.8\.8/country_name\?}, 'valid path field is requested as /{ip}/{field}'); diff --git a/t/03-mock-responses.t b/t/03-mock-responses.t index 1d36f7b..72e41ce 100644 --- a/t/03-mock-responses.t +++ b/t/03-mock-responses.t @@ -15,9 +15,6 @@ my @mock_calls; # [ method, HTTP::Tiny object, url, opts ] for every request { no warnings 'redefine'; - my $orig_get = \&HTTP::Tiny::get; - my $orig_post = \&HTTP::Tiny::post; - *HTTP::Tiny::get = sub { my ($self, $url) = @_; push @mock_calls, ['get', $self, $url]; From 422c2d45ba7dfdc1d3cf4000fef4f4faf1d640fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:55:28 +0000 Subject: [PATCH 5/9] Let examples/basic.pl find the module from any directory The example used "use lib '../lib'", a path relative to the current directory, so it only ran from inside examples/. Resolve the lib directory relative to the script itself with FindBin. Co-Authored-By: Claude Fable 5.1 --- examples/basic.pl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/basic.pl b/examples/basic.pl index b0d33a9..4ea5c44 100644 --- a/examples/basic.pl +++ b/examples/basic.pl @@ -1,7 +1,8 @@ #!/usr/bin/env perl use strict; use warnings; -use lib '../lib'; +use FindBin qw($Bin); +use lib "$Bin/../lib"; # the module next to this script, whatever the current directory use Net::IPData; my $api_key = $ENV{IPDATA_API_KEY} or die "Set IPDATA_API_KEY environment variable\n"; From b7e837bb23c790e50103931c17bc0d7016ed8e0b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:55:28 +0000 Subject: [PATCH 6/9] Test on Perl 5.42 and 5.44, keep the matrix running after a failure Add the two Perl releases since 5.40 to the CI matrix and set fail-fast: false, so a failure on one Perl no longer cancels the jobs still running on the others and every result is visible in one run. The release workflow now installs the declared prerequisites before building, as the CI workflow does, so the release build tests the versions Makefile.PL requires rather than whatever its Perl bundles. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efc4fa..9912f0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,9 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false # let every Perl finish so one failure does not cancel the others matrix: - perl-version: ['5.14', '5.20', '5.26', '5.32', '5.38', '5.40'] + perl-version: ['5.14', '5.20', '5.26', '5.32', '5.38', '5.40', '5.42', '5.44'] name: Perl ${{ matrix.perl-version }} steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c88b3a8..3bdaf56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,9 @@ jobs: install-modules-with: cpanm install-modules: CPAN::Uploader + - name: Install dependencies + run: cpanm --installdeps . + - name: Build and test run: | perl Makefile.PL From 5c6f7e5198fdb5f3219a988c17c34f3e278d592d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:55:28 +0000 Subject: [PATCH 7/9] Document is_vpn and scores in the threat shape The API returns threat.is_vpn and threat.scores (vpn_score, proxy_score, threat_score, trust_score), both present in the test fixtures, but the documented response shape in the POD and the README omitted them. Co-Authored-By: Claude Fable 5.1 --- Changes | 2 ++ README.md | 8 +++++--- lib/Net/IPData.pm | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Changes b/Changes index 06844ee..9de5fce 100644 --- a/Changes +++ b/Changes @@ -16,6 +16,8 @@ Unreleased string "null" and escape sequences in strings stayed undecoded - The test suite no longer needs network access (t/02-validation.t used to send requests to api.ipdata.co with a placeholder key) + - Documentation: the threat shape lists is_vpn and scores (vpn_score, + proxy_score, threat_score, trust_score), which the API returns - Connection reuse: the HTTP::Tiny user agent keeps one HTTPS connection alive across requests (keep_alive => 1, now explicit and configurable); HTTP/2 is not available with HTTP::Tiny, so the saving is the TLS diff --git a/README.md b/README.md index 5990df9..763fb4f 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ my $ipdata = Net::IPData->new(api_key => 'YOUR_API_KEY', ua => $ua); | `lookup_field($ip, $field)` | varies | Single field via path API | | `bulk(\@ips, %opts)` | arrayref | Bulk lookup for up to 100 IPs | | `asn($ip)` | hashref | ASN data (`asn`, `name`, `domain`, `route`, `type`) | -| `threat($ip)` | hashref | Threat flags (`is_tor`, `is_proxy`, `is_threat`, ...) | +| `threat($ip)` | hashref | Threat flags (`is_tor`, `is_vpn`, `is_proxy`, `is_threat`, ...), `blocklists` and `scores` | | `carrier($ip)` | hashref | Mobile carrier (`name`, `mcc`, `mnc`) | | `currency($ip)` | hashref | Currency (`name`, `code`, `symbol`, `native`, `plural`) | | `time_zone($ip)` | hashref | Timezone (`name`, `abbr`, `offset`, `is_dst`, `current_time`) | @@ -270,11 +270,13 @@ A full lookup returns a hashref with this shape: languages => [{ name, native, code }], currency => { name, code, symbol, native, plural }, time_zone => { name, abbr, offset, is_dst, current_time }, - threat => { is_tor, is_icloud_relay, is_proxy, is_datacenter, + threat => { is_tor, is_vpn, is_icloud_relay, is_proxy, is_datacenter, is_anonymous, is_known_attacker, is_known_abuser, is_threat, is_bogon, blocklists => [{ name, site, type, - first_seen, last_seen, days_seen }] }, + first_seen, last_seen, days_seen }], + scores => { vpn_score, proxy_score, + threat_score, trust_score } }, } ``` diff --git a/lib/Net/IPData.pm b/lib/Net/IPData.pm index e628eb8..05f8923 100644 --- a/lib/Net/IPData.pm +++ b/lib/Net/IPData.pm @@ -598,11 +598,13 @@ A full lookup returns a hashref with the following keys: languages => [{ name, native, code }], currency => { name, code, symbol, native, plural }, time_zone => { name, abbr, offset, is_dst, current_time }, - threat => { is_tor, is_icloud_relay, is_proxy, is_datacenter, + threat => { is_tor, is_vpn, is_icloud_relay, is_proxy, is_datacenter, is_anonymous, is_known_attacker, is_known_abuser, is_threat, is_bogon, blocklists => [{ name, site, type, - first_seen, last_seen, days_seen }] }, + first_seen, last_seen, days_seen }], + scores => { vpn_score, proxy_score, + threat_score, trust_score } }, count => N (for bulk lookups) The JSON is passed through unchanged, so keys the API adds later simply From 9f1cea20e2066665586431cb0d78adac9ce37474 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:59:19 +0000 Subject: [PATCH 8/9] Silence the used-only-once warning in t/03 The two variables removed from t/03-mock-responses.t had been the second mention of the HTTP::Tiny::get and HTTP::Tiny::post globs, so after their removal Perl warned 'Name "HTTP::Tiny::get" used only once: possible typo' for the assignments that install the mocks, and the CI runner surfaced the warnings as errors. Disable the 'once' warning category in that block, next to the 'redefine' one it already disables. Co-Authored-By: Claude Fable 5.1 --- t/03-mock-responses.t | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/t/03-mock-responses.t b/t/03-mock-responses.t index 72e41ce..30f2152 100644 --- a/t/03-mock-responses.t +++ b/t/03-mock-responses.t @@ -13,7 +13,9 @@ my @mock_responses; my @mock_calls; # [ method, HTTP::Tiny object, url, opts ] for every request made { - no warnings 'redefine'; + # 'once': each glob is assigned here and nowhere else, which would + # otherwise draw a "used only once: possible typo" warning + no warnings qw(redefine once); *HTTP::Tiny::get = sub { my ($self, $url) = @_; From b2420ead6167bd43d8dfaf5346cd0bc3486f62cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 12:59:20 +0000 Subject: [PATCH 9/9] Update actions/checkout to v5 Every CI job logs that actions/checkout@v4 targets the deprecated Node.js 20 runtime and is being forced onto Node.js 24. v5 targets Node.js 24 natively; the setup-perl action already does. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9912f0a..e34e6f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: name: Perl ${{ matrix.perl-version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shogo82148/actions-setup-perl@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bdaf56..54079c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: shogo82148/actions-setup-perl@v1 with: