Skip to content

Commit 5e17e46

Browse files
Merge pull request #11 from ipdata/http-reuse-and-new-fields
Reuse HTTPS connections, negotiate HTTP/2, add new response fields
2 parents a873513 + 4525892 commit 5e17e46

7 files changed

Lines changed: 380 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
# Change Log
22

3+
## 0.1.3
4+
5+
### Added
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.
8+
- README section "Connection reuse and HTTP/2": reuse one `Ipdata` and one PSR-18 client per process to keep the HTTPS connection alive.
9+
- 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.
10+
11+
### Fixed
12+
13+
- The `$httpClient` and `$requestFactory` constructor parameters are declared explicitly nullable (`?ClientInterface`, `?RequestFactoryInterface`); PHP 8.4 deprecates implicitly nullable parameters.
14+
15+
## 0.1.2
16+
17+
### Added
18+
19+
- `lookup()` without arguments looks up the IP address of the caller.
20+
- `Ipdata::EU_BASE_URL` and a trailing `$baseUrl` constructor argument to route requests through ipdata's EU endpoint (eu-api.ipdata.co).
21+
- `bulkLookup()`; `buildLookup()` is kept as a deprecated alias.
22+
23+
### Fixed
24+
25+
- Bulk requests are sent with `Content-Type: application/json` instead of `text/plain`.
26+
27+
### Removed
28+
29+
- Unused dev dependencies `php-http/httplug` and `symfony/http-client`.
30+
331
## 0.1.1
432

533
### Fixed

‎README.md‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,67 @@ echo json_encode($data, JSON_PRETTY_PRINT);
179179
}
180180
```
181181

182+
## Connection reuse and HTTP/2
183+
184+
An `Ipdata` instance keeps the PSR-18 client you give it for as long as it lives and sends every request through
185+
it, so the persistent HTTPS connections of that HTTP client are reused across lookups. Create one `Ipdata` per
186+
process (or one per API key or endpoint), register it as a service in your container, and reuse it for every
187+
lookup: each new client (and each new HTTP client) pays a fresh TLS handshake, which costs more than the lookup
188+
itself. Requests to api.ipdata.co are multiplexed over HTTP/2 when the platform negotiates it, and fall back to
189+
HTTP/1.1 keep-alive otherwise.
190+
191+
The library is transport-agnostic, so "the platform" is the PSR-18 client you inject:
192+
193+
* **Symfony HttpClient with ext-curl** (`Symfony\Component\HttpClient\Psr18Client`, used in the examples above):
194+
nothing to configure. One `Psr18Client` keeps its connections alive and negotiates HTTP/2 through ALPN on its
195+
own. By default it also ignores the protocol version of the PSR-7 request, so leave `$httpVersion` at `null`.
196+
* **Guzzle and php-http/curl-client** honour the protocol version of the PSR-7 request. Opt in to HTTP/2 by
197+
passing `'2.0'` as the trailing `$httpVersion` constructor argument; curl then negotiates HTTP/2 through ALPN
198+
and falls back to HTTP/1.1 when the server does not offer it:
199+
200+
```php
201+
use GuzzleHttp\Client;
202+
use Ipdata\ApiClient\Ipdata;
203+
use Nyholm\Psr7\Factory\Psr17Factory;
204+
205+
$ipdata = new Ipdata('my_api_key', new Client(), new Psr17Factory(), 'https://api.ipdata.co', '2.0');
206+
```
207+
208+
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')`.
209+
Without the argument the request keeps the version set by your PSR-17 factory (HTTP/1.1 with `nyholm/psr7`),
210+
and the connection is still kept alive between calls on the same instance.
211+
* **Stream-based clients** (Guzzle without ext-curl, php-http/socket-client, anything built on PHP's `http://`
212+
stream wrapper) must keep `$httpVersion` at `null`: they write the version literally into the request line,
213+
and a request line reading `HTTP/2.0` breaks the request.
214+
215+
A few things to keep in mind:
216+
217+
* Under PHP-FPM or mod_php, PHP destroys all objects, and with them the curl handles, at the end of each PHP
218+
request, so a connection is only reused by the lookups made within one PHP request. Long-running workers
219+
(Symfony Messenger consumers, queue workers, RoadRunner, FrankenPHP worker mode, Swoole, CLI daemons) that keep
220+
the same `Ipdata` instance benefit fully.
221+
* The PSR-18 API is synchronous, one request at a time, so HTTP/2 multiplexing brings nothing here: the saving is
222+
the TLS handshake on every call after the first.
223+
* PHP runs one request per process, so there is no thread-safety concern. After `pcntl_fork()`, build a new
224+
`Ipdata` and HTTP client in the child instead of sharing the parent's sockets.
225+
* `Ipdata` has no `close()` method because it does not own the connections: they belong to the PSR-18 client you
226+
injected and are closed when that client is released, at the latest when the process ends. Timeouts, proxies
227+
and other transport settings are configured on that client as well.
228+
182229
## Available Fields
183230

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

