Completed
Push — master ( 0f7540...5c1599 )
by David
01:50 queued 11s
created

CachePlugin::isCacheableRequest()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 5
cts 5
cp 1
rs 9.9332
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
1
<?php
2
3
namespace Http\Client\Common\Plugin;
4
5
use Http\Client\Common\Plugin;
6
use Http\Client\Common\Plugin\Exception\RewindStreamException;
7
use Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator;
8
use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator;
9
use Http\Client\Common\Plugin\Cache\Listener\CacheListener;
10
use Http\Message\StreamFactory;
11
use Http\Promise\FulfilledPromise;
12
use Psr\Cache\CacheItemInterface;
13
use Psr\Cache\CacheItemPoolInterface;
14
use Psr\Http\Message\RequestInterface;
15
use Psr\Http\Message\ResponseInterface;
16
use Symfony\Component\OptionsResolver\Options;
17
use Symfony\Component\OptionsResolver\OptionsResolver;
18
19
/**
20
 * Allow for caching a response.
21
 *
22
 * @author Tobias Nyholm <[email protected]>
23
 */
24
final class CachePlugin implements Plugin
25
{
26
    use VersionBridgePlugin;
27
28
    /**
29
     * @var CacheItemPoolInterface
30
     */
31
    private $pool;
32
33
    /**
34
     * @var StreamFactory
35
     */
36
    private $streamFactory;
37
38
    /**
39
     * @var array
40
     */
41
    private $config;
42
43
    /**
44
     * Cache directives indicating if a response can not be cached.
45
     *
46
     * @var array
47
     */
48
    private $noCacheFlags = ['no-cache', 'private', 'no-store'];
49
50
    /**
51
     * @param CacheItemPoolInterface $pool
52
     * @param StreamFactory          $streamFactory
53
     * @param array                  $config        {
54
     *
55
     *     @var bool $respect_cache_headers Whether to look at the cache directives or ignore them
56
     *     @var int $default_ttl (seconds) If we do not respect cache headers or can't calculate a good ttl, use this
57
     *              value
58
     *     @var string $hash_algo The hashing algorithm to use when generating cache keys
59
     *     @var int $cache_lifetime (seconds) To support serving a previous stale response when the server answers 304
60
     *              we have to store the cache for a longer time than the server originally says it is valid for.
61
     *              We store a cache item for $cache_lifetime + max age of the response.
62
     *     @var array $methods list of request methods which can be cached
63
     *     @var array $blacklisted_paths list of regex patterns of paths explicitly not to be cached
64
     *     @var array $respect_response_cache_directives list of cache directives this plugin will respect while caching responses
65
     *     @var CacheKeyGenerator $cache_key_generator an object to generate the cache key. Defaults to a new instance of SimpleGenerator
66
     *     @var CacheListener[] $cache_listeners an array of objects to act on the response based on the results of the cache check.
67
     *              Defaults to an empty array
68
     * }
69
     */
70 16
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
71
    {
72 16
        $this->pool = $pool;
73 16
        $this->streamFactory = $streamFactory;
74
75 16
        if (isset($config['respect_cache_headers']) && isset($config['respect_response_cache_directives'])) {
76
            throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.');
77
        }
78
79 16
        $optionsResolver = new OptionsResolver();
80 16
        $this->configureOptions($optionsResolver);
81 16
        $this->config = $optionsResolver->resolve($config);
82
83 15
        if (null === $this->config['cache_key_generator']) {
84 14
            $this->config['cache_key_generator'] = new SimpleGenerator();
85
        }
86 15
    }
87
88
    /**
89
     * This method will setup the cachePlugin in client cache mode. When using the client cache mode the plugin will
90
     * cache responses with `private` cache directive.
91
     *
92
     * @param CacheItemPoolInterface $pool
93
     * @param StreamFactory          $streamFactory
94
     * @param array                  $config        For all possible config options see the constructor docs
95
     *
96
     * @return CachePlugin
97
     */
98 4
    public static function clientCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
99
    {
100
        // Allow caching of private requests
101 4
        if (isset($config['respect_response_cache_directives'])) {
102
            $config['respect_response_cache_directives'][] = 'no-cache';
103
            $config['respect_response_cache_directives'][] = 'max-age';
104
            $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']);
105
        } else {
106 4
            $config['respect_response_cache_directives'] = ['no-cache', 'max-age'];
107
        }
108
109 4
        return new self($pool, $streamFactory, $config);
110
    }
111
112
    /**
113
     * This method will setup the cachePlugin in server cache mode. This is the default caching behavior it refuses to
114
     * cache responses with the `private`or `no-cache` directives.
115
     *
116
     * @param CacheItemPoolInterface $pool
117
     * @param StreamFactory          $streamFactory
118
     * @param array                  $config        For all possible config options see the constructor docs
119
     *
120
     * @return CachePlugin
121
     */
122
    public static function serverCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
123
    {
124
        return new self($pool, $streamFactory, $config);
125
    }
126
127 13
    protected function doHandleRequest(RequestInterface $request, callable $next, callable $first)
0 ignored issues
show
Unused Code introduced by
The parameter $first is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
128
    {
129 13
        $method = strtoupper($request->getMethod());
130
        // if the request not is cachable, move to $next
131 13
        if (!in_array($method, $this->config['methods'])) {
132
            return $next($request)->then(function (ResponseInterface $response) use ($request) {
133 1
                $response = $this->handleCacheListeners($request, $response, false, null);
134
135 1
                return $response;
136 1
            });
137
        }
138
139
        // If we can cache the request
140 12
        $key = $this->createCacheKey($request);
141 12
        $cacheItem = $this->pool->getItem($key);
142
143 12
        if ($cacheItem->isHit()) {
144 4
            $data = $cacheItem->get();
145
            // The array_key_exists() is to be removed in 2.0.
146 4
            if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) {
147
                // This item is still valid according to previous cache headers
148 2
                $response = $this->createResponseFromCacheItem($cacheItem);
149 2
                $response = $this->handleCacheListeners($request, $response, true, $cacheItem);
150
151 2
                return new FulfilledPromise($response);
152
            }
153
154
            // Add headers to ask the server if this cache is still valid
155 2
            if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) {
156 2
                $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue);
157
            }
