Completed
Push — master ( c573ac...5ae8a8 )
by David
09:14
created

CachePlugin   B

Complexity

Total Complexity 53

Size/Duplication

Total Lines 452
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Test Coverage

Coverage 85.47%

Importance

Changes 0
Metric Value
wmc 53
lcom 1
cbo 10
dl 0
loc 452
ccs 153
cts 179
cp 0.8547
rs 7.4757
c 0
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 20 4
A clientCache() 0 13 2
A serverCache() 0 4 1
D handleRequest() 0 82 13
A calculateCacheItemExpiresAfter() 0 8 3
A calculateResponseExpiresAt() 0 8 2
A isCacheable() 0 15 4
A getCacheControlDirective() 0 16 4
A createCacheKey() 0 6 1
B getMaxAge() 0 25 5
B configureOptions() 0 44 4
A createResponseFromCacheItem() 0 18 2
A getModifiedSinceHeaderValue() 0 13 2
A getETag() 0 14 4
A handleCacheListeners() 0 8 2

How to fix   Complexity   

Complex Class

Complex classes like CachePlugin often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use CachePlugin, and based on these observations, apply Extract Interface, too.

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