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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Change Log

## 0.1.3

### Added

- Opt-in HTTP protocol version: the constructor takes a trailing `?string $httpVersion` (for example `'2.0'`) that is applied to every request with `withProtocolVersion()`, so curl based PSR-18 clients such as Guzzle and php-http/curl-client negotiate HTTP/2. `null` (the default) leaves the version to the request factory.
- README section "Connection reuse and HTTP/2": reuse one `Ipdata` and one PSR-18 client per process to keep the HTTPS connection alive.
- Documentation and tests for the new API response fields: top-level `is_satellite` and `is_anycast` (present only when `true`) and `first_seen`, `last_seen` (`YYYY-MM-DD`, UTC) and `days_seen` on `threat.blocklists` entries (absent for some feeds). The client passes them through unchanged.

### Fixed

- The `$httpClient` and `$requestFactory` constructor parameters are declared explicitly nullable (`?ClientInterface`, `?RequestFactoryInterface`); PHP 8.4 deprecates implicitly nullable parameters.

## 0.1.2

### Added

- `lookup()` without arguments looks up the IP address of the caller.
- `Ipdata::EU_BASE_URL` and a trailing `$baseUrl` constructor argument to route requests through ipdata's EU endpoint (eu-api.ipdata.co).
- `bulkLookup()`; `buildLookup()` is kept as a deprecated alias.

### Fixed

- Bulk requests are sent with `Content-Type: application/json` instead of `text/plain`.

### Removed

- Unused dev dependencies `php-http/httplug` and `symfony/http-client`.

## 0.1.1

### Fixed
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,67 @@ echo json_encode($data, JSON_PRETTY_PRINT);
}
```

## Connection reuse and HTTP/2

An `Ipdata` instance keeps the PSR-18 client you give it for as long as it lives and sends every request through
it, so the persistent HTTPS connections of that HTTP client are reused across lookups. Create one `Ipdata` per
process (or one per API key or endpoint), register it as a service in your container, and reuse it for every
lookup: each new client (and each new HTTP client) pays a fresh TLS handshake, which costs more than the lookup
itself. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to
HTTP/1.1 keep-alive otherwise.

The library is transport-agnostic, so "the platform" is the PSR-18 client you inject:

* **Symfony HttpClient with ext-curl** (`Symfony\Component\HttpClient\Psr18Client`, used in the examples above):
nothing to configure. One `Psr18Client` keeps its connections alive and negotiates HTTP/2 through ALPN on its
own. By default it also ignores the protocol version of the PSR-7 request, so leave `$httpVersion` at `null`.
* **Guzzle and php-http/curl-client** honour the protocol version of the PSR-7 request. Opt in to HTTP/2 by
passing `'2.0'` as the trailing `$httpVersion` constructor argument; curl then negotiates HTTP/2 through ALPN
and falls back to HTTP/1.1 when the server does not offer it:

```php
use GuzzleHttp\Client;
use Ipdata\ApiClient\Ipdata;
use Nyholm\Psr7\Factory\Psr17Factory;

$ipdata = new Ipdata('my_api_key', new Client(), new Psr17Factory(), 'https://api.ipdata.co', '2.0');
```

On PHP 8 you can skip the base URL with a named argument: `new Ipdata('my_api_key', new Client(), new Psr17Factory(), httpVersion: '2.0')`.
Without the argument the request keeps the version set by your PSR-17 factory (HTTP/1.1 with `nyholm/psr7`),
and the connection is still kept alive between calls on the same instance.
* **Stream-based clients** (Guzzle without ext-curl, php-http/socket-client, anything built on PHP's `http://`
stream wrapper) must keep `$httpVersion` at `null`: they write the version literally into the request line,
and a request line reading `HTTP/2.0` breaks the request.

A few things to keep in mind:

* Under PHP-FPM or mod_php, PHP destroys all objects, and with them the curl handles, at the end of each PHP
request, so a connection is only reused by the lookups made within one PHP request. Long-running workers
(Symfony Messenger consumers, queue workers, RoadRunner, FrankenPHP worker mode, Swoole, CLI daemons) that keep
the same `Ipdata` instance benefit fully.
* The PSR-18 API is synchronous, one request at a time, so HTTP/2 multiplexing brings nothing here: the saving is
the TLS handshake on every call after the first.
* PHP runs one request per process, so there is no thread-safety concern. After `pcntl_fork()`, build a new
`Ipdata` and HTTP client in the child instead of sharing the parent's sockets.
* `Ipdata` has no `close()` method because it does not own the connections: they belong to the PSR-18 client you
injected and are closed when that client is released, at the latest when the process ends. Timeouts, proxies
and other transport settings are configured on that client as well.

