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

CachePlugin::clientCache()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

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