Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5

- uses: shogo82148/actions-setup-perl@v1
with:
perl-version: '5.38'
install-modules-with: cpanm
install-modules: CPAN::Uploader

- name: Install dependencies
run: cpanm --installdeps .

- name: Build and test
run: |
perl Makefile.PL
Expand Down
19 changes: 18 additions & 1 deletion Changes
Original file line number Diff line number Diff line change
@@ -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/<version> (was
Net-IPData-Perl/<version>)
- 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
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<version>`. 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.
Expand All @@ -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);
```

Expand All @@ -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
Expand All @@ -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`) |
Expand Down Expand Up @@ -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 } },
}
```

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/basic.pl
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
91 changes: 73 additions & 18 deletions lib/Net/IPData.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -93,6 +103,7 @@ sub new {
timeout => $timeout,
keep_alive => $keep_alive,
keep_alive_timeout => $keep_alive_timeout,
verify_ssl => $verify_ssl,
}, $class;
}

Expand Down Expand Up @@ -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 ---
Expand All @@ -237,20 +249,28 @@ 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
# a POST, so retry exactly once when the failure was at the socket level
# (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 {
Expand All @@ -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

Expand Down Expand Up @@ -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
);

Expand All @@ -386,15 +407,30 @@ such setting and ignores it, in which case the value is merely stored (see
L</ACCESSORS>). api.ipdata.co keeps idle connections open for roughly two
minutes, so 60 is a sensible value. Not applied when C<ua> 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<HTTP::Tiny> as C<verify_SSL>, 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<PERL_HTTP_TINY_SSL_INSECURE_BY_DEFAULT> environment variable.
Verification needs a CA bundle: HTTP::Tiny uses L<Mozilla::CA> when it is
installed and otherwise the operating system's bundle (see "SSL SUPPORT" in
L<HTTP::Tiny>); without one every request fails with a C<Network error>.
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<base_url>: the
connection is still encrypted but the server's identity is not checked. Not
applied when C<ua> is supplied.

=item ua

An object to use instead of a new L<HTTP::Tiny>: anything with
HTTP::Tiny-compatible C<get($url)> and C<post($url, \%options)> methods that
return a hashref with C<status>, C<content>, C<headers> and C<success>. Use
it to share one pre-configured HTTP::Tiny (proxy, C<verify_SSL>,
C<SSL_options>) or to inject a mock in tests. C<timeout>, C<keep_alive> and
C<keep_alive_timeout> are not applied to a supplied object; configure it
yourself.
it to share one pre-configured HTTP::Tiny (proxy, C<SSL_options> such as a
private CA file) or to inject a mock in tests. C<timeout>, C<keep_alive>,
C<keep_alive_timeout> and C<verify_ssl> are not applied to a supplied
object; configure it yourself.

=back

Expand Down Expand Up @@ -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<ua> to control the transport
completely.

=head1 REQUEST HEADERS

Every request carries C<Accept: application/json> and the user agent
C<ipdata-perl/VERSION>, VERSION being the module version. The bulk POST,
the only request with a body, also sends C<Content-Type: application/json>;
GET requests send no Content-Type. A supplied C<ua> 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)
Expand Down Expand Up @@ -516,6 +561,8 @@ The following methods call C<lookup_field> 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<HTTP::Tiny> unless one was injected)

=back
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -607,6 +656,12 @@ with Perl 5.22 and later, older Perls install it from CPAN)

=back

HTTPS itself needs L<IO::Socket::SSL> 1.56 and L<Net::SSLeay> 1.49 or later,
plus a CA bundle (L<Mozilla::CA> or the operating system's) for the
certificate verification that is on by default (see L</verify_ssl>). They
are recommended rather than required prerequisites so that the module can be
installed where they are not available.

=head1 SEE ALSO

L<https://docs.ipdata.co/> - ipdata API documentation
Expand Down
Loading
Loading