Completed
Push — master ( 25a29b...eef8c8 )
by Tobias
04:03 queued 02:46
created

CachePlugin::getETag()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 4.5923

Importance

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