Test Failed
Pull Request — master (#128)
by Matthias
03:20
created

CacheMiddleware::setHttpMethods()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 3
cp 0
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 2
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 15
                } elseif ($staleResponse || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache)) {
157
                    /*
158
                     * Client is willing to accept a response that has exceeded its freshness lifetime,
159
                     * possibly by not more than $maxStaleCache (https://tools.ietf.org/html/rfc7234#section-5.2.1.2).
160
                     *
161
                     * Return the cached, stale response.
162
                     */
163
                    return new FulfilledPromise(
164
                        $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
165
                    );
166 15
                } elseif ($cacheEntry->staleWhileValidate() && ($maxStaleCache === null || $cacheEntry->getStaleAge() <= $maxStaleCache)) {
167
                    /*
168
                     * The cached response indicated that it may be served stale while background revalidation (or fetch)
169
                     * occurs, and the client did not limit maximum staleness. (https://tools.ietf.org/html/rfc5861#section-3)
170
                     *
171
                     * Return the cached, stale response; initiate deferred revalidation/re-fetch.
172
                     */
173 2
                    static::addReValidationRequest(
174 2
                        static::getRequestWithReValidationHeader($request, $cacheEntry),
175 2
                        $this->cacheStorage,
176
                        $cacheEntry
177 2
                    );
178
179 2
                    return new FulfilledPromise(
180 2
                        $cacheEntry->getResponse()
181 25
                            ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE)
182 2
                    );
183 13
                } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) {
184
                    // Re-validation header
185 3
                    $request = static::getRequestWithReValidationHeader($request, $cacheEntry);
186 3
                }
187 13
            } else {
188 41
                $cacheEntry = null;
189
            }
190
191 41
            if ($cacheEntry === null && $onlyFromCache) {
192
                // Explicit asking of a cached response => 504
193 1
                return new FulfilledPromise(
194 1
                    new Response(504)
195 1
                );
196
            }
197
198
            /** @var Promise $promise */
199 41
            $promise = $handler($request, $options);
200
201 41
            return $promise->then(
202
                function (ResponseInterface $response) use ($request, $cacheEntry) {
203
                    // Check if error and looking for a staled content
204 41
                    if ($response->getStatusCode() >= 500) {
205 1
                        $responseStale = static::getStaleResponse($cacheEntry);
206 1
                        if ($responseStale instanceof ResponseInterface) {
207 1
                            return $responseStale;
208
                        }
209
                    }
210
211 41
                    $update = false;
212
213 41
                    if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) {
214
                        // Not modified => cache entry is re-validate
215
                        /** @var ResponseInterface $response */
216
                        $response = $response
217 2
                            ->withStatus($cacheEntry->getResponse()->getStatusCode())
218 2
                            ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT);
219 2
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
220
221
                        // Merge headers of the "304 Not Modified" and the cache entry
222
                        /**
223
                         * @var string $headerName
224
                         * @var string[] $headerValue
225
                         */
226 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...
227 2
                            if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) {
228 2
                                $response = $response->withHeader($headerName, $headerValue);
229 2
                            }
230 2
                        }
231
232 2
                        $update = true;
233 2
                    } else {
234 41
                        $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
235
                    }
236
237 41
                    return static::addToCache($this->cacheStorage, $request, $response, $update);
238 41
                },
239
                function ($reason) use ($cacheEntry) {
240
                    if ($reason instanceof TransferException) {
241
                        $response = static::getStaleResponse($cacheEntry);
242
                        if ($response instanceof ResponseInterface) {
243
                            return $response;
244
                        }
245
                    }
246
247
                    return new RejectedPromise($reason);
248
                }
249 41
            );
250 43
        };
251
    }
252
253
    /**
254
     * @param CacheStrategyInterface $cache
255
     * @param RequestInterface $request
256
     * @param ResponseInterface $response
257
     * @param bool $update cache
258
     * @return ResponseInterface
259
     */
