Completed
Pull Request — master (#12)
by Graham
09:29 queued 03:30
created

CachePlugin::handleRequest()   C

Complexity

Conditions 7
Paths 3

Size

Total Lines 40
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 7.392

Importance

Changes 2
Bugs 0 Features 0
Metric Value
c 2
b 0
f 0
dl 0
loc 40
ccs 20
cts 25
cp 0.8
rs 6.7272
cc 7
eloc 23
nc 3
nop 3
crap 7.392
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\CacheItemPoolInterface;
9
use Psr\Http\Message\RequestInterface;
10
use Psr\Http\Message\ResponseInterface;
11
use Symfony\Component\OptionsResolver\OptionsResolver;
12
13
/**
14
 * Allow for caching a response.
15
 *
16
 * @author Tobias Nyholm <[email protected]>
17
 */
18
final class CachePlugin implements Plugin
19
{
20
    /**
21
     * @var CacheItemPoolInterface
22
     */
23
    private $pool;
24
25
    /**
26
     * @var StreamFactory
27
     */
28
    private $streamFactory;
29
30
    /**
31
     * @var array
32
     */
33
    private $config;
34
35
    /**
36
     * @param CacheItemPoolInterface $pool
37
     * @param StreamFactory          $streamFactory
38
     * @param array                  $config        {
39
     *
40
     *     @var bool $respect_cache_headers Whether to look at the cache directives or ignore them
41
     *     @var int $default_ttl If we do not respect cache headers or can't calculate a good ttl, use this value
42
     *     @var string $hash_algo The hashing algorithm to use when generating cache keys.
43
     * }
44
     */
45 6
    public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = [])
46
    {
47 6
        $this->pool = $pool;
48 6
        $this->streamFactory = $streamFactory;
49
50 6
        $optionsResolver = new OptionsResolver();
51 6
        $this->configureOptions($optionsResolver);
52 6
        $this->config = $optionsResolver->resolve($config);
53 6
    }
54
55
    /**
56
     * {@inheritdoc}
57
     */
58 4
    public function handleRequest(RequestInterface $request, callable $next, callable $first)
59
    {
60 4
        $method = strtoupper($request->getMethod());
61
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
            // return cached response
73
            $data = $cacheItem->get();
74
            $response = $data['response'];
75
            $response = $response->withBody($this->streamFactory->createStream($data['body']));
76
77
            return new FulfilledPromise($response);
78
        }
79
80 3
        return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
81 3
            if ($this->isCacheable($response) && ($maxAge = $this->getMaxAge($response)) > 0) {
82 2
                $bodyStream = $response->getBody();
83 2
                $body = $bodyStream->__toString();
84 2
                if ($bodyStream->isSeekable()) {
85 2
                    $bodyStream->rewind();
86 2
                } else {
87
                    $response = $response->withBody($this->streamFactory->createStream($body));
88
                }
89
90 2
                $cacheItem->set(['response' => $response, 'body' => $body])
91 2
                    ->expiresAfter($maxAge);
92 2
                $this->pool->save($cacheItem);
93 2
            }
94
95 3
            return $response;
96 3
        });
97
    }
98
99
    /**
100
     * Verify that we can cache this response.
101
     *
102
     * @param ResponseInterface $response
103
     *
104
     * @return bool
105
     */
106 3
    protected function isCacheable(ResponseInterface $response)
107
    {
108 3
        if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) {
109 1
            return false;
110
        }
111 2
        if (!$this->config['respect_cache_headers']) {
112
            return true;
113
        }
114 2
        if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) {
115
            return false;
116
        }
117
118 2
        return true;
119
    }
120
121
    /**
122
     * Get the value of a parameter in the cache control header.
123
     *
124
     * @param ResponseInterface $response
125
     * @param string            $name     The field of Cache-Control to fetch
126
     *
127
     * @return bool|string The value of the directive, true if directive without value, false if directive not present
128
     */
129 2
    private function getCacheControlDirective(ResponseInterface $response, $name)
130
    {
131 2
        $headers = $response->getHeader('Cache-Control');
132 2
        foreach ($headers as $header) {
133 1
            if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
134
135
                // return the value for $name if it exists
136 1
                if (isset($matches[1])) {
137 1
                    return $matches[1];
138
                }
139
140
                return true;
141
            }
142 2
        }
143
144 2
        return false;
145
    }
146
147
    /**
148
     * @param RequestInterface $request
149
     *
150
     * @return string
151
     */
152 3
    private function createCacheKey(RequestInterface $request)
153
    {
154 3
        return hash($this->config['hash_algo'], $request->getMethod().' '.$request->getUri());
155
    }
156
157
    /**
158
     * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl.
159
     *
160
     * @param ResponseInterface $response
161
     *
162
     * @return int|null
163
     */
164 2
    private function getMaxAge(ResponseInterface $response)
165
    {
166 2
        if (!$this->config['respect_cache_headers']) {
167
            return $this->config['default_ttl'];
168
        }
169
170
        // check for max age in the Cache-Control header
171 2
        $maxAge = $this->getCacheControlDirective($response, 'max-age');
172 2
        if (!is_bool($maxAge)) {
173 1
            $ageHeaders = $response->getHeader('Age');
174 1
            foreach ($ageHeaders as $age) {
175 1
                return $maxAge - ((int) $age);
176
            }
177
178
            return (int) $maxAge;
179
        }
180
181
        // check for ttl in the Expires header
182 1
        $headers = $response->getHeader('Expires');
183 1
        foreach ($headers as $header) {
184
            return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
185 1
        }
186
187 1
        return $this->config['default_ttl'];
188
    }
189
190
    /**
191
     * Configure an options resolver.
192
     *
193
     * @param OptionsResolver $resolver
194
     */
195 6
    private function configureOptions(OptionsResolver $resolver)
196
    {
197 6
        $resolver->setDefaults([
198 6
            'default_ttl' => null,
199 6
            'respect_cache_headers' => true,
200 6
            'hash_algo' => 'sha1',
201 6
        ]);
202
203 6
        $resolver->setAllowedTypes('default_ttl', ['int', 'null']);
204 6
        $resolver->setAllowedTypes('respect_cache_headers', 'bool');
205 6
        $resolver->setAllowedValues('hash_algo', hash_algos());
206 6
    }
207
}
208