CachePlugin::addResponseHeaders()   C
last analyzed

Complexity

Conditions 8
Paths 72

Size

Total Lines 39
Code Lines 26

Duplication

Lines 16
Ratio 41.03 %

Importance

Changes 0
Metric Value
dl 16
loc 39
rs 5.3846
c 0
b 0
f 0
cc 8
eloc 26
nc 72
nop 2
1
<?php
2
3
namespace Guzzle\Plugin\Cache;
4
5
use Guzzle\Cache\CacheAdapterFactory;
6
use Guzzle\Cache\CacheAdapterInterface;
7
use Guzzle\Common\Event;
8
use Guzzle\Common\Exception\InvalidArgumentException;
9
use Guzzle\Common\Version;
10
use Guzzle\Http\Message\RequestInterface;
11
use Guzzle\Http\Message\Response;
12
use Guzzle\Cache\DoctrineCacheAdapter;
13
use Guzzle\Http\Exception\CurlException;
14
use Doctrine\Common\Cache\ArrayCache;
15
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
16
17
/**
18
 * Plugin to enable the caching of GET and HEAD requests.  Caching can be done on all requests passing through this
19
 * plugin or only after retrieving resources with cacheable response headers.
20
 *
21
 * This is a simple implementation of RFC 2616 and should be considered a private transparent proxy cache, meaning
22
 * authorization and private data can be cached.
23
 *
24
 * It also implements RFC 5861's `stale-if-error` Cache-Control extension, allowing stale cache responses to be used
25
 * when an error is encountered (such as a `500 Internal Server Error` or DNS failure).
26
 */
27
class CachePlugin implements EventSubscriberInterface
28
{
29
    /** @var RevalidationInterface Cache revalidation strategy */
30
    protected $revalidation;
31
32
    /** @var CanCacheStrategyInterface Object used to determine if a request can be cached */
33
    protected $canCache;
34
35
    /** @var CacheStorageInterface $cache Object used to cache responses */
36
    protected $storage;
37
38
    /** @var bool */
39
    protected $autoPurge;
40
41
    /**
42
     * @param array|CacheAdapterInterface|CacheStorageInterface $options Array of options for the cache plugin,
43
     *     cache adapter, or cache storage object.
44
     *     - CacheStorageInterface storage:      Adapter used to cache responses
45
     *     - RevalidationInterface revalidation: Cache revalidation strategy
46
     *     - CanCacheInterface     can_cache:    Object used to determine if a request can be cached
47
     *     - bool                  auto_purge    Set to true to automatically PURGE resources when non-idempotent
48
     *                                           requests are sent to a resource. Defaults to false.
49
     * @throws InvalidArgumentException if no cache is provided and Doctrine cache is not installed
50
     */
51
    public function __construct($options = null)
52
    {
53
        if (!is_array($options)) {
54
            if ($options instanceof CacheAdapterInterface) {
55
                $options = array('storage' => new DefaultCacheStorage($options));
56
            } elseif ($options instanceof CacheStorageInterface) {
57
                $options = array('storage' => $options);
58
            } elseif ($options) {
59
                $options = array('storage' => new DefaultCacheStorage(CacheAdapterFactory::fromCache($options)));
60
            } elseif (!class_exists('Doctrine\Common\Cache\ArrayCache')) {
61
                // @codeCoverageIgnoreStart
62
                throw new InvalidArgumentException('No cache was provided and Doctrine is not installed');
63
                // @codeCoverageIgnoreEnd
64
            }
65
        }
66
67
        $this->autoPurge = isset($options['auto_purge']) ? $options['auto_purge'] : false;
68
69
        // Add a cache storage if a cache adapter was provided
70
        $this->storage = isset($options['storage'])
71
            ? $options['storage']
72
            : new DefaultCacheStorage(new DoctrineCacheAdapter(new ArrayCache()));
73
74
        if (!isset($options['can_cache'])) {
75
            $this->canCache = new DefaultCanCacheStrategy();
76
        } else {
77
            $this->canCache = is_callable($options['can_cache'])
78
                ? new CallbackCanCacheStrategy($options['can_cache'])
79
                : $options['can_cache'];
80
        }
81
82
        // Use the provided revalidation strategy or the default
83
        $this->revalidation = isset($options['revalidation'])
84
            ? $options['revalidation']
85
            : new DefaultRevalidation($this->storage, $this->canCache);
86
    }
87
88
    public static function getSubscribedEvents()
89
    {
90
        return array(
91
            'request.before_send' => array('onRequestBeforeSend', -255),
92
            'request.sent'        => array('onRequestSent', 255),
93
            'request.error'       => array('onRequestError', 0),
94
            'request.exception'   => array('onRequestException', 0),
95
        );
96
    }
97
98
    /**
99
     * Check if a response in cache will satisfy the request before sending
100
     *
101
     * @param Event $event
102
     */
103
    public function onRequestBeforeSend(Event $event)
104
    {
105
        $request = $event['request'];
106
        $request->addHeader('Via', sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION));
107
108
        if (!$this->canCache->canCacheRequest($request)) {
109
            switch ($request->getMethod()) {
110
                case 'PURGE':
111
                    $this->purge($request);
112
                    $request->setResponse(new Response(200, array(), 'purged'));
113
                    break;
114
                case 'PUT':
115
                case 'POST':
116
                case 'DELETE':
117
                case 'PATCH':
118
                    if ($this->autoPurge) {
119
                        $this->purge($request);
120
                    }
121
            }
122
            return;
123
        }
124
125
        if ($response = $this->storage->fetch($request)) {
126
            $params = $request->getParams();
127
            $params['cache.lookup'] = true;
128
            $response->setHeader(
129
                'Age',
130
                time() - strtotime($response->getDate() ? : $response->getLastModified() ?: 'now')
131
            );
132
            // Validate that the response satisfies the request
133
            if ($this->canResponseSatisfyRequest($request, $response)) {
134
                if (!isset($params['cache.hit'])) {
135
                    $params['cache.hit'] = true;
136
                }
137
                $request->setResponse($response);
138
            }
139
        }
140
    }
141
142
    /**
143
     * If possible, store a response in cache after sending
144
     *
145
     * @param Event $event
146
     */
147
    public function onRequestSent(Event $event)
148
    {
149
        $request = $event['request'];
150
        $response = $event['response'];
151
152
        if ($request->getParams()->get('cache.hit') === null &&
153
            $this->canCache->canCacheRequest($request) &&
154
            $this->canCache->canCacheResponse($response)
155
        ) {
156
            $this->storage->cache($request, $response);
157
        }
158
159
        $this->addResponseHeaders($request, $response);
160
    }
161
162
    /**
163
     * If possible, return a cache response on an error
164
     *
165
     * @param Event $event
166
     */
167
    public function onRequestError(Event $event)
168
    {
169
        $request = $event['request'];
170
171
        if (!$this->canCache->canCacheRequest($request)) {
172
            return;
173
        }
174
175 View Code Duplication
        if ($response = $this->storage->fetch($request)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
176
            $response->setHeader(
177
                'Age',
178
                time() - strtotime($response->getLastModified() ? : $response->getDate() ?: 'now')
179
            );
180
181
            if ($this->canResponseSatisfyFailedRequest($request, $response)) {
182
                $request->getParams()->set('cache.hit', 'error');
183
                $this->addResponseHeaders($request, $response);
184
                $event['response'] = $response;
185
                $event->stopPropagation();
186
            }
187
        }
188
    }
189
190
    /**
191
     * If possible, set a cache response on a cURL exception
192
     *
193
     * @param Event $event
194
     *
195
     * @return null
196
     */
197
    public function onRequestException(Event $event)
198
    {
199
        if (!$event['exception'] instanceof CurlException) {
200
            return;
201
        }
202
203
        $request = $event['request'];
204
        if (!$this->canCache->canCacheRequest($request)) {
205
            return;
206
        }
207
208 View Code Duplication
        if ($response = $this->storage->fetch($request)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
209
            $response->setHeader('Age', time() - strtotime($response->getDate() ? : 'now'));
210
            if (!$this->canResponseSatisfyFailedRequest($request, $response)) {
211
                return;
212
            }
213
            $request->getParams()->set('cache.hit', 'error');
214
            $request->setResponse($response);
215
            $this->addResponseHeaders($request, $response);
216
            $event->stopPropagation();
217
        }
218
    }
219
220
    /**
221
     * Check if a cache response satisfies a request's caching constraints
222
     *
223
     * @param RequestInterface $request  Request to validate
224
     * @param Response         $response Response to validate
225
     *
226
     * @return bool
227
     */
228
    public function canResponseSatisfyRequest(RequestInterface $request, Response $response)
229
    {
230
        $responseAge = $response->calculateAge();
231
        $reqc = $request->getHeader('Cache-Control');
232
        $resc = $response->getHeader('Cache-Control');
233
234
        // Check the request's max-age header against the age of the response
235
        if ($reqc && $reqc->hasDirective('max-age') &&
236
            $responseAge > $reqc->getDirective('max-age')) {
237
            return false;
238
        }
239
240
        // Check the response's max-age header
241
        if ($response->isFresh() === false) {
242
            $maxStale = $reqc ? $reqc->getDirective('max-stale') : null;
243
            if (null !== $maxStale) {
244
                if ($maxStale !== true && $response->getFreshness() < (-1 * $maxStale)) {
245
                    return false;
246
                }
247
            } elseif ($resc && $resc->hasDirective('max-age')
248
                && $responseAge > $resc->getDirective('max-age')
249
            ) {
250
                return false;
251
            }
252
        }
253
254
        if ($this->revalidation->shouldRevalidate($request, $response)) {
255
            try {
256
                return $this->revalidation->revalidate($request, $response);
257
            } catch (CurlException $e) {
258
                $request->getParams()->set('cache.hit', 'error');
259
                return $this->canResponseSatisfyFailedRequest($request, $response);
260
            }
261
        }
262
263
        return true;
264
    }
265
266
    /**
267
     * Check if a cache response satisfies a failed request's caching constraints
268
     *
269
     * @param RequestInterface $request  Request to validate
270
     * @param Response         $response Response to validate
271
     *
272
     * @return bool
273
     */
274
    public function canResponseSatisfyFailedRequest(RequestInterface $request, Response $response)
275
    {
276
        $reqc = $request->getHeader('Cache-Control');
277
        $resc = $response->getHeader('Cache-Control');
278
        $requestStaleIfError = $reqc ? $reqc->getDirective('stale-if-error') : null;
279
        $responseStaleIfError = $resc ? $resc->getDirective('stale-if-error') : null;
280
281
        if (!$requestStaleIfError && !$responseStaleIfError) {
282
            return false;
283
        }
284
285
        if (is_numeric($requestStaleIfError) && $response->getAge() - $response->getMaxAge() > $requestStaleIfError) {
286
            return false;
287
        }
288
289
        if (is_numeric($responseStaleIfError) && $response->getAge() - $response->getMaxAge() > $responseStaleIfError) {
290
            return false;
291
        }
292
293
        return true;
294
    }
295
296
    /**
297
     * Purge all cache entries for a given URL
298
     *
299
     * @param string $url URL to purge
300
     */
301
    public function purge($url)
302
    {
303
        // BC compatibility with previous version that accepted a Request object
304
        $url = $url instanceof RequestInterface ? $url->getUrl() : $url;
305
        $this->storage->purge($url);
306
    }
307
308
    /**
309
     * Add the plugin's headers to a response
310
     *
311
     * @param RequestInterface $request  Request
312
     * @param Response         $response Response to add headers to
313
     */
314
    protected function addResponseHeaders(RequestInterface $request, Response $response)
315
    {
316
        $params = $request->getParams();
317
        $response->setHeader('Via', sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION));
318
319
        $lookup = ($params['cache.lookup'] === true ? 'HIT' : 'MISS') . ' from GuzzleCache';
320 View Code Duplication
        if ($header = $response->getHeader('X-Cache-Lookup')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
321
            // Don't add duplicates
322
            $values = $header->toArray();
323
            $values[] = $lookup;
324
            $response->setHeader('X-Cache-Lookup', array_unique($values));
325
        } else {
326
            $response->setHeader('X-Cache-Lookup', $lookup);
327
        }
328
329
        if ($params['cache.hit'] === true) {
330
            $xcache = 'HIT from GuzzleCache';
331
        } elseif ($params['cache.hit'] == 'error') {
332
            $xcache = 'HIT_ERROR from GuzzleCache';
333
        } else {
334
            $xcache = 'MISS from GuzzleCache';
335
        }
336
337 View Code Duplication
        if ($header = $response->getHeader('X-Cache')) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
338
            // Don't add duplicates
339
            $values = $header->toArray();
340
            $values[] = $xcache;
341
            $response->setHeader('X-Cache', array_unique($values));
342
        } else {
343
            $response->setHeader('X-Cache', $xcache);
344
        }
345
346
        if ($response->isFresh() === false) {
347
            $response->addHeader('Warning', sprintf('110 GuzzleCache/%s "Response is stale"', Version::VERSION));
348
            if ($params['cache.hit'] === 'error') {
349
                $response->addHeader('Warning', sprintf('111 GuzzleCache/%s "Revalidation failed"', Version::VERSION));
350
            }
351
        }
352
    }
353
}
354