## Available Fields

A list of all the fields returned by the API is maintained at [Response Fields](https://docs.ipdata.co/api-reference/response-fields)

The client returns the decoded JSON unchanged, so new fields reach you as soon as the API sends them. Recent additions:

- `is_satellite` (boolean): the IP address belongs to a satellite internet provider. Present only when `true`; the key is omitted otherwise, so read it as `$data['is_satellite'] ?? false`.
- `is_anycast` (boolean): the IP address is an anycast address announced from several locations. Present only when `true`; read it as `$data['is_anycast'] ?? false`.
- `threat.blocklists[].first_seen` (string, `YYYY-MM-DD`, UTC): the first day the address appeared on that blocklist.
- `threat.blocklists[].last_seen` (string, `YYYY-MM-DD`, UTC): the most recent day the address appeared on that blocklist.
- `threat.blocklists[].days_seen` (integer): the number of days the address has been seen on that blocklist.

Some feeds, for example ipdata's own vpn feed, carry none of the three blocklist date fields, so read them as `$entry['first_seen'] ?? null`. The dates are passed through as strings; the client does not parse them. `is_satellite` and `is_anycast` can also be requested through the `$fields` argument of `lookup()` and `bulkLookup()`.

## Errors

A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes)
35 changes: 32 additions & 3 deletions src/Ipdata.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
Expand Down Expand Up @@ -42,13 +43,27 @@ class Ipdata
*/
private $baseUrl;

/**
* @var string|null
*/
private $httpVersion;

