Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions docs/guide/en/http-cache-middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
23 changes: 23 additions & 0 deletions src/HttpCache/ETagValueNormalizer/ETagValueNormalizerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer;

/**
* 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 (for example, a web server compression module such as Apache
* `mod_deflate` or `mod_brotli`) modifies the ETag header value by appending a suffix (`-gzip`, `-br`, etc.).
*/
interface ETagValueNormalizerInterface
{
/**
* Returns the normalized ETag value.
*
* @param string $value The raw ETag value (without quotes and `W/` prefix) to normalize.
* @return string The normalized ETag value.
*/
public function normalize(string $value): string;
}
16 changes: 16 additions & 0 deletions src/HttpCache/ETagValueNormalizer/NullETagValueNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer;

/**
* Returns ETag values unmodified. It can be used when ETag normalization is not required.
*/
final class NullETagValueNormalizer implements ETagValueNormalizerInterface
{
public function normalize(string $value): string
{
return $value;
}
}
48 changes: 48 additions & 0 deletions src/HttpCache/ETagValueNormalizer/SuffixETagValueNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer;

use function is_string;
use function str_ends_with;
use function strlen;
use function substr;

/**
* Removes a suffix from ETag values.
*
* This is useful for normalizing ETag values modified by web server compression modules. For example, Apache
* `mod_deflate` and `mod_brotli` append `-gzip` and `-br` suffixes to the ETag header value
* (see {@link https://httpd.apache.org/docs/2.4/mod/mod_deflate.html#deflatealteretag}).
*
* Only the first matching suffix is removed. For example, for value `content-gzip-br` and suffixes
* `['-gzip', '-br']`, the result is `content-gzip`.
*/
final class SuffixETagValueNormalizer implements ETagValueNormalizerInterface
{
/**
* @var string[] The suffixes to remove.
*/
private readonly array $suffixes;

/**
* @param string|string[] $suffix A single suffix or a list of suffixes to remove from ETag values.
*
* @psalm-param string|list<string> $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;
}
}
13 changes: 8 additions & 5 deletions src/HttpCache/HttpCacheMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -33,13 +35,16 @@ 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,
private readonly CacheControlProviderInterface $cacheControlProvider = new NullCacheControlProvider(),
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
Expand Down Expand Up @@ -82,9 +87,6 @@ private function validateCache(
}

$headerETags = $this->extractRawETagValues($request);
if ($headerETags === []) {
return false;
}
return in_array($eTagHeader->rawValue(), $headerETags, true);
}

Expand Down Expand Up @@ -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),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware\Tests\HttpCache\ETagValueNormalizer;

use PHPUnit\Framework\TestCase;
use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\NullETagValueNormalizer;

use function PHPUnit\Framework\assertSame;

final class NullETagValueNormalizerTest extends TestCase
{
public function testBase(): void
{
$normalizer = new NullETagValueNormalizer();

assertSame('tag1', $normalizer->normalize('tag1'));
assertSame('tag1-gzip', $normalizer->normalize('tag1-gzip'));
assertSame('', $normalizer->normalize(''));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Yiisoft\HttpMiddleware\Tests\HttpCache\ETagValueNormalizer;

use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Yiisoft\HttpMiddleware\HttpCache\ETagValueNormalizer\SuffixETagValueNormalizer;

use function PHPUnit\Framework\assertSame;

final class SuffixETagValueNormalizerTest extends TestCase
{
#[TestWith(['tag1-gzip', 'tag1'])]
#[TestWith(['tag1-br', 'tag1'])]
#[TestWith(['tag1', 'tag1'])]
#[TestWith(['gzip', 'gzip'])]
public function testSingleSuffix(string $value, string $expected): void
{
$normalizer = new SuffixETagValueNormalizer(['-gzip', '-br']);

assertSame($expected, $normalizer->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'));
}
}
63 changes: 63 additions & 0 deletions tests/HttpCache/HttpCacheMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading