HttpCache   A
last analyzed

Complexity

Total Complexity 26

Size/Duplication

Total Lines 196
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 57
dl 0
loc 196
ccs 61
cts 61
cp 1
rs 10
c 1
b 0
f 0
wmc 26

8 Methods

Rating   Name   Duplication   Size   Complexity  
A withWeakEtag() 0 5 1
A generateEtag() 0 4 2
A validateCache() 0 24 6
C process() 0 45 13
A withCacheControlHeader() 0 5 1
A withParams() 0 5 1
A withLastModified() 0 5 1
A withEtagSeed() 0 5 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Middleware;
6
7
use Psr\Http\Message\ResponseInterface;
8
use Psr\Http\Message\ServerRequestInterface;
9
use Psr\Http\Server\MiddlewareInterface;
10
use Psr\Http\Server\RequestHandlerInterface;
11
use Yiisoft\Http\Header;
12
use Yiisoft\Http\Method;
13
use Yiisoft\Http\Status;
14
15
use function base64_encode;
16
use function in_array;
17
use function rtrim;
18
use function preg_split;
19
use function sha1;
20
use function str_replace;
21
use function strtotime;
22
23
/**
24
 * HttpCache implements client-side caching by utilizing the `Last-Modified` and `ETag` HTTP headers.
25
 */