158
159 2
            if ($etag = $this->getETag($cacheItem)) {
160 2
                $request = $request->withHeader('If-None-Match', $etag);
161
            }
162
        }
163
164
        return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) {
165 10
            if (304 === $response->getStatusCode()) {
166 2
                if (!$cacheItem->isHit()) {
167
                    /*
168
                     * We do not have the item in cache. This plugin did not add If-Modified-Since
169
                     * or If-None-Match headers. Return the response from server.
170
                     */
171 1
                    return $this->handleCacheListeners($request, $response, false, $cacheItem);
172
                }
173
174
                // The cached response we have is still valid
175 1
                $data = $cacheItem->get();
176 1
                $maxAge = $this->getMaxAge($response);
177 1
                $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge);
178 1
                $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge));
179 1
                $this->pool->save($cacheItem);
180
181 1
                return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem);
182
            }
183
184 8
            if ($this->isCacheable($response) && $this->isCacheableRequest($request)) {
185 6
                $bodyStream = $response->getBody();
186 6
                $body = $bodyStream->__toString();
187 6
                if ($bodyStream->isSeekable()) {
188 6
                    $bodyStream->rewind();
189
                } else {
190
                    $response = $response->withBody($this->streamFactory->createStream($body));
191
                }
192
193 6
                $maxAge = $this->getMaxAge($response);
194
                $cacheItem
195 6
                    ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge))
196 6
                    ->set([
197 6
                        'response' => $response,
198 6
                        'body' => $body,
199 6
                        'expiresAt' => $this->calculateResponseExpiresAt($maxAge),
200 6
                        'createdAt' => time(),
201 6
                        'etag' => $response->getHeader('ETag'),
202
                    ]);
203 6
                $this->pool->save($cacheItem);
204
            }
205
206 8
            return $this->handleCacheListeners($request, $response, false, isset($cacheItem) ? $cacheItem : null);
207 10
        });
208
    }
