Completed
Pull Request — master (#26)
by Jeroen
42:56
created

CachePlugin::serverCache()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

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
eloc 2
nc 1
nop 3
crap 2
1
<?php
2
3
namespace Http\Client\Common\Plugin;
4
5
use Http\Client\Common\Plugin;
6
use Http\Message\StreamFactory;
7
use Http\Promise\FulfilledPromise;
8
use Psr\Cache\CacheItemInterface;
9
use Psr\Cache\CacheItemPoolInterface;
10
use Psr\Http\Message\RequestInterface;
11
use Psr\Http\Message\ResponseInterface;
12
use Symfony\Component\OptionsResolver\OptionsResolver;
13
14
/**
15
 * Allow for caching a response.
16
 *
17
 * @author Tobias Nyholm <[email protected]>
18
 */
19
final class CachePlugin implements Plugin
20
{
21
    /**
22
     * @var CacheItemPoolInterface
23
     */
24
    private $pool;
25
26
    /**
27
     * @var StreamFactory
28
     */
29
    private $streamFactory;
30
31
    /**
32
     * @var array
33
     */
34
    private $config;
35
36
    /**
37
     * @param CacheItemPoolInterface $pool
38
     * @param StreamFactory          $streamFactory
39
     * @param array                  $config        {
40
     *
41
     *     @var bool $respect_cache_headers Whether to look at the cache directives or ignore them
42
     *     @var int $default_ttl (seconds) If we do not respect cache headers or can't calculate a good ttl, use this
43
     *              value
44
     *     @var string $hash_algo The hashing algorithm to use when generating cache keys
45
     *     @var int $cache_lifetime (seconds) To support serving a previous stale response when the server answers 304
46
     *              we have to store the cache for a longer time than the server originally says it is valid for.
47
     *              We store a cache item for $cache_lifetime + max age of the response.
48
     *     @var array $methods list of request methods which can be cached.
49
     * }
50
     */
51 12
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
52
    {
53 12
        $this->pool = $pool;
54 12
        $this->streamFactory = $streamFactory;
55
56 12
        $optionsResolver = new OptionsResolver();
57 12
        $this->configureOptions($optionsResolver);
58 12
        $this->config = $optionsResolver->resolve($config);
59 11
    }
60
61 1
    public static function clientCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
62
    {
63
        // Allow caching of private requests
64
        $config['excluded_directives'] = ['no-cache'];
65
66 1
        self::__construct($pool, $streamFactory, $config);
67
    }
68
69
    public static function serverCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
70
    {
71
        self::__construct($pool, $streamFactory, $config);
72
    }
73
74
    /**
75
     * {@inheritdoc}
76
     */
77 9
    public function handleRequest(RequestInterface $request, callable $next, callable $first)
78
    {
79 9
        $method = strtoupper($request->getMethod());
80
        // if the request not is cachable, move to $next
81 9
        if (!in_array($method, $this->config['methods'])) {
82 1
            return $next($request);
83
        }
84
85
        // If we can cache the request
86 8
        $key = $this->createCacheKey($request);
87 8
        $cacheItem = $this->pool->getItem($key);
88
89 8
        if ($cacheItem->isHit()) {
90 3
            $data = $cacheItem->get();
91
            // The array_key_exists() is to be removed in 2.0.
92 3
            if (array_key_exists('expiresAt', $data) && ($data['expiresAt'] === null || time() < $data['expiresAt'])) {
93
                // This item is still valid according to previous cache headers
94 1
                return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem));
95
            }
96
97
            // Add headers to ask the server if this cache is still valid
98 2
            if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) {
99 2
                $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue);
100 2
            }
101
102 2
            if ($etag = $this->getETag($cacheItem)) {
103 2
                $request = $request->withHeader('If-None-Match', $etag);
104 2
            }
105 2
        }
106
107
        return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
108 7
            if (304 === $response->getStatusCode()) {
109 2
                if (!$cacheItem->isHit()) {
110
                    /*
111
                     * We do not have the item in cache. This plugin did not add If-Modified-Since
112
                     * or If-None-Match headers. Return the response from server.
113
                     */
114 1
                    return $response;
115
                }
116
117
                // The cached response we have is still valid
118 1
                $data = $cacheItem->get();
119 1
                $maxAge = $this->getMaxAge($response);
120 1
                $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge);
