Completed
Push — master ( c8dfcc...08ccb6 )
by David
9s
created

CachePlugin::clientCache()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2.5

Importance

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