Passed
Pull Request — master (#119)
by
unknown
06:28
created

CacheMiddleware::getStaleResponse()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3.072

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 4
cts 5
cp 0.8
rs 9.9332
c 0
b 0
f 0
cc 3
nc 2
nop 1
crap 3.072
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_CACHE_INFO = 'X-Kevinrob-Cache';
23
    const HEADER_CACHE_HIT = 'HIT';
24
    const HEADER_CACHE_MISS = 'MISS';
25
    const HEADER_CACHE_STALE = 'STALE';
26
27
    /**
28
     * @var array of Promise
29
     */
30
    protected $waitingRevalidate = [];
31
32
    /**
33
     * @var Client
34
     */
35
    protected $client;
36
37
    /**
38
     * @var CacheStrategyInterface
39
     */
40
    protected $cacheStorage;
41
42
    /**
43
     * List of allowed HTTP methods to cache
44
     * Key = method name (upscaling)
45
     * Value = true.
46
     *
47
     * @var array
48
     */
49
    protected $httpMethods = ['GET' => true];
50
51
    /**
52
     * @param CacheStrategyInterface|null $cacheStrategy
53
     */
54 43
    public function __construct(CacheStrategyInterface $cacheStrategy = null)
55
    {
56 43
        $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy();
57
58 43
        register_shutdown_function([$this, 'purgeReValidation']);
59 43
    }
60
61
    /**
62
     * @param Client $client
63
     */
64 3
    public function setClient(Client $client)
65
    {
66 3
        $this->client = $client;
67 3
    }
68
69
    /**
70
     * @param CacheStrategyInterface $cacheStorage
71
     */
72
    public function setCacheStorage(CacheStrategyInterface $cacheStorage)
73
    {
74
        $this->cacheStorage = $cacheStorage;
75
    }
76
77
    /**
78
     * @return CacheStrategyInterface
79
     */
80
    public function getCacheStorage()
81
    {
82
        return $this->cacheStorage;
83
    }
84
85
    /**
86
     * @param array $methods
87
     */
88
    public function setHttpMethods(array $methods)
89
    {
90
        $this->httpMethods = $methods;
91
    }
92
93
    public function getHttpMethods()
94
    {
95
        return $this->httpMethods;
96
    }
97
98
    /**
99
     * Will be called at the end of the script.
100
     */
101 1
    public function purgeReValidation()
102
    {
103 1
        \GuzzleHttp\Promise\inspect_all($this->waitingRevalidate);
104 1
    }
105
106
    /**
107
     * @param callable $handler
108
     *
109
     * @return callable
110
     */
111 42
    public function __invoke(callable $handler)
112
    {
113
        return function (RequestInterface $request, array $options) use (&$handler) {
114 42
            if (!isset($this->httpMethods[strtoupper($request->getMethod())])) {
115
                // No caching for this method allowed
116
117
                // Invalidate cache after a non-safe method on the same URI will be invalidated
118 2
                $this->cacheStorage->delete($request);
119
120 2
                return $handler($request, $options)->then(
121
                    function (ResponseInterface $response) {
122 2
                        return $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
123
                    }
124 2
                );
125
            }
126
127 40
            if ($request->hasHeader(self::HEADER_RE_VALIDATION)) {
128
                // It's a re-validation request, so bypass the cache!
129 1
                return $handler($request->withoutHeader(self::HEADER_RE_VALIDATION), $options);
130
            }
131
132
            // Retrieve information from request (Cache-Control)
133 40
            $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control'));
134 40
            $onlyFromCache = $reqCacheControl->has('only-if-cached');
135 40
            $staleResponse = $reqCacheControl->has('max-stale')
136 40
                && $reqCacheControl->get('max-stale') === '';
137 40
            $maxStaleCache = $reqCacheControl->get('max-stale', null);
138 40
            $minFreshCache = $reqCacheControl->get('min-fresh', null);
139
140
            // If cache => return new FulfilledPromise(...) with response
141 40
            $cacheEntry = $this->cacheStorage->fetch($request);
142 40
            if ($cacheEntry instanceof CacheEntry) {
143 34
                $body = $cacheEntry->getResponse()->getBody();
144 34
                if ($body->tell() > 0) {
145 2
                    $body->rewind();
146 2
                }
147
148 34
                if ($cacheEntry->isFresh()
149 34
                    && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0)
150 34
                ) {
151
                    // Cache HIT!
152 28
                    return new FulfilledPromise(
153 28
                        $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
154 28
                    );
155
                } elseif ($staleResponse
156 15
                    || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache)
157 15
                ) {
158
                    // Staled cache!
159
                    return new FulfilledPromise(
160
                        $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
161
                    );
162 15
                } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) {
163
                    // Re-validation header
164 4
                    $request = static::getRequestWithReValidationHeader($request, $cacheEntry);
165
166 4
                    if ($cacheEntry->staleWhileValidate()) {
167 1
                        static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry);
168
169 1
                        return new FulfilledPromise(
170 1
                            $cacheEntry->getResponse()
171 1
                                ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE)
172 1
                        );
173
                    }
174 3
                }
175 14
            } else {
176 40
                $cacheEntry = null;
177
            }
178
179 40
            if ($cacheEntry === null && $onlyFromCache) {
180
                // Explicit asking of a cached response => 504
181 25
                return new FulfilledPromise(
182 1
                    new Response(504)
183 1
                );
184
            }
185
186
            /** @var Promise $promise */
187 40
            $promise = $handler($request, $options);
