Passed
Push — master ( e1430d...752221 )
by Kevin
02:57
created

CacheMiddleware::invalidateCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 1
1
<?php
2
3
namespace Kevinrob\GuzzleCache;
4
5
use GuzzleHttp\Client;
6
use GuzzleHttp\Exception\TransferException;
7
use GuzzleHttp\Promise\FulfilledPromise;
8
use GuzzleHttp\Promise\Promise;
9
use GuzzleHttp\Promise\RejectedPromise;
10
use GuzzleHttp\Psr7\Response;
11
use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface;
12
use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
13
use Psr\Http\Message\RequestInterface;
14
use Psr\Http\Message\ResponseInterface;
15
16
/**
17
 * Class CacheMiddleware.
18
 */
19
class CacheMiddleware
20
{
21
    const HEADER_RE_VALIDATION = 'X-Kevinrob-GuzzleCache-ReValidation';
22
    const HEADER_INVALIDATION = 'X-Kevinrob-GuzzleCache-Invalidation';
23
    const HEADER_CACHE_INFO = 'X-Kevinrob-Cache';
24
    const HEADER_CACHE_HIT = 'HIT';
25
    const HEADER_CACHE_MISS = 'MISS';
26
    const HEADER_CACHE_STALE = 'STALE';
27
28
    /**
29
     * @var array of Promise
30
     */
31
    protected $waitingRevalidate = [];
32
33
    /**
34
     * @var Client
35
     */
36
    protected $client;
37
38
    /**
39
     * @var CacheStrategyInterface
40
     */
41
    protected $cacheStorage;
42
43
    /**
44
     * List of allowed HTTP methods to cache
45
     * Key = method name (upscaling)
46
     * Value = true.
47
     *
48
     * @var array
49
     */
50
    protected $httpMethods = ['GET' => true];
51
52
    /**
53
     * @param CacheStrategyInterface|null $cacheStrategy
54
     */
55 44
    public function __construct(CacheStrategyInterface $cacheStrategy = null)
56 1
    {
57 44
        $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy();
58
59 44
        register_shutdown_function([$this, 'purgeReValidation']);
60 44
    }
61
62
    /**
63
     * @param Client $client
64
     */
65 3
    public function setClient(Client $client)
66
    {
67 3
        $this->client = $client;
68 3
    }
69
70
    /**
71
     * @param CacheStrategyInterface $cacheStorage
72
     */
73
    public function setCacheStorage(CacheStrategyInterface $cacheStorage)
74
    {
75
        $this->cacheStorage = $cacheStorage;
76
    }
77
78
    /**
79
     * @return CacheStrategyInterface
80
     */
81
    public function getCacheStorage()
82
    {
83
        return $this->cacheStorage;
84
    }
85
86
    /**
87
     * @param array $methods
88
     */
89
    public function setHttpMethods(array $methods)
90
    {
91
        $this->httpMethods = $methods;
92
    }
93
94
    public function getHttpMethods()
95
    {
96
        return $this->httpMethods;
97
    }
98
99
    /**
100
     * Will be called at the end of the script.
101
     */
102 1
    public function purgeReValidation()
103
    {
104 1
        \GuzzleHttp\Promise\inspect_all($this->waitingRevalidate);
105 1
    }
106
107
    /**
108
     * @param callable $handler
109
     *
110
     * @return callable
111
     */
112 43
    public function __invoke(callable $handler)
113
    {
114
        return function (RequestInterface $request, array $options) use (&$handler) {
115 43
            if (!isset($this->httpMethods[strtoupper($request->getMethod())])) {
116
                // No caching for this method allowed
117
118 3
                return $handler($request, $options)->then(
119
                    function (ResponseInterface $response) use ($request) {
120
                        // Invalidate cache after a call of non-safe method on the same URI
121 3
                        $response = $this->invalidateCache($request, $response);
122
123 3
                        return $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
124
                    }
125 3
                );
126
            }
127
128 41
            if ($request->hasHeader(self::HEADER_RE_VALIDATION)) {
129
                // It's a re-validation request, so bypass the cache!
130 1
                return $handler($request->withoutHeader(self::HEADER_RE_VALIDATION), $options);
131
            }
132
133
            // Retrieve information from request (Cache-Control)
134 41
            $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control'));
135 41
            $onlyFromCache = $reqCacheControl->has('only-if-cached');
136 41
            $staleResponse = $reqCacheControl->has('max-stale')
137 41
                && $reqCacheControl->get('max-stale') === '';
138 41
            $maxStaleCache = $reqCacheControl->get('max-stale', null);
139 41
            $minFreshCache = $reqCacheControl->get('min-fresh', null);
140
141
            // If cache => return new FulfilledPromise(...) with response
142 41
            $cacheEntry = $this->cacheStorage->fetch($request);
143 41
            if ($cacheEntry instanceof CacheEntry) {
144 34
                $body = $cacheEntry->getResponse()->getBody();
145 34
                if ($body->tell() > 0) {
146 2
                    $body->rewind();
147 2
                }
148
149 34
                if ($cacheEntry->isFresh()
150 34
                    && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0)
151 34
                ) {
152
                    // Cache HIT!
153 28
                    return new FulfilledPromise(
154 28
                        $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
155 28
                    );
156
                } elseif ($staleResponse
157 15
                    || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache)
158 15
                ) {
159
                    // Staled cache!
160
                    return new FulfilledPromise(
161
                        $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
162
                    );
163 15
                } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) {
164
                    // Re-validation header
165 4
                    $request = static::getRequestWithReValidationHeader($request, $cacheEntry);
166
167 4
                    if ($cacheEntry->staleWhileValidate()) {
168 1
                        static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry);
169
170 1
                        return new FulfilledPromise(
171 1
                            $cacheEntry->getResponse()
172 1
                                ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE)
173 1
                        );
174
                    }
