Completed
Pull Request — master (#26)
by Jeroen
10:42
created

CachePlugin::clientCache()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

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