188
189 40
            return $promise->then(
190
                function (ResponseInterface $response) use ($request, $cacheEntry) {
191
                    // Check if error and looking for a staled content
192 40
                    if ($response->getStatusCode() >= 500) {
193 1
                        $responseStale = static::getStaleResponse($cacheEntry);
194 1
                        if ($responseStale instanceof ResponseInterface) {
195 1
                            return $responseStale;
196
                        }
197
                    }
198
199 40
                    $update = false;
200
201 40
                    if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) {
202
                        // Not modified => cache entry is re-validate
203
                        /** @var ResponseInterface $response */
204
                        $response = $response
205 2
                            ->withStatus($cacheEntry->getResponse()->getStatusCode())
206 2
                            ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT);
207 2
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
208
209
                        // Merge headers of the "304 Not Modified" and the cache entry
210
                        /**
211
                         * @var string $headerName
212
                         * @var string[] $headerValue
213
                         */
214 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...
215 2
                            if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) {
216 2
                                $response = $response->withHeader($headerName, $headerValue);
217 2
                            }
218 2
                        }
219
220 2
                        $update = true;
221 2
                    } else {
222 40
                        $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
223
                    }
224
225 40
                    return static::addToCache($this->cacheStorage, $request, $response, $update);
226 40
                },
227
                function ($reason) use ($cacheEntry) {
228
                    if ($reason instanceof TransferException) {
229
                        $response = static::getStaleResponse($cacheEntry);
230
                        if ($response instanceof ResponseInterface) {
231
                            return $response;
232
                        }
233
                    }
234
235
                    return new RejectedPromise($reason);
236
                }
237 40
            );
238 42
        };
239
    }
240
241
    /**
242
     * @param CacheStrategyInterface $cache
243
     * @param RequestInterface $request
244
     * @param ResponseInterface $response
245
     * @param bool $update cache
246
     * @return ResponseInterface
247
     */
248 40
    protected static function addToCache(
249
        CacheStrategyInterface $cache,
250
        RequestInterface $request,
251
        ResponseInterface $response,
252
        $update = false
253
    ) {
254
        // If the body is not seekable, we have to replace it by a seekable one
255 40
        if (!$response->getBody()->isSeekable()) {
256 1
            $response = $response->withBody(
257 1
                \GuzzleHttp\Psr7\stream_for($response->getBody()->getContents())
258 1
            );
259 1
        }
260
261 40
        if ($update) {
262 3
            $cache->update($request, $response);
263 3
        } else {
264 40
            $cache->cache($request, $response);
265
        }
266
267 40
        return $response;
268
    }
269
270
    /**
271
     * @param RequestInterface       $request
272
     * @param CacheStrategyInterface $cacheStorage
273
     * @param CacheEntry             $cacheEntry
274
     *
275
     * @return bool if added
276
     */
277 1
    protected function addReValidationRequest(
278
        RequestInterface $request,
279
        CacheStrategyInterface &$cacheStorage,
280
        CacheEntry $cacheEntry
281
    ) {
282
        // Add the promise for revalidate
283 1
        if ($this->client !== null) {
284
            /** @var RequestInterface $request */
285 1
            $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1');
286 1
            $this->waitingRevalidate[] = $this->client
287 1
                ->sendAsync($request)
288 1
                ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) {
289 1
                    $update = false;
290
291 1
                    if ($response->getStatusCode() == 304) {
292
                        // Not modified => cache entry is re-validate
293
                        /** @var ResponseInterface $response */
294 1
                        $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode());
295 1
                        $response = $response->withBody($cacheEntry->getResponse()->getBody());
296
297
                        // Merge headers of the "304 Not Modified" and the cache entry
298 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...
299 1
                            if (!$response->hasHeader($headerName)) {
300 1
                                $response = $response->withHeader($headerName, $headerValue);
301 1
                            }
302 1
                        }
303
304 1
                        $update = true;
305 1
                    }
306
307 1
                    static::addToCache($cacheStorage, $request, $response, $update);
308 1
                });
309
310 1
            return true;
311
        }
312
313
        return false;
314
    }
315
316
    /**
317
     * @param CacheEntry|null $cacheEntry
318
     *
319
     * @return null|ResponseInterface
320
     */
321 1
    protected static function getStaleResponse(CacheEntry $cacheEntry = null)
322
    {
323
        // Return staled cache entry if we can
324 1
        if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) {
325 1
            return $cacheEntry->getResponse()
326 1
                ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE);
327
        }
328
329
        return;
330
    }
331
332
    /**
333
     * @param RequestInterface $request
334
     * @param CacheEntry       $cacheEntry
335
     *
336
     * @return RequestInterface
337
     */
338 4
    protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry)
339
    {
340 4
        if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) {
341
            $request = $request->withHeader(
342
                'If-Modified-Since',
343
                $cacheEntry->getResponse()->getHeader('Last-Modified')
344
            );
345
        }
346 4
        if ($cacheEntry->getResponse()->hasHeader('Etag')) {
347 4
            $request = $request->withHeader(
348 4
                'If-None-Match',
349 4
                $cacheEntry->getResponse()->getHeader('Etag')
350 4
            );
351 4
        }
352
353 4
        return $request;
354
    }
355
356
    /**
357
     * @param CacheStrategyInterface|null $cacheStorage
358
     *
359
     * @return CacheMiddleware the Middleware for Guzzle HandlerStack
360
     *
361
     * @deprecated Use constructor => `new CacheMiddleware()`
362
     */
363
    public static function getMiddleware(CacheStrategyInterface $cacheStorage = null)
364
    {
365
        return new self($cacheStorage);
366
    }
367
}
368