/**
* Get an instance of the API client. Give it an API key, a PSR-18 client and a PSR-17 request factory.
*
* The instance keeps the PSR-18 client for its whole lifetime and sends every request through it, so the
* persistent connections of that client are reused. Create one Ipdata per process and reuse it for every lookup.
*
* Pass $httpVersion = '2.0' to make curl based clients that honour the PSR-7 protocol version (Guzzle,
* php-http/curl-client) negotiate HTTP/2. Leave it null for Symfony HttpClient, which negotiates HTTP/2 on its
* own, and for stream based clients, which would write "HTTP/2.0" literally into the request line.
*
* @param ClientInterface|null $httpClient if null, we will try to use php-http/discovery to find an installed client
* @param RequestFactoryInterface|null $requestFactory if null, we will try to use php-http/discovery to find an installed factory
* @param string $baseUrl API endpoint, pass self::EU_BASE_URL to use the EU servers
* @param string|null $httpVersion HTTP protocol version applied to every request, null keeps the request factory's default
*/
public function __construct(string $apiKey, ClientInterface $httpClient = null, RequestFactoryInterface $requestFactory = null, string $baseUrl = self::DEFAULT_BASE_URL)
public function __construct(string $apiKey, ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, string $baseUrl = self::DEFAULT_BASE_URL, ?string $httpVersion = null)
{
if (null === $httpClient) {
if (!class_exists(Psr18ClientDiscovery::class)) {
Expand Down Expand Up @@ -78,6 +93,7 @@ public function __construct(string $apiKey, ClientInterface $httpClient = null,
$this->apiKey = $apiKey;
$this->requestFactory = $requestFactory;
$this->baseUrl = $baseUrl;
$this->httpVersion = $httpVersion;
}

/**
Expand All @@ -96,7 +112,7 @@ public function lookup(string $ip = '', array $fields = []): array
}

$url = $ip !== '' ? sprintf('%s/%s', $this->baseUrl, $ip) : $this->baseUrl;
$request = $this->requestFactory->createRequest('GET', sprintf('%s?%s', $url, http_build_query($query)));
$request = $this->createRequest('GET', sprintf('%s?%s', $url, http_build_query($query)));
$response = $this->httpClient->sendRequest($request);

return $this->parseResponse($response);
Expand All @@ -120,7 +136,7 @@ public function bulkLookup(array $ips, array $fields = []): array
$query['fields'] = implode(',', $fields);
}

$request = $this->requestFactory->createRequest('POST', sprintf('%s/bulk?%s', $this->baseUrl, http_build_query($query)));
$request = $this->createRequest('POST', sprintf('%s/bulk?%s', $this->baseUrl, http_build_query($query)));
$request->getBody()->write(json_encode($ips));
$request = $request->withHeader('Content-Type', 'application/json');
$response = $this->httpClient->sendRequest($request);
Expand All @@ -141,6 +157,19 @@ public function buildLookup(array $ips, array $fields = []): array
return $this->bulkLookup($ips, $fields);
}

/**
* Create the request through the PSR-17 factory and apply the configured HTTP protocol version, if any.
*/
private function createRequest(string $method, string $url): RequestInterface
{
$request = $this->requestFactory->createRequest($method, $url);
if (null !== $this->httpVersion) {
$request = $request->withProtocolVersion($this->httpVersion);
}

return $request;
}

private function parseResponse(ResponseInterface $response): array
{
$body = $response->getBody()->__toString();
Expand Down
35 changes: 35 additions & 0 deletions tests/Fixtures/lookup_anycast.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"ip": "8.8.8.8",
"is_anycast": true,
"is_eu": false,
"city": null,
"region": null,
"region_code": null,
"region_type": null,
"country_name": "United States",
"country_code": "US",
"continent_name": "North America",
"continent_code": "NA",
"latitude": 39.7599983215332,
"longitude": -98.5,
"postal": null,
"calling_code": "1",
"flag": "https://ipdata.co/flags/us.png",
"emoji_flag": "🇺🇸",
"emoji_unicode": "U+1F1FA U+1F1F8",
"asn": { "asn": "AS15169", "name": "Google LLC", "domain": "about.google", "route": "8.8.8.0/24", "type": "hosting" },
"company": { "name": "Google LLC", "domain": "about.google", "network": "8.8.8.0/24", "type": "hosting" },
"languages": [ { "name": "English", "native": "English", "code": "en" } ],
"currency": { "name": "US Dollar", "code": "USD", "symbol": "$", "native": "$", "plural": "US dollars" },
"time_zone": { "name": null, "abbr": null, "offset": null, "is_dst": null, "current_time": null },
"threat": {
"is_tor": false, "is_vpn": false, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true,
"is_anonymous": false, "is_known_attacker": false, "is_known_abuser": false, "is_threat": false, "is_bogon": false,
"blocklists": [
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" },
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "open_resolvers" }
],
"scores": { "vpn_score": 100, "proxy_score": 93, "threat_score": 100, "trust_score": 2 }
},
"count": "0"
}
10 changes: 10 additions & 0 deletions tests/Fixtures/lookup_satellite.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"ip": "98.97.34.0",
"is_satellite": true,
"is_eu": false,
"city": "Seattle",
"country_code": "US",
"asn": { "asn": "AS14593", "name": "Space Exploration Technologies Corporation", "domain": "spacex.com", "route": "98.97.32.0/21", "type": "business" },
"threat": { "is_tor": false, "is_vpn": false, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": false, "is_anonymous": false, "is_known_attacker": false, "is_known_abuser": false, "is_threat": false, "is_bogon": false, "blocklists": [], "scores": { "vpn_score": 0, "proxy_score": 1, "threat_score": 8, "trust_score": 97 } },
"count": "0"
}
18 changes: 18 additions & 0 deletions tests/Fixtures/lookup_vpn.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"ip": "187.13.250.1",
"is_eu": false,
"threat": {
"is_tor": false, "is_vpn": true, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true,
"is_anonymous": true, "is_known_attacker": true, "is_known_abuser": true, "is_threat": true, "is_bogon": false,
"blocklists": [
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Gluetun", "site": "https://github.com/qdm12/gluetun", "type": "vpn" },
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" },
{ "name": "ipdata", "site": "https://ipdata.co", "type": "vpn" },
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "NordVPN", "site": "https://nordvpn.com", "type": "vpn" },
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Wikimedia Global Blocklist", "site": "https://wikimedia.org", "type": "general" },
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "X4BNet", "site": "https://github.com/X4BNet/lists_vpn", "type": "vpn" }
],
"scores": { "vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0 }
},
"count": "0"
}
Loading
Loading