Completed
Push — master ( 2895cf...a1cb3e )
by David
09:07 queued 13s
created

CachePlugin::getETag()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4.25

Importance

Changes 0
Metric Value
dl 0
loc 14
ccs 6
cts 8
cp 0.75
rs 9.7998
c 0
b 0
f 0
cc 4
nc 4
nop 1
crap 4.25
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 Psr\Http\Message\StreamFactoryInterface;
17
use Symfony\Component\OptionsResolver\Options;
18
use Symfony\Component\OptionsResolver\OptionsResolver;
19
20
/**
21
 * Allow for caching a response with a PSR-6 compatible caching engine.
22
 *
23
 * It can follow the RFC-7234 caching specification or use a fixed cache lifetime.
24
 *
25
 * @author Tobias Nyholm <[email protected]>
26
 */
27
final class CachePlugin implements Plugin
28
{
29
    use VersionBridgePlugin;
30
31
    /**
32
     * @var CacheItemPoolInterface
33
     */
34
    private $pool;
35
36
    /**
37
     * @var StreamFactory|StreamFactoryInterface
38
     */
39
    private $streamFactory;
40
41
    /**
42
     * @var array
43
     */
44
    private $config;
45
46
    /**
47
     * Cache directives indicating if a response can not be cached.
48
     *
49
     * @var array
50
     */
51
    private $noCacheFlags = ['no-cache', 'private', 'no-store'];
52
53
    /**
54
     * @param CacheItemPoolInterface               $pool
55
     * @param StreamFactory|StreamFactoryInterface $streamFactory
56
     * @param array                                $config        {
57
     *
58
     *     @var bool $respect_cache_headers Whether to look at the cache directives or ignore them
59
     *     @var int $default_ttl (seconds) If we do not respect cache headers or can't calculate a good ttl, use this
60
     *              value
61
     *     @var string $hash_algo The hashing algorithm to use when generating cache keys
62
     *     @var int $cache_lifetime (seconds) To support serving a previous stale response when the server answers 304
63
     *              we have to store the cache for a longer time than the server originally says it is valid for.
64
     *              We store a cache item for $cache_lifetime + max age of the response.
65
     *     @var array $methods list of request methods which can be cached
66
     *     @var array $blacklisted_paths list of regex for URLs explicitly not to be cached
67
     *     @var array $respect_response_cache_directives list of cache directives this plugin will respect while caching responses
68
     *     @var CacheKeyGenerator $cache_key_generator an object to generate the cache key. Defaults to a new instance of SimpleGenerator
69
     *     @var CacheListener[] $cache_listeners an array of objects to act on the response based on the results of the cache check.
70
     *              Defaults to an empty array
71
     * }
72
     */
73 16
    public function __construct(CacheItemPoolInterface $pool, $streamFactory, array $config = [])
74
    {
75 16
        if (!($streamFactory instanceof StreamFactory) && !($streamFactory instanceof StreamFactoryInterface)) {
76
            throw new \TypeError(\sprintf('Argument 2 passed to %s::__construct() must be of type %s|%s, %s given.', self::class, StreamFactory::class, StreamFactoryInterface::class, \is_object($streamFactory) ? \get_class($streamFactory) : \gettype($streamFactory)));
0 ignored issues
show
Unused Code introduced by
The call to TypeError::__construct() has too many arguments starting with \sprintf('Argument 2 pas...ettype($streamFactory)).

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

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