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
9 changes: 4 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
name: CI

on:
push:
branches: ['*']
pull_request:
branches: [main]
on: [push, pull_request]

jobs:
test:
Expand All @@ -21,6 +17,9 @@ jobs:
with:
perl-version: ${{ matrix.perl-version }}

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

- name: Build
run: |
perl Makefile.PL
Expand Down
23 changes: 23 additions & 0 deletions Changes
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
Revision history for Net-IPData

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);
HTTP/2 is not available with HTTP::Tiny, so the saving is the TLS
handshake, not multiplexing
- New constructor options keep_alive (default 1), keep_alive_timeout
(idle timeout in seconds, honoured by HTTP::Tiny 0.092 and later,
forwarded only when set) and ua (inject any object with
HTTP::Tiny-compatible get/post), with matching accessors
- bulk() retries the POST once when a kept-alive connection turns out to
be dead before any response arrived (HTTP::Tiny status 599 with Socket
closed / Unexpected end of stream / Connection reset); HTTP statuses are
never retried
- Require HTTP::Tiny 0.049 or later (fork- and thread-safe keep-alive)
- CI runs on every push and pull request and installs the declared
prerequisites before testing
- New API response fields: top-level is_satellite and is_anycast (present
only when true; a missing key reads as false) and first_seen, last_seen
and days_seen on threat.blocklists entries (optional; dates kept as
YYYY-MM-DD strings); the JSON passes through unchanged
- is_satellite and is_anycast are accepted in fields => and lookup_field,
with is_satellite($ip) and is_anycast($ip) convenience methods

1.0.0 2026-02-23
- Initial release
- Full ipdata.co API support: lookup, bulk, field filtering, single field
Expand Down
4 changes: 4 additions & 0 deletions MANIFEST
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ t/01-constructor.t
t/02-validation.t
t/03-mock-responses.t
t/04-live.t
t/05-response-fields.t
t/fixtures/anycast.json
t/fixtures/satellite.json
t/fixtures/vpn_blocklists.json
examples/basic.pl
19 changes: 17 additions & 2 deletions Makefile.PL
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ use strict;
use warnings;
use ExtUtils::MakeMaker;