175 3
                }
176 14
            } else {
177 41
                $cacheEntry = null;
178
            }
179
180 41
            if ($cacheEntry === null && $onlyFromCache) {
181
                // Explicit asking of a cached response => 504
182 1
                return new FulfilledPromise(
183 1
                    new Response(504)
184 1
                );
185
            }
186
187
            /** @var Promise $promise */
188 41
            $promise = $handler($request, $options);
189
190 41
            return $promise->then(
191
                function (ResponseInterface $response) use ($request, $cacheEntry) {
192
                    // Check if error and looking for a staled content
193 41
                    if ($response->getStatusCode() >= 500) {
194 1
                        $responseStale = static::getStaleResponse($cacheEntry);
195 1
                        if ($responseStale instanceof ResponseInterface) {
196 1
                            return $responseStale;
197
                        }
198
                    }
199
200 41
                    $update = false;
201
202 41
                    if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) {
203
                        // Not modified => cache entry is re-validate
204
                        /** @var ResponseInterface $response */
205
                        $response = $response
206 2
                            ->withStatus($cacheEntry->getResponse()->getStatusCode())
207 2
                            ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT);
208 2
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
209
210
                        // Merge headers of the "304 Not Modified" and the cache entry
211
                        /**
212
                         * @var string $headerName
213
                         * @var string[] $headerValue
214
                         */
215 2 View Code Duplication
                        foreach ($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
216 2
                            if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) {
217 2
                                $response = $response->withHeader($headerName, $headerValue);
218 2
                            }
219 2
                        }
220
221 2
                        $update = true;
222 2
                    } else {
223 41
                        $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
224
                    }
225
226 41
                    return static::addToCache($this->cacheStorage, $request, $response, $update);
227 41
                },
228
                function ($reason) use ($cacheEntry) {
229
                    if ($reason instanceof TransferException) {
230
                        $response = static::getStaleResponse($cacheEntry);
231
                        if ($response instanceof ResponseInterface) {
232
                            return $response;
233
                        }
234
                    }
235
236
                    return new RejectedPromise($reason);
237
                }
238 41
            );
239 43
        };
240
    }
241
242
    /**
243
     * @param CacheStrategyInterface $cache
244
     * @param RequestInterface $request
245
     * @param ResponseInterface $response
246
     * @param bool $update cache
247
     * @return ResponseInterface
248
     */