209
210
    /**
211
     * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be
212
     * returned is $maxAge.
213
     *
214
     * @param int|null $maxAge
215
     *
216
     * @return int|null Unix system time passed to the PSR-6 cache
217
     */
218 7
    private function calculateCacheItemExpiresAfter($maxAge)
219
    {
220 7
        if (null === $this->config['cache_lifetime'] && null === $maxAge) {
221
            return;
222
        }
223
224 7
        return $this->config['cache_lifetime'] + $maxAge;
225
    }
226
227
    /**
228
     * Calculate the timestamp when a response expires. After that timestamp, we need to send a
229
     * If-Modified-Since / If-None-Match request to validate the response.
230
     *
231
     * @param int|null $maxAge
232
     *
233
     * @return int|null Unix system time. A null value means that the response expires when the cache item expires
234
     */
235 7
    private function calculateResponseExpiresAt($maxAge)
236
    {
237 7
        if (null === $maxAge) {
238
            return;
239
        }
240
241 7
        return time() + $maxAge;
242
    }
243
244
    /**
245
     * Verify that we can cache this response.
246
     *
247
     * @param ResponseInterface $response
248
     *
249
     * @return bool
250
     */
251 8
    protected function isCacheable(ResponseInterface $response)
252
    {
253 8
        if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) {
254 1
            return false;
255
        }
256
257 7
        $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags);
258 7
        foreach ($nocacheDirectives as $nocacheDirective) {
259 7
            if ($this->getCacheControlDirective($response, $nocacheDirective)) {
260 7
                return false;
261
            }
262
        }
263
264 7
        return true;
265
    }
266
267
    /**
268
     * Verify that we can cache this request.
269
     *
270
     * @param RequestInterface $request
271
     *
272
     * @return bool
273
     */
274 7
    private function isCacheableRequest(RequestInterface $request)
275
    {
276 7
        foreach ($this->config['blacklisted_paths'] as $not_to_cache_path) {
277 2
            if (1 === preg_match('/'.$not_to_cache_path.'/', $request->getUri())) {
278 2
                return false;
279
            }
280
        }
281
282 6
        return true;
283
    }
284
285
    /**
286
     * Get the value of a parameter in the cache control header.
287
     *
288
     * @param ResponseInterface $response
289
     * @param string            $name     The field of Cache-Control to fetch
290
     *
291
     * @return bool|string The value of the directive, true if directive without value, false if directive not present
292
     */
293 8
    private function getCacheControlDirective(ResponseInterface $response, $name)
294
    {
295 8
        $headers = $response->getHeader('Cache-Control');
296 8
        foreach ($headers as $header) {
297 2
            if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
298
                // return the value for $name if it exists
299 1
                if (isset($matches[1])) {
300 1
                    return $matches[1];
301
                }
302
303 2
                return true;
304
            }
305
        }
306
307 8
        return false;
308
    }
309
310
    /**
311
     * @param RequestInterface $request
312
     *
313
     * @return string
314
     */
315 12
    private function createCacheKey(RequestInterface $request)
316
    {
317 12
        $key = $this->config['cache_key_generator']->generate($request);
318
319 12
        return hash($this->config['hash_algo'], $key);
320
    }
321
322
    /**
323
     * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl.
324
     *
325
     * @param ResponseInterface $response
326
     *
327
     * @return int|null
328
     */
329 7
    private function getMaxAge(ResponseInterface $response)
330
    {
331 7
        if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) {
332
            return $this->config['default_ttl'];
333
        }
334
335
        // check for max age in the Cache-Control header
336 7
        $maxAge = $this->getCacheControlDirective($response, 'max-age');
337 7
        if (!is_bool($maxAge)) {
338 1
            $ageHeaders = $response->getHeader('Age');
339 1
            foreach ($ageHeaders as $age) {
340 1
                return $maxAge - ((int) $age);
341
            }
342
343
            return (int) $maxAge;
344
        }
345
346
        // check for ttl in the Expires header
347 6
        $headers = $response->getHeader('Expires');
348 6
        foreach ($headers as $header) {
349
            return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
350
        }
351
352 6
        return $this->config['default_ttl'];
353
    }