121 1
                $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge));
122 1
                $this->pool->save($cacheItem);
123
124 1
                return $this->createResponseFromCacheItem($cacheItem);
125
            }
126
127 5
            if ($this->isCacheable($response)) {
128 4
                $bodyStream = $response->getBody();
129 4
                $body = $bodyStream->__toString();
130 4
                if ($bodyStream->isSeekable()) {
131 4
                    $bodyStream->rewind();
132 4
                } else {
133
                    $response = $response->withBody($this->streamFactory->createStream($body));
134
                }
135
136 4
                $maxAge = $this->getMaxAge($response);
137
                $cacheItem
138 4
                    ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge))
139 4
                    ->set([
140 4
                        'response' => $response,
141 4
                        'body' => $body,
142 4
                        'expiresAt' => $this->calculateResponseExpiresAt($maxAge),
143 4
                        'createdAt' => time(),
144 4
                        'etag' => $response->getHeader('ETag'),
145 4
                    ]);
146 4
                $this->pool->save($cacheItem);
147 4
            }
148
149 5
            return $response;
150 7
        });
151
    }
152
153
    /**
154
     * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be
155
     * returned is $maxAge.
156
     *
157
     * @param int|null $maxAge
158
     *
159
     * @return int|null Unix system time passed to the PSR-6 cache
160
     */
161 5
    private function calculateCacheItemExpiresAfter($maxAge)
162
    {
163 5
        if ($this->config['cache_lifetime'] === null && $maxAge === null) {
164
            return;
165
        }
166
167 5
        return $this->config['cache_lifetime'] + $maxAge;
168
    }
169
170
    /**
171
     * Calculate the timestamp when a response expires. After that timestamp, we need to send a
172
     * If-Modified-Since / If-None-Match request to validate the response.
173
     *
174
     * @param int|null $maxAge
175
     *
176
     * @return int|null Unix system time. A null value means that the response expires when the cache item expires
177
     */
178 5
    private function calculateResponseExpiresAt($maxAge)
179
    {
180 5
        if ($maxAge === null) {
181
            return;
182
        }
183
184 5
        return time() + $maxAge;
185
    }
186
187
    /**
188
     * Verify that we can cache this response.
189
     *
190
     * @param ResponseInterface $response
191
     *
192
     * @return bool
193
     */
194 5
    protected function isCacheable(ResponseInterface $response)
195
    {
196 5
        if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) {
197 1
            return false;
198
        }
199 4
        if (!$this->config['respect_cache_headers']) {
200
            return true;
201
        }
202
203 4
        foreach ($this->config['excluded_directives'] as $cacheDirective) {
204 4
            if ($this->getCacheControlDirective($response, $cacheDirective)) {
205
                return false;
206
            }
207 4
        }
208
209 4
        return true;
210
    }
211
212
    /**
213
     * Get the value of a parameter in the cache control header.
214
     *
215
     * @param ResponseInterface $response
216
     * @param string            $name     The field of Cache-Control to fetch
217
     *
218
     * @return bool|string The value of the directive, true if directive without value, false if directive not present
219
     */
220 5
    private function getCacheControlDirective(ResponseInterface $response, $name)
221
    {
222 5
        $headers = $response->getHeader('Cache-Control');
223 5
        foreach ($headers as $header) {
224 1
            if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
225
                // return the value for $name if it exists
226 1
                if (isset($matches[1])) {
227 1
                    return $matches[1];
228
                }
229
230
                return true;
231
            }
232 5
        }
233
234 5
        return false;
235
    }
236
237
    /**
238
     * @param RequestInterface $request
239
     *
240
     * @return string
241
     */
242 8
    private function createCacheKey(RequestInterface $request)
243
    {
244 8
        $body = (string) $request->getBody();
245 8
        if (!empty($body)) {
246 1
            $body = ' '.$body;
247 1
        }
248
249 8
        return hash($this->config['hash_algo'], $request->getMethod().' '.$request->getUri().$body);
250
    }
