From 688fef44bd58f41cb11a717efb3618c2dedfe8e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:32:31 +0000 Subject: [PATCH 1/2] Add opt-in HTTP protocol version and document connection reuse The client already holds one PSR-18 client for its whole lifetime, so the persistent connections of the injected transport are reused across lookups. Symfony HttpClient with ext-curl negotiates HTTP/2 on its own, but Guzzle and php-http/curl-client only use HTTP/2 when the PSR-7 request carries protocol version 2.0. Add a trailing, optional `?string $httpVersion` constructor argument that a new private createRequest() helper applies through withProtocolVersion() for both lookup() and bulkLookup(); null (the default) keeps today's behaviour, and the lookup()/bulkLookup() signatures are unchanged. While touching the constructor signature, declare the $httpClient and $requestFactory parameters explicitly nullable (?ClientInterface, ?RequestFactoryInterface): PHP 8.4 deprecates implicitly nullable parameters, and the syntax works from PHP 7.1. Tests cover the version being applied to GET and POST requests and left to the request factory when null, consecutive calls going through the same injected client, and an explicit null request factory. The README gains a "Connection reuse and HTTP/2" section (one Ipdata per process, Symfony automatic, Guzzle opt-in, stream clients must stay on null, PHP-FPM versus long-running workers, no multiplexing gain through the synchronous PSR-18 API). CHANGELOG gets the 0.1.3 entry and the missing 0.1.2 entry for PR #9. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 27 ++++++++++++++++ README.md | 47 ++++++++++++++++++++++++++++ src/Ipdata.php | 35 +++++++++++++++++++-- tests/IpdataTest.php | 73 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16024dc..0a12313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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. + +### 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 diff --git a/README.md b/README.md index ff960b5..7754571 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,53 @@ 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) diff --git a/src/Ipdata.php b/src/Ipdata.php index 8d22c5a..937c3f8 100644 --- a/src/Ipdata.php +++ b/src/Ipdata.php @@ -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; /** @@ -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)) { @@ -78,6 +93,7 @@ public function __construct(string $apiKey, ClientInterface $httpClient = null, $this->apiKey = $apiKey; $this->requestFactory = $requestFactory; $this->baseUrl = $baseUrl; + $this->httpVersion = $httpVersion; } /** @@ -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); @@ -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); @@ -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(); diff --git a/tests/IpdataTest.php b/tests/IpdataTest.php index c7048d8..c420413 100644 --- a/tests/IpdataTest.php +++ b/tests/IpdataTest.php @@ -191,6 +191,79 @@ public function testConstructWithDiscoveryThrowsWhenNoClientAvailable() new Ipdata('secret_key'); } + public function testHttpVersionIsAppliedToLookupRequests() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $ipdata = new Ipdata('secret_key', $httpClient, new Psr17Factory(), 'https://api.ipdata.co', '2.0'); + $ipdata->lookup('8.8.8.8'); + + $request = $httpClient->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/8.8.8.8', $request->getUri()->getPath()); + $this->assertEquals('2.0', $request->getProtocolVersion()); + } + + public function testHttpVersionIsAppliedToBulkLookupRequests() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $ipdata = new Ipdata('secret_key', $httpClient, new Psr17Factory(), 'https://api.ipdata.co', '2.0'); + $ipdata->bulkLookup(['8.8.8.8', '69.78.70.144']); + + $request = $httpClient->getLastRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('/bulk', $request->getUri()->getPath()); + $this->assertEquals('2.0', $request->getProtocolVersion()); + $this->assertEquals('["8.8.8.8","69.78.70.144"]', $request->getBody()->__toString()); + } + + public function testHttpVersionIsLeftToTheRequestFactoryWhenNull() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $httpClient->addResponse($this->createResponse()); + $ipdata = new Ipdata('secret_key', $httpClient, new Psr17Factory(), 'https://api.ipdata.co', null); + $ipdata->lookup('8.8.8.8'); + $ipdata->bulkLookup(['8.8.8.8']); + + $requests = $httpClient->getRequests(); + $this->assertCount(2, $requests); + // nyholm/psr7 creates HTTP/1.1 requests; without a configured version the client must not change that + $this->assertEquals('1.1', $requests[0]->getProtocolVersion()); + $this->assertEquals('1.1', $requests[1]->getProtocolVersion()); + } + + public function testConsecutiveCallsAreSentThroughTheSameClient() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $httpClient->addResponse($this->createResponse()); + $httpClient->addResponse($this->createResponse()); + $ipdata = $this->createIpdata($httpClient); + + $ipdata->lookup('8.8.8.8'); + $ipdata->lookup(); + $ipdata->bulkLookup(['8.8.8.8', '69.78.70.144']); + + $requests = $httpClient->getRequests(); + $this->assertCount(3, $requests); + $this->assertEquals('/8.8.8.8', $requests[0]->getUri()->getPath()); + $this->assertEquals('', $requests[1]->getUri()->getPath()); + $this->assertEquals('/bulk', $requests[2]->getUri()->getPath()); + } + + public function testExplicitNullRequestFactoryFallsBackToDiscovery() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $ipdata = new Ipdata('secret_key', $httpClient, null); + $result = $ipdata->lookup('8.8.8.8'); + + $this->assertEquals(200, $result['status']); + $this->assertEquals('/8.8.8.8', $httpClient->getLastRequest()->getUri()->getPath()); + } + private function createIpdata(ClientInterface $httpClient): Ipdata { return new Ipdata('secret_key', $httpClient, new Psr17Factory()); From 45258921a160baa6f51a40d1c22c57e3bccf3a93 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 10:33:23 +0000 Subject: [PATCH 2/2] Document and test the new response fields The API now returns two top-level booleans, is_satellite and is_anycast, which it includes only when true, and three optional keys on each threat.blocklists entry: first_seen and last_seen (YYYY-MM-DD, UTC) and days_seen (integer), which some feeds omit. This client returns the decoded JSON unchanged and does not validate field names, so no code change is needed. Add fixtures built from the API samples (satellite ISP, anycast address with dated blocklist entries, VPN exit with an undated entry) and tests asserting that lookup() and bulkLookup() pass the values through, that the flags are simply absent when false, that the dates stay strings, and that both new names can be requested through $fields. Document the five fields in the README ("present only when true") and in the 0.1.3 CHANGELOG entry. Claude-Session: https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + README.md | 10 +++ tests/Fixtures/lookup_anycast.json | 35 ++++++++ tests/Fixtures/lookup_satellite.json | 10 +++ tests/Fixtures/lookup_vpn.json | 18 ++++ tests/IpdataTest.php | 127 +++++++++++++++++++++++++++ 6 files changed, 201 insertions(+) create mode 100644 tests/Fixtures/lookup_anycast.json create mode 100644 tests/Fixtures/lookup_satellite.json create mode 100644 tests/Fixtures/lookup_vpn.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a12313..76df564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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 diff --git a/README.md b/README.md index 7754571..d5d6ab6 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,16 @@ A few things to keep in mind: 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) diff --git a/tests/Fixtures/lookup_anycast.json b/tests/Fixtures/lookup_anycast.json new file mode 100644 index 0000000..67424bb --- /dev/null +++ b/tests/Fixtures/lookup_anycast.json @@ -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" +} diff --git a/tests/Fixtures/lookup_satellite.json b/tests/Fixtures/lookup_satellite.json new file mode 100644 index 0000000..c0abd6f --- /dev/null +++ b/tests/Fixtures/lookup_satellite.json @@ -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" +} diff --git a/tests/Fixtures/lookup_vpn.json b/tests/Fixtures/lookup_vpn.json new file mode 100644 index 0000000..f4d1485 --- /dev/null +++ b/tests/Fixtures/lookup_vpn.json @@ -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" +} diff --git a/tests/IpdataTest.php b/tests/IpdataTest.php index c420413..b62bddd 100644 --- a/tests/IpdataTest.php +++ b/tests/IpdataTest.php @@ -264,6 +264,120 @@ public function testExplicitNullRequestFactoryFallsBackToDiscovery() $this->assertEquals('/8.8.8.8', $httpClient->getLastRequest()->getUri()->getPath()); } + public function testSatelliteFlagIsPassedThrough() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createFixtureResponse('lookup_satellite')); + $ipdata = $this->createIpdata($httpClient); + + $result = $ipdata->lookup('98.97.34.0'); + + $this->assertSame(200, $result['status']); + $this->assertTrue($result['is_satellite']); + $this->assertFalse($result['is_eu']); + // the API includes is_satellite and is_anycast only when they are true, so the key is absent here + $this->assertArrayNotHasKey('is_anycast', $result); + $this->assertFalse($result['is_anycast'] ?? false); + $this->assertSame('AS14593', $result['asn']['asn']); + $this->assertSame([], $result['threat']['blocklists']); + } + + public function testAnycastFlagAndDatedBlocklistEntriesArePassedThrough() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createFixtureResponse('lookup_anycast')); + $ipdata = $this->createIpdata($httpClient); + + $result = $ipdata->lookup('8.8.8.8'); + + $this->assertTrue($result['is_anycast']); + $this->assertArrayNotHasKey('is_satellite', $result); + $this->assertFalse($result['is_satellite'] ?? false); + $this->assertNull($result['city']); + $this->assertTrue($result['threat']['is_datacenter']); + + $blocklists = $result['threat']['blocklists']; + $this->assertCount(2, $blocklists); + $this->assertSame([ + 'days_seen' => 1, + 'first_seen' => '2026-11-02', + 'last_seen' => '2026-11-02', + 'name' => 'ipdata', + 'site' => 'https://ipdata.co', + 'type' => 'cloud', + ], $blocklists[0]); + $this->assertSame('open_resolvers', $blocklists[1]['type']); + // the dates stay plain YYYY-MM-DD strings and days_seen stays an integer + $this->assertSame('2026-11-02', $blocklists[1]['first_seen']); + $this->assertSame('2026-11-02', $blocklists[1]['last_seen']); + $this->assertSame(1, $blocklists[1]['days_seen']); + } + + public function testBlocklistEntriesWithoutDatesArePassedThrough() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createFixtureResponse('lookup_vpn')); + $ipdata = $this->createIpdata($httpClient); + + $result = $ipdata->lookup('187.13.250.1'); + + // neither flag is present for this address + $this->assertArrayNotHasKey('is_satellite', $result); + $this->assertArrayNotHasKey('is_anycast', $result); + $this->assertTrue($result['threat']['is_vpn']); + + $blocklists = $result['threat']['blocklists']; + $this->assertCount(6, $blocklists); + $this->assertSame(2, $blocklists[0]['days_seen']); + $this->assertSame('2026-10-31', $blocklists[0]['first_seen']); + $this->assertSame('2026-11-02', $blocklists[0]['last_seen']); + // some feeds, for example ipdata's own vpn feed, carry none of the three date fields + $this->assertSame(['name' => 'ipdata', 'site' => 'https://ipdata.co', 'type' => 'vpn'], $blocklists[2]); + $this->assertArrayNotHasKey('first_seen', $blocklists[2]); + $this->assertArrayNotHasKey('last_seen', $blocklists[2]); + $this->assertArrayNotHasKey('days_seen', $blocklists[2]); + $this->assertNull($blocklists[2]['first_seen'] ?? null); + } + + public function testBulkLookupItemsCarryTheNewFields() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse([ + $this->loadFixture('lookup_satellite'), + $this->loadFixture('lookup_anycast'), + $this->loadFixture('lookup_vpn'), + ])); + $ipdata = $this->createIpdata($httpClient); + + $result = $ipdata->bulkLookup(['98.97.34.0', '8.8.8.8', '187.13.250.1']); + + $this->assertSame(200, $result['status']); + $this->assertTrue($result[0]['is_satellite']); + $this->assertArrayNotHasKey('is_anycast', $result[0]); + $this->assertTrue($result[1]['is_anycast']); + $this->assertArrayNotHasKey('is_satellite', $result[1]); + $this->assertSame('2026-11-02', $result[1]['threat']['blocklists'][0]['first_seen']); + $this->assertSame(1, $result[1]['threat']['blocklists'][0]['days_seen']); + $this->assertArrayNotHasKey('is_satellite', $result[2]); + $this->assertArrayNotHasKey('is_anycast', $result[2]); + $this->assertArrayNotHasKey('first_seen', $result[2]['threat']['blocklists'][2]); + } + + public function testNewFieldNamesCanBeRequested() + { + $httpClient = new MockClient(); + $httpClient->addResponse($this->createResponse()); + $httpClient->addResponse($this->createResponse()); + $ipdata = $this->createIpdata($httpClient); + + $ipdata->lookup('8.8.8.8', ['is_satellite', 'is_anycast', 'threat']); + $ipdata->bulkLookup(['8.8.8.8'], ['is_satellite', 'is_anycast']); + + $requests = $httpClient->getRequests(); + $this->assertEquals('api-key=secret_key&fields=is_satellite,is_anycast,threat', urldecode($requests[0]->getUri()->getQuery())); + $this->assertEquals('api-key=secret_key&fields=is_satellite,is_anycast', urldecode($requests[1]->getUri()->getQuery())); + } + private function createIpdata(ClientInterface $httpClient): Ipdata { return new Ipdata('secret_key', $httpClient, new Psr17Factory()); @@ -349,4 +463,17 @@ private function createResponse(array $data = [], int $statusCode = 200): Respon return new Response($statusCode, ['Content-Type' => 'application/json; charset=utf-8'], json_encode($data)); } + + /** + * @return array + */ + private function loadFixture(string $name): array + { + return json_decode(file_get_contents(__DIR__.'/Fixtures/'.$name.'.json'), true); + } + + private function createFixtureResponse(string $name, int $statusCode = 200): ResponseInterface + { + return new Response($statusCode, ['Content-Type' => 'application/json; charset=utf-8'], file_get_contents(__DIR__.'/Fixtures/'.$name.'.json')); + } }