260 41
    protected static function addToCache(
261
        CacheStrategyInterface $cache,
262
        RequestInterface $request,
263
        ResponseInterface $response,
264
        $update = false
265
    ) {
266
        // If the body is not seekable, we have to replace it by a seekable one
267 41
        if (!$response->getBody()->isSeekable()) {
268 1
            $response = $response->withBody(
269 1
                \GuzzleHttp\Psr7\stream_for($response->getBody()->getContents())
270 1
            );
271 1
        }
272
273 41
        if ($update) {
274 3
            $cache->update($request, $response);
275 3
        } else {
276 41
            $cache->cache($request, $response);
277
        }
278
279 41
        return $response;
280
    }
281
282
    /**
283
     * @param RequestInterface       $request
284
     * @param CacheStrategyInterface $cacheStorage
285
     * @param CacheEntry             $cacheEntry
286
     *
287
     * @return bool if added
288
     */
289 2
    protected function addReValidationRequest(
290
        RequestInterface $request,
291
        CacheStrategyInterface &$cacheStorage,
292
        CacheEntry $cacheEntry
293
    ) {
294
        // Add the promise for revalidate
295 2
        if ($this->client !== null) {
296
            /** @var RequestInterface $request */
297 1
            $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1');
298 1
            $this->waitingRevalidate[] = $this->client
299 1
                ->sendAsync($request)
300 1
                ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) {
301 1
                    $update = false;
302
303 1
                    if ($response->getStatusCode() == 304) {
304
                        // Not modified => cache entry is re-validate
305
                        /** @var ResponseInterface $response */
306 1
                        $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode());
307 1
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
308
309
                        // Merge headers of the "304 Not Modified" and the cache entry
310 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...
311 1
                            if (!$response->hasHeader($headerName)) {
312 1
                                $response = $response->withHeader($headerName, $headerValue);
313 1
                            }
314 1
                        }
315
316 1
                        $update = true;
317 1
                    }
318
319 1
                    static::addToCache($cacheStorage, $request, $response, $update);
320 1
                });
321
322 1
            return true;
323
        }
324
325 1
        return false;
326
    }
327
328
    /**
329
     * @param CacheEntry|null $cacheEntry
330
     *
331
     * @return null|ResponseInterface
332
     */
333 1
    protected static function getStaleResponse(CacheEntry $cacheEntry = null)
334
    {
335
        // Return staled cache entry if we can
336 1
        if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) {
337 1
            return $cacheEntry->getResponse()
338 1
                ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE);
339
        }
340
341
        return;
342
    }
343
344
    /**
345
     * @param RequestInterface $request
346
     * @param CacheEntry       $cacheEntry
347
     *
348
     * @return RequestInterface
349
     */
350 5
    protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry)
351
    {
352 5
        if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) {
353
            $request = $request->withHeader(
354
                'If-Modified-Since',
355
                $cacheEntry->getResponse()->getHeader('Last-Modified')
356
            );
357
        }
358 5
        if ($cacheEntry->getResponse()->hasHeader('Etag')) {
359 4
            $request = $request->withHeader(
360 4
                'If-None-Match',
361 4
                $cacheEntry->getResponse()->getHeader('Etag')
362 4
            );
363 4
        }
364
365 5
        return $request;
366
    }
367
368
    /**
369
     * @param CacheStrategyInterface|null $cacheStorage
370
     *
371
     * @return CacheMiddleware the Middleware for Guzzle HandlerStack
372
     *
373
     * @deprecated Use constructor => `new CacheMiddleware()`
374
     */
375
    public static function getMiddleware(CacheStrategyInterface $cacheStorage = null)
376
    {
377
        return new self($cacheStorage);
378
    }
379
380
    /**
381
     * @param RequestInterface $request
382
     *
383
     * @param ResponseInterface $response
384
     *
385
     * @return ResponseInterface
386
     */
387 3
    private function invalidateCache(RequestInterface $request, ResponseInterface $response)
388
    {
389 3
        $this->cacheStorage->delete($request);
390
391 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...
392
    }
393
}
394