251
252
    /**
253
     * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl.
254
     *
255
     * @param ResponseInterface $response
256
     *
257
     * @return int|null
258
     */
259 5
    private function getMaxAge(ResponseInterface $response)
260
    {
261 5
        if (!$this->config['respect_cache_headers']) {
262
            return $this->config['default_ttl'];
263
        }
264
265
        // check for max age in the Cache-Control header
266 5
        $maxAge = $this->getCacheControlDirective($response, 'max-age');
267 5
        if (!is_bool($maxAge)) {
268 1
            $ageHeaders = $response->getHeader('Age');
269 1
            foreach ($ageHeaders as $age) {
270 1
                return $maxAge - ((int) $age);
271
            }
272
273
            return (int) $maxAge;
274
        }
275
276
        // check for ttl in the Expires header
277 4
        $headers = $response->getHeader('Expires');
278 4
        foreach ($headers as $header) {
279
            return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
280 4
        }
281
282 4
        return $this->config['default_ttl'];
283
    }
284
285
    /**
286
     * Configure an options resolver.
287
     *
288
     * @param OptionsResolver $resolver
289
     */
290 12
    private function configureOptions(OptionsResolver $resolver)
291
    {
292 12
        $resolver->setDefaults([
293 12
            'cache_lifetime' => 86400 * 30, // 30 days
294 12
            'default_ttl' => 0,
295 12
            'respect_cache_headers' => true,
296 12
            'hash_algo' => 'sha1',
297 12
            'methods' => ['GET', 'HEAD'],
298 12
            'excluded_directives' => ['no-cache', 'private'],
299 12
        ]);
300
301 12
        $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']);
302 12
        $resolver->setAllowedTypes('default_ttl', ['int', 'null']);
303 12
        $resolver->setAllowedTypes('respect_cache_headers', 'bool');
304 12
        $resolver->setAllowedTypes('methods', 'array');
305 12
        $resolver->setAllowedValues('hash_algo', hash_algos());
306 12
        $resolver->setAllowedValues('methods', function ($value) {
307
            /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */
308 12
            $matches = preg_grep('/[^A-Z0-9!#$%&\'*\/+\-.^_`|~]/', $value);
309
310 12
            return empty($matches);
311 12
        });
312 12
    }
313
314
    /**
315
     * @param CacheItemInterface $cacheItem
316
     *
317
     * @return ResponseInterface
318
     */
319 2
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
320
    {
321 2
        $data = $cacheItem->get();
322
323
        /** @var ResponseInterface $response */
324 2
        $response = $data['response'];
325 2
        $response = $response->withBody($this->streamFactory->createStream($data['body']));
326
327 2
        return $response;
328
    }
329
330
    /**
331
     * Get the value of the "If-Modified-Since" header.
332
     *
333
     * @param CacheItemInterface $cacheItem
334
     *
335
     * @return string|null
336
     */
337 2
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
338
    {
339 2
        $data = $cacheItem->get();
340
        // The isset() is to be removed in 2.0.
341 2
        if (!isset($data['createdAt'])) {
342
            return;
343
        }
344
345 2
        $modified = new \DateTime('@'.$data['createdAt']);
346 2
        $modified->setTimezone(new \DateTimeZone('GMT'));
347
348 2
        return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s'));
349
    }
350
351
    /**
352
     * Get the ETag from the cached response.
353
     *
354
     * @param CacheItemInterface $cacheItem
355
     *
356
     * @return string|null
357
     */
358 2
    private function getETag(CacheItemInterface $cacheItem)
359
    {
360 2
        $data = $cacheItem->get();
361
        // The isset() is to be removed in 2.0.
362 2
        if (!isset($data['etag'])) {
363
            return;
364
        }
365
366 2
        if (!is_array($data['etag'])) {
367
            return $data['etag'];
368
        }
369
370 2
        foreach ($data['etag'] as $etag) {
371 2
            if (!empty($etag)) {
372 2
                return $etag;
373
            }
374
        }
375
    }
376
}
377