Skip to content

Reuse HTTPS connections, negotiate HTTP/2, add new response fields - #11

Merged
jonathan-kosgei merged 2 commits into
masterfrom
http-reuse-and-new-fields
Nov 3, 2026
Merged

jonathan-kosgei merged 2 commits into
masterfrom
http-reuse-and-new-fields

Conversation

@jonathan-kosgei

@jonathan-kosgei jonathan-kosgei commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Part of the change that brings connection reuse, HTTP/2 and the new response fields to all official ipdata client libraries. Two logical commits:

  1. 688fef4 – Add opt-in HTTP protocol version and document connection reuse. Ipdata already holds one PSR-18 client for its whole lifetime, so the persistent connections of the injected transport are reused across lookups; the library is transport-agnostic. 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. The constructor gains a trailing, optional ?string $httpVersion = null; a new private createRequest(string $method, string $url): RequestInterface applies it with withProtocolVersion() at both request sites (lookup() and bulkLookup() keep their signatures, so the phpstan baseline stays valid). While touching the signature, parameters 2 and 3 are declared explicitly nullable (?ClientInterface, ?RequestFactoryInterface) to avoid the PHP 8.4 implicit-nullable deprecation (PHP 7.1+ syntax). Tests, README section "Connection reuse and HTTP/2", CHANGELOG 0.1.3 plus the backfilled 0.1.2 entry for Add EU endpoint support and improve API client functionality #9.
  2. 4525892 – Document and test the new response fields. is_satellite and is_anycast (top level, 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 returns the decoded JSON unchanged and does not validate field names, so no code change was needed: fixtures built from the API samples (tests/Fixtures/*.json), tests for lookup() and bulkLookup(), README "Available Fields" documentation and the CHANGELOG bullet.

No CI changes: ci.yml already runs on pull_request.

What changes for users

  • Nothing changes unless you opt in. Every existing constructor and call signature keeps working; defaults are untouched.
  • New optional fifth constructor argument $httpVersion. Pass '2.0' with Guzzle or php-http/curl-client to negotiate HTTP/2 (curl falls back to HTTP/1.1 if the server does not offer h2). Leave it null (default) with Symfony's Psr18Client, which negotiates HTTP/2 itself and ignores the PSR-7 version by default, and with stream-based clients, which would write HTTP/2.0 literally into the request line. Because Ipdata::DEFAULT_BASE_URL is private, the positional form repeats the base URL: new Ipdata($key, new GuzzleHttp\Client(), new Psr17Factory(), 'https://api.ipdata.co', '2.0'); PHP 8 users can write httpVersion: '2.0'.
  • $httpClient and $requestFactory are explicitly nullable: same behaviour, no deprecation notice on PHP 8.4.
  • New README guidance: create one Ipdata (and one PSR-18 client) per process and register it as a service; under PHP-FPM/mod_php reuse spans the lookups of one PHP request, long-running workers benefit fully; the synchronous PSR-18 API gains nothing from multiplexing, the saving is the TLS handshake; no close() because the connections belong to the injected HTTP client.
  • New API fields arrive unchanged in the returned arrays: is_satellite / is_anycast are present only when true (read $data['is_anycast'] ?? false); blocklist entries may carry first_seen, last_seen, days_seen, which some feeds omit ($entry['first_seen'] ?? null). Both flag names can be requested through the $fields argument.

Release notes

Version 0.1.3. After merging, push tag 0.1.3 on the merge commit (git tag 0.1.3 && git push origin 0.1.3); the Packagist hook publishes ipdata/api-client 0.1.3.

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

The CHANGELOG now also carries the previously missing 0.1.2 entry (caller IP lookup, EU endpoint, bulkLookup(), bulk Content-Type fix, removed unused dev dependencies), reconstructed from #9.

Verification

Environment: PHP 8.3.6 CLI (ext-curl 8.5.0, OpenSSL 3.0.13, HTTP/2 capable), PHPUnit 8.5.56, Composer with composer install of the repository's require-dev.

  • vendor/bin/phpunit: baseline on master OK (17 tests, 38 assertions); after commit 1 OK (22 tests, 54 assertions); after commit 2 (pushed state, run again right before the push) OK (27 tests, 96 assertions).
  • Static analysis. CI pins PHPStan 0.12.88, Psalm 4.7.2 and PHP-CS-Fixer 2.19.0 on PHP 8.0; those exact versions cannot run on this PHP 8.3 sandbox (the 0.12.88 phar aborts with PhpParser\Lexer::getNextToken(): Return value must be of type int, null returned, and Psalm 4 does not support PHP 8.2+), so the checks were run with the newest compatible releases against the repository's own configuration:
    • PHPStan 1.12.34, phpstan.neon.dist (level 6 + phpstan-baseline.neon): no errors in the code; two "Ignored error pattern ... was not matched" reports for the baseline entries about bulkLookup()/buildLookup() $ips. Identical on the pristine master tree (checked with git stash), so not introduced here.
    • Psalm 5.26.1, psalm.xml (errorLevel 2): No errors found!
    • PHP-CS-Fixer 2.19.0 (PHP_CS_FIXER_IGNORE_ENV=1, repository .php_cs, paths src tests) and PHP-CS-Fixer 3.95.27 with an equivalent @Symfony + short array syntax config: both report only the two pre-existing hits in src/Ipdata.php (yoda_style on $ip !== '', phpdoc_annotation_without_dot on @deprecated Use bulkLookup() instead.), identical before and after both commits; nothing on changed lines.
    • composer-normalize --dry-run: composer.json is not normalized today (key order); this PR does not touch it.
  • Live protocol check (R16), through the library's own stack: a scratch project with symfony/http-client 7.4.22 and nyholm/psr7, one Ipdata with one Symfony\Component\HttpClient\Psr18Client, a dummy API key (no real key used anywhere), two sequential lookup() calls, curl transfer info read from the Symfony responses:
    • request 1 GET /8.8.8.8: 401 "You have not provided a valid API Key.", http_version=3 (CURL_HTTP_VERSION_2_0, i.e. HTTP/2), curl log ALPN: curl offers h2,http/1.1 / ALPN: server accepted h2 / using HTTP/2, total 0.458 s.
    • request 2 GET /1.1.1.1: 401, HTTP/2, curl log Re-using existing connection, connect_time=0.0000s, total 0.066 s. Both requests went through the same PSR-18 client instance. The same result with $httpVersion = '2.0', confirming Symfony's Psr18Client ignores the PSR-7 version by default.
    • Plain curl from the same sandbox for comparison: http_version=2 status=401 num_connects=1, then num_connects=0 for a second URL in the same invocation.
    • Caveat: this sandbox's egress proxy re-terminates TLS, so the negotiated protocol observed is the one between the client and the proxy (CONNECT tunnel to 127.0.0.1); the server's own h2 support was verified by the spec author on 2026-11-02 (curl from there: HTTP/2, 401, second request reused the connection with 0 new connects).
  • Not verifiable here: the Guzzle / php-http/curl-client HTTP/2 path end to end (neither is a dependency of this repository; the mapping of PSR-7 protocol version 2.0 to CURLOPT_HTTP_VERSION = CURL_HTTP_VERSION_2_0 comes from their source), and the pinned analyzer versions on PHP 8.0.
  • CI: the CI workflow (pull_request trigger: "Build lowest version" on PHP 7.2 with --prefer-lowest, "Build" on PHP 7.2/7.3/7.4/8.0) and the Static analysis workflow run on this PR; see the Checks tab. Status at the time this description was last updated (2026-11-03 10:45 UTC): CI run #22 (https://github.com/ipdata/php/actions/runs/41620684185) success, all five jobs green (Build lowest version, Build 7.2, 7.3, 7.4, 8.0), each logging OK (27 tests, 96 assertions); Static analysis run #19 (https://github.com/ipdata/php/actions/runs/41620684422) queued: its four jobs request the retired ubuntu-20.04 runner label, no runner picks them up, and GitHub cancels such runs after 24 h (exactly what happened to runs #17 and #18). The equivalent checks were run locally, see above.

Noticed, not changed

  • .github/workflows/static.yml runs its four jobs on ubuntu-20.04, a runner image GitHub has retired: the workflow's recent runs (#17 on Add EU endpoint support and improve API client functionality #9, #18 on Dependabot Bump shivammathur/setup-php from 1.7.0 to 2.37.1 in /.github/workflows #10) ended as cancelled after queuing for 24 h, so PHPStan, Psalm, PHP-CS-Fixer and composer-normalize do not actually run on pull requests today. The pinned tools are also old (PHPStan 0.12.88, Psalm 4.7.2, PHP-CS-Fixer 2.19.0 on PHP 8.0).
  • .github/workflows/ci.yml has a stale matrix: PHP 7.2 to 8.0 only, shivammathur/setup-php@1.7.0, actions/checkout@v2, tools: prestissimo (a Composer 1 plugin). Dependabot Bump shivammathur/setup-php from 1.7.0 to 2.37.1 in /.github/workflows #10 (setup-php 2.37.1) currently fails CI.
  • Pre-existing style findings in src/Ipdata.php on lines this PR does not touch: $url = $ip !== '' ? ... (yoda_style) and @deprecated Use bulkLookup() instead. (phpdoc_annotation_without_dot).
  • phpstan-baseline.neon (added in Add EU endpoint support and improve API client functionality #9, whose static-analysis run was cancelled) has two entries for bulkLookup()/buildLookup() $ips that PHPStan 1.12 reports as unmatched because both methods document @param array<string> $ips; not checked with the pinned 0.12.88.
  • composer.json is not composer-normalized (the type key order), so the Composer Normalize job would fail if it ran.
  • .php_cs calls Finder::exclude(__DIR__.'/vendor') with an absolute path; exclude() takes a directory name relative to in(), so vendor is not actually excluded when the fixer runs from the config alone.
  • Ipdata::DEFAULT_BASE_URL is private, so the new fifth argument requires repeating 'https://api.ipdata.co' positionally (or a PHP 8 named argument). Making the constant public would be an additive follow-up.
  • The client sends no User-Agent header and no Accept header; the only header it sets is Content-Type on the bulk POST.
  • GitHub reported two moderate Dependabot alerts on the default branch at push time (not inspected, dev dependencies).
  • Spec decisions taken in this PR: (a) no Accept: application/json header was added (R9 permits it only where the request path already sets headers; the PHP section lists the exact code changes and the lookup() path sets no headers, so adding it to the bulk POST alone would be inconsistent); (b) the pinned analyzers were replaced by current releases for local verification only, CI stays as it is; (c) the 0.1.2 CHANGELOG entry was reconstructed from Add EU endpoint support and improve API client functionality #9's description and commits; (d) the fixtures are JSON files under tests/Fixtures/ rather than inline arrays; (e) the README section sits after the last "How to use" subsection ("Bulk request") and before "Available Fields".

https://claude.ai/code/session_01VQiRp4zEJKUXHrKkFaeUke

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@jonathan-kosgei
jonathan-kosgei merged commit 5e17e46 into master Nov 3, 2026
5 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants