Completed
Pull Request — master (#29)
by Théo
02:38
created

CachePlugin::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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