Completed
Push — master ( 778913...55f531 )
by Tobias
02:45
created

CachePlugin::calculateResponseExpiresAt()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2.0625

Importance

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