354
355
    /**
356
     * Configure an options resolver.
357
     *
358
     * @param OptionsResolver $resolver
359
     */
360 16
    private function configureOptions(OptionsResolver $resolver)
361
    {
362 16
        $resolver->setDefaults([
363 16
            'cache_lifetime' => 86400 * 30, // 30 days
364
            'default_ttl' => 0,
365
            //Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead
366
            'respect_cache_headers' => null,
367
            'hash_algo' => 'sha1',
368
            'methods' => ['GET', 'HEAD'],
369
            'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'],
370
            'cache_key_generator' => null,
371
            'cache_listeners' => [],
372
            'blacklisted_paths' => [],
373
        ]);
374
375 16
        $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']);
376 16
        $resolver->setAllowedTypes('default_ttl', ['int', 'null']);
377 16
        $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']);
378 16
        $resolver->setAllowedTypes('methods', 'array');
379 16
        $resolver->setAllowedTypes('cache_key_generator', ['null', 'Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator']);
380 16
        $resolver->setAllowedTypes('blacklisted_paths', 'array');
381 16
        $resolver->setAllowedValues('hash_algo', hash_algos());
382
        $resolver->setAllowedValues('methods', function ($value) {
383
            /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */
384 16
            $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value);
385
386 16
            return empty($matches);
387 16
        });
388 16
        $resolver->setAllowedTypes('cache_listeners', ['array']);
389
390
        $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) {
391 16
            if (null !== $value) {
392
                @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...
393
            }
394
395 16
            return null === $value ? true : $value;
396 16
        });
397
398 16
        $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) {
399 15
            if (false === $options['respect_cache_headers']) {
400
                return [];
401
            }
402
403 15
            return $value;
404 16
        });
405 16
    }
406
407
    /**
408
     * @param CacheItemInterface $cacheItem
409
     *
410
     * @return ResponseInterface
411
     */
412 3
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
413
    {
414 3
        $data = $cacheItem->get();
415
416
        /** @var ResponseInterface $response */
417 3
        $response = $data['response'];
418 3
        $stream = $this->streamFactory->createStream($data['body']);
419
420
        try {
421 3
            $stream->rewind();
422
        } catch (\Exception $e) {
423
            throw new RewindStreamException('Cannot rewind stream.', 0, $e);
424
        }
425
426 3
        $response = $response->withBody($stream);
427
428 3
        return $response;
429
    }
430
431
    /**
432
     * Get the value of the "If-Modified-Since" header.
433
     *
434
     * @param CacheItemInterface $cacheItem
435
     *
436
     * @return string|null
437
     */
438 2
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
439
    {
440 2
        $data = $cacheItem->get();
441
        // The isset() is to be removed in 2.0.
442 2
        if (!isset($data['createdAt'])) {
443
            return;
444
        }
445
446 2
        $modified = new \DateTime('@'.$data['createdAt']);
447 2
        $modified->setTimezone(new \DateTimeZone('GMT'));
448
449 2
        return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s'));
450
    }
451
452
    /**
453
     * Get the ETag from the cached response.
454
     *
455
     * @param CacheItemInterface $cacheItem
456
     *
457
     * @return string|null
458
     */
459 2
    private function getETag(CacheItemInterface $cacheItem)
460
    {
461 2
        $data = $cacheItem->get();
462
        // The isset() is to be removed in 2.0.
463 2
        if (!isset($data['etag'])) {
464
            return;
465
        }
466
467 2
        foreach ($data['etag'] as $etag) {
468 2
            if (!empty($etag)) {
469 2
                return $etag;
470
            }
471
        }
472
    }
473
474
    /**
475
     * Call the cache listeners, if they are set.
476
     *
477
     * @param RequestInterface        $request
478
     * @param ResponseInterface       $response
479
     * @param bool                    $cacheHit
480
     * @param CacheItemInterface|null $cacheItem
481
     *
482
     * @return ResponseInterface
483
     */
484 13
    private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, $cacheHit, $cacheItem)
485
    {
486 13
        foreach ($this->config['cache_listeners'] as $cacheListener) {
487
            $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem);
488
        }
489
490 13
        return $response;
491
    }
492
}
493