Completed
Pull Request — master (#8)
by Tobias
07:58
created

CachePlugin::getETag()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 6.2017

Importance

Changes 4
Bugs 1 Features 1
Metric Value
c 4
b 1
f 1
dl 0
loc 17
ccs 7
cts 11
cp 0.6364
rs 8.8571
cc 5
eloc 9
nc 5
nop 1
crap 6.2017
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 that the server originally says it is valid for.
47
     *              We store a cache item for $cache_lifetime + max age of the response.
48
     * }
49
     */
50 10
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
51
    {
52 10
        $this->pool = $pool;
53 10
        $this->streamFactory = $streamFactory;
54
55 10
        $optionsResolver = new OptionsResolver();
56 10
        $this->configureOptions($optionsResolver);
57 10
        $this->config = $optionsResolver->resolve($config);
58 10
    }
59
60
    /**
61
     * {@inheritdoc}
62
     */
63 8
    public function handleRequest(RequestInterface $request, callable $next, callable $first)
64 1
    {
65 8
        $method = strtoupper($request->getMethod());
66
        // if the request not is cachable, move to $next
67 8
        if ($method !== 'GET' && $method !== 'HEAD') {
68 1
            return $next($request);
69
        }
70
71
        // If we can cache the request
72 7
        $key = $this->createCacheKey($request);
73 7
        $cacheItem = $this->pool->getItem($key);
74
75 7
        if ($cacheItem->isHit()) {
76 3
            $data = $cacheItem->get();
77 3
            if (isset($data['expiresAt']) && time() < $data['expiresAt']) {
78
                // This item is still valid according to previous cache headers
79 1
                return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem));
80
            }
81
82
            // Add headers to ask the server if this cache is still valid
83 2
            if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) {
84 2
                $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue);
85 2
            }
86
87 2
            if ($etag = $this->getETag($cacheItem)) {
88 2
                $request = $request->withHeader('If-None-Match', $etag);
89 2
            }
90 2
        }
91
92 6
        return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
93 6
            if (304 === $response->getStatusCode()) {
94 2
                if (!$cacheItem->isHit()) {
95
                    /*
96
                     * We do not have the item in cache. This plugin did not add If-Modified-Since
97
                     * or If-None-Match headers. Return the response from server.
98
                     */
99 1
                    return $response;
100
                }
101
102
                // The cached response we have is still valid
103 1
                $data = $cacheItem->get();
104 1
                $maxAge = $this->getMaxAge($response);
105 1
                $data['expiresAt'] = time() + $maxAge;
106 1
                $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime'] + $maxAge);
107 1
                $this->pool->save($cacheItem);
108
109 1
                return $this->createResponseFromCacheItem($cacheItem);
110
            }
111
112 4
            if ($this->isCacheable($response)) {
113 3
                $bodyStream = $response->getBody();
114 3
                $body = $bodyStream->__toString();
115 3
                if ($bodyStream->isSeekable()) {
116 3
                    $bodyStream->rewind();
117 3
                } else {
118
                    $response = $response->withBody($this->streamFactory->createStream($body));
119
                }
120
121 3
                $maxAge = $this->getMaxAge($response);
122 3
                $currentTime = time();
123
                $cacheItem
124 3
                    ->expiresAfter($this->config['cache_lifetime'] + $maxAge)
125 3
                    ->set([
126 3
                        'response' => $response,
127 3
                        'body' => $body,
128 3
                        'expiresAt' => $currentTime + $maxAge,
129 3
                        'createdAt' => $currentTime,
130 3
                        'etag' => $response->getHeader('ETag'),
131 3
                    ]);
132 3
                $this->pool->save($cacheItem);
133 3
            }
134
135 4
            return $response;
136 6
        });
137
    }
138
139
    /**
140
     * Verify that we can cache this response.
141
     *
142
     * @param ResponseInterface $response
143
     *
144
     * @return bool
145
     */
146 4
    protected function isCacheable(ResponseInterface $response)
147
    {
148 4
        if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) {
149 1
            return false;
150
        }
151 3
        if (!$this->config['respect_cache_headers']) {
152
            return true;
153
        }
154 3
        if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) {
155
            return false;
156
        }
157
158 3
        return true;
159
    }