26
final class HttpCache implements MiddlewareInterface
27
{
28
    /**
29
     * @var callable|null
30
     */
31
    private $lastModified = null;
32
33
    /**
34
     * @var callable|null
35
     */
36
    private $etagSeed = null;
37
38
    private bool $weakEtag = false;
39
    private mixed $params = null;
40
    private ?string $cacheControlHeader = 'public, max-age=3600';
41
42
    /**
43
     * Returns a new instance with the specified callable that generates the last modified UNIX timestamp.
44
     *
45
     * @param callable $lastModified A PHP callback that returns the UNIX timestamp of the last modification time.
46
     *
47
     * The callback's signature should be:
48
     *
49
     * ```php
50
     * function (ServerRequestInterface $request, mixed $params): int;
51
     * ```
52
     *
53
     * Where `$request` is the {@see ServerRequestInterface} object that this filter is currently handling;
54
     * `$params` takes the value of {@see withParams()}. The callback should return a UNIX timestamp.
55
     *
56
     * @see https://tools.ietf.org/html/rfc7232#section-2.2
57
     */
58 8
    public function withLastModified(callable $lastModified): self
59
    {
60 8
        $new = clone $this;
61 8
        $new->lastModified = $lastModified;
62 8
        return $new;
63
    }
64
65
    /**
66
     * Returns a new instance with the specified callable that generates the ETag seed string.
67
     *
68
     * @param callable $etagSeed A PHP callback that generates the ETag seed string.
69
     *
70
     * The callback's signature should be:
71
     *
72
     * ```php
73
     * function (ServerRequestInterface $request, mixed $params): string;
74
     * ```
75
     *
76
     * Where `$request` is the {@see ServerRequestInterface} object that this middleware is currently handling;
77
     * `$params` takes the value of {@see withParams()}. The callback should return a string serving
78
     * as the seed for generating an ETag.
79
     */
80 9
    public function withEtagSeed(callable $etagSeed): self
81
    {
82 9
        $new = clone $this;
83 9
        $new->etagSeed = $etagSeed;
84 9
        return $new;
85
    }
86
87
    /**
88
     * Returns a new instance with weak ETags generation enabled. Disabled by default.
89
     *
90
     * You should use weak ETags if the content is semantically equal, but not byte-equal.
91
     *
92
     * @see https://tools.ietf.org/html/rfc7232#section-2.3
93
     */
94 2
    public function withWeakEtag(): self
95
    {
96 2
        $new = clone $this;
97 2
        $new->weakEtag = true;
98 2
        return $new;
99
    }
100
101
    /**
102
     * Returns a new instance with the specified extra parameters for ETag seed string generation.
103
     *
104
     * @param mixed $params Extra parameters for {@see withEtagSeed()} callbacks.
105
     */
106 1
    public function withParams(mixed $params): self
107
    {
108 1
        $new = clone $this;
109 1
        $new->params = $params;
110 1
        return $new;
111
    }
112
113
    /**
114
     * Returns a new instance with the specified value of the `Cache-Control` HTTP header.
115
     *
116
     * @param string|null $header The value of the `Cache-Control` HTTP header. If `null`, the header won't be sent.
117
     *
118
     * @see https://tools.ietf.org/html/rfc2616#section-14.9
119
     */
120 1
    public function withCacheControlHeader(?string $header): self
121
    {
122 1
        $new = clone $this;
123 1
        $new->cacheControlHeader = $header;
124 1
        return $new;
125
    }
126
127 12
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
128
    {
129
        if (
130 12
            ($this->lastModified === null && $this->etagSeed === null) ||
131 12
            !in_array($request->getMethod(), [Method::GET, Method::HEAD], true)
132
        ) {
133 1
            return $handler->handle($request);
134
        }
135
136
        /** @var int|null $lastModified */
137 11
        $lastModified = $this->lastModified === null ? null : ($this->lastModified)($request, $this->params);
138 11
        $etag = null;
139
140 11
        if ($this->etagSeed !== null) {
141
            /** @var string|null $seed */
142 8
            $seed = ($this->etagSeed)($request, $this->params);
143
144 8
            if ($seed !== null) {
145 8
                $etag = $this->generateEtag($seed);
146
            }
147
        }
148
149 11
        $response = $handler->handle($request);
150
151 11
        if ($this->cacheControlHeader !== null) {
152 11
            $response = $response->withHeader(Header::CACHE_CONTROL, $this->cacheControlHeader);
153
        }
154 11
        if ($etag !== null) {
155 8
            $response = $response->withHeader(Header::ETAG, $etag);
156
        }
157
158 11
        $cacheIsValid = $this->validateCache($request, $lastModified, $etag);
159 11
        if ($cacheIsValid) {
160 5
            $response = $response->withStatus(Status::NOT_MODIFIED);
161
        }
162
163
        /** @see https://tools.ietf.org/html/rfc7232#section-4.1 */
164 11
        if ($lastModified !== null && (!$cacheIsValid || $etag === null)) {
165 4
            $response = $response->withHeader(
166 4
                Header::LAST_MODIFIED,
167 4
                gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
168 4
            );
169
        }
170
171 11
        return $response;
172
    }
173
174
    /**
175
     * Validates if the HTTP cache has valid content.
176
     * If both `Last-Modified` and `ETag` are `null`, it returns `false`.
177
     *
178
     * @param ServerRequestInterface $request The server request instance.
179
     * @param int|null $lastModified The calculated Last-Modified value in terms of a UNIX timestamp.
180
     * If `null`, the `Last-Modified` header won't be validated.
181
     * @param string|null $etag The calculated `ETag` value. If `null`, the `ETag` header won't be validated.
182
     *
183
     * @return bool Whether the HTTP cache is still valid.
184
     */
185 11
    private function validateCache(ServerRequestInterface $request, ?int $lastModified, ?string $etag): bool
186
    {
187 11
        if ($request->hasHeader(Header::IF_NONE_MATCH)) {
188 6
            if ($etag === null) {
189 1
                return false;
190
            }
191
192 5
            $headerParts = preg_split(
193 5
                '/[\s,]+/',
194 5
                str_replace('-gzip', '', $request->getHeaderLine(Header::IF_NONE_MATCH)),
195 5
                flags: PREG_SPLIT_NO_EMPTY,
196 5
            );
197
198
            // "HTTP_IF_NONE_MATCH" takes precedence over "HTTP_IF_MODIFIED_SINCE".
199
            // https://tools.ietf.org/html/rfc7232#section-3.3
200 5
            return $headerParts !== false && in_array($etag, $headerParts, true);
201
        }
202
203 5
        if ($request->hasHeader(Header::IF_MODIFIED_SINCE)) {
204 4
            $header = $request->getHeaderLine(Header::IF_MODIFIED_SINCE);
205 4
            return $lastModified !== null && @strtotime($header) >= $lastModified;
206
        }
207
208 1
        return false;
209
    }
210
211
    /**
212
     * Generates an ETag from the given seed string.
213
     *
214
     * @param string $seed Seed for the ETag.
215
     *
216
     * @return string The generated ETag.
217
     */
218 8
    private function generateEtag(string $seed): string
219
    {
220 8
        $etag = '"' . rtrim(base64_encode(sha1($seed, true)), '=') . '"';
221 8
        return $this->weakEtag ? 'W/' . $etag : $etag;
222
    }
223
}
224