Completed
Push — master ( 7dcd80...a06b74 )
by David
08:14 queued 11s
created

CachePlugin::createResponseFromCacheItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2.0438

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 7
cts 9
cp 0.7778
rs 9.6666
c 0
b 0
f 0
cc 2
nc 2
nop 1
crap 2.0438
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
     * }
68
     */
69 14
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
70
    {
71 14
        $this->pool = $pool;
72 14
        $this->streamFactory = $streamFactory;
73
74 14
        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
        }
80
81 14
        $optionsResolver = new OptionsResolver();
82 14
        $this->configureOptions($optionsResolver);
83 14
        $this->config = $optionsResolver->resolve($config);
84
85 13
        if (null === $this->config['cache_key_generator']) {
86 12
            $this->config['cache_key_generator'] = new SimpleGenerator();
87
        }
88 13
    }
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
     * @return CachePlugin
99
     */
100 2
    public static function clientCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
101
    {
102
        // Allow caching of private requests
103 2
        if (isset($config['respect_response_cache_directives'])) {
104
            $config['respect_response_cache_directives'][] = 'no-cache';
105
            $config['respect_response_cache_directives'][] = 'max-age';
106
            $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']);
107
        } else {
108 2
            $config['respect_response_cache_directives'] = ['no-cache', 'max-age'];
109
        }
110
111 2
        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 11
    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
    {
131 11
        $method = strtoupper($request->getMethod());
132
        // if the request not is cachable, move to $next
133 11
        if (!in_array($method, $this->config['methods'])) {
134
            return $next($request)->then(function (ResponseInterface $response) use ($request) {
135 1
                $response = $this->handleCacheListeners($request, $response, false, null);
136
137 1
                return $response;
138 1
            });
139
        }
140
141
        // If we can cache the request
142 10
        $key = $this->createCacheKey($request);
143 10
        $cacheItem = $this->pool->getItem($key);
144
145 10
        if ($cacheItem->isHit()) {
146 4
            $data = $cacheItem->get();
147
            // The array_key_exists() is to be removed in 2.0.
148 4
            if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) {
149
                // This item is still valid according to previous cache headers
150 2
                $response = $this->createResponseFromCacheItem($cacheItem);
151 2
                $response = $this->handleCacheListeners($request, $response, true, $cacheItem);
152
153 2
                return new FulfilledPromise($response);
154
            }
155
156
            // Add headers to ask the server if this cache is still valid
157 2
            if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) {
158 2
                $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue);
159
            }
160
161 2
            if ($etag = $this->getETag($cacheItem)) {
162 2
                $request = $request->withHeader('If-None-Match', $etag);
163
            }
164
        }
165
166
        return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) {
167 8
            if (304 === $response->getStatusCode()) {
168 2
                if (!$cacheItem->isHit()) {
169
                    /*
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 1
                    return $this->handleCacheListeners($request, $response, false, $cacheItem);
174
                }
175
176
                // The cached response we have is still valid
177 1
                $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
183 1
                return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem);
184
            }
185
186 6
            if ($this->isCacheable($response)) {
187 5
                $bodyStream = $response->getBody();
188 5
                $body = $bodyStream->__toString();
189 5
                if ($bodyStream->isSeekable()) {
190 5
                    $bodyStream->rewind();
191
                } else {
192
                    $response = $response->withBody($this->streamFactory->createStream($body));
193
                }
194
195 5
                $maxAge = $this->getMaxAge($response);
196
                $cacheItem
197 5
                    ->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
                    ]);
205 5
                $this->pool->save($cacheItem);
206
            }
207
208 6
            return $this->handleCacheListeners($request, $response, false, isset($cacheItem) ? $cacheItem : null);
209 8
        });
210
    }
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 6
    private function calculateCacheItemExpiresAfter($maxAge)
221
    {
222 6
        if (null === $this->config['cache_lifetime'] && null === $maxAge) {
223
            return;
224
        }
225
226 6
        return $this->config['cache_lifetime'] + $maxAge;
227
    }
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 6
    private function calculateResponseExpiresAt($maxAge)
238
    {
239 6
        if (null === $maxAge) {
240
            return;
241
        }
242
243 6
        return time() + $maxAge;
244
    }
245
246
    /**
247
     * Verify that we can cache this response.
248
     *
249
     * @param ResponseInterface $response
250
     *
251
     * @return bool
252
     */
253 6
    protected function isCacheable(ResponseInterface $response)
254
    {
255 6
        if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) {
256 1
            return false;
257
        }
258
259 5
        $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
266 5
        return true;
267
    }
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 6
    private function getCacheControlDirective(ResponseInterface $response, $name)
278
    {
279 6
        $headers = $response->getHeader('Cache-Control');
280 6
        foreach ($headers as $header) {
281 2
            if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
282
                // return the value for $name if it exists
283 1
                if (isset($matches[1])) {
284 1
                    return $matches[1];
285
                }
286
287 2
                return true;
288
            }
289
        }
290
291 6
        return false;
292
    }
293
294
    /**
295
     * @param RequestInterface $request
296
     *
297
     * @return string
298
     */
299 10
    private function createCacheKey(RequestInterface $request)
300
    {
301 10
        $key = $this->config['cache_key_generator']->generate($request);
302
303 10
        return hash($this->config['hash_algo'], $key);
304
    }
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 6
    private function getMaxAge(ResponseInterface $response)
314
    {
315 6
        if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) {
316
            return $this->config['default_ttl'];
317
        }
318
319
        // check for max age in the Cache-Control header
320 6
        $maxAge = $this->getCacheControlDirective($response, 'max-age');
321 6
        if (!is_bool($maxAge)) {
322 1
            $ageHeaders = $response->getHeader('Age');
323 1
            foreach ($ageHeaders as $age) {
324 1
                return $maxAge - ((int) $age);
325
            }
326
327
            return (int) $maxAge;
328
        }
329
330
        // check for ttl in the Expires header
331 5
        $headers = $response->getHeader('Expires');
332 5
        foreach ($headers as $header) {
333
            return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
334
        }
335
336 5
        return $this->config['default_ttl'];
337
    }
