From 5e633f615975c502a05c79c5b3ae1d426745a996 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Thu, 20 Aug 2026 14:48:43 +0200 Subject: [PATCH 1/3] Add ETag value normalization to HttpCacheMiddleware Introduce ETagValueNormalizerInterface to normalize raw ETag values from the If-None-Match request header before comparison. This fixes cache validation when a web server compression module (e.g. Apache mod_deflate or mod_brotli) appends a suffix such as -gzip or -br to the ETag value. Includes NullETagValueNormalizer (default, no changes) and SuffixETagValueNormalizer (removes the first matching suffix). Fixes #11 --- CHANGELOG.md | 3 +- docs/guide/en/http-cache-middleware.md | 26 ++++++++++ .../ETagValueNormalizerInterface.php | 23 +++++++++ .../NullETagValueNormalizer.php | 16 +++++++ .../SuffixETagValueNormalizer.php | 48 +++++++++++++++++++ src/HttpCache/HttpCacheMiddleware.php | 10 +++- .../NullETagValueNormalizerTest.php | 22 +++++++++ .../SuffixETagValueNormalizerTest.php | 47 ++++++++++++++++++ tests/HttpCache/HttpCacheMiddlewareTest.php | 30 ++++++++++++ 9 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php create mode 100644 src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php create mode 100644 src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php create mode 100644 tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php create mode 100644 tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 547555e..30675fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.2.2 under development -- no changes in this release. +- New #11: Add ETag value normalization in `HttpCacheMiddleware` via `ETagValueNormalizerInterface` with + `NullETagValueNormalizer` and `SuffixETagValueNormalizer` implementations (@KalimeroMK) ## 1.2.1 August 10, 2026 diff --git a/docs/guide/en/http-cache-middleware.md b/docs/guide/en/http-cache-middleware.md index f2befe8..524aeb4 100644 --- a/docs/guide/en/http-cache-middleware.md +++ b/docs/guide/en/http-cache-middleware.md @@ -90,6 +90,32 @@ Default: `new DefaultETagGenerator()` An instance of `ETagGeneratorInterface` that generates a string `ETag` value based on the provided seed. +### `$eTagValueNormalizer` + +Type: `Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface` + +Default: `new NullETagValueNormalizer()` + +An instance of `ETagValueNormalizerInterface` that normalizes raw ETag values obtained from the `If-None-Match` +request header before comparing them with the application generated ETag value. + +Normalization is needed when an intermediary, such as a web server compression module, modifies the ETag header value. +For example, Apache `mod_deflate` and `mod_brotli` append `-gzip` and `-br` suffixes to the ETag value +(see [mod_deflate documentation](https://httpd.apache.org/docs/2.4/mod/mod_deflate.html#deflatealteretag)). + +Implementations out of the box: + +- `NullETagValueNormalizer` — returns ETag values unmodified. +- `SuffixETagValueNormalizer` — removes the first matching suffix from a given list of suffixes. + +Example usage for a server that appends compression suffixes: + +```php +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; + +$eTagValueNormalizer = new SuffixETagValueNormalizer(['-gzip', '-br']); +``` + ## `Cache-Control` header value providers A provider should implement the `CacheControlProviderInterface` interface to supply the value of the `Cache-Control` diff --git a/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php b/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php new file mode 100644 index 0000000..280369f --- /dev/null +++ b/src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php @@ -0,0 +1,23 @@ + $suffix + */ + public function __construct(string|array $suffix) + { + $this->suffixes = is_string($suffix) ? [$suffix] : $suffix; + } + + public function normalize(string $value): string + { + foreach ($this->suffixes as $suffix) { + if ($suffix !== '' && str_ends_with($value, $suffix)) { + return substr($value, 0, -strlen($suffix)); + } + } + return $value; + } +} diff --git a/src/HttpCache/HttpCacheMiddleware.php b/src/HttpCache/HttpCacheMiddleware.php index bf3e0d4..f2763ed 100644 --- a/src/HttpCache/HttpCacheMiddleware.php +++ b/src/HttpCache/HttpCacheMiddleware.php @@ -16,6 +16,8 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagGenerator\ETagGeneratorInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\ETagProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NullETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\LastModifiedProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; @@ -33,6 +35,8 @@ final class HttpCacheMiddleware implements MiddlewareInterface * @param LastModifiedProviderInterface $lastModifiedProvider The last modified dates provider. * @param ETagProviderInterface $eTagProvider The provider for {@see ETag}. * @param ETagGeneratorInterface $eTagGenerator The {@see ETag} string values generator. + * @param ETagValueNormalizerInterface $eTagValueNormalizer The normalizer for raw ETag values obtained from + * the `If-None-Match` request header. */ public function __construct( private readonly ResponseFactoryInterface $responseFactory, @@ -40,6 +44,7 @@ public function __construct( private readonly LastModifiedProviderInterface $lastModifiedProvider = new NullLastModifiedProvider(), private readonly ETagProviderInterface $eTagProvider = new NullETagProvider(), private readonly ETagGeneratorInterface $eTagGenerator = new DefaultETagGenerator(), + private readonly ETagValueNormalizerInterface $eTagValueNormalizer = new NullETagValueNormalizer(), ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface @@ -145,11 +150,12 @@ private function extractRawETagValues(ServerRequestInterface $request): array } return array_map( - static function (string $value): string { + function (string $value): string { /** * @var string We use a correct pattern, so `preg_replace` always returns a string. */ - return preg_replace('~^\s*(?:W/)?"([^"]+)"\s*$~', '$1', $value); + $rawETag = preg_replace('~^\s*(?:W/)?"([^"]+)"\s*$~', '$1', $value); + return $this->eTagValueNormalizer->normalize($rawETag); }, explode(',', $rawValue), ); diff --git a/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php b/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php new file mode 100644 index 0000000..c5a9632 --- /dev/null +++ b/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php @@ -0,0 +1,22 @@ +normalize('tag1')); + assertSame('tag1-gzip', $normalizer->normalize('tag1-gzip')); + assertSame('', $normalizer->normalize('')); + } +} diff --git a/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php b/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php new file mode 100644 index 0000000..3768284 --- /dev/null +++ b/tests/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizerTest.php @@ -0,0 +1,47 @@ +normalize($value)); + } + + public function testStringSuffix(): void + { + $normalizer = new SuffixETagValueNormalizer('-gzip'); + + assertSame('tag1', $normalizer->normalize('tag1-gzip')); + assertSame('tag1-br', $normalizer->normalize('tag1-br')); + } + + public function testOnlyFirstMatchingSuffixIsRemoved(): void + { + $normalizer = new SuffixETagValueNormalizer(['-gzip', '-br']); + + assertSame('content-gzip', $normalizer->normalize('content-gzip-br')); + } + + public function testEmptySuffixIsIgnored(): void + { + $normalizer = new SuffixETagValueNormalizer(''); + + assertSame('tag1', $normalizer->normalize('tag1')); + } +} diff --git a/tests/HttpCache/HttpCacheMiddlewareTest.php b/tests/HttpCache/HttpCacheMiddlewareTest.php index c0b29af..57c6682 100644 --- a/tests/HttpCache/HttpCacheMiddlewareTest.php +++ b/tests/HttpCache/HttpCacheMiddlewareTest.php @@ -16,6 +16,7 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; use Yiisoft\HttpMiddleware\HttpCache\HttpCacheMiddleware; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\PredefinedETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\PredefinedLastModifiedProvider; use Yiisoft\HttpMiddleware\Tests\Support\FakeRequestHandler; @@ -173,6 +174,35 @@ public function testIfNoneMatchEquals(array $ifNoneMatchValues): void ); } + #[TestWith([['"tag1-gzip"']])] + #[TestWith([['W/"tag1-gzip"']])] + public function testIfNoneMatchEqualsWithSuffixNormalizer(array $ifNoneMatchValues): void + { + $request = new ServerRequest( + headers: [ + 'If-None-Match' => $ifNoneMatchValues, + ], + ); + $middleware = new HttpCacheMiddleware( + new ResponseFactory(), + eTagProvider: new PredefinedETagProvider([new ETag('tag1')]), + eTagGenerator: new CallableETagGenerator( + static fn(string $seed) => $seed, + ), + eTagValueNormalizer: new SuffixETagValueNormalizer(['-gzip', '-br']), + ); + + $response = $middleware->process($request, new FakeRequestHandler()); + + assertSame(304, $response->getStatusCode()); + assertSame( + [ + 'ETag' => ['"tag1"'], + ], + $response->getHeaders(), + ); + } + public function testIfNoneMatchWithoutEtag(): void { $request = new ServerRequest( From ab620f877a9fd15cbcdcb91f038e6bb2fe660125 Mon Sep 17 00:00:00 2001 From: KalimeroMK Date: Thu, 20 Aug 2026 15:08:48 +0200 Subject: [PATCH 2/3] Kill escaped mutants in HttpCacheMiddleware - Remove redundant early return for an empty If-None-Match header value: in_array() with an empty list already returns false (equivalent mutant reported by Infection). - Add a test asserting the ETag value normalizer is not called for an empty If-None-Match header value. MSI for the file is now 100%. --- src/HttpCache/HttpCacheMiddleware.php | 3 -- tests/HttpCache/HttpCacheMiddlewareTest.php | 33 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/HttpCache/HttpCacheMiddleware.php b/src/HttpCache/HttpCacheMiddleware.php index f2763ed..5b360f9 100644 --- a/src/HttpCache/HttpCacheMiddleware.php +++ b/src/HttpCache/HttpCacheMiddleware.php @@ -87,9 +87,6 @@ private function validateCache( } $headerETags = $this->extractRawETagValues($request); - if ($headerETags === []) { - return false; - } return in_array($eTagHeader->rawValue(), $headerETags, true); } diff --git a/tests/HttpCache/HttpCacheMiddlewareTest.php b/tests/HttpCache/HttpCacheMiddlewareTest.php index 57c6682..b996ead 100644 --- a/tests/HttpCache/HttpCacheMiddlewareTest.php +++ b/tests/HttpCache/HttpCacheMiddlewareTest.php @@ -16,11 +16,13 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; use Yiisoft\HttpMiddleware\HttpCache\HttpCacheMiddleware; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\PredefinedETagProvider; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\PredefinedLastModifiedProvider; use Yiisoft\HttpMiddleware\Tests\Support\FakeRequestHandler; +use function PHPUnit\Framework\assertFalse; use function PHPUnit\Framework\assertSame; final class HttpCacheMiddlewareTest extends TestCase @@ -247,6 +249,37 @@ public function testEmptyIfNoneMatch(): void ); } + public function testEmptyIfNoneMatchDoesNotCallETagValueNormalizer(): void + { + $request = new ServerRequest( + headers: [ + 'If-None-Match' => [''], + ], + ); + $normalizer = new class implements ETagValueNormalizerInterface { + public bool $called = false; + + public function normalize(string $value): string + { + $this->called = true; + return $value; + } + }; + $middleware = new HttpCacheMiddleware( + new ResponseFactory(), + eTagProvider: new PredefinedETagProvider([new ETag('test')]), + eTagGenerator: new CallableETagGenerator( + static fn(string $seed) => $seed, + ), + eTagValueNormalizer: $normalizer, + ); + + $response = $middleware->process($request, new FakeRequestHandler()); + + assertSame(200, $response->getStatusCode()); + assertFalse($normalizer->called); + } + public function testIfModifiedSinceTrue(): void { $request = new ServerRequest( From d0d3275000b0f849eafa27c528065674036dc9c4 Mon Sep 17 00:00:00 2001 From: zoran Date: Sun, 23 Aug 2026 14:57:58 +0200 Subject: [PATCH 3/3] Apply review suggestions: rename to NoopETagValueNormalizer, simplify suffix cast --- CHANGELOG.md | 2 +- docs/guide/en/http-cache-middleware.md | 4 ++-- ...lETagValueNormalizer.php => NoopETagValueNormalizer.php} | 2 +- .../ETagValueNormalizer/SuffixETagValueNormalizer.php | 3 +-- src/HttpCache/HttpCacheMiddleware.php | 4 ++-- ...ueNormalizerTest.php => NoopETagValueNormalizerTest.php} | 6 +++--- 6 files changed, 10 insertions(+), 11 deletions(-) rename src/HttpCache/ETagValueNormalizer/{NullETagValueNormalizer.php => NoopETagValueNormalizer.php} (82%) rename tests/HttpCache/ETagValueNormalizer/{NullETagValueNormalizerTest.php => NoopETagValueNormalizerTest.php} (71%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30675fd..51343bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.2.2 under development - New #11: Add ETag value normalization in `HttpCacheMiddleware` via `ETagValueNormalizerInterface` with - `NullETagValueNormalizer` and `SuffixETagValueNormalizer` implementations (@KalimeroMK) + `NoopETagValueNormalizer` and `SuffixETagValueNormalizer` implementations (@KalimeroMK) ## 1.2.1 August 10, 2026 diff --git a/docs/guide/en/http-cache-middleware.md b/docs/guide/en/http-cache-middleware.md index 524aeb4..50251c1 100644 --- a/docs/guide/en/http-cache-middleware.md +++ b/docs/guide/en/http-cache-middleware.md @@ -94,7 +94,7 @@ An instance of `ETagGeneratorInterface` that generates a string `ETag` value bas Type: `Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface` -Default: `new NullETagValueNormalizer()` +Default: `new NoopETagValueNormalizer()` An instance of `ETagValueNormalizerInterface` that normalizes raw ETag values obtained from the `If-None-Match` request header before comparing them with the application generated ETag value. @@ -105,7 +105,7 @@ For example, Apache `mod_deflate` and `mod_brotli` append `-gzip` and `-br` suff Implementations out of the box: -- `NullETagValueNormalizer` — returns ETag values unmodified. +- `NoopETagValueNormalizer` — returns ETag values unmodified. - `SuffixETagValueNormalizer` — removes the first matching suffix from a given list of suffixes. Example usage for a server that appends compression suffixes: diff --git a/src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php b/src/HttpCache/ETagValueNormalizer/NoopETagValueNormalizer.php similarity index 82% rename from src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php rename to src/HttpCache/ETagValueNormalizer/NoopETagValueNormalizer.php index 3e656e0..ebc4bf9 100644 --- a/src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php +++ b/src/HttpCache/ETagValueNormalizer/NoopETagValueNormalizer.php @@ -7,7 +7,7 @@ /** * Returns ETag values unmodified. It can be used when ETag normalization is not required. */ -final class NullETagValueNormalizer implements ETagValueNormalizerInterface +final class NoopETagValueNormalizer implements ETagValueNormalizerInterface { public function normalize(string $value): string { diff --git a/src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php b/src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php index f2202ad..0143a49 100644 --- a/src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php +++ b/src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php @@ -4,7 +4,6 @@ namespace Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer; -use function is_string; use function str_ends_with; use function strlen; use function substr; @@ -33,7 +32,7 @@ final class SuffixETagValueNormalizer implements ETagValueNormalizerInterface */ public function __construct(string|array $suffix) { - $this->suffixes = is_string($suffix) ? [$suffix] : $suffix; + $this->suffixes = (array) $suffix; } public function normalize(string $value): string diff --git a/src/HttpCache/HttpCacheMiddleware.php b/src/HttpCache/HttpCacheMiddleware.php index 5b360f9..5965018 100644 --- a/src/HttpCache/HttpCacheMiddleware.php +++ b/src/HttpCache/HttpCacheMiddleware.php @@ -17,7 +17,7 @@ use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\ETagProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\ETagProvider\NullETagProvider; use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\ETagValueNormalizerInterface; -use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NullETagValueNormalizer; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NoopETagValueNormalizer; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\LastModifiedProviderInterface; use Yiisoft\HttpMiddleware\HttpCache\LastModifiedProvider\NullLastModifiedProvider; @@ -44,7 +44,7 @@ public function __construct( private readonly LastModifiedProviderInterface $lastModifiedProvider = new NullLastModifiedProvider(), private readonly ETagProviderInterface $eTagProvider = new NullETagProvider(), private readonly ETagGeneratorInterface $eTagGenerator = new DefaultETagGenerator(), - private readonly ETagValueNormalizerInterface $eTagValueNormalizer = new NullETagValueNormalizer(), + private readonly ETagValueNormalizerInterface $eTagValueNormalizer = new NoopETagValueNormalizer(), ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface diff --git a/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php b/tests/HttpCache/ETagValueNormalizer/NoopETagValueNormalizerTest.php similarity index 71% rename from tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php rename to tests/HttpCache/ETagValueNormalizer/NoopETagValueNormalizerTest.php index c5a9632..5cbf1f5 100644 --- a/tests/HttpCache/ETagValueNormalizer/NullETagValueNormalizerTest.php +++ b/tests/HttpCache/ETagValueNormalizer/NoopETagValueNormalizerTest.php @@ -5,15 +5,15 @@ namespace Yiisoft\HttpMiddleware\Tests\HttpCache\ETagValueNormalizer; use PHPUnit\Framework\TestCase; -use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NullETagValueNormalizer; +use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NoopETagValueNormalizer; use function PHPUnit\Framework\assertSame; -final class NullETagValueNormalizerTest extends TestCase +final class NoopETagValueNormalizerTest extends TestCase { public function testBase(): void { - $normalizer = new NullETagValueNormalizer(); + $normalizer = new NoopETagValueNormalizer(); assertSame('tag1', $normalizer->normalize('tag1')); assertSame('tag1-gzip', $normalizer->normalize('tag1-gzip'));