├── LICENSE ├── README.md ├── bmc_qr.png ├── composer.json └── src ├── BodyStore.php ├── CacheEntry.php ├── CacheMiddleware.php ├── KeyValueHttpHeader.php ├── Storage ├── CacheStorageInterface.php ├── CompressedDoctrineCacheStorage.php ├── DoctrineCacheStorage.php ├── FlysystemStorage.php ├── LaravelCacheStorage.php ├── Psr16CacheStorage.php ├── Psr6CacheStorage.php ├── VolatileRuntimeStorage.php └── WordPressObjectCacheStorage.php └── Strategy ├── CacheStrategyInterface.php ├── Delegate ├── DelegatingCacheStrategy.php └── RequestMatcherInterface.php ├── GreedyCacheStrategy.php ├── NullCacheStrategy.php ├── PrivateCacheStrategy.php └── PublicCacheStrategy.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Kevin Robatel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # guzzle-cache-middleware 2 | 3 | [![Latest Stable Version](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/v/stable)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) [![Total Downloads](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/downloads)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) [![License](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/license)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) 4 | ![Tests](https://github.com/Kevinrob/guzzle-cache-middleware/workflows/Tests/badge.svg) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/?branch=master) 5 | 6 | 7 | A HTTP Cache for [Guzzle](https://github.com/guzzle/guzzle) 6+. It's a simple Middleware to be added in the HandlerStack. 8 | 9 | ## Goals 10 | - RFC 7234 compliance 11 | - Performance and transparency 12 | - Assured compatibility with PSR-7 13 | 14 | ## Built-in storage interfaces 15 | - [Doctrine cache](https://github.com/doctrine/cache) 16 | - [Laravel cache](https://laravel.com/docs/5.2/cache) 17 | - [Flysystem](https://github.com/thephpleague/flysystem) 18 | - [PSR6](https://github.com/php-fig/cache) 19 | - [WordPress Object Cache](https://codex.wordpress.org/Class_Reference/WP_Object_Cache) 20 | 21 | ## Installation 22 | 23 | `composer require kevinrob/guzzle-cache-middleware` 24 | 25 | or add it the your `composer.json` and run `composer update kevinrob/guzzle-cache-middleware`. 26 | 27 | # Why? 28 | Performance. It's very common to do some HTTP calls to an API for rendering a page and it takes times to do it. 29 | 30 | # How? 31 | With a simple Middleware added at the top of the `HandlerStack` of Guzzle. 32 | 33 | ```php 34 | use GuzzleHttp\Client; 35 | use GuzzleHttp\HandlerStack; 36 | use Kevinrob\GuzzleCache\CacheMiddleware; 37 | 38 | // Create default HandlerStack 39 | $stack = HandlerStack::create(); 40 | 41 | // Add this middleware to the top with `push` 42 | $stack->push(new CacheMiddleware(), 'cache'); 43 | 44 | // Initialize the client with the handler option 45 | $client = new Client(['handler' => $stack]); 46 | ``` 47 | 48 | # Examples 49 | 50 | ## Doctrine/Cache 51 | You can use a cache from `Doctrine/Cache`: 52 | ```php 53 | [...] 54 | use Doctrine\Common\Cache\FilesystemCache; 55 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 56 | use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; 57 | 58 | [...] 59 | $stack->push( 60 | new CacheMiddleware( 61 | new PrivateCacheStrategy( 62 | new DoctrineCacheStorage( 63 | new FilesystemCache('/tmp/') 64 | ) 65 | ) 66 | ), 67 | 'cache' 68 | ); 69 | ``` 70 | 71 | You can use `ChainCache` for using multiple `CacheProvider` instances. With that provider, you have to sort the different caches from the faster to the slower. Like that, you can have a very fast cache. 72 | ```php 73 | [...] 74 | use Doctrine\Common\Cache\ChainCache; 75 | use Doctrine\Common\Cache\ArrayCache; 76 | use Doctrine\Common\Cache\FilesystemCache; 77 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 78 | use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; 79 | 80 | [...] 81 | $stack->push(new CacheMiddleware( 82 | new PrivateCacheStrategy( 83 | new DoctrineCacheStorage( 84 | new ChainCache([ 85 | new ArrayCache(), 86 | new FilesystemCache('/tmp/'), 87 | ]) 88 | ) 89 | ) 90 | ), 'cache'); 91 | ``` 92 | 93 | ## Laravel cache 94 | You can use a cache with Laravel, e.g. Redis, Memcache etc.: 95 | ```php 96 | [...] 97 | use Illuminate\Support\Facades\Cache; 98 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 99 | use Kevinrob\GuzzleCache\Storage\LaravelCacheStorage; 100 | 101 | [...] 102 | 103 | $stack->push( 104 | new CacheMiddleware( 105 | new PrivateCacheStrategy( 106 | new LaravelCacheStorage( 107 | Cache::store('redis') 108 | ) 109 | ) 110 | ), 111 | 'cache' 112 | ); 113 | ``` 114 | 115 | ## Flysystem 116 | ```php 117 | [...] 118 | use League\Flysystem\Local\LocalFilesystemAdapter; 119 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 120 | use Kevinrob\GuzzleCache\Storage\FlysystemStorage; 121 | 122 | [...] 123 | 124 | $stack->push( 125 | new CacheMiddleware( 126 | new PrivateCacheStrategy( 127 | new FlysystemStorage( 128 | new LocalFilesystemAdapter('/path/to/cache') 129 | ) 130 | ) 131 | ), 132 | 'cache' 133 | ); 134 | ``` 135 | 136 | ## WordPress Object Cache 137 | ```php 138 | [...] 139 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 140 | use Kevinrob\GuzzleCache\Storage\WordPressObjectCacheStorage; 141 | 142 | [...] 143 | 144 | $stack->push( 145 | new CacheMiddleware( 146 | new PrivateCacheStrategy( 147 | new WordPressObjectCacheStorage() 148 | ) 149 | ), 150 | 'cache' 151 | ); 152 | ``` 153 | 154 | ## Public and shared 155 | It's possible to add a public shared cache to the stack: 156 | ```php 157 | [...] 158 | use Doctrine\Common\Cache\FilesystemCache; 159 | use Doctrine\Common\Cache\PredisCache; 160 | use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 161 | use Kevinrob\GuzzleCache\Strategy\PublicCacheStrategy; 162 | use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; 163 | 164 | [...] 165 | // Private caching 166 | $stack->push( 167 | new CacheMiddleware( 168 | new PrivateCacheStrategy( 169 | new DoctrineCacheStorage( 170 | new FilesystemCache('/tmp/') 171 | ) 172 | ) 173 | ), 174 | 'private-cache' 175 | ); 176 | 177 | // Public caching 178 | $stack->push( 179 | new CacheMiddleware( 180 | new PublicCacheStrategy( 181 | new DoctrineCacheStorage( 182 | new PredisCache( 183 | new Predis\Client('tcp://10.0.0.1:6379') 184 | ) 185 | ) 186 | ) 187 | ), 188 | 'shared-cache' 189 | ); 190 | ``` 191 | 192 | ## Greedy caching 193 | In some cases servers might send insufficient or no caching headers at all. 194 | Using the greedy caching strategy allows defining an expiry TTL on your own while 195 | disregarding any possibly present caching headers: 196 | ```php 197 | [...] 198 | use Kevinrob\GuzzleCache\KeyValueHttpHeader; 199 | use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy; 200 | use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; 201 | use Doctrine\Common\Cache\FilesystemCache; 202 | 203 | [...] 204 | // Greedy caching 205 | $stack->push( 206 | new CacheMiddleware( 207 | new GreedyCacheStrategy( 208 | new DoctrineCacheStorage( 209 | new FilesystemCache('/tmp/') 210 | ), 211 | 1800, // the TTL in seconds 212 | new KeyValueHttpHeader(['Authorization']) // Optional - specify the headers that can change the cache key 213 | ) 214 | ), 215 | 'greedy-cache' 216 | ); 217 | ``` 218 | 219 | ## Delegate caching 220 | Because your client may call different apps, on different domains, you may need to define which strategy is suitable to your requests. 221 | 222 | To solve this, all you have to do is to define a default cache strategy, and override it by implementing your own Request Matchers. 223 | 224 | Here's an example: 225 | ```php 226 | namespace App\RequestMatcher; 227 | 228 | use Kevinrob\GuzzleCache\Strategy\Delegate\RequestMatcherInterface; 229 | use Psr\Http\Message\RequestInterface; 230 | 231 | class ExampleOrgRequestMatcher implements RequestMatcherInterface 232 | { 233 | 234 | /** 235 | * @inheritDoc 236 | */ 237 | public function matches(RequestInterface $request) 238 | { 239 | return false !== strpos($request->getUri()->getHost(), 'example.org'); 240 | } 241 | } 242 | ``` 243 | 244 | ```php 245 | namespace App\RequestMatcher; 246 | 247 | use Kevinrob\GuzzleCache\Strategy\Delegate\RequestMatcherInterface; 248 | use Psr\Http\Message\RequestInterface; 249 | 250 | class TwitterRequestMatcher implements RequestMatcherInterface 251 | { 252 | 253 | /** 254 | * @inheritDoc 255 | */ 256 | public function matches(RequestInterface $request) 257 | { 258 | return false !== strpos($request->getUri()->getHost(), 'twitter.com'); 259 | } 260 | } 261 | ``` 262 | 263 | ```php 264 | require_once __DIR__ . '/vendor/autoload.php'; 265 | 266 | use App\RequestMatcher\ExampleOrgRequestMatcher; 267 | use App\RequestMatcher\TwitterRequestMatcher; 268 | use GuzzleHttp\Client; 269 | use GuzzleHttp\HandlerStack; 270 | use Kevinrob\GuzzleCache\CacheMiddleware; 271 | use Kevinrob\GuzzleCache\Strategy; 272 | 273 | $strategy = new Strategy\Delegate\DelegatingCacheStrategy($defaultStrategy = new Strategy\NullCacheStrategy()); 274 | $strategy->registerRequestMatcher(new ExampleOrgRequestMatcher(), new Strategy\PublicCacheStrategy()); 275 | $strategy->registerRequestMatcher(new TwitterRequestMatcher(), new Strategy\PrivateCacheStrategy()); 276 | 277 | $stack = HandlerStack::create(); 278 | $stack->push(new CacheMiddleware($strategy)); 279 | $guzzle = new Client(['handler' => $stack]); 280 | ``` 281 | 282 | With this example: 283 | * All requests to `example.org` will be handled by `PublicCacheStrategy` 284 | * All requests to `twitter.com` will be handled by `PrivateCacheStrategy` 285 | * All other requests won't be cached. 286 | 287 | ## Drupal 288 | See [Guzzle Cache](https://www.drupal.org/project/guzzle_cache) module. 289 | 290 | # Links that talk about the project 291 | - [Caching HTTP-Requests with Guzzle 6 and PSR-6](http://a.kabachnik.info/caching-http-requests-with-guzzle-6-and-psr-6.html) 292 | 293 | # Buy me a coffee 294 | If you like this project, you can [buy me a coffee](https://buymeacoffee.com/kevinrob)! (or a beer 😉) 295 | [](https://buymeacoffee.com/kevinrob) 296 | 297 | # Development 298 | 299 | ## Docker quick start 300 | 301 | ### Initialization 302 | ```bash 303 | make init 304 | ``` 305 | ### Running test 306 | ```bash 307 | make test 308 | ``` 309 | ### Entering container shell 310 | ```bash 311 | make shell 312 | ``` 313 | -------------------------------------------------------------------------------- /bmc_qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kevinrob/guzzle-cache-middleware/23cd61505ce989a0c011429939487f5f3efa02b5/bmc_qr.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kevinrob/guzzle-cache-middleware", 3 | "type": "library", 4 | "description": "A HTTP/1.1 Cache for Guzzle 6. It's a simple Middleware to be added in the HandlerStack. (RFC 7234)", 5 | "keywords": ["guzzle", "guzzle6", "cache", "http", "http 1.1", "psr6", "psr7", "handler", "middleware", "cache-control", "rfc7234", "performance", "php", "promise", "expiration", "validation", "Etag", "flysystem", "doctrine"], 6 | "homepage": "https://github.com/Kevinrob/guzzle-cache-middleware", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Kevin Robatel", 11 | "email": "kevinrob2@gmail.com", 12 | "homepage": "https://github.com/Kevinrob" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=8.1", 17 | "guzzlehttp/guzzle": "^7.9.2", 18 | "guzzlehttp/promises": "^2.0.3", 19 | "guzzlehttp/psr7": "^2.7.0" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "^9.6.21", 23 | "doctrine/cache": "^1.10", 24 | "league/flysystem": "^2.5", 25 | "psr/cache": "^1.0", 26 | "cache/array-adapter": "^0.4 || ^0.5 || ^1.0", 27 | "illuminate/cache": "^5.0", 28 | "cache/simple-cache-bridge": "^0.1 || ^1.0", 29 | "symfony/phpunit-bridge": "^7.1.4", 30 | "symfony/cache": "^4.4 || ^5.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Kevinrob\\GuzzleCache\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Kevinrob\\GuzzleCache\\Tests\\": "tests/" 40 | } 41 | }, 42 | "suggest": { 43 | "guzzlehttp/guzzle": "For using this library. It was created for Guzzle6 (but you can use it with any PSR-7 HTTP client).", 44 | "doctrine/cache": "This library has a lot of ready-to-use cache storage (to be used with Kevinrob\\GuzzleCache\\Storage\\DoctrineCacheStorage). Use only versions >=1.4.0 < 2.0.0", 45 | "league/flysystem": "To be used with Kevinrob\\GuzzleCache\\Storage\\FlysystemStorage", 46 | "psr/cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr6CacheStorage", 47 | "psr/simple-cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr16CacheStorage", 48 | "laravel/framework": "To be used with Kevinrob\\GuzzleCache\\Storage\\LaravelCacheStorage" 49 | }, 50 | "scripts": { 51 | "test": "vendor/bin/phpunit" 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "kylekatarnls/update-helper": true 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/BodyStore.php: -------------------------------------------------------------------------------- 1 | body = $body; 22 | $this->toRead = strlen($this->body); 23 | } 24 | 25 | /** 26 | * @param int $length 27 | * @return false|string 28 | */ 29 | public function __invoke(int $length) 30 | { 31 | if ($this->toRead <= 0) { 32 | return false; 33 | } 34 | 35 | $length = min($length, $this->toRead); 36 | 37 | $body = substr( 38 | $this->body, 39 | $this->read, 40 | $length 41 | ); 42 | $this->toRead -= $length; 43 | $this->read += $length; 44 | return $body; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/CacheEntry.php: -------------------------------------------------------------------------------- 1 | dateCreated = new \DateTime(); 64 | 65 | $this->request = $request; 66 | $this->response = $response; 67 | $this->staleAt = $staleAt; 68 | 69 | $values = new KeyValueHttpHeader($response->getHeader('Cache-Control')); 70 | 71 | if ($staleIfErrorTo === null && $values->has('stale-if-error')) { 72 | $this->staleIfErrorTo = (new \DateTime( 73 | '@'.($this->staleAt->getTimestamp() + (int) $values->get('stale-if-error')) 74 | )); 75 | } else { 76 | $this->staleIfErrorTo = $staleIfErrorTo; 77 | } 78 | 79 | if ($staleWhileRevalidateTo === null && $values->has('stale-while-revalidate')) { 80 | $this->staleWhileRevalidateTo = new \DateTime( 81 | '@'.($this->staleAt->getTimestamp() + (int) $values->get('stale-while-revalidate')) 82 | ); 83 | } else { 84 | $this->staleWhileRevalidateTo = $staleWhileRevalidateTo; 85 | } 86 | } 87 | 88 | /** 89 | * @return ResponseInterface 90 | */ 91 | public function getResponse() 92 | { 93 | return $this->response 94 | ->withHeader('Age', $this->getAge()); 95 | } 96 | 97 | /** 98 | * @return ResponseInterface 99 | */ 100 | public function getOriginalResponse() 101 | { 102 | return $this->response; 103 | } 104 | 105 | /** 106 | * @return RequestInterface 107 | */ 108 | public function getOriginalRequest() 109 | { 110 | return $this->request; 111 | } 112 | 113 | /** 114 | * @param RequestInterface $request 115 | * @return bool 116 | */ 117 | public function isVaryEquals(RequestInterface $request) 118 | { 119 | if ($this->response->hasHeader('Vary')) { 120 | if ($this->request === null) { 121 | return false; 122 | } 123 | 124 | foreach ($this->getVaryHeaders() as $key => $value) { 125 | if (!$this->request->hasHeader($key) 126 | && !$request->hasHeader($key) 127 | ) { 128 | // Absent from both 129 | continue; 130 | } elseif ($this->request->getHeaderLine($key) 131 | == $request->getHeaderLine($key) 132 | ) { 133 | // Same content 134 | continue; 135 | } 136 | 137 | return false; 138 | } 139 | } 140 | 141 | return true; 142 | } 143 | 144 | /** 145 | * Get the vary headers that should be honoured by the cache. 146 | * 147 | * @return KeyValueHttpHeader 148 | */ 149 | public function getVaryHeaders() 150 | { 151 | return new KeyValueHttpHeader($this->response->getHeader('Vary')); 152 | } 153 | 154 | /** 155 | * @return \DateTime 156 | */ 157 | public function getStaleAt() 158 | { 159 | return $this->staleAt; 160 | } 161 | 162 | /** 163 | * @return bool 164 | */ 165 | public function isFresh() 166 | { 167 | return !$this->isStale(); 168 | } 169 | 170 | /** 171 | * @return bool 172 | */ 173 | public function isStale() 174 | { 175 | return $this->getStaleAge() > 0; 176 | } 177 | 178 | /** 179 | * @return int positive value equal staled 180 | */ 181 | public function getStaleAge() 182 | { 183 | // This object is immutable 184 | if ($this->timestampStale === null) { 185 | $this->timestampStale = $this->staleAt->getTimestamp(); 186 | } 187 | 188 | return time() - $this->timestampStale; 189 | } 190 | 191 | /** 192 | * @return bool 193 | */ 194 | public function serveStaleIfError() 195 | { 196 | return $this->staleIfErrorTo !== null 197 | && $this->staleIfErrorTo->getTimestamp() >= (new \DateTime())->getTimestamp(); 198 | } 199 | 200 | /** 201 | * @return bool 202 | */ 203 | public function staleWhileValidate() 204 | { 205 | return $this->staleWhileRevalidateTo !== null 206 | && $this->staleWhileRevalidateTo->getTimestamp() >= (new \DateTime())->getTimestamp(); 207 | } 208 | 209 | /** 210 | * @return bool 211 | */ 212 | public function hasValidationInformation() 213 | { 214 | return $this->response->hasHeader('Etag') || $this->response->hasHeader('Last-Modified'); 215 | } 216 | 217 | /** 218 | * Time in seconds how long the entry should be kept in the cache 219 | * 220 | * This will not give the time (in seconds) that the response will still be fresh for 221 | * from the HTTP point of view, but an upper bound on how long it is necessary and 222 | * reasonable to keep the response in a cache (to re-use it or re-validate it later on). 223 | * 224 | * @return int TTL in seconds (0 = infinite) 225 | */ 226 | public function getTTL() 227 | { 228 | if ($this->hasValidationInformation()) { 229 | // No TTL if we have a way to re-validate the cache 230 | return 0; 231 | } 232 | 233 | $ttl = 0; 234 | 235 | // Keep it when stale if error 236 | if ($this->staleIfErrorTo !== null) { 237 | $ttl = max($ttl, $this->staleIfErrorTo->getTimestamp() - time()); 238 | } 239 | 240 | // Keep it when stale-while-revalidate 241 | if ($this->staleWhileRevalidateTo !== null) { 242 | $ttl = max($ttl, $this->staleWhileRevalidateTo->getTimestamp() - time()); 243 | } 244 | 245 | // Keep it until it become stale 246 | $ttl = max($ttl, $this->staleAt->getTimestamp() - time()); 247 | 248 | // Don't return 0, it's reserved for infinite TTL 249 | return $ttl !== 0 ? (int) $ttl : -1; 250 | } 251 | 252 | /** 253 | * @return int Age in seconds 254 | */ 255 | public function getAge() 256 | { 257 | return time() - $this->dateCreated->getTimestamp(); 258 | } 259 | 260 | public function __serialize(): array 261 | { 262 | return [ 263 | 'request' => self::toSerializeableMessage($this->request), 264 | 'response' => $this->response !== null ? self::toSerializeableMessage($this->response) : null, 265 | 'staleAt' => $this->staleAt, 266 | 'staleIfErrorTo' => $this->staleIfErrorTo, 267 | 'staleWhileRevalidateTo' => $this->staleWhileRevalidateTo, 268 | 'dateCreated' => $this->dateCreated, 269 | 'timestampStale' => $this->timestampStale, 270 | ]; 271 | } 272 | 273 | public function __unserialize(array $data): void 274 | { 275 | $prefix = ''; 276 | if (isset($data["\0*\0request"])) { 277 | // We are unserializing a cache entry which was serialized with a version < 4.1.1 278 | $prefix = "\0*\0"; 279 | } 280 | $this->request = self::restoreStreamBody($data[$prefix.'request']); 281 | $this->response = $data[$prefix.'response'] !== null ? self::restoreStreamBody($data[$prefix.'response']) : null; 282 | $this->staleAt = $data[$prefix.'staleAt']; 283 | $this->staleIfErrorTo = $data[$prefix.'staleIfErrorTo']; 284 | $this->staleWhileRevalidateTo = $data[$prefix.'staleWhileRevalidateTo']; 285 | $this->dateCreated = $data[$prefix.'dateCreated']; 286 | $this->timestampStale = $data[$prefix.'timestampStale']; 287 | } 288 | 289 | /** 290 | * Stream/Resource can't be serialized... So we copy the content into an implementation of `Psr\Http\Message\StreamInterface` 291 | * 292 | * @template T of MessageInterface 293 | * 294 | * @param T $message 295 | * @return T 296 | */ 297 | private static function toSerializeableMessage(MessageInterface $message): MessageInterface 298 | { 299 | $bodyString = (string)$message->getBody(); 300 | 301 | return $message->withBody( 302 | new PumpStream( 303 | new BodyStore($bodyString), 304 | [ 305 | 'size' => strlen($bodyString), 306 | ] 307 | ) 308 | ); 309 | } 310 | 311 | /** 312 | * @template T of MessageInterface 313 | * 314 | * @param T $message 315 | * @return T 316 | */ 317 | private static function restoreStreamBody(MessageInterface $message): MessageInterface 318 | { 319 | return $message->withBody( 320 | \GuzzleHttp\Psr7\Utils::streamFor((string) $message->getBody()) 321 | ); 322 | } 323 | 324 | public function serialize() 325 | { 326 | return serialize($this->__serialize()); 327 | } 328 | 329 | public function unserialize($data) 330 | { 331 | $this->__unserialize(unserialize($data)); 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/CacheMiddleware.php: -------------------------------------------------------------------------------- 1 | true]; 51 | 52 | /** 53 | * List of safe methods 54 | * 55 | * https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1 56 | * 57 | * @var array 58 | */ 59 | protected $safeMethods = ['GET' => true, 'HEAD' => true, 'OPTIONS' => true, 'TRACE' => true]; 60 | 61 | /** 62 | * @param CacheStrategyInterface|null $cacheStrategy 63 | */ 64 | public function __construct(?CacheStrategyInterface $cacheStrategy = null) 65 | { 66 | $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy(); 67 | 68 | register_shutdown_function([$this, 'purgeReValidation']); 69 | } 70 | 71 | /** 72 | * @param Client $client 73 | */ 74 | public function setClient(Client $client) 75 | { 76 | $this->client = $client; 77 | } 78 | 79 | /** 80 | * @param CacheStrategyInterface $cacheStorage 81 | */ 82 | public function setCacheStorage(CacheStrategyInterface $cacheStorage) 83 | { 84 | $this->cacheStorage = $cacheStorage; 85 | } 86 | 87 | /** 88 | * @return CacheStrategyInterface 89 | */ 90 | public function getCacheStorage() 91 | { 92 | return $this->cacheStorage; 93 | } 94 | 95 | /** 96 | * @param array $methods 97 | */ 98 | public function setHttpMethods(array $methods) 99 | { 100 | $this->httpMethods = $methods; 101 | } 102 | 103 | public function getHttpMethods() 104 | { 105 | return $this->httpMethods; 106 | } 107 | 108 | /** 109 | * Will be called at the end of the script. 110 | */ 111 | public function purgeReValidation() 112 | { 113 | \GuzzleHttp\Promise\Utils::inspectAll($this->waitingRevalidate); 114 | } 115 | 116 | /** 117 | * @param callable $handler 118 | * 119 | * @return callable 120 | */ 121 | public function __invoke(callable $handler) 122 | { 123 | return function (RequestInterface $request, array $options) use (&$handler) { 124 | if (!isset($this->httpMethods[strtoupper($request->getMethod())])) { 125 | // No caching for this method allowed 126 | 127 | return $handler($request, $options)->then( 128 | function (ResponseInterface $response) use ($request) { 129 | if (!isset($this->safeMethods[$request->getMethod()])) { 130 | // Invalidate cache after a call of non-safe method on the same URI 131 | $response = $this->invalidateCache($request, $response); 132 | } 133 | 134 | return $response->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_MISS); 135 | } 136 | ); 137 | } 138 | 139 | if ($request->hasHeader(static::HEADER_RE_VALIDATION)) { 140 | // It's a re-validation request, so bypass the cache! 141 | return $handler($request->withoutHeader(static::HEADER_RE_VALIDATION), $options); 142 | } 143 | 144 | // Retrieve information from request (Cache-Control) 145 | $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); 146 | $onlyFromCache = $reqCacheControl->has('only-if-cached'); 147 | $staleResponse = $reqCacheControl->has('max-stale') 148 | && $reqCacheControl->get('max-stale') === ''; 149 | $maxStaleCache = $reqCacheControl->get('max-stale', null); 150 | $minFreshCache = $reqCacheControl->get('min-fresh', null); 151 | 152 | // If cache => return new FulfilledPromise(...) with response 153 | $cacheEntry = $this->cacheStorage->fetch($request); 154 | if ($cacheEntry instanceof CacheEntry) { 155 | $body = $cacheEntry->getResponse()->getBody(); 156 | if ($body->tell() > 0) { 157 | $body->rewind(); 158 | } 159 | 160 | if ($cacheEntry->isFresh() 161 | && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0) 162 | ) { 163 | // Cache HIT! 164 | return new FulfilledPromise( 165 | $cacheEntry->getResponse()->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_HIT) 166 | ); 167 | } elseif ($staleResponse 168 | || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache) 169 | ) { 170 | // Staled cache! 171 | return new FulfilledPromise( 172 | $cacheEntry->getResponse()->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_HIT) 173 | ); 174 | } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) { 175 | // Re-validation header 176 | $request = static::getRequestWithReValidationHeader($request, $cacheEntry); 177 | 178 | if ($cacheEntry->staleWhileValidate()) { 179 | static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry); 180 | 181 | return new FulfilledPromise( 182 | $cacheEntry->getResponse() 183 | ->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_STALE) 184 | ); 185 | } 186 | } 187 | } else { 188 | $cacheEntry = null; 189 | } 190 | 191 | if ($cacheEntry === null && $onlyFromCache) { 192 | // Explicit asking of a cached response => 504 193 | return new FulfilledPromise( 194 | new Response(504) 195 | ); 196 | } 197 | 198 | /** @var Promise $promise */ 199 | $promise = $handler($request, $options); 200 | 201 | return $promise->then( 202 | function (ResponseInterface $response) use ($request, $cacheEntry) { 203 | // Check if error and looking for a staled content 204 | if ($response->getStatusCode() >= 500) { 205 | $responseStale = static::getStaleResponse($cacheEntry); 206 | if ($responseStale instanceof ResponseInterface) { 207 | return $responseStale; 208 | } 209 | } 210 | 211 | $update = false; 212 | 213 | if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) { 214 | // Not modified => cache entry is re-validate 215 | /** @var ResponseInterface $response */ 216 | $response = $response 217 | ->withStatus($cacheEntry->getResponse()->getStatusCode()) 218 | ->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_HIT); 219 | $response = $response->withBody($cacheEntry->getResponse()->getBody()); 220 | 221 | // Merge headers of the "304 Not Modified" and the cache entry 222 | /** 223 | * @var string $headerName 224 | * @var string[] $headerValue 225 | */ 226 | foreach ($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue) { 227 | if (!$response->hasHeader($headerName) && $headerName !== static::HEADER_CACHE_INFO) { 228 | $response = $response->withHeader($headerName, $headerValue); 229 | } 230 | } 231 | 232 | $update = true; 233 | } else { 234 | $response = $response->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_MISS); 235 | } 236 | 237 | return static::addToCache($this->cacheStorage, $request, $response, $update); 238 | }, 239 | function ($reason) use ($cacheEntry) { 240 | $response = static::getStaleResponse($cacheEntry); 241 | if ($response instanceof ResponseInterface) { 242 | return $response; 243 | } 244 | 245 | return new RejectedPromise($reason); 246 | } 247 | ); 248 | }; 249 | } 250 | 251 | /** 252 | * @param CacheStrategyInterface $cache 253 | * @param RequestInterface $request 254 | * @param ResponseInterface $response 255 | * @param bool $update cache 256 | * @return ResponseInterface 257 | */ 258 | protected static function addToCache( 259 | CacheStrategyInterface $cache, 260 | RequestInterface $request, 261 | ResponseInterface $response, 262 | $update = false 263 | ) { 264 | $body = $response->getBody(); 265 | 266 | // If the body is not seekable, we have to replace it by a seekable one 267 | if (!$body->isSeekable()) { 268 | $response = $response->withBody( 269 | \GuzzleHttp\Psr7\Utils::streamFor($body->getContents()) 270 | ); 271 | } 272 | 273 | if ($update) { 274 | $cache->update($request, $response); 275 | } else { 276 | $cache->cache($request, $response); 277 | } 278 | 279 | // always rewind back to the start otherwise other middlewares may get empty "content" 280 | if ($body->isSeekable()) { 281 | $response->getBody()->rewind(); 282 | } 283 | 284 | return $response; 285 | } 286 | 287 | /** 288 | * @param RequestInterface $request 289 | * @param CacheStrategyInterface $cacheStorage 290 | * @param CacheEntry $cacheEntry 291 | * 292 | * @return bool if added 293 | */ 294 | protected function addReValidationRequest( 295 | RequestInterface $request, 296 | CacheStrategyInterface &$cacheStorage, 297 | CacheEntry $cacheEntry 298 | ) { 299 | // Add the promise for revalidate 300 | if ($this->client !== null) { 301 | /** @var RequestInterface $request */ 302 | $request = $request->withHeader(static::HEADER_RE_VALIDATION, '1'); 303 | $this->waitingRevalidate[] = $this->client 304 | ->sendAsync($request) 305 | ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) { 306 | $update = false; 307 | 308 | if ($response->getStatusCode() == 304) { 309 | // Not modified => cache entry is re-validate 310 | /** @var ResponseInterface $response */ 311 | $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode()); 312 | $response = $response->withBody($cacheEntry->getResponse()->getBody()); 313 | 314 | // Merge headers of the "304 Not Modified" and the cache entry 315 | foreach ($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue) { 316 | if (!$response->hasHeader($headerName)) { 317 | $response = $response->withHeader($headerName, $headerValue); 318 | } 319 | } 320 | 321 | $update = true; 322 | } 323 | 324 | static::addToCache($cacheStorage, $request, $response, $update); 325 | }); 326 | 327 | return true; 328 | } 329 | 330 | return false; 331 | } 332 | 333 | /** 334 | * @param CacheEntry|null $cacheEntry 335 | * 336 | * @return null|ResponseInterface 337 | */ 338 | protected static function getStaleResponse(?CacheEntry $cacheEntry = null) 339 | { 340 | // Return staled cache entry if we can 341 | if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) { 342 | return $cacheEntry->getResponse() 343 | ->withHeader(static::HEADER_CACHE_INFO, static::HEADER_CACHE_STALE); 344 | } 345 | 346 | return; 347 | } 348 | 349 | /** 350 | * @param RequestInterface $request 351 | * @param CacheEntry $cacheEntry 352 | * 353 | * @return RequestInterface 354 | */ 355 | protected static function getRequestWithReValidationHeader(RequestInterface $request, ?CacheEntry $cacheEntry) 356 | { 357 | if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) { 358 | $request = $request->withHeader( 359 | 'If-Modified-Since', 360 | $cacheEntry->getResponse()->getHeader('Last-Modified') 361 | ); 362 | } 363 | if ($cacheEntry->getResponse()->hasHeader('Etag')) { 364 | $request = $request->withHeader( 365 | 'If-None-Match', 366 | $cacheEntry->getResponse()->getHeader('Etag') 367 | ); 368 | } 369 | 370 | return $request; 371 | } 372 | 373 | /** 374 | * @param CacheStrategyInterface|null $cacheStorage 375 | * 376 | * @return CacheMiddleware the Middleware for Guzzle HandlerStack 377 | * 378 | * @deprecated Use constructor => `new CacheMiddleware()` 379 | */ 380 | public static function getMiddleware(?CacheStrategyInterface $cacheStorage = null) 381 | { 382 | return new self($cacheStorage); 383 | } 384 | 385 | /** 386 | * @param RequestInterface $request 387 | * 388 | * @param ResponseInterface $response 389 | * 390 | * @return ResponseInterface 391 | */ 392 | private function invalidateCache(RequestInterface $request, ResponseInterface $response) 393 | { 394 | foreach (array_keys($this->httpMethods) as $method) { 395 | $this->cacheStorage->delete($request->withMethod($method)); 396 | } 397 | 398 | return $response->withHeader(static::HEADER_INVALIDATION, true); 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /src/KeyValueHttpHeader.php: -------------------------------------------------------------------------------- 1 | @\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\\\]|\\\\.)*)\")))?/'; 11 | 12 | /** 13 | * @var string[] 14 | */ 15 | protected $values = []; 16 | 17 | /** 18 | * @param array $values 19 | */ 20 | public function __construct(array $values) 21 | { 22 | foreach ($values as $value) { 23 | $matches = []; 24 | if (preg_match_all(self::REGEX_SPLIT, $value, $matches, PREG_SET_ORDER)) { 25 | foreach ($matches as $match) { 26 | $val = ''; 27 | if (count($match) == 3) { 28 | $val = $match[2]; 29 | } elseif (count($match) > 3) { 30 | $val = $match[3]; 31 | } 32 | 33 | $this->values[$match[1]] = $val; 34 | } 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * @param string $key 41 | * 42 | * @return bool 43 | */ 44 | public function has($key) 45 | { 46 | // For performance, we can use isset, 47 | // but it will not match if value == 0 48 | return isset($this->values[$key]) || array_key_exists($key, $this->values); 49 | } 50 | 51 | /** 52 | * @param string $key 53 | * @param string $default the value to return if don't exist 54 | * @return string 55 | */ 56 | public function get($key, $default = '') 57 | { 58 | if ($this->has($key)) { 59 | return $this->values[$key]; 60 | } 61 | 62 | return $default; 63 | } 64 | 65 | /** 66 | * @return bool 67 | */ 68 | public function isEmpty() 69 | { 70 | return count($this->values) === 0; 71 | } 72 | 73 | /** 74 | * Return the current element 75 | * @link http://php.net/manual/en/iterator.current.php 76 | * @return mixed Can return any type. 77 | * @since 5.0.0 78 | */ 79 | #[\ReturnTypeWillChange] 80 | public function current() 81 | { 82 | return current($this->values); 83 | } 84 | 85 | /** 86 | * Move forward to next element 87 | * @link http://php.net/manual/en/iterator.next.php 88 | * @return void Any returned value is ignored. 89 | * @since 5.0.0 90 | */ 91 | public function next(): void 92 | { 93 | next($this->values); 94 | } 95 | 96 | /** 97 | * Return the key of the current element 98 | * @link http://php.net/manual/en/iterator.key.php 99 | * @return mixed scalar on success, or null on failure. 100 | * @since 5.0.0 101 | * 102 | */ 103 | #[\ReturnTypeWillChange] 104 | public function key() 105 | { 106 | return key($this->values); 107 | } 108 | 109 | /** 110 | * Checks if current position is valid 111 | * @link http://php.net/manual/en/iterator.valid.php 112 | * @return boolean The return value will be casted to boolean and then evaluated. 113 | * Returns true on success or false on failure. 114 | * @since 5.0.0 115 | */ 116 | public function valid(): bool 117 | { 118 | return key($this->values) !== null; 119 | } 120 | 121 | /** 122 | * Rewind the Iterator to the first element 123 | * @link http://php.net/manual/en/iterator.rewind.php 124 | * @return void Any returned value is ignored. 125 | * @since 5.0.0 126 | */ 127 | public function rewind(): void 128 | { 129 | reset($this->values); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Storage/CacheStorageInterface.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 21 | } 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function fetch($key) 27 | { 28 | try { 29 | $cache = unserialize(gzuncompress($this->cache->fetch($key))); 30 | if ($cache instanceof CacheEntry) { 31 | return $cache; 32 | } 33 | } catch (\Exception $ignored) { 34 | return; 35 | } 36 | 37 | return; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function save($key, CacheEntry $data) 44 | { 45 | try { 46 | $lifeTime = $data->getTTL(); 47 | if ($lifeTime >= 0) { 48 | return $this->cache->save( 49 | $key, 50 | gzcompress(serialize($data)), 51 | $lifeTime 52 | ); 53 | } 54 | } catch (\Exception $ignored) { 55 | // No fail if we can't save it the storage 56 | } 57 | 58 | return false; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function delete($key) 65 | { 66 | try { 67 | return $this->cache->delete($key); 68 | } catch (\Exception $ignored) { 69 | // Don't fail if we can't delete it 70 | } 71 | 72 | return false; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Storage/DoctrineCacheStorage.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 21 | } 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function fetch($key) 27 | { 28 | try { 29 | $cache = unserialize($this->cache->fetch($key)); 30 | if ($cache instanceof CacheEntry) { 31 | return $cache; 32 | } 33 | } catch (\Exception $ignored) { 34 | return; 35 | } 36 | 37 | return; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function save($key, CacheEntry $data) 44 | { 45 | try { 46 | $lifeTime = $data->getTTL(); 47 | if ($lifeTime >= 0) { 48 | return $this->cache->save( 49 | $key, 50 | serialize($data), 51 | $lifeTime 52 | ); 53 | } 54 | } catch (\Exception $ignored) { 55 | // No fail if we can't save it the storage 56 | } 57 | 58 | return false; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | public function delete($key) 65 | { 66 | try { 67 | return $this->cache->delete($key); 68 | } catch (\Exception $ignored) { 69 | // Don't fail if we can't delete it 70 | } 71 | 72 | return false; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Storage/FlysystemStorage.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem($adapter); 21 | } 22 | 23 | /** 24 | * @inheritdoc 25 | */ 26 | public function fetch($key) 27 | { 28 | if ($this->filesystem->fileExists($key)) { 29 | // The file exist, read it! 30 | $data = @unserialize( 31 | $this->filesystem->read($key) 32 | ); 33 | 34 | if ($data instanceof CacheEntry) { 35 | return $data; 36 | } 37 | } 38 | 39 | return; 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public function save($key, CacheEntry $data) 46 | { 47 | try { 48 | $this->filesystem->write($key, serialize($data)); 49 | return true; 50 | } catch (FilesystemException $e) { 51 | return false; 52 | } 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function delete($key) 59 | { 60 | try { 61 | $this->filesystem->delete($key); 62 | return true; 63 | } catch (FilesystemException $ex) { 64 | return true; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Storage/LaravelCacheStorage.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 21 | } 22 | 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | public function fetch($key) 27 | { 28 | try { 29 | $cache = unserialize($this->cache->get($key, '')); 30 | if ($cache instanceof CacheEntry) { 31 | return $cache; 32 | } 33 | } catch (\Exception $ignored) { 34 | return; 35 | } 36 | 37 | return; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function save($key, CacheEntry $data) 44 | { 45 | try { 46 | $lifeTime = $this->getLifeTime($data); 47 | if ($lifeTime === 0) { 48 | return $this->cache->forever( 49 | $key, 50 | serialize($data) 51 | ); 52 | } else if ($lifeTime > 0) { 53 | return $this->cache->add( 54 | $key, 55 | serialize($data), 56 | $lifeTime 57 | ); 58 | } 59 | } catch (\Exception $ignored) { 60 | // No fail if we can't save it the storage 61 | } 62 | 63 | return false; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | public function delete($key) 70 | { 71 | return $this->cache->forget($key); 72 | } 73 | 74 | protected function getLifeTime(CacheEntry $data) 75 | { 76 | $version = app()->version(); 77 | if (preg_match('/^\d+(\.\d+)?(\.\d+)?/', $version) && version_compare($version, '5.8.0') < 0) { 78 | // getTTL returns seconds, Laravel needs minutes before v5.8 79 | return $data->getTTL() / 60; 80 | } 81 | 82 | return $data->getTTL(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Storage/Psr16CacheStorage.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 18 | } 19 | 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function fetch($key) 24 | { 25 | $data = $this->cache->get($key); 26 | if ($data instanceof CacheEntry) { 27 | return $data; 28 | } 29 | 30 | return null; 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function save($key, CacheEntry $data) 37 | { 38 | $ttl = $data->getTTL(); 39 | if ($ttl === 0) { 40 | return $this->cache->set($key, $data); 41 | } 42 | return $this->cache->set($key, $data, $data->getTTL()); 43 | } 44 | 45 | /** 46 | * {@inheritdoc} 47 | */ 48 | public function delete($key) 49 | { 50 | return $this->cache->delete($key); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Storage/Psr6CacheStorage.php: -------------------------------------------------------------------------------- 1 | cachePool = $cachePool; 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function fetch($key) 40 | { 41 | $item = $this->cachePool->getItem($key); 42 | $this->lastItem = $item; 43 | 44 | $cache = $item->get(); 45 | 46 | if ($cache instanceof CacheEntry) { 47 | return $cache; 48 | } 49 | 50 | return null; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function save($key, CacheEntry $data) 57 | { 58 | if ($this->lastItem && $this->lastItem->getKey() == $key) { 59 | $item = $this->lastItem; 60 | } else { 61 | $item = $this->cachePool->getItem($key); 62 | } 63 | 64 | $this->lastItem = null; 65 | 66 | $item->set($data); 67 | 68 | $ttl = $data->getTTL(); 69 | if ($ttl === 0) { 70 | // No expiration 71 | $item->expiresAfter(null); 72 | } else { 73 | $item->expiresAfter($ttl); 74 | } 75 | 76 | return $this->cachePool->save($item); 77 | } 78 | 79 | /** 80 | * @param string $key 81 | * 82 | * @return bool 83 | */ 84 | public function delete($key) 85 | { 86 | if (null !== $this->lastItem && $this->lastItem->getKey() === $key) { 87 | $this->lastItem = null; 88 | } 89 | 90 | return $this->cachePool->deleteItem($key); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Storage/VolatileRuntimeStorage.php: -------------------------------------------------------------------------------- 1 | cache[$key])) { 26 | return $this->cache[$key]; 27 | } 28 | 29 | return; 30 | } 31 | 32 | /** 33 | * @param string $key 34 | * @param CacheEntry $data 35 | * 36 | * @return bool 37 | */ 38 | public function save($key, CacheEntry $data) 39 | { 40 | $this->cache[$key] = $data; 41 | 42 | return true; 43 | } 44 | 45 | /** 46 | * @param string $key 47 | * 48 | * @return bool 49 | */ 50 | public function delete($key) 51 | { 52 | if (true === array_key_exists($key, $this->cache)) { 53 | unset($this->cache[$key]); 54 | 55 | return true; 56 | } 57 | 58 | return false; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Storage/WordPressObjectCacheStorage.php: -------------------------------------------------------------------------------- 1 | group = $group; 20 | } 21 | 22 | /** 23 | * @param string $key 24 | * 25 | * @return CacheEntry|null the data or false 26 | */ 27 | public function fetch($key) 28 | { 29 | try { 30 | $cache = unserialize(wp_cache_get($key, $this->group)); 31 | if ($cache instanceof CacheEntry) { 32 | return $cache; 33 | } 34 | } catch (\Exception $ignored) { 35 | // Don't fail if we can't load it 36 | } 37 | 38 | return null; 39 | } 40 | 41 | /** 42 | * @param string $key 43 | * @param CacheEntry $data 44 | * 45 | * @return bool 46 | */ 47 | public function save($key, CacheEntry $data) 48 | { 49 | try { 50 | return wp_cache_set($key, serialize($data), $this->group, $data->getTTL()); 51 | } catch (\Exception $ignored) { 52 | // Don't fail if we can't save it 53 | } 54 | 55 | return false; 56 | } 57 | 58 | /** 59 | * @param string $key 60 | * 61 | * @return bool 62 | */ 63 | public function delete($key) 64 | { 65 | try { 66 | return wp_cache_delete($key, $this->group); 67 | } catch (\Exception $ignored) { 68 | // Don't fail if we can't delete it 69 | } 70 | 71 | return false; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Strategy/CacheStrategyInterface.php: -------------------------------------------------------------------------------- 1 | defaultCacheStrategy = $defaultCacheStrategy ?: new NullCacheStrategy(); 28 | } 29 | 30 | /** 31 | * @param CacheStrategyInterface $defaultCacheStrategy 32 | */ 33 | public function setDefaultCacheStrategy(CacheStrategyInterface $defaultCacheStrategy) 34 | { 35 | $this->defaultCacheStrategy = $defaultCacheStrategy; 36 | } 37 | 38 | /** 39 | * @param RequestMatcherInterface $requestMatcher 40 | * @param CacheStrategyInterface $cacheStrategy 41 | */ 42 | final public function registerRequestMatcher(RequestMatcherInterface $requestMatcher, CacheStrategyInterface $cacheStrategy) 43 | { 44 | $this->requestMatchers[] = [ 45 | $requestMatcher, 46 | $cacheStrategy, 47 | ]; 48 | } 49 | 50 | /** 51 | * @param RequestInterface $request 52 | * @return CacheStrategyInterface 53 | */ 54 | private function getStrategyFor(RequestInterface $request) 55 | { 56 | /** 57 | * @var RequestMatcherInterface $requestMatcher 58 | * @var CacheStrategyInterface $cacheStrategy 59 | */ 60 | foreach ($this->requestMatchers as $requestMatcher) { 61 | list($requestMatcher, $cacheStrategy) = $requestMatcher; 62 | if ($requestMatcher->matches($request)) { 63 | return $cacheStrategy; 64 | } 65 | } 66 | 67 | return $this->defaultCacheStrategy; 68 | } 69 | 70 | /** 71 | * @inheritDoc 72 | */ 73 | public function fetch(RequestInterface $request) 74 | { 75 | return $this->getStrategyFor($request)->fetch($request); 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | public function cache(RequestInterface $request, ResponseInterface $response) 82 | { 83 | return $this->getStrategyFor($request)->cache($request, $response); 84 | } 85 | 86 | /** 87 | * @inheritDoc 88 | */ 89 | public function update(RequestInterface $request, ResponseInterface $response) 90 | { 91 | return $this->getStrategyFor($request)->update($request, $response); 92 | } 93 | 94 | /** 95 | * {@inheritdoc} 96 | */ 97 | public function delete(RequestInterface $request) 98 | { 99 | return $this->getStrategyFor($request)->delete($request); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Strategy/Delegate/RequestMatcherInterface.php: -------------------------------------------------------------------------------- 1 | defaultTtl = $defaultTtl; 40 | $this->varyHeaders = $varyHeaders; 41 | parent::__construct($cache); 42 | } 43 | 44 | protected function getCacheKey(RequestInterface $request, ?KeyValueHttpHeader $varyHeaders = null) 45 | { 46 | if (null === $varyHeaders || $varyHeaders->isEmpty()) { 47 | return hash( 48 | 'sha256', 49 | 'greedy'.$request->getMethod().$request->getUri() 50 | ); 51 | } 52 | 53 | $cacheHeaders = []; 54 | foreach ($varyHeaders as $key => $value) { 55 | if ($request->hasHeader($key)) { 56 | $cacheHeaders[$key] = $request->getHeader($key); 57 | } 58 | } 59 | 60 | return hash( 61 | 'sha256', 62 | 'greedy'.$request->getMethod().$request->getUri().json_encode($cacheHeaders) 63 | ); 64 | } 65 | 66 | public function cache(RequestInterface $request, ResponseInterface $response) 67 | { 68 | $warningMessage = sprintf('%d - "%s" "%s"', 69 | 299, 70 | 'Cached although the response headers indicate not to do it!', 71 | (new \DateTime())->format(\DateTime::RFC1123) 72 | ); 73 | 74 | $response = $response->withAddedHeader('Warning', $warningMessage); 75 | 76 | if ($cacheObject = $this->getCacheObject($request, $response)) { 77 | return $this->storage->save( 78 | $this->getCacheKey($request, $this->varyHeaders), 79 | $cacheObject 80 | ); 81 | } 82 | 83 | return false; 84 | } 85 | 86 | protected function getCacheObject(RequestInterface $request, ResponseInterface $response) 87 | { 88 | if (!array_key_exists($response->getStatusCode(), $this->statusAccepted)) { 89 | // Don't cache it 90 | return null; 91 | } 92 | 93 | if (null !== $this->varyHeaders && $this->varyHeaders->has('*')) { 94 | // This will never match with a request 95 | return; 96 | } 97 | 98 | $response = $response->withoutHeader('Etag')->withoutHeader('Last-Modified'); 99 | 100 | $ttl = $this->defaultTtl; 101 | if ($request->hasHeader(static::HEADER_TTL)) { 102 | $ttlHeaderValues = $request->getHeader(static::HEADER_TTL); 103 | $ttl = (int)reset($ttlHeaderValues); 104 | } 105 | 106 | return new CacheEntry($request->withoutHeader(static::HEADER_TTL), $response, new \DateTime(sprintf('%+d seconds', $ttl))); 107 | } 108 | 109 | public function fetch(RequestInterface $request) 110 | { 111 | $cache = $this->storage->fetch($this->getCacheKey($request, $this->varyHeaders)); 112 | return $cache; 113 | } 114 | 115 | /** 116 | * {@inheritdoc} 117 | */ 118 | public function delete(RequestInterface $request) 119 | { 120 | return $this->storage->delete($this->getCacheKey($request)); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Strategy/NullCacheStrategy.php: -------------------------------------------------------------------------------- 1 | 200, 35 | 203 => 203, 36 | 204 => 204, 37 | 300 => 300, 38 | 301 => 301, 39 | 404 => 404, 40 | 405 => 405, 41 | 410 => 410, 42 | 414 => 414, 43 | 418 => 418, 44 | 501 => 501, 45 | ]; 46 | 47 | /** 48 | * @var string[] 49 | */ 50 | protected $ageKey = [ 51 | 'max-age', 52 | ]; 53 | 54 | public function __construct(?CacheStorageInterface $cache = null) 55 | { 56 | $this->storage = $cache !== null ? $cache : new VolatileRuntimeStorage(); 57 | } 58 | 59 | /** 60 | * @param RequestInterface $request 61 | * @param ResponseInterface $response 62 | * @return CacheEntry|null entry to save, null if can't cache it 63 | */ 64 | protected function getCacheObject(RequestInterface $request, ResponseInterface $response) 65 | { 66 | if (!isset($this->statusAccepted[$response->getStatusCode()])) { 67 | // Don't cache it 68 | return; 69 | } 70 | 71 | $cacheControl = new KeyValueHttpHeader($response->getHeader('Cache-Control')); 72 | $varyHeader = new KeyValueHttpHeader($response->getHeader('Vary')); 73 | 74 | if ($varyHeader->has('*')) { 75 | // This will never match with a request 76 | return; 77 | } 78 | 79 | if ($cacheControl->has('no-store')) { 80 | // No store allowed (maybe some sensitives data...) 81 | return; 82 | } 83 | 84 | if ($cacheControl->has('no-cache')) { 85 | // Stale response see RFC7234 section 5.2.1.4 86 | $entry = new CacheEntry($request, $response, new \DateTime('-1 seconds')); 87 | 88 | return $entry->hasValidationInformation() ? $entry : null; 89 | } 90 | 91 | foreach ($this->ageKey as $key) { 92 | if ($cacheControl->has($key)) { 93 | return new CacheEntry( 94 | $request, 95 | $response, 96 | new \DateTime('+'.(int) $cacheControl->get($key).'seconds') 97 | ); 98 | } 99 | } 100 | 101 | if ($response->hasHeader('Expires')) { 102 | $expireAt = \DateTime::createFromFormat(\DateTime::RFC1123, $response->getHeaderLine('Expires')); 103 | if ($expireAt !== false) { 104 | return new CacheEntry( 105 | $request, 106 | $response, 107 | $expireAt 108 | ); 109 | } 110 | } 111 | 112 | return new CacheEntry($request, $response, new \DateTime('-1 seconds')); 113 | } 114 | 115 | /** 116 | * Generate a key for the response cache. 117 | * 118 | * @param RequestInterface $request 119 | * @param null|KeyValueHttpHeader $varyHeaders The vary headers which should be honoured by the cache (optional) 120 | * 121 | * @return string 122 | */ 123 | protected function getCacheKey(RequestInterface $request, ?KeyValueHttpHeader $varyHeaders = null) 124 | { 125 | if (!$varyHeaders) { 126 | return hash('sha256', $request->getMethod().$request->getUri()); 127 | } 128 | 129 | $cacheHeaders = []; 130 | 131 | foreach ($varyHeaders as $key => $value) { 132 | if ($request->hasHeader($key)) { 133 | $cacheHeaders[$key] = $request->getHeader($key); 134 | } 135 | } 136 | 137 | return hash('sha256', $request->getMethod().$request->getUri().json_encode($cacheHeaders)); 138 | } 139 | 140 | /** 141 | * Return a CacheEntry or null if no cache. 142 | * 143 | * @param RequestInterface $request 144 | * 145 | * @return CacheEntry|null 146 | */ 147 | public function fetch(RequestInterface $request) 148 | { 149 | /** @var int|null $maxAge */ 150 | $maxAge = null; 151 | 152 | if ($request->hasHeader('Cache-Control')) { 153 | $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); 154 | if ($reqCacheControl->has('no-cache')) { 155 | // Can't return cache 156 | return null; 157 | } 158 | 159 | $maxAge = $reqCacheControl->get('max-age', null); 160 | } elseif ($request->hasHeader('Pragma')) { 161 | $pragma = new KeyValueHttpHeader($request->getHeader('Pragma')); 162 | if ($pragma->has('no-cache')) { 163 | // Can't return cache 164 | return null; 165 | } 166 | } 167 | 168 | $cache = $this->storage->fetch($this->getCacheKey($request)); 169 | if ($cache !== null) { 170 | $varyHeaders = $cache->getVaryHeaders(); 171 | 172 | // vary headers exist from a previous response, check if we have a cache that matches those headers 173 | if (!$varyHeaders->isEmpty()) { 174 | $cache = $this->storage->fetch($this->getCacheKey($request, $varyHeaders)); 175 | 176 | if (!$cache) { 177 | return null; 178 | } 179 | } 180 | 181 | if ((string)$cache->getOriginalRequest()->getUri() !== (string)$request->getUri()) { 182 | return null; 183 | } 184 | 185 | if ($maxAge !== null) { 186 | if ($cache->getAge() > $maxAge) { 187 | // Cache entry is too old for the request requirements! 188 | return null; 189 | } 190 | } 191 | 192 | if (!$cache->isVaryEquals($request)) { 193 | return null; 194 | } 195 | } 196 | 197 | return $cache; 198 | } 199 | 200 | /** 201 | * @param RequestInterface $request 202 | * @param ResponseInterface $response 203 | * 204 | * @return bool true if success 205 | */ 206 | public function cache(RequestInterface $request, ResponseInterface $response) 207 | { 208 | $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); 209 | if ($reqCacheControl->has('no-store')) { 210 | // No caching allowed 211 | return false; 212 | } 213 | 214 | $cacheObject = $this->getCacheObject($request, $response); 215 | if ($cacheObject !== null) { 216 | // store the cache against the URI-only key 217 | $success = $this->storage->save( 218 | $this->getCacheKey($request), 219 | $cacheObject 220 | ); 221 | 222 | $varyHeaders = $cacheObject->getVaryHeaders(); 223 | 224 | if (!$varyHeaders->isEmpty()) { 225 | // also store the cache against the vary headers based key 226 | $success = $this->storage->save( 227 | $this->getCacheKey($request, $varyHeaders), 228 | $cacheObject 229 | ); 230 | } 231 | 232 | return $success; 233 | } 234 | 235 | return false; 236 | } 237 | 238 | /** 239 | * @param RequestInterface $request 240 | * @param ResponseInterface $response 241 | * 242 | * @return bool true if success 243 | */ 244 | public function update(RequestInterface $request, ResponseInterface $response) 245 | { 246 | return $this->cache($request, $response); 247 | } 248 | 249 | /** 250 | * {@inheritdoc} 251 | */ 252 | public function delete(RequestInterface $request) 253 | { 254 | return $this->storage->delete($this->getCacheKey($request)); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/Strategy/PublicCacheStrategy.php: -------------------------------------------------------------------------------- 1 | ageKey, 's-maxage'); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | protected function getCacheObject(RequestInterface $request, ResponseInterface $response) 34 | { 35 | $cacheControl = new KeyValueHttpHeader($response->getHeader('Cache-Control')); 36 | if ($cacheControl->has('private')) { 37 | return; 38 | } 39 | 40 | return parent::getCacheObject($request, $response); 41 | } 42 | } 43 | --------------------------------------------------------------------------------