338
339
    /**
340
     * Configure an options resolver.
341
     *
342
     * @param OptionsResolver $resolver
343
     */
344 14
    private function configureOptions(OptionsResolver $resolver)
345
    {
346 14
        $resolver->setDefaults([
347 14
            'cache_lifetime' => 86400 * 30, // 30 days
348
            'default_ttl' => 0,
349
            //Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead
350
            'respect_cache_headers' => null,
351
            'hash_algo' => 'sha1',
352
            'methods' => ['GET', 'HEAD'],
353
            'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'],
354
            'cache_key_generator' => null,
355
            'cache_listeners' => [],
356
        ]);
357
358 14
        $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
        $resolver->setAllowedValues('methods', function ($value) {
365
            /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */
366 14
            $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value);
367
368 14
            return empty($matches);
369 14
        });
370 14
        $resolver->setAllowedTypes('cache_listeners', ['array']);
371
372
        $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) {
373 14
            if (null !== $value) {
374
                @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 14
            return null === $value ? true : $value;
378 14
        });
379
380 14
        $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) {
381 13
            if (false === $options['respect_cache_headers']) {
382
                return [];
383
            }
384
385 13
            return $value;
386 14
        });
387 14
    }
388
389
    /**
390
     * @param CacheItemInterface $cacheItem
391
     *
392
     * @return ResponseInterface
393
     */
394 3
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
395
    {
396 3
        $data = $cacheItem->get();
397
398
        /** @var ResponseInterface $response */
399 3
        $response = $data['response'];
400 3
        $stream = $this->streamFactory->createStream($data['body']);
401
402
        try {
403 3
            $stream->rewind();
404
        } catch (\Exception $e) {
405
            throw new RewindStreamException('Cannot rewind stream.', 0, $e);
406
        }
407
408 3
        $response = $response->withBody($stream);
409
410 3
        return $response;
411
    }
412
413
    /**
414
     * Get the value of the "If-Modified-Since" header.
415
     *
416
     * @param CacheItemInterface $cacheItem
417
     *
418
     * @return string|null
419
     */
420 2
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
421
    {
422 2
        $data = $cacheItem->get();
423
        // The isset() is to be removed in 2.0.
424 2
        if (!isset($data['createdAt'])) {
425
            return;
426
        }
427
428 2
        $modified = new \DateTime('@'.$data['createdAt']);
429 2
        $modified->setTimezone(new \DateTimeZone('GMT'));
430
431 2
        return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s'));
432
    }
433
434
    /**
435
     * Get the ETag from the cached response.
436
     *
437
     * @param CacheItemInterface $cacheItem
438
     *
439
     * @return string|null
440
     */
441 2
    private function getETag(CacheItemInterface $cacheItem)
442
    {
443 2
        $data = $cacheItem->get();
444
        // The isset() is to be removed in 2.0.
445 2
        if (!isset($data['etag'])) {
446
            return;
447
        }
448
449 2
        foreach ($data['etag'] as $etag) {
450 2
            if (!empty($etag)) {
451 2
                return $etag;
452
            }
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 11
    private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, $cacheHit, $cacheItem)
467
    {
468 11
        foreach ($this->config['cache_listeners'] as $cacheListener) {
469
            $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem);
470
        }
471
472 11
        return $response;
473
    }
474
}
475