233+
The client returns the decoded JSON unchanged, so new fields reach you as soon as the API sends them. Recent additions:
234+
235+
- `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`.
236+
- `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`.
237+
- `threat.blocklists[].first_seen` (string, `YYYY-MM-DD`, UTC): the first day the address appeared on that blocklist.
238+
- `threat.blocklists[].last_seen` (string, `YYYY-MM-DD`, UTC): the most recent day the address appeared on that blocklist.
239+
- `threat.blocklists[].days_seen` (integer): the number of days the address has been seen on that blocklist.
240+
241+
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()`.
242+
186243
## Errors
187244

188245
A list of possible errors is available at [Status Codes](https://docs.ipdata.co/api-reference/status-codes)

‎src/Ipdata.php‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Nyholm\Psr7\Factory\Psr17Factory;
1111
use Psr\Http\Client\ClientInterface;
1212
use Psr\Http\Message\RequestFactoryInterface;
13+
use Psr\Http\Message\RequestInterface;
1314
use Psr\Http\Message\ResponseInterface;
1415

1516
/**
@@ -42,13 +43,27 @@ class Ipdata
4243
*/
4344
private $baseUrl;
4445

46+
/**
47+
* @var string|null
48+
*/
49+
private $httpVersion;
50+
4551
/**
4652
* Get an instance of the API client. Give it an API key, a PSR-18 client and a PSR-17 request factory.
4753
*
54+
* The instance keeps the PSR-18 client for its whole lifetime and sends every request through it, so the
55+
* persistent connections of that client are reused. Create one Ipdata per process and reuse it for every lookup.
56+
*
57+
* Pass $httpVersion = '2.0' to make curl based clients that honour the PSR-7 protocol version (Guzzle,
58+
* php-http/curl-client) negotiate HTTP/2. Leave it null for Symfony HttpClient, which negotiates HTTP/2 on its
59+
* own, and for stream based clients, which would write "HTTP/2.0" literally into the request line.
60+
*
4861
* @param ClientInterface|null $httpClient if null, we will try to use php-http/discovery to find an installed client
4962
* @param RequestFactoryInterface|null $requestFactory if null, we will try to use php-http/discovery to find an installed factory
63+
* @param string $baseUrl API endpoint, pass self::EU_BASE_URL to use the EU servers
64+
* @param string|null $httpVersion HTTP protocol version applied to every request, null keeps the request factory's default
5065
*/
51-
public function __construct(string $apiKey, ClientInterface $httpClient = null, RequestFactoryInterface $requestFactory = null, string $baseUrl = self::DEFAULT_BASE_URL)
66+
public function __construct(string $apiKey, ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, string $baseUrl = self::DEFAULT_BASE_URL, ?string $httpVersion = null)
5267
{
5368
if (null === $httpClient) {
5469
if (!class_exists(Psr18ClientDiscovery::class)) {
@@ -78,6 +93,7 @@ public function __construct(string $apiKey, ClientInterface $httpClient = null,
7893
$this->apiKey = $apiKey;
7994
$this->requestFactory = $requestFactory;
8095
$this->baseUrl = $baseUrl;
96+
$this->httpVersion = $httpVersion;
8197
}
8298

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

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

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

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

160+
/**
161+
* Create the request through the PSR-17 factory and apply the configured HTTP protocol version, if any.
162+
*/
163+
private function createRequest(string $method, string $url): RequestInterface
164+
{
165+
$request = $this->requestFactory->createRequest($method, $url);
166+
if (null !== $this->httpVersion) {
167+
$request = $request->withProtocolVersion($this->httpVersion);
168+
}
169+
170+
return $request;
171+
}
172+
144173
private function parseResponse(ResponseInterface $response): array
145174
{
146175
$body = $response->getBody()->__toString();

‎tests/Fixtures/lookup_anycast.json‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"ip": "8.8.8.8",
3+
"is_anycast": true,
4+
"is_eu": false,
5+
"city": null,
6+
"region": null,
7+
"region_code": null,
8+
"region_type": null,
9+
"country_name": "United States",
10+
"country_code": "US",
11+
"continent_name": "North America",
12+
"continent_code": "NA",
13+
"latitude": 39.7599983215332,
14+
"longitude": -98.5,
15+
"postal": null,
16+
"calling_code": "1",
17+
"flag": "https://ipdata.co/flags/us.png",
18+
"emoji_flag": "🇺🇸",
19+
"emoji_unicode": "U+1F1FA U+1F1F8",
20+
"asn": { "asn": "AS15169", "name": "Google LLC", "domain": "about.google", "route": "8.8.8.0/24", "type": "hosting" },
21+
"company": { "name": "Google LLC", "domain": "about.google", "network": "8.8.8.0/24", "type": "hosting" },
22+
"languages": [ { "name": "English", "native": "English", "code": "en" } ],
23+
"currency": { "name": "US Dollar", "code": "USD", "symbol": "$", "native": "$", "plural": "US dollars" },
24+
"time_zone": { "name": null, "abbr": null, "offset": null, "is_dst": null, "current_time": null },
25+
"threat": {
26+
"is_tor": false, "is_vpn": false, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true,
27+
"is_anonymous": false, "is_known_attacker": false, "is_known_abuser": false, "is_threat": false, "is_bogon": false,
28+
"blocklists": [
29+
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" },
30+
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "open_resolvers" }
31+
],
32+
"scores": { "vpn_score": 100, "proxy_score": 93, "threat_score": 100, "trust_score": 2 }
33+
},
34+
"count": "0"
35+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"ip": "98.97.34.0",
3+
"is_satellite": true,
4+
"is_eu": false,
5+
"city": "Seattle",
6+
"country_code": "US",
7+
"asn": { "asn": "AS14593", "name": "Space Exploration Technologies Corporation", "domain": "spacex.com", "route": "98.97.32.0/21", "type": "business" },
8+
"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 } },
9+
"count": "0"
10+
}

‎tests/Fixtures/lookup_vpn.json‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"ip": "187.13.250.1",
3+
"is_eu": false,
4+
"threat": {
5+
"is_tor": false, "is_vpn": true, "is_icloud_relay": false, "is_proxy": false, "is_datacenter": true,
6+
"is_anonymous": true, "is_known_attacker": true, "is_known_abuser": true, "is_threat": true, "is_bogon": false,
7+
"blocklists": [
8+
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Gluetun", "site": "https://github.com/qdm12/gluetun", "type": "vpn" },
9+
{ "days_seen": 1, "first_seen": "2026-11-02", "last_seen": "2026-11-02", "name": "ipdata", "site": "https://ipdata.co", "type": "cloud" },
10+
{ "name": "ipdata", "site": "https://ipdata.co", "type": "vpn" },
11+
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "NordVPN", "site": "https://nordvpn.com", "type": "vpn" },
12+
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "Wikimedia Global Blocklist", "site": "https://wikimedia.org", "type": "general" },
13+
{ "days_seen": 2, "first_seen": "2026-10-31", "last_seen": "2026-11-02", "name": "X4BNet", "site": "https://github.com/X4BNet/lists_vpn", "type": "vpn" }
14+
],
15+
"scores": { "vpn_score": 100, "proxy_score": 99, "threat_score": 100, "trust_score": 0 }
16+
},
17+
"count": "0"
18+
}

0 commit comments

Comments
 (0)