Completed
Pull Request — master (#8)
by Tobias
08:46
created

CachePlugin::getETag()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 7.1941

Importance

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