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..5b360f9 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 @@ -82,9 +87,6 @@ private function validateCache( } $headerETags = $this->extractRawETagValues($request); - if ($headerETags === []) { - return false; - } return in_array($eTagHeader->rawValue(), $headerETags, true); } @@ -145,11 +147,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..b996ead 100644 --- a/tests/HttpCache/HttpCacheMiddlewareTest.php +++ b/tests/HttpCache/HttpCacheMiddlewareTest.php @@ -16,10 +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 @@ -173,6 +176,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( @@ -217,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(