Completed
Pull Request — master (#26)
by Jeroen
10:32
created

CachePlugin::isCacheable()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4.128

Importance

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

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
358
            }
359
360
            return $value;
361
        });
362
363
        $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) {
364
            if (false === $options['respect_cache_headers']) {
365
                return [];
366
            }
367
368
            return $value;
369
        });
370
    }
371
372
    /**
373
     * @param CacheItemInterface $cacheItem
374
     *
375
     * @return ResponseInterface
376
     */
377
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
378
    {
379
        $data = $cacheItem->get();
380
381
        /** @var ResponseInterface $response */
382
        $response = $data['response'];
383
        $response = $response->withBody($this->streamFactory->createStream($data['body']));
384
385
        return $response;
386
    }
387
388
    /**
389
     * Get the value of the "If-Modified-Since" header.
390
     *
391
     * @param CacheItemInterface $cacheItem
392
     *
393
     * @return string|null
394
     */
395
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
396
    {
397
        $data = $cacheItem->get();
398
        // The isset() is to be removed in 2.0.
399
        if (!isset($data['createdAt'])) {
400
            return;
401
        }
402
403
        $modified = new \DateTime('@'.$data['createdAt']);
404
        $modified->setTimezone(new \DateTimeZone('GMT'));
405
406
        return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s'));
407
    }
408
409
    /**
410
     * Get the ETag from the cached response.
411
     *
412
     * @param CacheItemInterface $cacheItem
413
     *
414
     * @return string|null
415
     */
416
    private function getETag(CacheItemInterface $cacheItem)
417
    {
418
        $data = $cacheItem->get();
419
        // The isset() is to be removed in 2.0.
420
        if (!isset($data['etag'])) {
421
            return;
422
        }
423
424
        if (!is_array($data['etag'])) {
425
            return $data['etag'];
426
        }
427
428
        foreach ($data['etag'] as $etag) {
429
            if (!empty($etag)) {
430
                return $etag;
431
            }
432
        }
433
    }
434
}
435