Completed
Push — master ( 5ae8a8...561b54 )
by Tobias
05:55
created

CachePlugin::doHandleRequest()   C

Complexity

Conditions 13
Paths 7

Size

Total Lines 82

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 49
CRAP Score 13.0013

Importance

Changes 0
Metric Value
dl 0
loc 82
ccs 49
cts 50
cp 0.98
rs 5.686
c 0
b 0
f 0
cc 13
nc 7
nop 3
crap 13.0013

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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