WriteMakefile(
my %WriteMakefileArgs = (
NAME => 'Net::IPData',
VERSION_FROM => 'lib/Net/IPData.pm',
ABSTRACT_FROM => 'lib/Net/IPData.pm',
AUTHOR => 'Net::IPData contributors',
LICENSE => 'perl_5',
MIN_PERL_VERSION => '5.010001',
PREREQ_PM => {
'HTTP::Tiny' => '0.034', # core since 5.14, need SSL support
'HTTP::Tiny' => '0.049', # core since 5.14; 0.049+ for fork/thread-safe keep-alive
'JSON::PP' => '2.27', # core since 5.14
'Carp' => '0',
'Scalar::Util' => '0',
Expand Down Expand Up @@ -43,3 +43,18 @@ WriteMakefile(
dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz' },
clean => { FILES => 'Net-IPData-*' },
);

# ExtUtils::MakeMaker before 6.64 does not know TEST_REQUIRES, and before
# 6.68 it cannot express a meta-spec 2 META_MERGE: it writes a MYMETA that
# claims version 2 but keeps the 1.4 layout, so CPAN::Meta drops the
# prerequisites and "cpanm --installdeps ." installs nothing (seen with the
# MakeMaker bundled with Perl 5.14). Fall back to what those versions know.
unless (eval { ExtUtils::MakeMaker->VERSION('6.64') }) {
my $test_requires = delete $WriteMakefileArgs{TEST_REQUIRES};
$WriteMakefileArgs{PREREQ_PM} = { %{ $WriteMakefileArgs{PREREQ_PM} }, %$test_requires };
}
unless (eval { ExtUtils::MakeMaker->VERSION('6.68') }) {
delete $WriteMakefileArgs{META_MERGE};
}

WriteMakefile(%WriteMakefileArgs);
53 changes: 51 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Perl client for the [ipdata.co](https://ipdata.co/) IP geolocation and threat in
- **Rich data** -- geolocation, ASN, company, carrier, currency, timezone, and language info
- **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
- **Production ready** -- input validation, structured error handling, configurable timeouts

## Installation
Expand Down Expand Up @@ -62,6 +63,8 @@ printf "Timezone: %s\n", $result->{time_zone}{name};
printf "Currency: %s (%s)\n", $result->{currency}{name}, $result->{currency}{code};
printf "EU: %s\n", $result->{is_eu} ? 'Yes' : 'No';
printf "Threat: %s\n", $result->{threat}{is_threat} ? 'Yes' : 'No';
printf "Satellite: %s\n", $result->{is_satellite} ? 'Yes' : 'No'; # key present only when true
printf "Anycast: %s\n", $result->{is_anycast} ? 'Yes' : 'No'; # key present only when true
```

### Look Up Your Own IP
Expand Down Expand Up @@ -121,6 +124,8 @@ my $langs = $ipdata->languages('8.8.8.8'); # arrayref
my $country = $ipdata->country_name('8.8.8.8'); # string
my $code = $ipdata->country_code('8.8.8.8'); # string
my $eu = $ipdata->is_eu('8.8.8.8'); # boolean
my $sat = $ipdata->is_satellite('8.8.8.8'); # boolean
my $anycast = $ipdata->is_anycast('8.8.8.8'); # boolean

# Or use the generic method for any path-accessible field
my $city = $ipdata->lookup_field('8.8.8.8', 'city');
Expand Down Expand Up @@ -169,6 +174,29 @@ Error messages include the HTTP status code and the API's error message:
| 429 | Rate limit exceeded |
| 599 | Network/connection failure |

## 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.

```perl
my $ipdata = Net::IPData->new(api_key => 'YOUR_API_KEY'); # one TLS handshake...

for my $ip (@ips) {
my $result = $ipdata->lookup($ip); # ...reused by every lookup
}
```

The reuse is `HTTP::Tiny`'s own (`keep_alive => 1`, now explicit and configurable): a connection the server has already closed is detected and replaced transparently, a `GET` on a socket that died silently is retried once by `HTTP::Tiny` itself, and `bulk` (a non-idempotent `POST`) gets the same single retry from this module when the failure happened at the socket level before any response arrived (`HTTP::Tiny` status 599 with *Socket closed*, *Unexpected end of stream* or *Connection reset*). A request that received an HTTP status is never retried. With `HTTP::Tiny` 0.092 or later, `keep_alive_timeout => 60` additionally stops reusing a connection that has been idle for a minute, before the server (which keeps idle connections for roughly two minutes) can drop it silently.

A `Net::IPData` object is not shared between threads: use one per thread. It is fork-safe: `HTTP::Tiny` 0.049 and later never reuses a socket inherited from another process or thread, so a forked child opens its own connection on its first request. There is nothing to close explicitly; the connection is shut down when the object is destroyed, so letting it go out of scope (or `undef $ipdata`) at shutdown is all the cleanup needed.

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 $ipdata = Net::IPData->new(api_key => 'YOUR_API_KEY', ua => $ua);
```

## Constructor Options

| Parameter | Type | Default | Description |
Expand All @@ -177,6 +205,9 @@ Error messages include the HTTP status code and the API's error message:
| `eu` | bool | `0` | Use the EU endpoint (`eu-api.ipdata.co`) |
| `base_url` | string | `https://api.ipdata.co` | Custom base URL (overrides `eu`) |
| `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 |
| `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 @@ -195,6 +226,8 @@ Error messages include the HTTP status code and the API's error message:
| `country_name($ip)` | string | Country name |
| `country_code($ip)` | string | ISO 3166-1 alpha-2 country code |
| `is_eu($ip)` | boolean | EU member state |
| `is_satellite($ip)` | boolean | Satellite internet provider |
| `is_anycast($ip)` | boolean | Anycast address |

## API Response Structure

Expand All @@ -204,6 +237,8 @@ A full lookup returns a hashref with this shape:
{
ip => "8.8.8.8",
is_eu => false,
is_satellite => true, # present only when true
is_anycast => true, # present only when true
city => "Mountain View",
region => "California",
region_code => "CA",
Expand All @@ -227,14 +262,28 @@ A full lookup returns a hashref with this shape:
time_zone => { name, abbr, offset, is_dst, current_time },
threat => { is_tor, is_icloud_relay, is_proxy, is_datacenter,
is_anonymous, is_known_attacker, is_known_abuser,
is_threat, is_bogon },
is_threat, is_bogon,
blocklists => [{ name, site, type,
first_seen, last_seen, days_seen }] },
}
```

The JSON is passed through unchanged, so keys the API adds later simply appear in the hashref. Five fields deserve a note:

| Field | Type | Description |
|-------|------|-------------|
| `is_satellite` | boolean | The address belongs to a satellite internet provider. The API includes the key only when it is true, so test it with a plain `if ($result->{is_satellite})`, never with `exists`; a missing key reads as false |
| `is_anycast` | boolean | The address is an anycast address. Present only when true, like `is_satellite` |
| `threat.blocklists[].first_seen` | string | First calendar date (UTC, `YYYY-MM-DD`) on which that blocklist listed the address; left as a string |
| `threat.blocklists[].last_seen` | string | Most recent such date (UTC, `YYYY-MM-DD`); left as a string |
| `threat.blocklists[].days_seen` | integer | Number of days that blocklist has listed the address |

Some feeds, for example ipdata's own vpn feed, omit all three blocklist fields, so check for them with `exists` before use. Bulk results carry the same shape per item.

## Requirements

- Perl 5.10.1 or later
- Core modules only: [HTTP::Tiny](https://metacpan.org/pod/HTTP::Tiny), [JSON::PP](https://metacpan.org/pod/JSON::PP), [Carp](https://metacpan.org/pod/Carp), [Scalar::Util](https://metacpan.org/pod/Scalar::Util)
- 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

## Testing
Expand Down
2 changes: 2 additions & 0 deletions examples/basic.pl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
printf "Currency: %s (%s)\n", $result->{currency}{name}, $result->{currency}{code};
printf "EU: %s\n", $result->{is_eu} ? 'Yes' : 'No';
printf "Threat: %s\n", $result->{threat}{is_threat} ? 'Yes' : 'No';
printf "Satellite: %s\n", $result->{is_satellite} ? 'Yes' : 'No'; # key present only when true
printf "Anycast: %s\n", $result->{is_anycast} ? 'Yes' : 'No'; # key present only when true

print "\n--- Bulk lookup ---\n";
my $bulk = $ipdata->bulk(['8.8.8.8', '1.1.1.1', '9.9.9.9']);
Expand Down
Loading
Loading