160
161
    /**
162
     * Get the value of a parameter in the cache control header.
163
     *
164
     * @param ResponseInterface $response
165
     * @param string            $name     The field of Cache-Control to fetch
166
     *
167
     * @return bool|string The value of the directive, true if directive without value, false if directive not present
168
     */
169 4
    private function getCacheControlDirective(ResponseInterface $response, $name)
170
    {
171 4
        $headers = $response->getHeader('Cache-Control');
172 4
        foreach ($headers as $header) {
173 1
            if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
174
175
                // return the value for $name if it exists
176 1
                if (isset($matches[1])) {
177 1
                    return $matches[1];
178
                }
179
180
                return true;
181
            }
182 4
        }
183
184 4
        return false;
185
    }
186
187
    /**
188
     * @param RequestInterface $request
189
     *
190
     * @return string
191
     */
192 7
    private function createCacheKey(RequestInterface $request)
193
    {
194 7
        return hash($this->config['hash_algo'], $request->getMethod().' '.$request->getUri());
195
    }
196
197
    /**
198
     * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl.
199
     *
200
     * @param ResponseInterface $response
201
     *
202
     * @return int|null
203
     */
204 4
    private function getMaxAge(ResponseInterface $response)
205
    {
206 4
        if (!$this->config['respect_cache_headers']) {
207
            return $this->config['default_ttl'];
208
        }
209
210
        // check for max age in the Cache-Control header
211 4
        $maxAge = $this->getCacheControlDirective($response, 'max-age');
212 4
        if (!is_bool($maxAge)) {
213 1
            $ageHeaders = $response->getHeader('Age');
214 1
            foreach ($ageHeaders as $age) {
215 1
                return $maxAge - ((int) $age);
216
            }
217
218
            return (int) $maxAge;
219
        }
220
221
        // check for ttl in the Expires header
222 3
        $headers = $response->getHeader('Expires');
223 3
        foreach ($headers as $header) {
224
            return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
225 3
        }
226
227 3
        return $this->config['default_ttl'];
228
    }
229
230
    /**
231
     * Configure an options resolver.
232
     *
233
     * @param OptionsResolver $resolver
234
     */
235 10
    private function configureOptions(OptionsResolver $resolver)
236
    {
237 10
        $resolver->setDefaults([
238 10
            'cache_lifetime' => 86400 * 30, // 30 days
239 10
            'default_ttl' => null,
240 10
            'respect_cache_headers' => true,
241 10
            'hash_algo' => 'sha1',
242 10
        ]);
243
244 10
        $resolver->setAllowedTypes('cache_lifetime', 'int');
245 10
        $resolver->setAllowedTypes('default_ttl', ['int', 'null']);
246 10
        $resolver->setAllowedTypes('respect_cache_headers', 'bool');
247 10
        $resolver->setAllowedValues('hash_algo', hash_algos());
248 10
    }
249
250
    /**
251
     * @param CacheItemInterface $cacheItem
252
     *
253
     * @return ResponseInterface
254
     */
255 2
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
256
    {
257 2
        $data = $cacheItem->get();
258
259
        /** @var ResponseInterface $response */
260 2
        $response = $data['response'];
261 2
        $response = $response->withBody($this->streamFactory->createStream($data['body']));
262
263 2
        return $response;
264
    }
265
266
    /**
267
     * Get the value of the "If-Modified-Since" header.
268
     *
269
     * @param CacheItemInterface $cacheItem
270
     *
271
     * @return string|null
272
     */
273 2
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
274
    {
275 2
        $data = $cacheItem->get();
276 2
        if (!isset($data['createdAt'])) {
277
            return;
278
        }
279
280 2
        $modified = new \DateTime('@'.$data['createdAt']);
281 2
        $modified->setTimezone(new \DateTimeZone('GMT'));
282
283 2
        return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s'));
284
    }
285
286
    /**
287
     * Get the ETag from the cached response.
288
     *
289
     * @param CacheItemInterface $cacheItem
290
     *
291
     * @return string|null
292
     */
293 2
    private function getETag(CacheItemInterface $cacheItem)
294
    {
295 2
        $data = $cacheItem->get();
296 2
        if (!isset($data['etag'])) {
297
            return;
298
        }
299
300 2
        if (!is_array($data['etag'])) {
301
            return $data['etag'];
302
        }
303
304 2
        foreach ($data['etag'] as $etag) {
305 2
            if (!empty($etag)) {
306 2
                return $etag;
307
            }
308
        }
309
    }
310
}
311