├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.rst ├── composer.json ├── phpunit.xml.dist ├── src ├── CacheStorage.php ├── CacheStorageInterface.php ├── CacheSubscriber.php ├── PurgeSubscriber.php ├── Utils.php └── ValidationSubscriber.php └── tests ├── CacheStorageTest.php ├── CacheSubscriberTest.php ├── IntegrationTest.php └── UtilsTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_STORE 3 | coverage 4 | phpunit.xml 5 | composer.lock 6 | vendor/ 7 | build/artifacts 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.6 5 | - 7.0 6 | - 7.1 7 | - 7.2 8 | - 7.3 9 | 10 | sudo: false 11 | 12 | install: travis_retry composer install --no-interaction --prefer-source 13 | 14 | script: make test 15 | 16 | matrix: 17 | fast_finish: true 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.2.0 - 2019-09-16 4 | 5 | * Improvement: Tests for expired items without must-revalidate #20 6 | * Improvement: Support for including Vary headers in cache keys #21 7 | * Improvement: Test for the can_cache option #23 8 | * Bugfix: Not adding timezone to dates #24 9 | * Improvement: Add $defaultTtl to CacheStorage constructor #25 10 | * Bugfix: Error caches responses for without vary headers #26 11 | * Bugfix: stale-if-header not being added to max-age #27 12 | * Bugfix: max-age and freshness confusing zero and null #28 13 | * Bugfix: Use date() method to fix missing GMT #29 14 | * Improvement: Tests for stale-if-error behaviour #30 15 | * Improvement: Delete cache entries on both 404 and 410 responses #33 16 | * Refactoring: Minor ValidationSubscriber.php cleanup #35 17 | * Refactoring: Minor ValidationSubscriber.php cleanup #35 18 | * Improvement: Extend caching ttl considerations #56 19 | * Improvement: Add purge method #57 20 | * Improvement: Integration test for the calculation of the "resident_time" #60 21 | * Improvement: Support for PHP 7.0 & 7.1 #73 22 | 23 | ## 0.1.0 - 2014-10-29 24 | 25 | * Initial release. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Michael Dowling, https://github.com/mtdowling 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: clean coverage 2 | 3 | test: 4 | vendor/bin/phpunit 5 | 6 | coverage: 7 | vendor/bin/phpunit --coverage-html=artifacts/coverage 8 | open artifacts/coverage/index.html 9 | 10 | view-coverage: 11 | open artifacts/coverage/index.html 12 | 13 | clean: 14 | rm -rf artifacts/* 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Guzzle Cache Subscriber 3 | ======================= 4 | 5 | .. important:: 6 | 7 | **This repo has not been updated for Guzzle 6 and only supports Guzzle 5.** 8 | 9 | See https://github.com/Kevinrob/guzzle-cache-middleware for a nice Guzzle 6 10 | compatible Cache middleware. 11 | 12 | Provides a private transparent proxy cache for caching HTTP responses. 13 | 14 | Here's a simple example of how it's used: 15 | 16 | .. code-block:: php 17 | 18 | use GuzzleHttp\Client; 19 | use GuzzleHttp\Subscriber\Cache\CacheSubscriber; 20 | 21 | $client = new Client(['defaults' => ['debug' => true]]); 22 | 23 | // Use the helper method to attach a cache to the client. 24 | CacheSubscriber::attach($client); 25 | 26 | // Send the first request 27 | $a = $client->get('http://en.wikipedia.org/wiki/Main_Page'); 28 | 29 | // Send the second request. This will find a cache hit which must be 30 | // validated. The validation request returns a 304, which yields the original 31 | // cached response. 32 | $b = $client->get('http://en.wikipedia.org/wiki/Main_Page'); 33 | 34 | Running the above code sample should output verbose cURL information that looks 35 | something like this: 36 | 37 | :: 38 | 39 | > GET /wiki/Main_Page HTTP/1.1 40 | Host: en.wikipedia.org 41 | User-Agent: Guzzle/4.2.1 curl/7.37.0 PHP/5.5.13 42 | Via: 1.1 GuzzleCache/4.2.1 43 | 44 | < HTTP/1.1 200 OK 45 | < Server: Apache 46 | < X-Content-Type-Options: nosniff 47 | < Content-language: en 48 | < X-UA-Compatible: IE=Edge 49 | < Vary: Accept-Encoding,Cookie 50 | < Last-Modified: Thu, 21 Aug 2014 01:51:49 GMT 51 | < Content-Type: text/html; charset=UTF-8 52 | < X-Varnish: 2345493325, 1998949714 1994269567 53 | < Via: 1.1 varnish, 1.1 varnish 54 | < Transfer-Encoding: chunked 55 | < Date: Thu, 21 Aug 2014 02:34:12 GMT 56 | < Age: 2541 57 | < Connection: keep-alive 58 | < X-Cache: cp1055 hit (1), cp1068 frontend hit (25353) 59 | < Cache-Control: private, s-maxage=0, max-age=0, must-revalidate 60 | < Set-Cookie: GeoIP=US:Seattle:47.6062:-122.3321:v4; Path=/; Domain=.wikipedia.org 61 | < 62 | * Connection #0 to host en.wikipedia.org left intact 63 | * Re-using existing connection! (#0) with host en.wikipedia.org 64 | > GET /wiki/Main_Page HTTP/1.1 65 | Host: en.wikipedia.org 66 | User-Agent: Guzzle/4.2.1 curl/7.37.0 PHP/5.5.13 67 | Via: 1.1 GuzzleCache/4.2.1, 1.1 GuzzleCache/4.2.1 68 | If-Modified-Since: Thu, 21 Aug 2014 01:51:49 GMT 69 | 70 | < HTTP/1.1 304 Not Modified 71 | < Server: Apache 72 | < X-Content-Type-Options: nosniff 73 | < Content-language: en 74 | < X-UA-Compatible: IE=Edge 75 | < Vary: Accept-Encoding,Cookie 76 | < Last-Modified: Thu, 21 Aug 2014 01:51:49 GMT 77 | < Content-Type: text/html; charset=UTF-8 78 | < X-Varnish: 2345493325, 1998950450 1994269567 79 | < Via: 1.1 varnish, 1.1 varnish 80 | < Date: Thu, 21 Aug 2014 02:34:12 GMT 81 | < Age: 2541 82 | < Connection: keep-alive 83 | < X-Cache: cp1055 hit (1), cp1068 frontend hit (25360) 84 | < Cache-Control: private, s-maxage=0, max-age=0, must-revalidate 85 | < Set-Cookie: GeoIP=US:Seattle:47.6062:-122.3321:v4; Path=/; Domain=.wikipedia.org 86 | < 87 | * Connection #0 to host en.wikipedia.org left intact 88 | 89 | Installing 90 | ---------- 91 | 92 | Add the following to your composer.json: 93 | 94 | .. code-block:: javascript 95 | 96 | { 97 | "require": { 98 | "guzzlehttp/cache-subscriber": "0.2.*@dev" 99 | } 100 | } 101 | 102 | or 103 | 104 | .. code-block:: console 105 | 106 | $ composer require guzzlehttp/cache-subscriber 107 | 108 | Creating a CacheSubscriber 109 | -------------------------- 110 | 111 | The easiest way to create a CacheSubscriber is using the ``attach()`` helper 112 | method of ``GuzzleHttp\Subscriber\Cache\CacheSubscriber``. This method accepts 113 | a request or client object and attaches the necessary subscribers used to 114 | perform cache lookups, validation requests, and automatic purging of resources. 115 | 116 | The ``attach()`` method accepts the following options: 117 | 118 | storage 119 | A ``GuzzleHttp\Subscriber\Cache\CacheStorageInterface`` object used to 120 | store cached responses. If no value is not provided, an in-memory array 121 | cache will be used. 122 | validate 123 | A Boolean value that determines if cached response are ever validated 124 | against the origin server. This setting defaults to ``true`` but can be 125 | disabled by passing ``false``. 126 | purge 127 | A Boolean value that determines if cached responses are purged when 128 | non-idempotent requests are sent to their URI. This setting defaults to 129 | ``true`` but can be disabled by passing ``false``. 130 | can_cache 131 | An optional callable used to determine if a request can be cached. The 132 | callable accepts a ``GuzzleHttp\Message\RequestInterface`` and returns a 133 | Boolean value. If no value is provided, the default behavior is utilized. 134 | 135 | .. warning:: 136 | 137 | This is a WIP update for Guzzle 5+. It hasn't been tested and is in 138 | active development. Expect bugs and breaks. 139 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guzzlehttp/cache-subscriber", 3 | "description": "Guzzle HTTP cache subscriber", 4 | "homepage": "http://guzzlephp.org/", 5 | "keywords": ["cache", "guzzle"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Michael Dowling", 10 | "email": "mtdowling@gmail.com", 11 | "homepage": "https://github.com/mtdowling" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.4.0", 16 | "guzzlehttp/guzzle": "~5.0", 17 | "doctrine/cache": "~1.3" 18 | }, 19 | "require-dev": { 20 | "phpunit/phpunit": "~4.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { "GuzzleHttp\\Subscriber\\Cache\\": "src" } 24 | }, 25 | "extra": { 26 | "branch-alias": { 27 | "dev-master": "0.2-dev" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | tests 7 | 8 | 9 | 10 | 11 | src 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/CacheStorage.php: -------------------------------------------------------------------------------- 1 | true, 31 | 'connection' => true, 32 | 'keep-alive' => true, 33 | 'proxy-authenticate' => true, 34 | 'proxy-authorization' => true, 35 | 'te' => true, 36 | 'trailers' => true, 37 | 'transfer-encoding' => true, 38 | 'upgrade' => true, 39 | 'set-cookie' => true, 40 | 'set-cookie2' => true, 41 | ]; 42 | 43 | /** 44 | * @param Cache $cache Cache backend. 45 | * @param string $keyPrefix (optional) Key prefix to add to each key. 46 | * @param int $defaultTtl (optional) The default TTL to set, in seconds. 47 | */ 48 | public function __construct(Cache $cache, $keyPrefix = null, $defaultTtl = 0) 49 | { 50 | $this->cache = $cache; 51 | $this->keyPrefix = $keyPrefix; 52 | $this->defaultTtl = $defaultTtl; 53 | } 54 | 55 | public function cache( 56 | RequestInterface $request, 57 | ResponseInterface $response 58 | ) { 59 | $ctime = time(); 60 | $ttl = $this->getTtl($response); 61 | $key = $this->getCacheKey($request, $this->normalizeVary($response)); 62 | $headers = $this->persistHeaders($request); 63 | $entries = $this->getManifestEntries($key, $ctime, $response, $headers); 64 | $bodyDigest = null; 65 | 66 | // Persist the Vary response header. 67 | if ($response->hasHeader('vary')) { 68 | $this->cacheVary($request, $response); 69 | } 70 | 71 | // Persist the response body if needed 72 | if ($response->getBody() && $response->getBody()->getSize() > 0) { 73 | $body = $response->getBody(); 74 | $bodyDigest = $this->getBodyKey($request->getUrl(), $body); 75 | $this->cache->save($bodyDigest, (string) $body, $ttl); 76 | } 77 | 78 | array_unshift($entries, [ 79 | $headers, 80 | $this->persistHeaders($response), 81 | $response->getStatusCode(), 82 | $bodyDigest, 83 | $ctime + $ttl 84 | ]); 85 | 86 | $this->cache->save($key, serialize($entries)); 87 | } 88 | 89 | public function delete(RequestInterface $request) 90 | { 91 | $vary = $this->fetchVary($request); 92 | $key = $this->getCacheKey($request, $vary); 93 | $entries = $this->cache->fetch($key); 94 | 95 | if (!$entries) { 96 | return; 97 | } 98 | 99 | // Delete each cached body 100 | foreach (unserialize($entries) as $entry) { 101 | if ($entry[3]) { 102 | $this->cache->delete($entry[3]); 103 | } 104 | } 105 | 106 | // Delete any cached Vary header responses. 107 | $this->deleteVary($request); 108 | 109 | $this->cache->delete($key); 110 | } 111 | 112 | public function purge($url) 113 | { 114 | foreach (['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PURGE'] as $m) { 115 | $this->delete(new Request($m, $url)); 116 | } 117 | } 118 | 119 | public function fetch(RequestInterface $request) 120 | { 121 | $vary = $this->fetchVary($request); 122 | if ($vary) { 123 | $key = $this->getCacheKey($request, $vary); 124 | } else { 125 | $key = $this->getCacheKey($request); 126 | } 127 | $entries = $this->cache->fetch($key); 128 | 129 | if (!$entries) { 130 | return null; 131 | } 132 | 133 | $match = $matchIndex = null; 134 | $headers = $this->persistHeaders($request); 135 | $entries = unserialize($entries); 136 | 137 | foreach ($entries as $index => $entry) { 138 | $vary = isset($entry[1]['vary']) ? $entry[1]['vary'] : ''; 139 | if ($this->requestsMatch($vary, $headers, $entry[0])) { 140 | $match = $entry; 141 | $matchIndex = $index; 142 | break; 143 | } 144 | } 145 | 146 | if (!$match) { 147 | return null; 148 | } 149 | 150 | // Ensure that the response is not expired 151 | $response = null; 152 | if ($match[4] < time()) { 153 | $response = -1; 154 | } else { 155 | $response = new Response($match[2], $match[1]); 156 | if ($match[3]) { 157 | if ($body = $this->cache->fetch($match[3])) { 158 | $response->setBody(Stream\Utils::create($body)); 159 | } else { 160 | // The response is not valid because the body was somehow 161 | // deleted 162 | $response = -1; 163 | } 164 | } 165 | } 166 | 167 | if ($response === -1) { 168 | // Remove the entry from the metadata and update the cache 169 | unset($entries[$matchIndex]); 170 | if ($entries) { 171 | $this->cache->save($key, serialize($entries)); 172 | } else { 173 | $this->cache->delete($key); 174 | } 175 | 176 | return null; 177 | } 178 | 179 | return $response; 180 | } 181 | 182 | /** 183 | * Hash a request URL into a string that returns cache metadata. 184 | * 185 | * @param RequestInterface $request The Request to generate the cache key 186 | * for. 187 | * @param array $vary (optional) An array of headers to vary 188 | * the cache key by. 189 | * 190 | * @return string 191 | */ 192 | private function getCacheKey(RequestInterface $request, array $vary = []) 193 | { 194 | $key = $request->getMethod() . ' ' . $request->getUrl(); 195 | 196 | // If Vary headers have been passed in, fetch each header and add it to 197 | // the cache key. 198 | foreach ($vary as $header) { 199 | $key .= " $header: " . $request->getHeader($header); 200 | } 201 | 202 | return $this->keyPrefix . md5($key); 203 | } 204 | 205 | /** 206 | * Create a cache key for a response's body. 207 | * 208 | * @param string $url URL of the entry 209 | * @param StreamInterface $body Response body 210 | * 211 | * @return string 212 | */ 213 | private function getBodyKey($url, StreamInterface $body) 214 | { 215 | return $this->keyPrefix . md5($url) . Stream\Utils::hash($body, 'md5'); 216 | } 217 | 218 | /** 219 | * Determines whether two Request HTTP header sets are non-varying. 220 | * 221 | * @param string $vary Response vary header 222 | * @param array $r1 HTTP header array 223 | * @param array $r2 HTTP header array 224 | * 225 | * @return bool 226 | */ 227 | private function requestsMatch($vary, $r1, $r2) 228 | { 229 | if ($vary) { 230 | foreach (explode(',', $vary) as $header) { 231 | $key = trim(strtolower($header)); 232 | $v1 = isset($r1[$key]) ? $r1[$key] : null; 233 | $v2 = isset($r2[$key]) ? $r2[$key] : null; 234 | if ($v1 !== $v2) { 235 | return false; 236 | } 237 | } 238 | } 239 | 240 | return true; 241 | } 242 | 243 | /** 244 | * Creates an array of cacheable and normalized message headers. 245 | * 246 | * @param MessageInterface $message 247 | * 248 | * @return array 249 | */ 250 | private function persistHeaders(MessageInterface $message) 251 | { 252 | // Clone the response to not destroy any necessary headers when caching 253 | $headers = array_diff_key($message->getHeaders(), self::$noCache); 254 | 255 | // Cast the headers to a string 256 | foreach ($headers as &$value) { 257 | $value = implode(', ', $value); 258 | } 259 | 260 | return $headers; 261 | } 262 | 263 | /** 264 | * Return the TTL to use when caching a Response. 265 | * 266 | * @param ResponseInterface $response The response being cached. 267 | * 268 | * @return int The TTL in seconds. 269 | */ 270 | private function getTtl(ResponseInterface $response) 271 | { 272 | $ttl = 0; 273 | 274 | if ($cacheControl = $response->getHeader('Cache-Control')) { 275 | $maxAge = Utils::getDirective($response, 'max-age'); 276 | if (is_numeric($maxAge)) { 277 | $ttl += $maxAge; 278 | } 279 | 280 | // According to RFC5861 stale headers are *in addition* to any 281 | // max-age values. 282 | $stale = Utils::getDirective($response, 'stale-if-error'); 283 | if (is_numeric($stale)) { 284 | $ttl += $stale; 285 | } 286 | } elseif ($expires = $response->getHeader('Expires')) { 287 | $ttl += strtotime($expires) - time(); 288 | } 289 | 290 | return $ttl ?: $this->defaultTtl; 291 | } 292 | 293 | private function getManifestEntries( 294 | $key, 295 | $currentTime, 296 | ResponseInterface $response, 297 | $persistedRequest 298 | ) { 299 | $entries = []; 300 | $manifest = $this->cache->fetch($key); 301 | 302 | if (!$manifest) { 303 | return $entries; 304 | } 305 | 306 | // Determine which cache entries should still be in the cache 307 | $vary = $response->getHeader('Vary'); 308 | 309 | foreach (unserialize($manifest) as $entry) { 310 | // Check if the entry is expired 311 | if ($entry[4] < $currentTime) { 312 | continue; 313 | } 314 | 315 | $varyCmp = isset($entry[1]['vary']) ? $entries[1]['vary'] : ''; 316 | 317 | if ($vary != $varyCmp || 318 | !$this->requestsMatch($vary, $entry[0], $persistedRequest) 319 | ) { 320 | $entries[] = $entry; 321 | } 322 | } 323 | 324 | return $entries; 325 | } 326 | 327 | /** 328 | * Return a sorted list of Vary headers. 329 | * 330 | * While headers are case-insensitive, header values are not. We can only 331 | * normalize the order of headers to combine cache entries. 332 | * 333 | * @param ResponseInterface $response The Response with Vary headers. 334 | * 335 | * @return array An array of sorted headers. 336 | */ 337 | private function normalizeVary(ResponseInterface $response) 338 | { 339 | $parts = AbstractMessage::normalizeHeader($response, 'vary'); 340 | sort($parts); 341 | 342 | return $parts; 343 | } 344 | 345 | /** 346 | * Cache the Vary headers from a response. 347 | * 348 | * @param RequestInterface $request The Request that generated the Vary 349 | * headers. 350 | * @param ResponseInterface $response The Response with Vary headers. 351 | */ 352 | private function cacheVary( 353 | RequestInterface $request, 354 | ResponseInterface $response 355 | ) { 356 | $key = $this->getVaryKey($request); 357 | $this->cache->save($key, $this->normalizeVary($response), $this->getTtl($response)); 358 | } 359 | 360 | /** 361 | * Fetch the Vary headers associated with a request, if they exist. 362 | * 363 | * Only responses, and not requests, contain Vary headers. However, we need 364 | * to be able to determine what Vary headers were set for a given URL and 365 | * request method on a future request. 366 | * 367 | * @param RequestInterface $request The Request to fetch headers for. 368 | * 369 | * @return array An array of headers. 370 | */ 371 | private function fetchVary(RequestInterface $request) 372 | { 373 | $key = $this->getVaryKey($request); 374 | $varyHeaders = $this->cache->fetch($key); 375 | 376 | return is_array($varyHeaders) ? $varyHeaders : []; 377 | } 378 | 379 | /** 380 | * Delete the headers associated with a Vary request. 381 | * 382 | * @param RequestInterface $request The Request to delete headers for. 383 | */ 384 | private function deleteVary(RequestInterface $request) 385 | { 386 | $key = $this->getVaryKey($request); 387 | $this->cache->delete($key); 388 | } 389 | 390 | /** 391 | * Get the cache key for Vary headers. 392 | * 393 | * @param RequestInterface $request The Request to fetch the key for. 394 | * 395 | * @return string The generated key. 396 | */ 397 | private function getVaryKey(RequestInterface $request) 398 | { 399 | $key = $this->keyPrefix . md5('vary ' . $this->getCacheKey($request)); 400 | 401 | return $key; 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/CacheStorageInterface.php: -------------------------------------------------------------------------------- 1 | storage = $cache; 49 | $this->canCache = $canCache; 50 | } 51 | 52 | /** 53 | * Helper method used to easily attach a cache to a request or client. 54 | * 55 | * This method accepts an array of options that are used to control the 56 | * caching behavior: 57 | * 58 | * - storage: An optional GuzzleHttp\Subscriber\Cache\CacheStorageInterface. 59 | * If no value is not provided, an in-memory array cache will be used. 60 | * - validate: Boolean value that determines if cached response are ever 61 | * validated against the origin server. Defaults to true but can be 62 | * disabled by passing false. 63 | * - purge: Boolean value that determines if cached responses are purged 64 | * when non-idempotent requests are sent to their URI. Defaults to true 65 | * but can be disabled by passing false. 66 | * - can_cache: An optional callable used to determine if a request can be 67 | * cached. The callable accepts a RequestInterface and returns a boolean 68 | * value. If no value is provided, the default behavior is utilized. 69 | * 70 | * @param HasEmitterInterface $subject Client or request to attach to, 71 | * @param array $options Options used to control the cache. 72 | * 73 | * @return array Returns an associative array containing a 'subscriber' key 74 | * that holds the created CacheSubscriber, and a 'storage' 75 | * key that contains the cache storage used by the subscriber. 76 | */ 77 | public static function attach( 78 | HasEmitterInterface $subject, 79 | array $options = [] 80 | ) { 81 | if (!isset($options['storage'])) { 82 | $options['storage'] = new CacheStorage(new ArrayCache()); 83 | } 84 | 85 | if (!isset($options['can_cache'])) { 86 | $options['can_cache'] = [ 87 | 'GuzzleHttp\Subscriber\Cache\Utils', 88 | 'canCacheRequest', 89 | ]; 90 | } 91 | 92 | $emitter = $subject->getEmitter(); 93 | $cache = new self($options['storage'], $options['can_cache']); 94 | $emitter->attach($cache); 95 | 96 | if (!isset($options['validate']) || $options['validate'] === true) { 97 | $emitter->attach(new ValidationSubscriber( 98 | $options['storage'], 99 | $options['can_cache']) 100 | ); 101 | } 102 | 103 | if (!isset($options['purge']) || $options['purge'] === true) { 104 | $emitter->attach(new PurgeSubscriber($options['storage'])); 105 | } 106 | 107 | return ['subscriber' => $cache, 'storage' => $options['storage']]; 108 | } 109 | 110 | public function getEvents() 111 | { 112 | return [ 113 | 'before' => ['onBefore', RequestEvents::LATE], 114 | 'complete' => ['onComplete', RequestEvents::EARLY], 115 | 'error' => ['onError', RequestEvents::EARLY] 116 | ]; 117 | } 118 | 119 | /** 120 | * Checks if a request can be cached, and if so, intercepts with a cached 121 | * response is available. 122 | * 123 | * @param BeforeEvent $event 124 | */ 125 | public function onBefore(BeforeEvent $event) 126 | { 127 | $request = $event->getRequest(); 128 | 129 | if (!$this->canCacheRequest($request)) { 130 | $this->cacheMiss($request); 131 | return; 132 | } 133 | 134 | if (!($response = $this->storage->fetch($request))) { 135 | $this->cacheMiss($request); 136 | return; 137 | } 138 | 139 | $response->setHeader('Age', Utils::getResponseAge($response)); 140 | $valid = $this->validate($request, $response); 141 | 142 | // Validate that the response satisfies the request 143 | if ($valid) { 144 | $request->getConfig()->set('cache_lookup', 'HIT'); 145 | $request->getConfig()->set('cache_hit', true); 146 | $event->intercept($response); 147 | } else { 148 | $this->cacheMiss($request); 149 | } 150 | } 151 | 152 | /** 153 | * Checks if the request and response can be cached, and if so, store it. 154 | * 155 | * @param CompleteEvent $event 156 | */ 157 | public function onComplete(CompleteEvent $event) 158 | { 159 | $request = $event->getRequest(); 160 | $response = $event->getResponse(); 161 | 162 | // Cache the response if it can be cached and isn't already 163 | if ($request->getConfig()->get('cache_lookup') === 'MISS' 164 | && call_user_func($this->canCache, $request) 165 | && Utils::canCacheResponse($response) 166 | ) { 167 | // Store the date when the response was cached 168 | $response->setHeader('X-Guzzle-Cache-Date', gmdate('D, d M Y H:i:s T', time())); 169 | $this->storage->cache($request, $response); 170 | } 171 | 172 | $this->addResponseHeaders($request, $response); 173 | } 174 | 175 | /** 176 | * If the request failed, then check if a cached response would suffice. 177 | * 178 | * @param ErrorEvent $event 179 | */ 180 | public function onError(ErrorEvent $event) 181 | { 182 | $request = $event->getRequest(); 183 | 184 | if (!call_user_func($this->canCache, $request)) { 185 | return; 186 | } 187 | 188 | $response = $this->storage->fetch($request); 189 | 190 | // Intercept the failed response if possible 191 | if ($response && $this->validateFailed($request, $response)) { 192 | $request->getConfig()->set('cache_hit', 'error'); 193 | $response->setHeader('Age', Utils::getResponseAge($response)); 194 | $event->intercept($response); 195 | } 196 | } 197 | 198 | private function cacheMiss(RequestInterface $request) 199 | { 200 | $request->getConfig()->set('cache_lookup', 'MISS'); 201 | } 202 | 203 | private function validate( 204 | RequestInterface $request, 205 | ResponseInterface $response 206 | ) { 207 | // Validation is handled in another subscriber and can be optionally 208 | // enabled/disabled. 209 | if (Utils::getDirective($response, 'must-revalidate')) { 210 | return true; 211 | } 212 | 213 | return Utils::isResponseValid($request, $response); 214 | } 215 | 216 | private function validateFailed( 217 | RequestInterface $request, 218 | ResponseInterface $response 219 | ) { 220 | $req = Utils::getDirective($request, 'stale-if-error'); 221 | $res = Utils::getDirective($response, 'stale-if-error'); 222 | 223 | if (!$req && !$res) { 224 | return false; 225 | } 226 | 227 | $responseAge = Utils::getResponseAge($response); 228 | $maxAge = Utils::getMaxAge($response); 229 | 230 | if (($req && $responseAge - $maxAge > $req) || 231 | ($responseAge - $maxAge > $res) 232 | ) { 233 | return false; 234 | } 235 | 236 | return true; 237 | } 238 | 239 | private function canCacheRequest(RequestInterface $request) 240 | { 241 | return !$request->getConfig()->get('cache.disable') 242 | && call_user_func($this->canCache, $request); 243 | } 244 | 245 | private function addResponseHeaders( 246 | RequestInterface $request, 247 | ResponseInterface $response 248 | ) { 249 | $params = $request->getConfig(); 250 | $lookup = $params['cache_lookup'] . ' from GuzzleCache'; 251 | $response->addHeader('X-Cache-Lookup', $lookup); 252 | 253 | if ($params['cache_hit'] === true) { 254 | $response->addHeader('X-Cache', 'HIT from GuzzleCache'); 255 | } elseif ($params['cache_hit'] == 'error') { 256 | $response->addHeader('X-Cache', 'HIT_ERROR from GuzzleCache'); 257 | } else { 258 | $response->addHeader('X-Cache', 'MISS from GuzzleCache'); 259 | } 260 | 261 | $freshness = Utils::getFreshness($response); 262 | 263 | // Only add a Warning header if we are returning a stale response. 264 | if ($params['cache_hit'] && $freshness !== null && $freshness <= 0) { 265 | $response->addHeader( 266 | 'Warning', 267 | sprintf( 268 | '%d GuzzleCache/' . ClientInterface::VERSION . ' "%s"', 269 | 110, 270 | 'Response is stale' 271 | ) 272 | ); 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/PurgeSubscriber.php: -------------------------------------------------------------------------------- 1 | true, 20 | 'POST' => true, 21 | 'DELETE' => true, 22 | 'PATCH' => true, 23 | 'PURGE' => true, 24 | ]; 25 | 26 | /** 27 | * @param CacheStorageInterface $storage Storage to modify if purging 28 | */ 29 | public function __construct($storage) 30 | { 31 | $this->storage = $storage; 32 | } 33 | 34 | public function getEvents() 35 | { 36 | return ['before' => ['onBefore', RequestEvents::LATE]]; 37 | } 38 | 39 | public function onBefore(BeforeEvent $event) 40 | { 41 | $request = $event->getRequest(); 42 | 43 | if (isset(self::$purgeMethods[$request->getMethod()])) { 44 | $this->storage->purge($request->getUrl()); 45 | 46 | if ('PURGE' === $request->getMethod()) { 47 | $event->intercept(new Response(204)); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | hasHeader('Age')) { 47 | return (int) $response->getHeader('Age'); 48 | } 49 | 50 | $date = strtotime($response->getHeader('Date') ?: 'now'); 51 | 52 | return time() - $date; 53 | } 54 | 55 | /** 56 | * Gets the number of seconds from the current time in which a response 57 | * is still considered fresh. 58 | * 59 | * @param ResponseInterface $response 60 | * 61 | * @return int|null Returns the number of seconds 62 | */ 63 | public static function getMaxAge(ResponseInterface $response) 64 | { 65 | $smaxage = Utils::getDirective($response, 's-maxage'); 66 | if (is_numeric($smaxage)) { 67 | return (int) $smaxage; 68 | } 69 | 70 | $maxage = Utils::getDirective($response, 'max-age'); 71 | if (is_numeric($maxage)) { 72 | return (int) $maxage; 73 | } 74 | 75 | if ($response->hasHeader('Expires')) { 76 | return strtotime($response->getHeader('Expires')) - time(); 77 | } 78 | 79 | return null; 80 | } 81 | 82 | /** 83 | * Get the freshness of a response by returning the difference of the 84 | * maximum lifetime of the response and the age of the response. 85 | * 86 | * Freshness values less than 0 mean that the response is no longer fresh 87 | * and is ABS(freshness) seconds expired. Freshness values of greater than 88 | * zero is the number of seconds until the response is no longer fresh. 89 | * A NULL result means that no freshness information is available. 90 | * 91 | * @param ResponseInterface $response Response to get freshness of 92 | * 93 | * @return int|null 94 | */ 95 | public static function getFreshness(ResponseInterface $response) 96 | { 97 | $maxAge = self::getMaxAge($response); 98 | $age = self::getResponseAge($response); 99 | 100 | return is_int($maxAge) && is_int($age) ? ($maxAge - $age) : null; 101 | } 102 | 103 | /** 104 | * Default function used to determine if a request can be cached. 105 | * 106 | * @param RequestInterface $request Request to check 107 | * 108 | * @return bool 109 | */ 110 | public static function canCacheRequest(RequestInterface $request) 111 | { 112 | $method = $request->getMethod(); 113 | 114 | // Only GET and HEAD requests can be cached 115 | if ($method !== 'GET' && $method !== 'HEAD') { 116 | return false; 117 | } 118 | 119 | // Don't fool with Range requests for now 120 | if ($request->hasHeader('Range')) { 121 | return false; 122 | } 123 | 124 | return self::getDirective($request, 'no-store') === null; 125 | } 126 | 127 | /** 128 | * Determines if a response can be cached. 129 | * 130 | * @param ResponseInterface $response Response to check 131 | * 132 | * @return bool 133 | */ 134 | public static function canCacheResponse(ResponseInterface $response) 135 | { 136 | static $cacheCodes = [200, 203, 300, 301, 410]; 137 | 138 | // Check if the response is cacheable based on the code 139 | if (!in_array((int) $response->getStatusCode(), $cacheCodes)) { 140 | return false; 141 | } 142 | 143 | // Make sure a valid body was returned and can be cached 144 | $body = $response->getBody(); 145 | if ($body && (!$body->isReadable() || !$body->isSeekable())) { 146 | return false; 147 | } 148 | 149 | // Never cache no-store resources (this is a private cache, so private 150 | // can be cached) 151 | if (self::getDirective($response, 'no-store')) { 152 | return false; 153 | } 154 | 155 | // Don't fool with Content-Range requests for now 156 | if ($response->hasHeader('Content-Range')) { 157 | return false; 158 | } 159 | 160 | $freshness = self::getFreshness($response); 161 | 162 | return $freshness === null // No freshness info. 163 | || $freshness >= 0 // It's fresh 164 | || $response->hasHeader('ETag') // Can validate 165 | || $response->hasHeader('Last-Modified'); // Can validate 166 | } 167 | 168 | public static function isResponseValid( 169 | RequestInterface $request, 170 | ResponseInterface $response 171 | ) { 172 | $responseAge = Utils::getResponseAge($response); 173 | $maxAge = Utils::getDirective($response, 'max-age'); 174 | 175 | // Increment the age based on the X-Guzzle-Cache-Date 176 | if ($cacheDate = $response->getHeader('X-Guzzle-Cache-Date')) { 177 | $responseAge += (time() - strtotime($cacheDate)); 178 | $response->setHeader('Age', $responseAge); 179 | } 180 | 181 | // Check the request's max-age header against the age of the response 182 | if ($maxAge !== null && $responseAge > $maxAge) { 183 | return false; 184 | } 185 | 186 | // Check the response's max-age header against the freshness level 187 | $freshness = Utils::getFreshness($response); 188 | 189 | if ($freshness !== null) { 190 | $maxStale = Utils::getDirective($request, 'max-stale'); 191 | if ($maxStale !== null) { 192 | if ($freshness < (-1 * $maxStale)) { 193 | return false; 194 | } 195 | } elseif ($maxAge !== null && $responseAge > $maxAge) { 196 | return false; 197 | } 198 | } 199 | 200 | return true; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/ValidationSubscriber.php: -------------------------------------------------------------------------------- 1 | true, 410 => true]; 26 | 27 | /** @var array */ 28 | private static $replaceHeaders = [ 29 | 'Date', 30 | 'Expires', 31 | 'Cache-Control', 32 | 'ETag', 33 | 'Last-Modified', 34 | ]; 35 | 36 | /** 37 | * @param CacheStorageInterface $cache Cache storage 38 | * @param callable $canCache Callable used to determine if a 39 | * request can be cached. Accepts a 40 | * RequestInterface and returns a 41 | * boolean value. 42 | */ 43 | public function __construct( 44 | CacheStorageInterface $cache, 45 | callable $canCache 46 | ) { 47 | $this->storage = $cache; 48 | $this->canCache = $canCache; 49 | } 50 | 51 | public function getEvents() 52 | { 53 | return ['complete' => ['onComplete', RequestEvents::EARLY]]; 54 | } 55 | 56 | public function onComplete(CompleteEvent $e) 57 | { 58 | $lookup = $e->getRequest()->getConfig()->get('cache_lookup'); 59 | 60 | if ($lookup == 'HIT' && 61 | $this->shouldvalidate($e->getRequest(), $e->getResponse()) 62 | ) { 63 | $this->validate($e->getRequest(), $e->getResponse(), $e); 64 | } 65 | } 66 | 67 | private function validate( 68 | RequestInterface $request, 69 | ResponseInterface $response, 70 | CompleteEvent $event 71 | ) { 72 | try { 73 | $validate = $this->createRevalidationRequest($request, $response); 74 | $validated = $event->getClient()->send($validate); 75 | } catch (BadResponseException $e) { 76 | $this->handleBadResponse($e); 77 | } 78 | 79 | if ($validated->getStatusCode() == 200) { 80 | $this->handle200Response($request, $validated, $event); 81 | } elseif ($validated->getStatusCode() == 304) { 82 | $this->handle304Response($request, $response, $validated, $event); 83 | } 84 | } 85 | 86 | private function shouldValidate( 87 | RequestInterface $request, 88 | ResponseInterface $response 89 | ) { 90 | if ($request->getMethod() != 'GET' 91 | || $request->getConfig()->get('cache.disable') 92 | ) { 93 | return false; 94 | } 95 | 96 | $validate = Utils::getDirective($request, 'Pragma') === 'no-cache' 97 | || Utils::getDirective($response, 'Pragma') === 'no-cache' 98 | || Utils::getDirective($request, 'must-revalidate') 99 | || Utils::getDirective($response, 'must-revalidate') 100 | || Utils::getDirective($request, 'no-cache') 101 | || Utils::getDirective($response, 'no-cache') 102 | || Utils::getDirective($response, 'max-age') === '0' 103 | || Utils::getDirective($response, 's-maxage') === '0'; 104 | 105 | // Use the strong ETag validator if available and the response contains 106 | // no Cache-Control directive 107 | if (!$validate 108 | && !$response->hasHeader('Cache-Control') 109 | && $response->hasHeader('ETag') 110 | ) { 111 | $validate = true; 112 | } 113 | 114 | return $validate; 115 | } 116 | 117 | /** 118 | * Handles a bad response when attempting to validate. 119 | * 120 | * If the resource no longer exists, then remove from the cache. 121 | * 122 | * @param BadResponseException $e Exception encountered 123 | * 124 | * @throws BadResponseException 125 | */ 126 | private function handleBadResponse(BadResponseException $e) 127 | { 128 | if (isset(self::$gone[$e->getResponse()->getStatusCode()])) { 129 | $this->storage->delete($e->getRequest()); 130 | } 131 | 132 | throw $e; 133 | } 134 | 135 | /** 136 | * Creates a request to use for revalidation. 137 | * 138 | * @param RequestInterface $request Request 139 | * @param ResponseInterface $response Response to validate 140 | * 141 | * @return RequestInterface returns a revalidation request 142 | */ 143 | private function createRevalidationRequest( 144 | RequestInterface $request, 145 | ResponseInterface $response 146 | ) { 147 | $validate = clone $request; 148 | $validate->getConfig()->set('cache.disable', true); 149 | $validate->removeHeader('Pragma'); 150 | $validate->removeHeader('Cache-Control'); 151 | $responseDate = $response->getHeader('Last-Modified') 152 | ?: $response->getHeader('Date'); 153 | $validate->setHeader('If-Modified-Since', $responseDate); 154 | 155 | if ($etag = $response->getHeader('ETag')) { 156 | $validate->setHeader('If-None-Match', $etag); 157 | } 158 | 159 | return $validate; 160 | } 161 | 162 | private function handle200Response( 163 | RequestInterface $request, 164 | ResponseInterface $validateResponse, 165 | CompleteEvent $event 166 | ) { 167 | // Store the 200 response in the cache if possible 168 | if (Utils::canCacheResponse($validateResponse)) { 169 | $this->storage->cache($request, $validateResponse); 170 | } 171 | 172 | $event->intercept($validateResponse); 173 | } 174 | 175 | private function handle304Response( 176 | RequestInterface $request, 177 | ResponseInterface $response, 178 | ResponseInterface $validated, 179 | CompleteEvent $event 180 | ) { 181 | // Make sure that this response has the same ETag 182 | if ($validated->getHeader('ETag') !== $response->getHeader('ETag')) { 183 | // Revalidation failed, so remove from cache and retry. 184 | $this->storage->delete($request); 185 | $event->intercept($event->getClient()->send($request)); 186 | 187 | return; 188 | } 189 | 190 | // Replace cached headers with any of these headers from the 191 | // origin server that might be more up to date 192 | $modified = false; 193 | foreach (self::$replaceHeaders as $name) { 194 | if ($validated->hasHeader($name) 195 | && $validated->getHeader($name) != $response->getHeader($name) 196 | ) { 197 | $modified = true; 198 | $response->setHeader($name, $validated->getHeader($name)); 199 | } 200 | } 201 | 202 | // Store the updated response in cache 203 | if ($modified) { 204 | $this->storage->cache($request, $response); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /tests/CacheStorageTest.php: -------------------------------------------------------------------------------- 1 | 'max-age=10', 23 | ]); 24 | 25 | $getTtl = $this->getMethod('getTtl'); 26 | $cache = new CacheStorage(new ArrayCache()); 27 | $ttl = $getTtl->invokeArgs($cache, [$response]); 28 | $this->assertEquals(10, $ttl); 29 | } 30 | 31 | /** 32 | * Test that the default TTL for cachable responses with no max-age headers 33 | * is zero. 34 | */ 35 | public function testGetTtlDefault() 36 | { 37 | $response = new Response(200); 38 | 39 | $getTtl = $this->getMethod('getTtl'); 40 | $cache = new CacheStorage(new ArrayCache()); 41 | $ttl = $getTtl->invokeArgs($cache, [$response]); 42 | 43 | // assertSame() here to be specific about null / false returns. 44 | $this->assertSame(0, $ttl); 45 | } 46 | 47 | /** 48 | * Test setting the default TTL. 49 | */ 50 | public function testSetTtlDefault() 51 | { 52 | $response = new Response(200); 53 | 54 | $getTtl = $this->getMethod('getTtl'); 55 | $cache = new CacheStorage(new ArrayCache(), null, 10); 56 | $ttl = $getTtl->invokeArgs($cache, [$response]); 57 | $this->assertEquals(10, $ttl); 58 | } 59 | 60 | /** 61 | * Test that stale-if-error is added to the max-age header. 62 | */ 63 | public function testGetTtlMaxAgeStaleIfError() 64 | { 65 | $response = new Response(200, [ 66 | 'Cache-control' => 'max-age=10, stale-if-error=10', 67 | ]); 68 | 69 | $getTtl = $this->getMethod('getTtl'); 70 | $cache = new CacheStorage(new ArrayCache()); 71 | $ttl = $getTtl->invokeArgs($cache, [$response]); 72 | $this->assertEquals(20, $ttl); 73 | } 74 | 75 | /** 76 | * Test that stale-if-error works without a max-age header. 77 | */ 78 | public function testGetTtlStaleIfErrorAlone() 79 | { 80 | $response = new Response(200, [ 81 | 'Cache-control' => 'stale-if-error=10', 82 | ]); 83 | 84 | $getTtl = $this->getMethod('getTtl'); 85 | $cache = new CacheStorage(new ArrayCache()); 86 | $ttl = $getTtl->invokeArgs($cache, [$response]); 87 | $this->assertEquals(10, $ttl); 88 | } 89 | 90 | /** 91 | * Test that expires is considered when cache-control is not available. 92 | */ 93 | public function testGetTtlExpires() 94 | { 95 | $expires = new \DateTime('+100 seconds'); 96 | $response = new Response(200, [ 97 | 'Expires' => $expires->format(DATE_RFC1123), 98 | ]); 99 | 100 | $getTtl = $this->getMethod('getTtl'); 101 | $cache = new CacheStorage(new ArrayCache()); 102 | $ttl = $getTtl->invokeArgs($cache, [$response]); 103 | $this->assertEquals(100, $ttl); 104 | } 105 | 106 | /** 107 | * Test that cache-control is considered before expires. 108 | */ 109 | public function testGetTtlCacheControlExpires() 110 | { 111 | $expires = new \DateTime('+100 seconds'); 112 | $response = new Response(200, [ 113 | 'Expires' => $expires->format(DATE_RFC1123), 114 | 'Cache-control' => 'max-age=10', 115 | ]); 116 | 117 | $getTtl = $this->getMethod('getTtl'); 118 | $cache = new CacheStorage(new ArrayCache()); 119 | $ttl = $getTtl->invokeArgs($cache, [$response]); 120 | $this->assertEquals(10, $ttl); 121 | } 122 | 123 | /** 124 | * Return a protected or private method. 125 | * 126 | * @param string $name The name of the method. 127 | * 128 | * @return \ReflectionMethod A method object. 129 | */ 130 | protected static function getMethod($name) 131 | { 132 | $class = new \ReflectionClass('GuzzleHttp\Subscriber\Cache\CacheStorage'); 133 | $method = $class->getMethod($name); 134 | $method->setAccessible(true); 135 | 136 | return $method; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /tests/CacheSubscriberTest.php: -------------------------------------------------------------------------------- 1 | assertArrayHasKey('subscriber', $cache); 14 | $this->assertArrayHasKey('storage', $cache); 15 | $this->assertInstanceOf( 16 | 'GuzzleHttp\Subscriber\Cache\CacheStorage', 17 | $cache['storage'] 18 | ); 19 | $this->assertInstanceOf( 20 | 'GuzzleHttp\Subscriber\Cache\CacheSubscriber', 21 | $cache['subscriber'] 22 | ); 23 | $this->assertTrue($client->getEmitter()->hasListeners('error')); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/IntegrationTest.php: -------------------------------------------------------------------------------- 1 | 'Accept-Encoding,Cookie,X-Use-HHVM', 34 | 'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT', 35 | 'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate', 36 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 37 | 'Age' => '1277' 38 | ]), 39 | new Response(304, [ 40 | 'Content-Type' => 'text/html; charset=UTF-8', 41 | 'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM', 42 | 'Date' => 'Wed, 29 Oct 2014 20:52:16 GMT', 43 | 'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate', 44 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 45 | 'Age' => '1278' 46 | ]), 47 | new Response(200, [ 48 | 'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM', 49 | 'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT', 50 | 'Cache-Control' => 'private, s-maxage=0, max-age=0', 51 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 52 | 'Age' => '1277' 53 | ]), 54 | new Response(200, [ 55 | 'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM', 56 | 'Date' => 'Wed, 29 Oct 2014 20:53:15 GMT', 57 | 'Cache-Control' => 'private, s-maxage=0, max-age=0', 58 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:53:00 GMT', 59 | 'Age' => '1277' 60 | ]), 61 | ]); 62 | 63 | $history = new History(); 64 | $client = $this->setupClient($history); 65 | 66 | $response1 = $client->get('/foo'); 67 | $this->assertEquals(200, $response1->getStatusCode()); 68 | $response2 = $client->get('/foo'); 69 | $this->assertEquals(200, $response2->getStatusCode()); 70 | $last = $history->getLastResponse(); 71 | $this->assertEquals('HIT from GuzzleCache', $last->getHeader('X-Cache-Lookup')); 72 | $this->assertEquals('HIT from GuzzleCache', $last->getHeader('X-Cache')); 73 | 74 | // Validate that expired requests without must-revalidate expire. 75 | $response3 = $client->get('/foo'); 76 | $this->assertEquals(200, $response3->getStatusCode()); 77 | $response4 = $client->get('/foo'); 78 | $this->assertEquals(200, $response4->getStatusCode()); 79 | $last = $history->getLastResponse(); 80 | $this->assertEquals('MISS from GuzzleCache', $last->getHeader('X-Cache-Lookup')); 81 | $this->assertEquals('MISS from GuzzleCache', $last->getHeader('X-Cache')); 82 | 83 | // Validate that all of our requests were received. 84 | $this->assertCount(4, Server::received()); 85 | } 86 | 87 | /** 88 | * Test that Warning headers aren't added to cache misses. 89 | */ 90 | public function testCacheMissNoWarning() 91 | { 92 | Server::enqueue([ 93 | new Response(200, [ 94 | 'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM', 95 | 'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT', 96 | 'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate', 97 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 98 | 'Age' => '1277', 99 | ]), 100 | ]); 101 | 102 | $client = $this->setupClient(); 103 | $response = $client->get('/foo'); 104 | $this->assertFalse($response->hasHeader('warning')); 105 | } 106 | 107 | /** 108 | * Test that the Vary header creates unique cache entries. 109 | * 110 | * @throws \Exception 111 | */ 112 | public function testVaryUniqueResponses() 113 | { 114 | $now = $this->date(); 115 | 116 | Server::enqueue( 117 | [ 118 | new Response( 119 | 200, [ 120 | 'Vary' => 'Accept', 121 | 'Content-type' => 'text/html', 122 | 'Date' => $now, 123 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 124 | 'Last-Modified' => $now, 125 | ], Stream::factory('It works!') 126 | ), 127 | new Response( 128 | 200, [ 129 | 'Vary' => 'Accept', 130 | 'Content-type' => 'application/json', 131 | 'Date' => $now, 132 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 133 | 'Last-Modified' => $now, 134 | ], Stream::factory(json_encode(['body' => 'It works!'])) 135 | ), 136 | ] 137 | ); 138 | 139 | $client = $this->setupClient(); 140 | 141 | $response1 = $client->get( 142 | '/foo', 143 | ['headers' => ['Accept' => 'text/html']] 144 | ); 145 | $this->assertEquals('It works!', $this->getResponseBody($response1)); 146 | 147 | $response2 = $client->get( 148 | '/foo', 149 | ['headers' => ['Accept' => 'application/json']] 150 | ); 151 | $this->assertEquals( 152 | 'MISS from GuzzleCache', 153 | $response2->getHeader('x-cache') 154 | ); 155 | 156 | $decoded = json_decode($this->getResponseBody($response2)); 157 | 158 | if (!isset($decoded) || !isset($decoded->body)) { 159 | $this->fail('JSON response could not be decoded.'); 160 | } else { 161 | $this->assertEquals('It works!', $decoded->body); 162 | } 163 | } 164 | 165 | public function testCachesResponsesForWithoutVaryHeader() 166 | { 167 | $now = $this->date(); 168 | 169 | Server::enqueue( 170 | [ 171 | new Response( 172 | 200, [ 173 | 'Content-type' => 'text/html', 174 | 'Date' => $now, 175 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000, must-revalidate', 176 | 'Last-Modified' => $now, 177 | ], Stream::factory() 178 | ), 179 | new Response( 180 | 200, [ 181 | 'Content-type' => 'text/html', 182 | 'Date' => $now, 183 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000, must-revalidate', 184 | 'Last-Modified' => $now, 185 | ], Stream::factory() 186 | ), 187 | ] 188 | ); 189 | 190 | $client = $this->setupClient(); 191 | 192 | $response1 = $client->get('/foo'); 193 | $this->assertEquals(200, $response1->getStatusCode()); 194 | $response2 = $client->get('/foo'); 195 | $this->assertEquals(200, $response2->getStatusCode()); 196 | $this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache')); 197 | } 198 | 199 | /** 200 | * Test that requests varying on both Accept and User-Agent properly split 201 | * different User-Agents into different cache items. 202 | */ 203 | public function testVaryUserAgent() 204 | { 205 | $this->setupMultipleVaryResponses(); 206 | $client = $this->setupClient(); 207 | 208 | $response1 = $client->get( 209 | '/foo', 210 | [ 211 | 'headers' => [ 212 | 'Accept' => 'text/html', 213 | 'User-Agent' => 'Testing/1.0', 214 | ] 215 | ] 216 | ); 217 | $this->assertEquals( 218 | 'Test/1.0 request.', 219 | $this->getResponseBody($response1) 220 | ); 221 | 222 | $response2 = $client->get( 223 | '/foo', 224 | [ 225 | 'headers' => [ 226 | 'Accept' => 'text/html', 227 | 'User-Agent' => 'Testing/2.0', 228 | ] 229 | ] 230 | ); 231 | $this->assertEquals( 232 | 'MISS from GuzzleCache', 233 | $response2->getHeader('x-cache') 234 | ); 235 | $this->assertEquals( 236 | 'Test/2.0 request.', 237 | $this->getResponseBody($response2) 238 | ); 239 | 240 | // Test that we get cache hits where both Vary headers match. 241 | $response5 = $client->get( 242 | '/foo', 243 | [ 244 | 'headers' => [ 245 | 'Accept' => 'text/html', 246 | 'User-Agent' => 'Testing/2.0', 247 | ] 248 | ] 249 | ); 250 | $this->assertEquals( 251 | 'HIT from GuzzleCache', 252 | $response5->getHeader('x-cache') 253 | ); 254 | $this->assertEquals( 255 | 'Test/2.0 request.', 256 | $this->getResponseBody($response5) 257 | ); 258 | } 259 | 260 | /** 261 | * Test that requests varying on Accept but not User-Agent return different responses. 262 | */ 263 | public function testVaryAccept() 264 | { 265 | $this->setupMultipleVaryResponses(); 266 | $client = $this->setupClient(); 267 | 268 | // Prime the cache. 269 | $client->get( 270 | '/foo', 271 | [ 272 | 'headers' => [ 273 | 'Accept' => 'text/html', 274 | 'User-Agent' => 'Testing/1.0', 275 | ] 276 | ] 277 | ); 278 | $client->get( 279 | '/foo', 280 | [ 281 | 'headers' => [ 282 | 'Accept' => 'text/html', 283 | 'User-Agent' => 'Testing/2.0', 284 | ] 285 | ] 286 | ); 287 | 288 | $response1 = $client->get( 289 | '/foo', 290 | [ 291 | 'headers' => [ 292 | 'Accept' => 'application/json', 293 | 'User-Agent' => 'Testing/1.0', 294 | ] 295 | ] 296 | ); 297 | $this->assertEquals( 298 | 'MISS from GuzzleCache', 299 | $response1->getHeader('x-cache') 300 | ); 301 | $this->assertEquals( 302 | 'Test/1.0 request.', 303 | json_decode($this->getResponseBody($response1))->body 304 | ); 305 | 306 | $response2 = $client->get( 307 | '/foo', 308 | [ 309 | 'headers' => [ 310 | 'Accept' => 'application/json', 311 | 'User-Agent' => 'Testing/2.0', 312 | ] 313 | ] 314 | ); 315 | $this->assertEquals( 316 | 'MISS from GuzzleCache', 317 | $response2->getHeader('x-cache') 318 | ); 319 | $this->assertEquals( 320 | 'Test/2.0 request.', 321 | json_decode($this->getResponseBody($response2))->body 322 | ); 323 | } 324 | 325 | /** 326 | * Test that we return cached responses when multiple Vary headers match. 327 | */ 328 | public function testMultipleVaryMatch() 329 | { 330 | $this->setupMultipleVaryResponses(); 331 | $client = $this->setupClient(); 332 | 333 | // Prime the cache. 334 | $client->get('/foo', 335 | [ 336 | 'headers' => [ 337 | 'Accept' => 'text/html', 338 | 'User-Agent' => 'Testing/1.0', 339 | ] 340 | ] 341 | ); 342 | $client->get('/foo', 343 | [ 344 | 'headers' => [ 345 | 'Accept' => 'text/html', 346 | 'User-Agent' => 'Testing/2.0', 347 | ] 348 | ] 349 | ); 350 | $client->get('/foo', 351 | [ 352 | 'headers' => [ 353 | 'Accept' => 'application/json', 354 | 'User-Agent' => 'Testing/1.0', 355 | ] 356 | ] 357 | ); 358 | $client->get('/foo', 359 | [ 360 | 'headers' => [ 361 | 'Accept' => 'application/json', 362 | 'User-Agent' => 'Testing/2.0', 363 | ] 364 | ] 365 | ); 366 | 367 | $response = $client->get( 368 | '/foo', 369 | [ 370 | 'headers' => [ 371 | 'Accept' => 'application/json', 372 | 'User-Agent' => 'Testing/2.0', 373 | ] 374 | ] 375 | ); 376 | $this->assertEquals( 377 | 'HIT from GuzzleCache', 378 | $response->getHeader('x-cache') 379 | ); 380 | $this->assertEquals( 381 | 'Test/2.0 request.', 382 | json_decode($this->getResponseBody($response))->body 383 | ); 384 | } 385 | 386 | /** 387 | * Test that stale responses are used on errors if allowed. 388 | */ 389 | public function testOnErrorStaleResponse() 390 | { 391 | $now = $this->date(); 392 | 393 | Server::enqueue([ 394 | new Response(200, [ 395 | 'Date' => $now, 396 | 'Cache-Control' => 'private, max-age=0, must-revalidate, stale-if-error=666', 397 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 398 | ], Stream::factory('It works!')), 399 | new Response(503, [ 400 | 'Date' => $now, 401 | 'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate', 402 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 403 | 'Age' => '1277' 404 | ]), 405 | ]); 406 | 407 | $client = $this->setupClient(); 408 | 409 | // Prime the cache. 410 | $response1 = $client->get('/foo'); 411 | $this->assertEquals(200, $response1->getStatusCode()); 412 | 413 | // This should return the first request. 414 | $response2 = $client->get('/foo'); 415 | $this->assertEquals(200, $response2->getStatusCode()); 416 | $this->assertEquals('It works!', $this->getResponseBody($response2)); 417 | $this->assertEquals('HIT_ERROR from GuzzleCache', $response2->getHeader('x-cache')); 418 | $this->assertCount(2, Server::received()); 419 | } 420 | 421 | /** 422 | * Test that expired stale responses aren't returned. 423 | */ 424 | public function testOnErrorStaleResponseExpired() 425 | { 426 | // These dates are in the past, so the responses will be expired. 427 | Server::enqueue([ 428 | new Response(200, [ 429 | 'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT', 430 | 'Cache-Control' => 'private, max-age=0, must-revalidate, stale-if-error=10', 431 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 432 | ]), 433 | new Response(503, [ 434 | 'Date' => 'Wed, 29 Oct 2014 20:55:15 GMT', 435 | 'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate', 436 | 'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT', 437 | ]), 438 | ]); 439 | 440 | $client = $this->setupClient(); 441 | 442 | // Prime the cache. 443 | $response1 = $client->get('/foo'); 444 | $this->assertEquals(200, $response1->getStatusCode()); 445 | $this->assertEquals('Wed, 29 Oct 2014 20:52:15 GMT', $response1->getHeader('Date')); 446 | 447 | try { 448 | $client->get('/foo'); 449 | $this->fail('503 was not thrown with an expired cache entry.'); 450 | } catch (ServerException $e) { 451 | $this->assertEquals(503, $e->getCode()); 452 | $this->assertEquals('Wed, 29 Oct 2014 20:55:15 GMT', $e->getResponse()->getHeader('Date')); 453 | $this->assertCount(2, Server::received()); 454 | } 455 | } 456 | 457 | /** 458 | * Test that the can_cache option can modify cache behaviour. 459 | */ 460 | public function testCanCache() 461 | { 462 | $now = $this->date(); 463 | 464 | // Return an uncacheable response, that is then cached by can_cache 465 | // returning TRUE. 466 | Server::enqueue( 467 | [ 468 | new Response( 469 | 200, [ 470 | 'Date' => $now, 471 | 'Cache-Control' => 'private, max-age=0, no-cache', 472 | 'Last-Modified' => $now, 473 | ], Stream::factory('It works!')), 474 | new Response( 475 | 304, [ 476 | 'Date' => $now, 477 | 'Cache-Control' => 'private, max-age=0, no-cache', 478 | 'Last-Modified' => $now, 479 | 'Age' => 0, 480 | ]), 481 | ] 482 | ); 483 | 484 | $client = new Client(['base_url' => Server::$url]); 485 | CacheSubscriber::attach( 486 | $client, 487 | [ 488 | 'can_cache' => function (RequestInterface $request) { 489 | return true; 490 | } 491 | ] 492 | ); 493 | 494 | $response1 = $client->get('/foo'); 495 | $this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup')); 496 | $response2 = $client->get('/foo'); 497 | $this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup')); 498 | $this->assertEquals('It works!', $this->getResponseBody($response2)); 499 | } 500 | 501 | /** 502 | * Test that PURGE can delete cached responses. 503 | */ 504 | public function testCanPurge() 505 | { 506 | $now = $this->date(); 507 | 508 | // Return a cached response that is then purged, and requested again 509 | Server::enqueue( 510 | [ 511 | new Response( 512 | 200, [ 513 | 'Date' => $now, 514 | 'Cache-Control' => 'public, max-age=60', 515 | 'Last-Modified' => $now, 516 | ], Stream::factory('It is foo!')), 517 | new Response( 518 | 200, [ 519 | 'Date' => $now, 520 | 'Cache-Control' => 'public, max-age=60', 521 | 'Last-Modified' => $now, 522 | ], Stream::factory('It is bar!')), 523 | ] 524 | ); 525 | 526 | $client = $this->setupClient(); 527 | 528 | $response1 = $client->get('/foo'); 529 | $this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup')); 530 | $this->assertEquals('It is foo!', $this->getResponseBody($response1)); 531 | 532 | $response2 = $client->get('/foo'); 533 | $this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup')); 534 | $this->assertEquals('It is foo!', $this->getResponseBody($response2)); 535 | 536 | $response3 = $client->send($client->createRequest('PURGE', '/foo')); 537 | $this->assertEquals(204, $response3->getStatusCode()); 538 | 539 | $response4 = $client->get('/foo'); 540 | $this->assertEquals('MISS from GuzzleCache', $response4->getHeader('X-Cache-Lookup')); 541 | $this->assertEquals('It is bar!', $this->getResponseBody($response4)); 542 | } 543 | 544 | /** 545 | * Test that cache entries are deleted when a response 404s. 546 | */ 547 | public function test404CacheDelete() 548 | { 549 | $this->fourXXCacheDelete(404); 550 | } 551 | 552 | /** 553 | * Test that cache entries are deleted when a response 410s. 554 | */ 555 | public function test410CacheDelete() 556 | { 557 | $this->fourXXCacheDelete(410); 558 | } 559 | 560 | /** 561 | * Test the resident_time calculation (RFC7234 4.2.3) 562 | */ 563 | public function testAgeIsIncremented() 564 | { 565 | Server::enqueue([ 566 | new Response(200, [ 567 | 'Date' => $this->date(), 568 | 'Cache-Control' => 'public, max-age=60', 569 | 'Age' => '59' 570 | ], Stream::factory('Age is 59!')), 571 | new Response(200, [ 572 | 'Date' => $this->date(), 573 | 'Cache-Control' => 'public, max-age=60', 574 | 'Age' => '0' 575 | ], Stream::factory('It works!')), 576 | ]); 577 | 578 | $client = $this->setupClient(); 579 | 580 | // First request : the response is cached 581 | $response1 = $client->get('/foo'); 582 | $this->assertEquals(200, $response1->getStatusCode()); 583 | $this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup')); 584 | $this->assertEquals('Age is 59!', $this->getResponseBody($response1)); 585 | 586 | // Second request : cache hit, age is now 60 587 | sleep(1); 588 | $response2 = $client->get('/foo'); 589 | $this->assertEquals(200, $response1->getStatusCode()); 590 | $this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup')); 591 | 592 | // This request should not be valid anymore : age is 59 + 2 = 61 which is strictly greater than 60 593 | sleep(1); 594 | $response3 = $client->get('/foo'); 595 | $this->assertEquals(200, $response3->getStatusCode()); 596 | $this->assertEquals('MISS from GuzzleCache', $response3->getHeader('X-Cache-Lookup')); 597 | $this->assertEquals('It works!', $this->getResponseBody($response3)); 598 | 599 | $this->assertCount(2, Server::received()); 600 | } 601 | 602 | /** 603 | * Decode a response body from TestServer. 604 | * 605 | * TestServer encodes all responses with base64, so we need to decode them 606 | * before we can do any assert's on them. 607 | * 608 | * @param Response $response The response with a body to decode. 609 | * 610 | * @return string 611 | */ 612 | private function getResponseBody($response) 613 | { 614 | return base64_decode($response->getBody()); 615 | } 616 | 617 | /** 618 | * Set up responses used by our Vary tests. 619 | * 620 | * @throws \Exception 621 | */ 622 | private function setupMultipleVaryResponses() 623 | { 624 | $now = $this->date(); 625 | 626 | Server::enqueue( 627 | [ 628 | new Response( 629 | 200, [ 630 | 'Vary' => 'Accept, User-Agent', 631 | 'Content-type' => 'text/html', 632 | 'Date' => $now, 633 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 634 | 'Last-Modified' => $now, 635 | ], Stream::factory('Test/1.0 request.') 636 | ), 637 | new Response( 638 | 200, 639 | [ 640 | 'Vary' => 'Accept, User-Agent', 641 | 'Content-type' => 'text/html', 642 | 'Date' => $now, 643 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 644 | 'Last-Modified' => $now, 645 | ], 646 | Stream::factory('Test/2.0 request.') 647 | ), 648 | new Response( 649 | 200, [ 650 | 'Vary' => 'Accept, User-Agent', 651 | 'Content-type' => 'application/json', 652 | 'Date' => $now, 653 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 654 | 'Last-Modified' => $now, 655 | ], Stream::factory(json_encode(['body' => 'Test/1.0 request.'])) 656 | ), 657 | new Response( 658 | 200, [ 659 | 'Vary' => 'Accept, User-Agent', 660 | 'Content-type' => 'application/json', 661 | 'Date' => $now, 662 | 'Cache-Control' => 'public, s-maxage=1000, max-age=1000', 663 | 'Last-Modified' => $now, 664 | ], Stream::factory(json_encode(['body' => 'Test/2.0 request.'])) 665 | ), 666 | ] 667 | ); 668 | } 669 | 670 | /** 671 | * Setup a Guzzle client for testing. 672 | * 673 | * @param History $history (optional) parameter of a History to track 674 | * requests in. 675 | * 676 | * @return Client A client ready to run test requests against. 677 | */ 678 | private function setupClient(History $history = null) 679 | { 680 | $client = new Client(['base_url' => Server::$url]); 681 | CacheSubscriber::attach($client); 682 | if ($history) { 683 | $client->getEmitter()->attach($history); 684 | } 685 | 686 | return $client; 687 | } 688 | 689 | /** 690 | * Return a date string suitable for using in an HTTP header. 691 | * 692 | * @param int $timestamp (optional) A Unix timestamp to generate the date. 693 | * 694 | * @return string The generated date string. 695 | */ 696 | private function date($timestamp = null) 697 | { 698 | if (!$timestamp) { 699 | $timestamp = time(); 700 | } 701 | 702 | return gmdate("D, d M Y H:i:s", $timestamp) . ' GMT'; 703 | } 704 | 705 | /** 706 | * Helper to test that a 400 response deletes cache entries. 707 | * 708 | * @param int $errorCode The error code to test, such as 404 or 410. 709 | * 710 | * @throws \Exception 711 | */ 712 | private function fourXXCacheDelete($errorCode) 713 | { 714 | $now = $this->date(); 715 | 716 | Server::enqueue( 717 | [ 718 | new Response( 719 | 200, [ 720 | 'Date' => $now, 721 | 'Cache-Control' => 'public, max-age=1000, must-revalidate', 722 | 'Last-Modified' => $now, 723 | ] 724 | ), 725 | new Response( 726 | 304, [ 727 | 'Date' => $now, 728 | 'Cache-Control' => 'public, max-age=1000, must-revalidate', 729 | 'Last-Modified' => $now, 730 | 'Age' => 0, 731 | ] 732 | ), 733 | new Response( 734 | $errorCode, [ 735 | 'Date' => $now, 736 | 'Cache-Control' => 'public, max-age=1000, must-revalidate', 737 | 'Last-Modified' => $now, 738 | ] 739 | ), 740 | new Response( 741 | 200, [ 742 | 'Date' => $now, 743 | 'Cache-Control' => 'public, max-age=1000, must-revalidate', 744 | 'Last-Modified' => $now, 745 | ] 746 | ), 747 | ] 748 | ); 749 | 750 | $client = $this->setupClient(); 751 | $response1 = $client->get('/foo'); 752 | $this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup')); 753 | $response2 = $client->get('/foo'); 754 | $this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup')); 755 | 756 | try { 757 | $client->get('/foo'); 758 | $this->fail($errorCode . ' was not thrown.'); 759 | } catch (RequestException $e) { 760 | $response3 = $e->getResponse(); 761 | $this->assertEquals($errorCode, $response3->getStatusCode()); 762 | $this->assertEquals('MISS from GuzzleCache', $response3->getHeader('X-Cache-Lookup')); 763 | } 764 | 765 | $response4 = $client->get('/foo'); 766 | $this->assertEquals('MISS from GuzzleCache', $response4->getHeader('X-Cache-Lookup')); 767 | } 768 | } 769 | -------------------------------------------------------------------------------- /tests/UtilsTest.php: -------------------------------------------------------------------------------- 1 | createResponse(200, ['Cache-Control' => 's-maxage=0']); 22 | $this->assertSame(0, Utils::getMaxAge($response)); 23 | 24 | $response = $messageFactory->createResponse(200, ['Cache-Control' => 'max-age=0']); 25 | $this->assertSame(0, Utils::getMaxAge($response)); 26 | 27 | $response = $messageFactory->createResponse(200, ['Expires' => gmdate('D, d M Y H:i:s') . ' GMT']); 28 | $this->assertLessThanOrEqual(0, Utils::getMaxAge($response)); 29 | } 30 | 31 | /** 32 | * Test that a response with no max-age information returns null. 33 | */ 34 | public function testGetMaxAgeNull() 35 | { 36 | $messageFactory = new MessageFactory(); 37 | $response = $messageFactory->createResponse(200); 38 | $this->assertSame(null, Utils::getMaxAge($response)); 39 | } 40 | 41 | /** 42 | * Test that a response that is zero fresh returns zero and not null. 43 | */ 44 | public function testGetFreshnessZero() 45 | { 46 | $messageFactory = new MessageFactory(); 47 | $response = $messageFactory->createResponse(200, 48 | [ 49 | 'Cache-Control' => 'max-age=0', 50 | 'Age' => 0, 51 | ]); 52 | 53 | $this->assertSame(0, Utils::getFreshness($response)); 54 | } 55 | 56 | /** 57 | * Test that responses that can't have freshness determined return null. 58 | */ 59 | public function testGetFreshnessNull() 60 | { 61 | $messageFactory = new MessageFactory(); 62 | $response = $messageFactory->createResponse(200); 63 | $this->assertSame(null, Utils::getFreshness($response)); 64 | } 65 | } 66 | --------------------------------------------------------------------------------