├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpstan.neon.dist └── src ├── Cache ├── Generator │ ├── CacheKeyGenerator.php │ ├── HeaderCacheKeyGenerator.php │ └── SimpleGenerator.php └── Listener │ ├── AddHeaderCacheListener.php │ └── CacheListener.php ├── CachePlugin.php └── Exception └── RewindStreamException.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | # Version 2 4 | 5 | ## 2.0.1 - 2024-10-02 6 | 7 | - Test with PHP 8.3 and 8.4. 8 | 9 | ## 2.0.0 - 2024-02-19 10 | 11 | ### Changed 12 | 13 | - Drop support of deprecated PHP-HTTP `StreamFactory`, only PSR-17 `StreamFactoryInterface` is now supported. 14 | 15 | # Version 1 16 | 17 | ## 1.8.1 - 2023-11-21 18 | 19 | - Allow installation with Symfony 7. 20 | 21 | ## 1.8.0 - 2023-04-28 22 | 23 | - Avoid PHP warning about serializing resources when serializing the response by detaching the stream. 24 | 25 | ## 1.7.6 - 2023-04-28 26 | 27 | - Test with PHP 8.1 and 8.2 28 | - Made phpspec tests compatible with PSR-7 2.0 strict typing 29 | - Detect `null` and use 0 explicitly to calculate expiration 30 | 31 | ## 1.7.5 - 2022-01-18 32 | 33 | - Allow installation with psr/cache 3.0 (1.0 and 2.0 are still allowed too) 34 | 35 | ## 1.7.4 - 2021-11-30 36 | 37 | ### Added 38 | 39 | - Allow installation with Symfony 6 40 | 41 | ## 1.7.3 - 2021-11-03 42 | 43 | ### Changed 44 | 45 | - Be more defensive about cache hits. A cache entry can technically contain `null`. 46 | 47 | ## 1.7.2 - 2021-04-14 48 | 49 | ### Added 50 | 51 | - Allow installation with psr/cache 2.0 (1.0 still allowed too) 52 | 53 | ## 1.7.1 - 2020-07-13 54 | 55 | ### Added 56 | 57 | - Support for PHP 8 58 | 59 | ## 1.7.0 - 2019-12-17 60 | 61 | ### Added 62 | 63 | * Support for Symfony 5. 64 | * Support for PSR-17 `StreamFactoryInterface`. 65 | * Added `blacklisted_paths` option, which takes an array of `strings` (regular expressions) and allows to define paths, that shall not be cached in any case. 66 | 67 | ## 1.6.0 - 2019-01-23 68 | 69 | ### Added 70 | 71 | * Support for HTTPlug 2 / PSR-18 72 | * Added `cache_listeners` option, which takes an array of `CacheListener`s, who get notified and can optionally act on a Response based on a cache hit or miss event. An implementation, `AddHeaderCacheListener`, is provided which will add an `X-Cache` header to the response with this information. 73 | 74 | ## 1.5.0 - 2017-11-29 75 | 76 | ### Added 77 | 78 | * Support for Symfony 4 79 | 80 | ### Changed 81 | 82 | * Removed check if etag is a string. Etag can never be a string, it is always an array. 83 | 84 | ## 1.4.0 - 2017-04-05 85 | 86 | ### Added 87 | 88 | - `CacheKeyGenerator` interface that allow you to configure how the PSR-6 cache key is created. There are two implementations 89 | of this interface: `SimpleGenerator` (default) and `HeaderCacheKeyGenerator`. 90 | 91 | ### Fixed 92 | 93 | - Issue where deprecation warning always was triggered. Not it is just triggered if `respect_cache_headers` is used. 94 | 95 | ## 1.3.0 - 2017-03-28 96 | 97 | ### Added 98 | 99 | - New `methods` option which allows to configure the request methods which can be cached. 100 | - New `respect_response_cache_directives` option to define specific cache directives to respect when handling responses. 101 | - Introduced `CachePlugin::clientCache` and `CachePlugin::serverCache` factory methods to easily setup the plugin with 102 | the correct config settigns for each usecase. 103 | 104 | ### Changed 105 | 106 | - The `no-cache` directive is now respected by the plugin and will not cache the response. If you need the previous behaviour, configure `respect_response_cache_directives`. 107 | - We always rewind the stream after loading response from cache. 108 | 109 | ### Deprecated 110 | 111 | - The `respect_cache_headers` option is deprecated and will be removed in 2.0. This option is replaced by the new `respect_response_cache_directives` option. 112 | If you had set `respect_cache_headers` to `false`, set the directives to `[]` to ignore all directives. 113 | 114 | 115 | ## 1.2.0 - 2016-08-16 116 | 117 | ### Changed 118 | 119 | - The default value for `default_ttl` is changed from `null` to `0`. 120 | 121 | ### Fixed 122 | 123 | - Issue when you use `respect_cache_headers=>false` in combination with `default_ttl=>null`. 124 | - We allow `cache_lifetime` to be set to `null`. 125 | 126 | 127 | ## 1.1.0 - 2016-08-04 128 | 129 | ### Added 130 | 131 | - Support for cache validation with ETag and Last-Modified headers. (Enabled automatically when the server sends the relevant headers.) 132 | - `hash_algo` config option used for cache key generation (defaults to **sha1**). 133 | 134 | ### Changed 135 | 136 | - Default hash algo used for cache generation (from **md5** to **sha1**). 137 | 138 | ### Fixed 139 | 140 | - Cast max age header to integer in order to get valid expiration value. 141 | 142 | 143 | ## 1.0.0 - 2016-05-05 144 | 145 | - Initial release 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2016 PHP HTTP Team 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 furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cache Plugin 2 | 3 | [![Latest Version](https://img.shields.io/github/release/php-http/cache-plugin.svg?style=flat-square)](https://github.com/php-http/cache-plugin/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Build Status](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml/badge.svg)](https://github.com/php-http/cache-plugin/actions/workflows/tests.yml) 6 | [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) 7 | [![Quality Score](https://img.shields.io/scrutinizer/g/php-http/cache-plugin.svg?style=flat-square)](https://scrutinizer-ci.com/g/php-http/cache-plugin) 8 | [![Total Downloads](https://img.shields.io/packagist/dt/php-http/cache-plugin.svg?style=flat-square)](https://packagist.org/packages/php-http/cache-plugin) 9 | 10 | **PSR-6 Cache plugin for HTTPlug.** 11 | 12 | 13 | ## Install 14 | 15 | Via Composer 16 | 17 | ``` bash 18 | composer require php-http/cache-plugin 19 | ``` 20 | 21 | 22 | ## Documentation 23 | 24 | Please see the [official documentation](http://docs.php-http.org/en/latest/plugins/cache.html). 25 | 26 | 27 | ## Testing 28 | 29 | ``` bash 30 | composer test 31 | ``` 32 | 33 | 34 | ## Contributing 35 | 36 | Please see our [contributing guide](http://docs.php-http.org/en/latest/development/contributing.html). 37 | 38 | 39 | ## Security 40 | 41 | If you discover any security related issues, please contact us at [security@php-http.org](mailto:security@php-http.org). 42 | 43 | 44 | ## License 45 | 46 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "php-http/cache-plugin", 3 | "description": "PSR-6 Cache plugin for HTTPlug", 4 | "license": "MIT", 5 | "keywords": ["cache", "http", "httplug", "plugin"], 6 | "homepage": "http://httplug.io", 7 | "authors": [ 8 | { 9 | "name": "Márk Sági-Kazár", 10 | "email": "mark.sagikazar@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.1 || ^8.0", 15 | "psr/cache": "^1.0 || ^2.0 || ^3.0", 16 | "php-http/client-common": "^1.9 || ^2.0", 17 | "psr/http-factory-implementation": "^1.0", 18 | "symfony/options-resolver": "^2.6 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" 19 | }, 20 | "require-dev": { 21 | "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0", 22 | "nyholm/psr7": "^1.6.1" 23 | }, 24 | "autoload": { 25 | "psr-4": { 26 | "Http\\Client\\Common\\Plugin\\": "src/" 27 | } 28 | }, 29 | "autoload-dev": { 30 | "psr-4": { 31 | "spec\\Http\\Client\\Common\\Plugin\\": "spec/" 32 | } 33 | }, 34 | "scripts": { 35 | "test": "vendor/bin/phpspec run", 36 | "test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - src 5 | treatPhpDocTypesAsCertain: false 6 | -------------------------------------------------------------------------------- /src/Cache/Generator/CacheKeyGenerator.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface CacheKeyGenerator 13 | { 14 | /** 15 | * Generate a cache key from a Request. 16 | * 17 | * @return string 18 | */ 19 | public function generate(RequestInterface $request); 20 | } 21 | -------------------------------------------------------------------------------- /src/Cache/Generator/HeaderCacheKeyGenerator.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class HeaderCacheKeyGenerator implements CacheKeyGenerator 13 | { 14 | /** 15 | * The header names we should take into account when creating the cache key. 16 | * 17 | * @var string[] 18 | */ 19 | private $headerNames; 20 | 21 | /** 22 | * @param string[] $headerNames 23 | */ 24 | public function __construct(array $headerNames) 25 | { 26 | $this->headerNames = $headerNames; 27 | } 28 | 29 | public function generate(RequestInterface $request) 30 | { 31 | $concatenatedHeaders = []; 32 | foreach ($this->headerNames as $headerName) { 33 | $concatenatedHeaders[] = sprintf(' %s:"%s"', $headerName, $request->getHeaderLine($headerName)); 34 | } 35 | 36 | return $request->getMethod().' '.$request->getUri().implode('', $concatenatedHeaders).' '.$request->getBody(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Cache/Generator/SimpleGenerator.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class SimpleGenerator implements CacheKeyGenerator 13 | { 14 | public function generate(RequestInterface $request) 15 | { 16 | $body = (string) $request->getBody(); 17 | if (!empty($body)) { 18 | $body = ' '.$body; 19 | } 20 | 21 | return $request->getMethod().' '.$request->getUri().$body; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Cache/Listener/AddHeaderCacheListener.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | class AddHeaderCacheListener implements CacheListener 15 | { 16 | /** @var string */ 17 | private $headerName; 18 | 19 | /** 20 | * @param string $headerName 21 | */ 22 | public function __construct($headerName = 'X-Cache') 23 | { 24 | $this->headerName = $headerName; 25 | } 26 | 27 | /** 28 | * Called before the cache plugin returns the response, with information on whether that response came from cache. 29 | * 30 | * @param bool $fromCache Whether the `$response` was from the cache or not. 31 | * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. 32 | * @param CacheItemInterface|null $cacheItem 33 | * 34 | * @return ResponseInterface 35 | */ 36 | public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem) 37 | { 38 | return $response->withHeader($this->headerName, $fromCache ? 'HIT' : 'MISS'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Cache/Listener/CacheListener.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | interface CacheListener 17 | { 18 | /** 19 | * Called before the cache plugin returns the response, with information on whether that response came from cache. 20 | * 21 | * @param bool $fromCache Whether the `$response` was from the cache or not. 22 | * Note that checking `$cacheItem->isHit()` is not sufficent to determine this. 23 | * @param CacheItemInterface|null $cacheItem 24 | * 25 | * @return ResponseInterface 26 | */ 27 | public function onCacheResponse(RequestInterface $request, ResponseInterface $response, $fromCache, $cacheItem); 28 | } 29 | -------------------------------------------------------------------------------- /src/CachePlugin.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | final class CachePlugin implements Plugin 27 | { 28 | use VersionBridgePlugin; 29 | 30 | /** 31 | * @var CacheItemPoolInterface 32 | */ 33 | private $pool; 34 | 35 | /** 36 | * @var StreamFactoryInterface 37 | */ 38 | private $streamFactory; 39 | 40 | /** 41 | * @var mixed[] 42 | */ 43 | private $config; 44 | 45 | /** 46 | * Cache directives indicating if a response can not be cached. 47 | * 48 | * @var string[] 49 | */ 50 | private $noCacheFlags = ['no-cache', 'private', 'no-store']; 51 | 52 | /** 53 | * @param mixed[] $config 54 | * 55 | * bool respect_cache_headers: Whether to look at the cache directives or ignore them 56 | * int default_ttl: (seconds) If we do not respect cache headers or can't calculate a good ttl, use this value 57 | * string hash_algo: The hashing algorithm to use when generating cache keys 58 | * int|null cache_lifetime: (seconds) To support serving a previous stale response when the server answers 304 59 | * we have to store the cache for a longer time than the server originally says it is valid for. 60 | * We store a cache item for $cache_lifetime + max age of the response. 61 | * string[] methods: list of request methods which can be cached 62 | * string[] blacklisted_paths: list of regex for URLs explicitly not to be cached 63 | * string[] respect_response_cache_directives: list of cache directives this plugin will respect while caching responses 64 | * CacheKeyGenerator cache_key_generator: an object to generate the cache key. Defaults to a new instance of SimpleGenerator 65 | * CacheListener[] cache_listeners: an array of objects to act on the response based on the results of the cache check. 66 | * Defaults to an empty array 67 | * } 68 | */ 69 | public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) 70 | { 71 | $this->pool = $pool; 72 | $this->streamFactory = $streamFactory; 73 | 74 | if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { 75 | throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); 76 | } 77 | 78 | $optionsResolver = new OptionsResolver(); 79 | $this->configureOptions($optionsResolver); 80 | $this->config = $optionsResolver->resolve($config); 81 | 82 | if (null === $this->config['cache_key_generator']) { 83 | $this->config['cache_key_generator'] = new SimpleGenerator(); 84 | } 85 | } 86 | 87 | /** 88 | * This method will setup the cachePlugin in client cache mode. When using the client cache mode the plugin will 89 | * cache responses with `private` cache directive. 90 | * 91 | * @param mixed[] $config For all possible config options see the constructor docs 92 | * 93 | * @return CachePlugin 94 | */ 95 | public static function clientCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) 96 | { 97 | // Allow caching of private requests 98 | if (\array_key_exists('respect_response_cache_directives', $config)) { 99 | $config['respect_response_cache_directives'][] = 'no-cache'; 100 | $config['respect_response_cache_directives'][] = 'max-age'; 101 | $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); 102 | } else { 103 | $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; 104 | } 105 | 106 | return new self($pool, $streamFactory, $config); 107 | } 108 | 109 | /** 110 | * This method will setup the cachePlugin in server cache mode. This is the default caching behavior it refuses to 111 | * cache responses with the `private`or `no-cache` directives. 112 | * 113 | * @param mixed[] $config For all possible config options see the constructor docs 114 | * 115 | * @return CachePlugin 116 | */ 117 | public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) 118 | { 119 | return new self($pool, $streamFactory, $config); 120 | } 121 | 122 | /** 123 | * {@inheritdoc} 124 | * 125 | * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) 126 | */ 127 | protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) 128 | { 129 | $method = strtoupper($request->getMethod()); 130 | // if the request not is cachable, move to $next 131 | if (!in_array($method, $this->config['methods'])) { 132 | return $next($request)->then(function (ResponseInterface $response) use ($request) { 133 | $response = $this->handleCacheListeners($request, $response, false, null); 134 | 135 | return $response; 136 | }); 137 | } 138 | 139 | // If we can cache the request 140 | $key = $this->createCacheKey($request); 141 | $cacheItem = $this->pool->getItem($key); 142 | 143 | if ($cacheItem->isHit()) { 144 | $data = $cacheItem->get(); 145 | if (is_array($data)) { 146 | // The array_key_exists() is to be removed in 2.0. 147 | if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { 148 | // This item is still valid according to previous cache headers 149 | $response = $this->createResponseFromCacheItem($cacheItem); 150 | $response = $this->handleCacheListeners($request, $response, true, $cacheItem); 151 | 152 | return new FulfilledPromise($response); 153 | } 154 | 155 | // Add headers to ask the server if this cache is still valid 156 | if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { 157 | $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); 158 | } 159 | 160 | if ($etag = $this->getETag($cacheItem)) { 161 | $request = $request->withHeader('If-None-Match', $etag); 162 | } 163 | } 164 | } 165 | 166 | return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { 167 | if (304 === $response->getStatusCode()) { 168 | if (!$cacheItem->isHit()) { 169 | /* 170 | * We do not have the item in cache. This plugin did not add If-Modified-Since 171 | * or If-None-Match headers. Return the response from server. 172 | */ 173 | return $this->handleCacheListeners($request, $response, false, $cacheItem); 174 | } 175 | 176 | // The cached response we have is still valid 177 | $data = $cacheItem->get(); 178 | $maxAge = $this->getMaxAge($response); 179 | $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); 180 | $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); 181 | $this->pool->save($cacheItem); 182 | 183 | return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); 184 | } 185 | 186 | if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { 187 | /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. 188 | * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. 189 | .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. 190 | */ 191 | $bodyStream = $response->getBody(); 192 | $body = $bodyStream->__toString(); 193 | $bodyStream->detach(); 194 | 195 | $maxAge = $this->getMaxAge($response); 196 | $cacheItem 197 | ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) 198 | ->set([ 199 | 'response' => $response, 200 | 'body' => $body, 201 | 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), 202 | 'createdAt' => time(), 203 | 'etag' => $response->getHeader('ETag'), 204 | ]); 205 | $this->pool->save($cacheItem); 206 | 207 | $bodyStream = $this->streamFactory->createStream($body); 208 | if ($bodyStream->isSeekable()) { 209 | $bodyStream->rewind(); 210 | } 211 | 212 | $response = $response->withBody($bodyStream); 213 | } 214 | 215 | return $this->handleCacheListeners($request, $response, false, $cacheItem); 216 | }); 217 | } 218 | 219 | /** 220 | * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be 221 | * returned is $maxAge. 222 | * 223 | * @return int|null Unix system time passed to the PSR-6 cache 224 | */ 225 | private function calculateCacheItemExpiresAfter(?int $maxAge): ?int 226 | { 227 | if (null === $this->config['cache_lifetime'] && null === $maxAge) { 228 | return null; 229 | } 230 | 231 | return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); 232 | } 233 | 234 | /** 235 | * Calculate the timestamp when a response expires. After that timestamp, we need to send a 236 | * If-Modified-Since / If-None-Match request to validate the response. 237 | * 238 | * @return int|null Unix system time. A null value means that the response expires when the cache item expires 239 | */ 240 | private function calculateResponseExpiresAt(?int $maxAge): ?int 241 | { 242 | if (null === $maxAge) { 243 | return null; 244 | } 245 | 246 | return time() + $maxAge; 247 | } 248 | 249 | /** 250 | * Verify that we can cache this response. 251 | * 252 | * @return bool 253 | */ 254 | protected function isCacheable(ResponseInterface $response) 255 | { 256 | if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { 257 | return false; 258 | } 259 | 260 | $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); 261 | foreach ($nocacheDirectives as $nocacheDirective) { 262 | if ($this->getCacheControlDirective($response, $nocacheDirective)) { 263 | return false; 264 | } 265 | } 266 | 267 | return true; 268 | } 269 | 270 | /** 271 | * Verify that we can cache this request. 272 | */ 273 | private function isCacheableRequest(RequestInterface $request): bool 274 | { 275 | $uri = $request->getUri()->__toString(); 276 | foreach ($this->config['blacklisted_paths'] as $regex) { 277 | if (1 === preg_match($regex, $uri)) { 278 | return false; 279 | } 280 | } 281 | 282 | return true; 283 | } 284 | 285 | /** 286 | * Get the value of a parameter in the cache control header. 287 | * 288 | * @param string $name The field of Cache-Control to fetch 289 | * 290 | * @return bool|string The value of the directive, true if directive without value, false if directive not present 291 | */ 292 | private function getCacheControlDirective(ResponseInterface $response, string $name) 293 | { 294 | $headers = $response->getHeader('Cache-Control'); 295 | foreach ($headers as $header) { 296 | if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { 297 | // return the value for $name if it exists 298 | if (isset($matches[1])) { 299 | return $matches[1]; 300 | } 301 | 302 | return true; 303 | } 304 | } 305 | 306 | return false; 307 | } 308 | 309 | private function createCacheKey(RequestInterface $request): string 310 | { 311 | $key = $this->config['cache_key_generator']->generate($request); 312 | 313 | return hash($this->config['hash_algo'], $key); 314 | } 315 | 316 | /** 317 | * Get a ttl in seconds. 318 | * 319 | * Returns null if we do not respect cache headers and got no defaultTtl. 320 | */ 321 | private function getMaxAge(ResponseInterface $response): ?int 322 | { 323 | if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { 324 | return $this->config['default_ttl']; 325 | } 326 | 327 | // check for max age in the Cache-Control header 328 | $maxAge = $this->getCacheControlDirective($response, 'max-age'); 329 | if (!is_bool($maxAge)) { 330 | $ageHeaders = $response->getHeader('Age'); 331 | foreach ($ageHeaders as $age) { 332 | return ((int) $maxAge) - ((int) $age); 333 | } 334 | 335 | return (int) $maxAge; 336 | } 337 | 338 | // check for ttl in the Expires header 339 | $headers = $response->getHeader('Expires'); 340 | foreach ($headers as $header) { 341 | return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); 342 | } 343 | 344 | return $this->config['default_ttl']; 345 | } 346 | 347 | /** 348 | * Configure an options resolver. 349 | */ 350 | private function configureOptions(OptionsResolver $resolver): void 351 | { 352 | $resolver->setDefaults([ 353 | 'cache_lifetime' => 86400 * 30, // 30 days 354 | 'default_ttl' => 0, 355 | // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead 356 | 'respect_cache_headers' => null, 357 | 'hash_algo' => 'sha1', 358 | 'methods' => ['GET', 'HEAD'], 359 | 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], 360 | 'cache_key_generator' => null, 361 | 'cache_listeners' => [], 362 | 'blacklisted_paths' => [], 363 | ]); 364 | 365 | $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); 366 | $resolver->setAllowedTypes('default_ttl', ['int', 'null']); 367 | $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); 368 | $resolver->setAllowedTypes('methods', 'array'); 369 | $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); 370 | $resolver->setAllowedTypes('blacklisted_paths', 'array'); 371 | $resolver->setAllowedValues('hash_algo', hash_algos()); 372 | $resolver->setAllowedValues('methods', function ($value) { 373 | /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ 374 | $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); 375 | 376 | return empty($matches); 377 | }); 378 | $resolver->setAllowedTypes('cache_listeners', ['array']); 379 | 380 | $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { 381 | if (null !== $value) { 382 | @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 2.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); 383 | } 384 | 385 | return null === $value ? true : $value; 386 | }); 387 | 388 | $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { 389 | if (false === $options['respect_cache_headers']) { 390 | return []; 391 | } 392 | 393 | return $value; 394 | }); 395 | } 396 | 397 | private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface 398 | { 399 | $data = $cacheItem->get(); 400 | 401 | /** @var ResponseInterface $response */ 402 | $response = $data['response']; 403 | $stream = $this->streamFactory->createStream($data['body']); 404 | 405 | try { 406 | $stream->rewind(); 407 | } catch (\Exception $e) { 408 | throw new RewindStreamException('Cannot rewind stream.', 0, $e); 409 | } 410 | 411 | return $response->withBody($stream); 412 | } 413 | 414 | /** 415 | * Get the value for the "If-Modified-Since" header. 416 | */ 417 | private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string 418 | { 419 | $data = $cacheItem->get(); 420 | // The isset() is to be removed in 2.0. 421 | if (!isset($data['createdAt'])) { 422 | return null; 423 | } 424 | 425 | $modified = new \DateTime('@'.$data['createdAt']); 426 | $modified->setTimezone(new \DateTimeZone('GMT')); 427 | 428 | return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); 429 | } 430 | 431 | /** 432 | * Get the ETag from the cached response. 433 | */ 434 | private function getETag(CacheItemInterface $cacheItem): ?string 435 | { 436 | $data = $cacheItem->get(); 437 | // The isset() is to be removed in 2.0. 438 | if (!isset($data['etag'])) { 439 | return null; 440 | } 441 | 442 | foreach ($data['etag'] as $etag) { 443 | if (!empty($etag)) { 444 | return $etag; 445 | } 446 | } 447 | 448 | return null; 449 | } 450 | 451 | /** 452 | * Call the registered cache listeners. 453 | */ 454 | private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface 455 | { 456 | foreach ($this->config['cache_listeners'] as $cacheListener) { 457 | $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); 458 | } 459 | 460 | return $response; 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /src/Exception/RewindStreamException.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class RewindStreamException extends \RuntimeException implements Exception 11 | { 12 | } 13 | --------------------------------------------------------------------------------