Completed
Push — master ( 158837...d80a1b )
by David
01:30 queued 11s
created

CachePlugin   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 473
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 60
lcom 1
cbo 10
dl 0
loc 473
ccs 2
cts 2
cp 1
rs 3.6
c 0
b 0
f 0

16 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 21 7
A clientCache() 0 13 2
A serverCache() 0 4 1
C doHandleRequest() 0 82 14
A calculateCacheItemExpiresAfter() 0 8 3
A calculateResponseExpiresAt() 0 8 2
A isCacheable() 0 15 4
A isCacheableRequest() 0 10 3
A getCacheControlDirective() 0 16 4
A createCacheKey() 0 6 1
A getMaxAge() 0 25 5
A configureOptions() 0 46 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 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 patterns of paths 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)) {
0 ignored issues
show
Bug introduced by
The class Psr\Http\Message\StreamFactoryInterface does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

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