Completed
Pull Request — master (#8)
by Tobias
03:25
created

CachePlugin::getETag()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 6.2017

Importance

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