Completed
Pull Request — master (#8)
by Tobias
05:35
created

CachePlugin::createResponseFromCacheItem()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 10
ccs 5
cts 5
cp 1
rs 9.4285
cc 1
eloc 5
nc 1
nop 1
crap 1
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
     */
46 10
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
47
    {
48 10
        $this->pool = $pool;
49 10
        $this->streamFactory = $streamFactory;
50
51 10
        $optionsResolver = new OptionsResolver();
52 10
        $this->configureOptions($optionsResolver);
53 10
        $this->config = $optionsResolver->resolve($config);
54 10
    }
55
56
    /**
57
     * {@inheritdoc}
58
     */
59 8
    public function handleRequest(RequestInterface $request, callable $next, callable $first)
60
    {
61 8
        $method = strtoupper($request->getMethod());
62
        // if the request not is cachable, move to $next
63 8
        if ($method !== 'GET' && $method !== 'HEAD') {
64 2
            return $next($request);
65
        }
66
67
        // If we can cache the request
68 7
        $key = $this->createCacheKey($request);
69 7
        $cacheItem = $this->pool->getItem($key);
70
71 7
        if ($cacheItem->isHit()) {
72 3
            $data = $cacheItem->get();
73 3
            if (isset($data['expiresAt']) && time() < $data['expiresAt']) {
74
                // This item is still valid according to previous cache headers
75 1
                return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem));
76
            }
77
78
            // 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 2
            }
82
83 2
            if ($etag = $this->getETag($cacheItem)) {
84 2
                $request = $request->withHeader('If-None-Match', $etag);
85 2
            }
86 2
        }
87
88 6
        return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
89 6
            if (304 === $response->getStatusCode()) {
90 2
                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
                     */
95 1
                    return $response;
96
                }
97
98
                // 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 1
                $this->pool->save($cacheItem);
104
105 1
                return $this->createResponseFromCacheItem($cacheItem);
106
            }
107
108 4
            if ($this->isCacheable($response)) {
109 3
                $bodyStream = $response->getBody();
110 3
                $body = $bodyStream->__toString();
111 3
                if ($bodyStream->isSeekable()) {
112 3
                    $bodyStream->rewind();
113 3
                } else {
114
                    $response = $response->withBody($this->streamFactory->createStream($body));
115
                }
116
117 3
                $maxAge = $this->getMaxAge($response);
118 3
                $currentTime = time();
119
                $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 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 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
     */
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' => 86400 * 30, // 30 days
235 10
            'default_ttl' => null,
236 10
            'respect_cache_headers' => true,
237 10
            'hash_algo' => 'sha1',
238 10
        ]);
239
240 10
        $resolver->setAllowedTypes('cache_lifetime', 'int');
241 10
        $resolver->setAllowedTypes('default_ttl', ['int', 'null']);
242 10
        $resolver->setAllowedTypes('respect_cache_headers', 'bool');
243 10
        $resolver->setAllowedValues('hash_algo', hash_algos());
244 10
    }
245
246
    /**
247
     * @param CacheItemInterface $cacheItem
248
     *
249
     * @return ResponseInterface
250
     */
251 2
    private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
252
    {
253 2
        $data = $cacheItem->get();
254
255
        /** @var ResponseInterface $response */
256 2
        $response = $data['response'];
257 2
        $response = $response->withBody($this->streamFactory->createStream($data['body']));
258
259 2
        return $response;
260
    }
261
262
    /**
263
     * Get the value of the "If-Modified-Since" header.
264
     *
265
     * @param CacheItemInterface $cacheItem
266
     *
267
     * @return string|null
268
     */
269 2
    private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem)
270
    {
271 2
        $data = $cacheItem->get();
272 2
        if (!isset($data['createdAt'])) {
273
            return;
274
        }
275
276 2
        $modified = new \DateTime('@'.$data['createdAt']);
277 2
        $modified->setTimezone(new \DateTimeZone('GMT'));
278
279 2
        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
     *
287
     * @return string|null
288
     */
289 2
    private function getETag(CacheItemInterface $cacheItem)
290
    {
291 2
        $data = $cacheItem->get();
292 2
        if (!isset($data['etag'])) {
293
            return;
294
        }
295
296 2
        if (!is_array($data['etag'])) {
297
            return $data['etag'];
298
        }
299
300 2
        foreach ($data['etag'] as $etag) {
301 2
            if (!empty($etag)) {
302 2
                return $etag;
303
            }
304
        }
305
306
        return;
307
    }
308
}
309