Completed
Pull Request — master (#8)
by Tobias
10:42
created

CachePlugin::getModifiedSinceHeaderValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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