Completed
Pull Request — master (#8)
by Tobias
09:24
created

CachePlugin::createResponseFromCacheItem()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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