Completed
Pull Request — master (#26)
by Jeroen
03:23
created

CachePlugin::clientCache()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 8.1239

Importance

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