Completed
Pull Request — master (#41)
by Kevin
02:04
created

CacheMiddleware::addToCache()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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