├── LICENSE ├── composer.json └── src └── CachePlugin.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2019 PHP HTTP Team 4 | Copyright (c) 2018-2019 Graham Campbell 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graham-campbell/cache-plugin", 3 | "description": "Provides A Simple HTTP Cache Plugin With Good Defaults", 4 | "keywords": ["http", "cache plugin", "cache-plugin", "Cache", "Cache Plugin", "Cache-Plugin", "Graham Campbell", "GrahamCampbell"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Graham Campbell", 9 | "email": "graham@alt-three.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^7.0", 14 | "psr/cache": "^1.0", 15 | "php-http/cache-plugin": "^1.6", 16 | "php-http/client-common": "^1.9|^2.0", 17 | "php-http/message-factory": "^1.0" 18 | }, 19 | "require-dev": { 20 | "graham-campbell/analyzer": "^2.1", 21 | "phpunit/phpunit": "^6.5|^7.0|^8.0" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "GrahamCampbell\\CachePlugin\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "GrahamCampbell\\Tests\\CachePlugin\\": "tests/" 31 | } 32 | }, 33 | "config": { 34 | "preferred-install": "dist" 35 | }, 36 | "extra": { 37 | "branch-alias": { 38 | "dev-master": "1.1-dev" 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true 43 | } 44 | -------------------------------------------------------------------------------- /src/CachePlugin.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * For the full copyright and license information, please view the LICENSE 11 | * file that was distributed with this source code. 12 | */ 13 | 14 | namespace GrahamCampbell\CachePlugin; 15 | 16 | use Exception; 17 | use Http\Client\Common\Plugin; 18 | use Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator; 19 | use Http\Client\Common\Plugin\Cache\Generator\HeaderCacheKeyGenerator; 20 | use Http\Client\Common\Plugin\Exception\RewindStreamException; 21 | use Http\Client\Common\Plugin\VersionBridgePlugin; 22 | use Http\Message\StreamFactory; 23 | use Psr\Cache\CacheItemInterface; 24 | use Psr\Cache\CacheItemPoolInterface; 25 | use Psr\Http\Message\RequestInterface; 26 | use Psr\Http\Message\ResponseInterface; 27 | 28 | /** 29 | * This is the response cache plugin class. 30 | * 31 | * @author Tobias Nyholm 32 | * @author Graham Campbell 33 | */ 34 | class CachePlugin implements Plugin 35 | { 36 | use VersionBridgePlugin; 37 | 38 | /** 39 | * The cache item pool instance. 40 | * 41 | * @var \Psr\Cache\CacheItemPoolInterface 42 | */ 43 | protected $pool; 44 | 45 | /** 46 | * The steam factory instance. 47 | * 48 | * @var \Http\Message\StreamFactory 49 | */ 50 | protected $streamFactory; 51 | 52 | /** 53 | * The cache key generator instance. 54 | * 55 | * @var \Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator 56 | */ 57 | protected $generator; 58 | 59 | /** 60 | * The cache lifetime in seconds. 61 | * 62 | * @var int 63 | */ 64 | protected $lifetime; 65 | 66 | /** 67 | * Create a new cache plugin. 68 | * 69 | * @param \Psr\Cache\CacheItemPoolInterface $pool 70 | * @param \Http\Message\StreamFactory $streamFactory 71 | * @param \Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator|null $generator 72 | * @param int|null $lifetime 73 | * 74 | * @return void 75 | */ 76 | public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, CacheKeyGenerator $generator = null, int $lifetime = null) 77 | { 78 | $this->pool = $pool; 79 | $this->streamFactory = $streamFactory; 80 | $this->generator = $generator ?: new HeaderCacheKeyGenerator(['Authorization', 'Cookie', 'Accept', 'Content-type']); 81 | $this->lifetime = $lifetime ?: 3600 * 48; 82 | } 83 | 84 | /** 85 | * Handle the request and return the response coming from the next callable. 86 | * 87 | * @param \Psr\Http\Message\RequestInterface $request 88 | * @param callable $next 89 | * @param callable $first 90 | * 91 | * @return \Http\Promise\Promise 92 | */ 93 | protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) 94 | { 95 | $method = strtoupper($request->getMethod()); 96 | // If the request not is cachable, move to $next 97 | if (!in_array($method, ['GET', 'HEAD'], true)) { 98 | return $next($request); 99 | } 100 | 101 | $cacheItem = $this->createCacheItem($request); 102 | 103 | if ($cacheItem->isHit() && ($etag = $this->getETag($cacheItem))) { 104 | $request = $request->withHeader('If-None-Match', $etag); 105 | } 106 | 107 | return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { 108 | if (304 === $response->getStatusCode()) { 109 | if (!$cacheItem->isHit()) { 110 | // We do not have the item in cache. This plugin did not 111 | // add If-None-Match headers. Return the response. 112 | return $response; 113 | } 114 | 115 | // The cached response we have is still valid 116 | $cacheItem->set($cacheItem->get())->expiresAfter($this->lifetime); 117 | $this->pool->save($cacheItem); 118 | 119 | return $this->createResponseFromCacheItem($cacheItem); 120 | } 121 | 122 | if ($this->isCacheable($response)) { 123 | $bodyStream = $response->getBody(); 124 | $body = $bodyStream->__toString(); 125 | if ($bodyStream->isSeekable()) { 126 | $bodyStream->rewind(); 127 | } else { 128 | $response = $response->withBody($this->streamFactory->createStream($body)); 129 | } 130 | 131 | $cacheItem 132 | ->expiresAfter($this->lifetime) 133 | ->set([ 134 | 'response' => $response, 135 | 'body' => $body, 136 | 'etag' => $response->getHeader('ETag'), 137 | ]); 138 | $this->pool->save($cacheItem); 139 | } 140 | 141 | return $response; 142 | }); 143 | } 144 | 145 | /** 146 | * Create a cache item for a request. 147 | * 148 | * @param \Psr\Http\Message\RequestInterface $request 149 | * 150 | * @return \Psr\Cache\CacheItemInterface 151 | */ 152 | protected function createCacheItem(RequestInterface $request) 153 | { 154 | $key = sha1($this->generator->generate($request)); 155 | 156 | return $this->pool->getItem($key); 157 | } 158 | 159 | /** 160 | * Verify that we can cache this response. 161 | * 162 | * @param \Psr\Http\Message\ResponseInterface $response 163 | * 164 | * @return bool 165 | */ 166 | protected function isCacheable(ResponseInterface $response) 167 | { 168 | if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { 169 | return false; 170 | } 171 | 172 | return !$this->getCacheControlDirective($response, 'no-cache'); 173 | } 174 | 175 | /** 176 | * Get the value of a parameter in the cache control header. 177 | * 178 | * @param \Psr\Http\Message\ResponseInterface $response 179 | * @param string $name 180 | * 181 | * @return bool|string 182 | */ 183 | protected function getCacheControlDirective(ResponseInterface $response, string $name) 184 | { 185 | foreach ($response->getHeader('Cache-Control') as $header) { 186 | if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { 187 | // return the value for $name if it exists 188 | if (isset($matches[1])) { 189 | return $matches[1]; 190 | } 191 | 192 | return true; 193 | } 194 | } 195 | 196 | return false; 197 | } 198 | 199 | /** 200 | * Create a response from a cache item. 201 | * 202 | * @param \Psr\Cache\CacheItemInterface $cacheItem 203 | * 204 | * @return \Psr\Http\Message\ResponseInterface 205 | */ 206 | protected function createResponseFromCacheItem(CacheItemInterface $cacheItem) 207 | { 208 | $data = $cacheItem->get(); 209 | 210 | $response = $data['response']; 211 | $stream = $this->streamFactory->createStream($data['body']); 212 | 213 | try { 214 | $stream->rewind(); 215 | } catch (Exception $e) { 216 | throw new RewindStreamException('Cannot rewind stream.', 0, $e); 217 | } 218 | 219 | $response = $response->withBody($stream); 220 | 221 | return $response; 222 | } 223 | 224 | /** 225 | * Get the ETag from the cached response. 226 | * 227 | * @param \Psr\Cache\CacheItemInterface $cacheItem 228 | * 229 | * @return string|null 230 | */ 231 | protected function getETag(CacheItemInterface $cacheItem) 232 | { 233 | $data = $cacheItem->get(); 234 | 235 | foreach ($data['etag'] as $etag) { 236 | if (!empty($etag)) { 237 | return $etag; 238 | } 239 | } 240 | } 241 | } 242 | --------------------------------------------------------------------------------