From 8cb32b7de8806c4bd6b6c220fcb01b14ba54bc49 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 14:35:15 +0200 Subject: [PATCH 1/3] test: add gzip regression tests and invariant comment --- src/Http/GuzzleHttpClient.php | 4 + tests/GzipTest.php | 151 ++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 tests/GzipTest.php diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index 94c06291..f5cff490 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -37,6 +37,10 @@ public function __construct(array $config = [], int $maxRetries = 3) 'http_errors' => false, // We'll handle errors ourselves ]; + // CHA-2964 invariant: do NOT set 'decode_content' => false in this + // config array. Guzzle's default decode_content=true advertises + // "Accept-Encoding: gzip, deflate" and decodes responses transparently. + // Setting it false disables both. $this->client = new GuzzleClient(array_merge($defaultConfig, $config)); $this->maxRetries = $maxRetries; } diff --git a/tests/GzipTest.php b/tests/GzipTest.php new file mode 100644 index 00000000..f0f37a65 --- /dev/null +++ b/tests/GzipTest.php @@ -0,0 +1,151 @@ + true advertises + * `Accept-Encoding: gzip, deflate` via curl and transparently decodes + * gzip-encoded responses. These regression tests guard against future + * `decode_content => false` overrides in src/Http/GuzzleHttpClient.php. + * + * Note: real Accept-Encoding negotiation and response decoding live in + * Guzzle's curl/StreamHandler layers and never run with MockHandler. The + * tests assert what is observable in middleware: the resolved + * `decode_content` option, and end-to-end behavior through a middleware + * that mirrors EasyHandle's decoding logic. + */ +class GzipTest extends TestCase +{ + /** + * Middleware that mirrors GuzzleHttp\Handler\EasyHandle::createResponse: + * when decode_content is truthy and Content-Encoding indicates gzip, + * the response body is decompressed and the Content-Encoding header is + * stripped before reaching the SDK. + * + * Letting decode_content default through (= true) is exactly what the + * SDK relies on; flipping it to false here would skip decoding. + */ + private static function decodingMiddleware(): callable + { + return Middleware::mapResponse(static function (ResponseInterface $response): ResponseInterface { + // The middleware doesn't see the options array directly through + // mapResponse, so the more faithful check (decode_content === true) + // is the dedicated test below. Here we always decode, matching + // Guzzle's behavior under the default decode_content=true. + $encoding = strtolower($response->getHeaderLine('Content-Encoding')); + if ($encoding === 'gzip' || $encoding === 'x-gzip') { + $raw = (string) $response->getBody(); + $decoded = gzdecode($raw); + if ($decoded === false) { + return $response; + } + return $response + ->withoutHeader('Content-Encoding') + ->withBody(Utils::streamFor($decoded)); + } + return $response; + }); + } + + /** + * Build a GuzzleHttpClient wired to a MockHandler and history capture. + * + * @param array $responses + * @param array> $capturedHistory + */ + private function makeClient(array $responses, array &$capturedHistory): GuzzleHttpClient + { + $mock = new MockHandler($responses); + $stack = HandlerStack::create($mock); + $stack->push(self::decodingMiddleware()); + $stack->push(Middleware::history($capturedHistory)); + + return new GuzzleHttpClient(['handler' => $stack], 0); + } + + /** + * @test + * + * Asserts the SDK does NOT override `decode_content` to false. With the + * default (true), Guzzle's curl layer sets CURLOPT_ENCODING => '', + * which advertises `Accept-Encoding: gzip, deflate` on the wire. + * + * Middleware::history records the request + the options array Guzzle + * passes to the handler, after applyOptions() has merged defaults and + * user config. Any future regression that sets decode_content => false + * will surface here. + */ + public function testRequestEnablesGzipDecoding(): void + { + $history = []; + $client = $this->makeClient( + [new Response(200, ['Content-Type' => 'application/json'], '{"ok":true}')], + $history, + ); + + $client->request('GET', 'https://example.invalid/ping'); + + self::assertCount(1, $history); + $options = $history[0]['options']; + self::assertArrayHasKey('decode_content', $options, 'decode_content option must reach the handler'); + self::assertNotFalse( + $options['decode_content'], + 'CHA-2964 invariant: SDK must NOT set decode_content => false (disables gzip advertise + decode)', + ); + self::assertTrue( + $options['decode_content'] === true, + 'decode_content must remain at Guzzle default (true) for transparent gzip decoding', + ); + + // Sanity: the request object reaching the handler has no Accept-Encoding + // header set by Guzzle itself when decode_content === true. Guzzle + // delegates that to curl (CURLOPT_ENCODING), so middleware-level + // assertions on the header alone would be misleading. We assert via + // the option, which is the real toggle. + /** @var RequestInterface $req */ + $req = $history[0]['request']; + self::assertNotNull($req); + } + + /** + * @test + * + * Asserts that when a response arrives gzip-encoded, the SDK's caller + * sees the decoded JSON, not raw gzip bytes. The decoding-middleware in + * makeClient stands in for Guzzle's curl/StreamHandler decoding path. + */ + public function testResponseIsTransparentlyGunzipped(): void + { + $expected = ['hello' => 'world', 'n' => 42]; + $plainJson = (string) json_encode($expected); + $gzipped = gzencode($plainJson); + self::assertNotFalse($gzipped, 'gzencode must succeed'); + + $history = []; + $client = $this->makeClient( + [new Response( + 200, + ['Content-Type' => 'application/json', 'Content-Encoding' => 'gzip'], + $gzipped, + )], + $history, + ); + + $response = $client->request('GET', 'https://example.invalid/ping'); + + self::assertSame($expected, $response->getData()); + self::assertSame($plainJson, $response->getRawBody()); + } +} From 48a22ffe873a9e999fb9bcc589db1371e62fd5ea Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 16:04:25 +0200 Subject: [PATCH 2/3] docs: trim gzip invariant comment --- src/Http/GuzzleHttpClient.php | 6 ++---- tests/GzipTest.php | 15 +++++---------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index f5cff490..b85f5133 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -37,10 +37,8 @@ public function __construct(array $config = [], int $maxRetries = 3) 'http_errors' => false, // We'll handle errors ourselves ]; - // CHA-2964 invariant: do NOT set 'decode_content' => false in this - // config array. Guzzle's default decode_content=true advertises - // "Accept-Encoding: gzip, deflate" and decodes responses transparently. - // Setting it false disables both. + // Don't set 'decode_content' => false; it disables Guzzle's + // Accept-Encoding advertisement and automatic gzip decoding. $this->client = new GuzzleClient(array_merge($defaultConfig, $config)); $this->maxRetries = $maxRetries; } diff --git a/tests/GzipTest.php b/tests/GzipTest.php index f0f37a65..93952852 100644 --- a/tests/GzipTest.php +++ b/tests/GzipTest.php @@ -15,15 +15,10 @@ use Psr\Http\Message\ResponseInterface; /** - * CHA-2964: Guzzle's default `decode_content` => true advertises - * `Accept-Encoding: gzip, deflate` via curl and transparently decodes - * gzip-encoded responses. These regression tests guard against future - * `decode_content => false` overrides in src/Http/GuzzleHttpClient.php. - * - * Note: real Accept-Encoding negotiation and response decoding live in - * Guzzle's curl/StreamHandler layers and never run with MockHandler. The - * tests assert what is observable in middleware: the resolved - * `decode_content` option, and end-to-end behavior through a middleware + * Regression tests guarding Guzzle's default `decode_content => true`, which + * gives transparent gzip advertise + decode. Real negotiation happens in + * Guzzle's curl layer (not visible to MockHandler), so the tests assert the + * resolved `decode_content` option and exercise end-to-end via a middleware * that mirrors EasyHandle's decoding logic. */ class GzipTest extends TestCase @@ -102,7 +97,7 @@ public function testRequestEnablesGzipDecoding(): void self::assertArrayHasKey('decode_content', $options, 'decode_content option must reach the handler'); self::assertNotFalse( $options['decode_content'], - 'CHA-2964 invariant: SDK must NOT set decode_content => false (disables gzip advertise + decode)', + 'SDK must not set decode_content => false; it disables gzip advertise + decode', ); self::assertTrue( $options['decode_content'] === true, From ad6a343cb49bc3422a9b65f31723edc3574f61b2 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 26 May 2026 16:43:45 +0200 Subject: [PATCH 3/3] docs: drop gzip warning comment, test covers invariant --- src/Http/GuzzleHttpClient.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Http/GuzzleHttpClient.php b/src/Http/GuzzleHttpClient.php index b85f5133..94c06291 100644 --- a/src/Http/GuzzleHttpClient.php +++ b/src/Http/GuzzleHttpClient.php @@ -37,8 +37,6 @@ public function __construct(array $config = [], int $maxRetries = 3) 'http_errors' => false, // We'll handle errors ourselves ]; - // Don't set 'decode_content' => false; it disables Guzzle's - // Accept-Encoding advertisement and automatic gzip decoding. $this->client = new GuzzleClient(array_merge($defaultConfig, $config)); $this->maxRetries = $maxRetries; }