Completed
Pull Request — master (#24)
by
unknown
04:47
created

CachePlugin::handleRequest()   C

Complexity

Conditions 12
Paths 7

Size

Total Lines 75
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 12.0014

Importance

Changes 0
Metric Value
dl 0
loc 75
ccs 45
cts 46
cp 0.9783
rs 5.3413
c 0
b 0
f 0
cc 12
eloc 42
nc 7
nop 3
crap 12.0014

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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