diff --git a/CHANGELOG.md b/CHANGELOG.md index 16024dc..76df564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ff960b5..d5d6ab6 100644 --- a/README.md +++ b/README.md @@ -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) 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/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 c7048d8..b62bddd 100644 --- a/tests/IpdataTest.php +++ b/tests/IpdataTest.php @@ -191,6 +191,193 @@ 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()); + } + + 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()); @@ -276,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')); + } }