249 41
    protected static function addToCache(
250
        CacheStrategyInterface $cache,
251
        RequestInterface $request,
252
        ResponseInterface $response,
253
        $update = false
254
    ) {
255
        // If the body is not seekable, we have to replace it by a seekable one
256 41
        if (!$response->getBody()->isSeekable()) {
257 1
            $response = $response->withBody(
258 1
                \GuzzleHttp\Psr7\stream_for($response->getBody()->getContents())
259 1
            );
260 1
        }
261
262 41
        if ($update) {
263 3
            $cache->update($request, $response);
264 3
        } else {
265 41
            $cache->cache($request, $response);
266
        }
267
268 41
        return $response;
269
    }
270
271
    /**
272
     * @param RequestInterface       $request
273
     * @param CacheStrategyInterface $cacheStorage
274
     * @param CacheEntry             $cacheEntry
275
     *
276
     * @return bool if added
277
     */
278 1
    protected function addReValidationRequest(
279
        RequestInterface $request,
280
        CacheStrategyInterface &$cacheStorage,
281
        CacheEntry $cacheEntry
282
    ) {
283
        // Add the promise for revalidate
284 1
        if ($this->client !== null) {
285
            /** @var RequestInterface $request */
286 1
            $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1');
287 1
            $this->waitingRevalidate[] = $this->client
288 1
                ->sendAsync($request)
289 1
                ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) {
290 1
                    $update = false;
291
292 1
                    if ($response->getStatusCode() == 304) {
293
                        // Not modified => cache entry is re-validate
294
                        /** @var ResponseInterface $response */
295 1
                        $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode());
296 1
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
297
298
                        // Merge headers of the "304 Not Modified" and the cache entry
299 1 View Code Duplication
                        foreach ($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
300 1
                            if (!$response->hasHeader($headerName)) {
301 1
                                $response = $response->withHeader($headerName, $headerValue);
302 1
                            }
303 1
                        }
304
305 1
                        $update = true;
306 1
                    }
307
308 1
                    static::addToCache($cacheStorage, $request, $response, $update);
309 1
                });
310
311 1
            return true;
312
        }
313
314
        return false;
315
    }
316
317
    /**
318
     * @param CacheEntry|null $cacheEntry
319
     *
320
     * @return null|ResponseInterface
321
     */
322 1
    protected static function getStaleResponse(CacheEntry $cacheEntry = null)
323
    {
324
        // Return staled cache entry if we can
325 1
        if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) {
326 1
            return $cacheEntry->getResponse()
327 1
                ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE);
328
        }
329
330
        return;
331
    }
332
333
    /**
334
     * @param RequestInterface $request
335
     * @param CacheEntry       $cacheEntry
336
     *
337
     * @return RequestInterface
338
     */
339 4
    protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry)
340
    {
341 4
        if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) {
342
            $request = $request->withHeader(
343
                'If-Modified-Since',
344
                $cacheEntry->getResponse()->getHeader('Last-Modified')
345
            );
346
        }
347 4
        if ($cacheEntry->getResponse()->hasHeader('Etag')) {
348 4
            $request = $request->withHeader(
349 4
                'If-None-Match',
350 4
                $cacheEntry->getResponse()->getHeader('Etag')
351 4
            );
352 4
        }
353
354 4
        return $request;
355
    }
356
357
    /**
358
     * @param CacheStrategyInterface|null $cacheStorage
359
     *
360
     * @return CacheMiddleware the Middleware for Guzzle HandlerStack
361
     *
362
     * @deprecated Use constructor => `new CacheMiddleware()`
363
     */
364
    public static function getMiddleware(CacheStrategyInterface $cacheStorage = null)
365
    {
366
        return new self($cacheStorage);
367
    }
368
369
    /**
370
     * @param RequestInterface $request
371
     *
372
     * @param ResponseInterface $response
373
     *
374
     * @return ResponseInterface
375
     */
376 3
    private function invalidateCache(RequestInterface $request, ResponseInterface $response)
377
    {
378 3
        $this->cacheStorage->delete($request);
379
380 3
        return $response->withHeader(self::HEADER_INVALIDATION, true);
0 ignored issues
show
Documentation introduced by
true is of type boolean, but the function expects a string|array<integer,string>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
381
    }
382
}
383