diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efc4fa..e34e6f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,13 @@ 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: - - 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 c88b3a8..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: @@ -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 diff --git a/Changes b/Changes index 59848bf..9de5fce 100644 --- a/Changes +++ b/Changes @@ -1,6 +1,23 @@ Revision history for Net-IPData -1.1.0 2026-11-03 +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 + - 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 + - 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 + - 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 6d0163c..763fb4f 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 @@ -174,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. @@ -193,7 +198,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 +216,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 @@ -218,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`) | @@ -260,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 } }, } ``` @@ -284,11 +296,11 @@ 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 -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/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"; diff --git a/lib/Net/IPData.pm b/lib/Net/IPData.pm index 34780bb..05f8923 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 @@ -52,6 +58,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 @@ -63,19 +70,22 @@ 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 + # 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. (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) @@ -93,6 +103,7 @@ sub new { timeout => $timeout, keep_alive => $keep_alive, keep_alive_timeout => $keep_alive_timeout, + verify_ssl => $verify_ssl, }, $class; } @@ -213,6 +224,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 --- @@ -237,7 +249,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 @@ -245,12 +257,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 { @@ -272,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 @@ -365,6 +385,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 +407,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 @@ -431,6 +467,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) @@ -516,6 +561,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 @@ -551,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 @@ -607,6 +656,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..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 => 35; +use Test::More tests => 44; use_ok('Net::IPData'); @@ -55,10 +55,25 @@ 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'); +# --- 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'); @@ -83,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, @@ -109,15 +124,18 @@ 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'); 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/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 4a3ca36..30f2152 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 => 61; use JSON::PP qw(encode_json decode_json); use Scalar::Util qw(refaddr); @@ -13,10 +13,9 @@ my @mock_responses; my @mock_calls; # [ method, HTTP::Tiny object, url, opts ] for every request made { - no warnings 'redefine'; - - my $orig_get = \&HTTP::Tiny::get; - my $orig_post = \&HTTP::Tiny::post; + # '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) = @_; @@ -168,6 +167,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 = [ @@ -177,6 +206,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 +277,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 +292,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'); }