1 <?php 2 3 namespace Kevinrob\GuzzleCache; 4 5 use GuzzleHttp\Client; 6 use GuzzleHttp\Exception\TransferException; 7 use GuzzleHttp\Promise\FulfilledPromise; 8 use GuzzleHttp\Promise\Promise; 9 use GuzzleHttp\Promise\RejectedPromise; 10 use GuzzleHttp\Promise\Utils; 11 use GuzzleHttp\Psr7\Response; 12 use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface; 13 use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; 14 use Psr\Http\Message\RequestInterface; 15 use Psr\Http\Message\ResponseInterface; 16 17 /** 18 * Class CacheMiddleware. 19 */ 20 class CacheMiddleware 21 { 22 const HEADER_RE_VALIDATION = 'X-Kevinrob-GuzzleCache-ReValidation'; 23 const HEADER_INVALIDATION = 'X-Kevinrob-GuzzleCache-Invalidation'; 24 const HEADER_CACHE_INFO = 'X-Kevinrob-Cache'; 25 const HEADER_CACHE_HIT = 'HIT'; 26 const HEADER_CACHE_MISS = 'MISS'; 27 const HEADER_CACHE_STALE = 'STALE'; 28 29 /** 30 * @var array of Promise 31 */ 32 protected $waitingRevalidate = []; 33 34 /** 35 * @var Client 36 */ 37 protected $client; 38 39 /** 40 * @var CacheStrategyInterface 41 */ 42 protected $cacheStorage; 43 44 /** 45 * List of allowed HTTP methods to cache 46 * Key = method name (upscaling) 47 * Value = true. 48 * 49 * @var array 50 */ 51 protected $httpMethods = ['GET' => true]; 52 53 /** 54 * List of safe methods 55 * 56 * https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1 57 * 58 * @var array 59 */ 60 protected $safeMethods = ['GET' => true, 'HEAD' => true, 'OPTIONS' => true, 'TRACE' => true]; 61 62 /** 63 * @param CacheStrategyInterface|null $cacheStrategy 64 */ 65 public function __construct(CacheStrategyInterface $cacheStrategy = null) 66 { 67 $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy(); 68 69 register_shutdown_function([$this, 'purgeReValidation']); 70 } 71 72 /** 73 * @param Client $client 74 */ 75 public function setClient(Client $client) 76 { 77 $this->client = $client; 78 } 79 80 /** 81 * @param CacheStrategyInterface $cacheStorage 82 */ 83 public function setCacheStorage(CacheStrategyInterface $cacheStorage) 84 { 85 $this->cacheStorage = $cacheStorage; 86 } 87 88 /** 89 * @return CacheStrategyInterface 90 */ 91 public function getCacheStorage() 92 { 93 return $this->cacheStorage; 94 } 95 96 /** 97 * @param array $methods 98 */ 99 public function setHttpMethods(array $methods) 100 { 101 $this->httpMethods = $methods; 102 } 103 104 public function getHttpMethods() 105 { 106 return $this->httpMethods; 107 } 108 109 /** 110 * Will be called at the end of the script. 111 */ 112 public function purgeReValidation() 113 { 114 // Call to \GuzzleHttp\Promise\inspect_all throws error, replacing with the latest one. 115 Utils::inspectAll($this->waitingRevalidate); 116 } 117 118 /** 119 * @param callable $handler 120 * 121 * @return callable 122 */ 123 public function __invoke(callable $handler) 124 { 125 return function (RequestInterface $request, array $options) use (&$handler) { 126 if (!isset($this->httpMethods[strtoupper($request->getMethod())])) { 127 // No caching for this method allowed 128 129 return $handler($request, $options)->then( 130 function (ResponseInterface $response) use ($request) { 131 if (!isset($this->safeMethods[$request->getMethod()])) { 132 // Invalidate cache after a call of non-safe method on the same URI 133 $response = $this->invalidateCache($request, $response); 134 } 135 136 return $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS); 137 } 138 ); 139 } 140 141 if ($request->hasHeader(self::HEADER_RE_VALIDATION)) { 142 // It's a re-validation request, so bypass the cache! 143 return $handler($request->withoutHeader(self::HEADER_RE_VALIDATION), $options); 144 } 145 146 // Retrieve information from request (Cache-Control) 147 $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); 148 $onlyFromCache = $reqCacheControl->has('only-if-cached'); 149 $staleResponse = $reqCacheControl->has('max-stale') 150 && $reqCacheControl->get('max-stale') === ''; 151 $maxStaleCache = $reqCacheControl->get('max-stale', null); 152 $minFreshCache = $reqCacheControl->get('min-fresh', null); 153 154 // If cache => return new FulfilledPromise(...) with response 155 $cacheEntry = $this->cacheStorage->fetch($request); 156 if ($cacheEntry instanceof CacheEntry) { 157 $body = $cacheEntry->getResponse()->getBody(); 158 if ($body->tell() > 0) { 159 $body->rewind(); 160 } 161 162 if ($cacheEntry->isFresh() 163 && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0) 164 ) { 165 // Cache HIT! 166 return new FulfilledPromise( 167 $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT) 168 ); 169 } elseif ($staleResponse 170 || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache) 171 ) { 172 // Staled cache! 173 return new FulfilledPromise( 174 $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT) 175 ); 176 } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) { 177 // Re-validation header 178 $request = static::getRequestWithReValidationHeader($request, $cacheEntry); 179 180 if ($cacheEntry->staleWhileValidate()) { 181 static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry); 182 183 return new FulfilledPromise( 184 $cacheEntry->getResponse() 185 ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE) 186 ); 187 } 188 } 189 } else { 190 $cacheEntry = null; 191 } 192 193 if ($cacheEntry === null && $onlyFromCache) { 194 // Explicit asking of a cached response => 504 195 return new FulfilledPromise( 196 new Response(504) 197 ); 198 } 199 200 /** @var Promise $promise */ 201 $promise = $handler($request, $options); 202 203 return $promise->then( 204 function (ResponseInterface $response) use ($request, $cacheEntry) { 205 // Check if error and looking for a staled content 206 if ($response->getStatusCode() >= 500) { 207 $responseStale = static::getStaleResponse($cacheEntry); 208 if ($responseStale instanceof ResponseInterface) { 209 return $responseStale; 210 } 211 } 212 213 $update = false; 214 215 if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) { 216 // Not modified => cache entry is re-validate 217 /** @var ResponseInterface $response */ 218 $response = $response 219 ->withStatus($cacheEntry->getResponse()->getStatusCode()) 220 ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT); 221 $response = $response->withBody($cacheEntry->getResponse()->getBody()); 222 223 // Merge headers of the "304 Not Modified" and the cache entry 224 /** 225 * @var string $headerName 226 * @var string[] $headerValue 227 */ 228 foreach ($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue) { 229 if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) { 230 $response = $response->withHeader($headerName, $headerValue); 231 } 232 } 233 234 $update = true; 235 } else { 236 $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS); 237 } 238 239 return static::addToCache($this->cacheStorage, $request, $response, $update); 240 }, 241 function ($reason) use ($cacheEntry) { 242 if ($reason instanceof TransferException) { 243 $response = static::getStaleResponse($cacheEntry); 244 if ($response instanceof ResponseInterface) { 245 return $response; 246 } 247 } 248 249 return new RejectedPromise($reason); 250 } 251 ); 252 }; 253 } 254 255 /** 256 * @param CacheStrategyInterface $cache 257 * @param RequestInterface $request 258 * @param ResponseInterface $response 259 * @param bool $update cache 260 * @return ResponseInterface 261 */ 262 protected static function addToCache( 263 CacheStrategyInterface $cache, 264 RequestInterface $request, 265 ResponseInterface $response, 266 $update = false 267 ) { 268 $body = $response->getBody(); 269 270 // If the body is not seekable, we have to replace it by a seekable one 271 if (!$body->isSeekable()) { 272 $response = $response->withBody( 273 \GuzzleHttp\Psr7\Utils::streamFor($body->getContents()) 274 ); 275 } 276 277 if ($update) { 278 $cache->update($request, $response); 279 } else { 280 $cache->cache($request, $response); 281 } 282 283 // always rewind back to the start otherwise other middlewares may get empty "content" 284 if ($body->isSeekable()) { 285 $response->getBody()->rewind(); 286 } 287 288 return $response; 289 } 290 291 /** 292 * @param RequestInterface $request 293 * @param CacheStrategyInterface $cacheStorage 294 * @param CacheEntry $cacheEntry 295 * 296 * @return bool if added 297 */ 298 protected function addReValidationRequest( 299 RequestInterface $request, 300 CacheStrategyInterface &$cacheStorage, 301 CacheEntry $cacheEntry 302 ) { 303 // Add the promise for revalidate 304 if ($this->client !== null) { 305 /** @var RequestInterface $request */ 306 $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1'); 307 $this->waitingRevalidate[] = $this->client 308 ->sendAsync($request) 309 ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) { 310 $update = false; 311 312 if ($response->getStatusCode() == 304) { 313 // Not modified => cache entry is re-validate 314 /** @var ResponseInterface $response */ 315 $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode()); 316 $response = $response->withBody($cacheEntry->getResponse()->getBody()); 317 318 // Merge headers of the "304 Not Modified" and the cache entry 319 foreach ($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue) { 320 if (!$response->hasHeader($headerName)) { 321 $response = $response->withHeader($headerName, $headerValue); 322 } 323 } 324 325 $update = true; 326 } 327 328 static::addToCache($cacheStorage, $request, $response, $update); 329 }); 330 331 return true; 332 } 333 334 return false; 335 } 336 337 /** 338 * @param CacheEntry|null $cacheEntry 339 * 340 * @return null|ResponseInterface 341 */ 342 protected static function getStaleResponse(CacheEntry $cacheEntry = null) 343 { 344 // Return staled cache entry if we can 345 if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) { 346 return $cacheEntry->getResponse() 347 ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE); 348 } 349 350 return; 351 } 352 353 /** 354 * @param RequestInterface $request 355 * @param CacheEntry $cacheEntry 356 * 357 * @return RequestInterface 358 */ 359 protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry) 360 { 361 if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) { 362 $request = $request->withHeader( 363 'If-Modified-Since', 364 $cacheEntry->getResponse()->getHeader('Last-Modified') 365 ); 366 } 367 if ($cacheEntry->getResponse()->hasHeader('Etag')) { 368 $request = $request->withHeader( 369 'If-None-Match', 370 $cacheEntry->getResponse()->getHeader('Etag') 371 ); 372 } 373 374 return $request; 375 } 376 377 /** 378 * @param CacheStrategyInterface|null $cacheStorage 379 * 380 * @return CacheMiddleware the Middleware for Guzzle HandlerStack 381 * 382 * @deprecated Use constructor => `new CacheMiddleware()` 383 */ 384 public static function getMiddleware(CacheStrategyInterface $cacheStorage = null) 385 { 386 return new self($cacheStorage); 387 } 388 389 /** 390 * @param RequestInterface $request 391 * 392 * @param ResponseInterface $response 393 * 394 * @return ResponseInterface 395 */ 396 private function invalidateCache(RequestInterface $request, ResponseInterface $response) 397 { 398 foreach (array_keys($this->httpMethods) as $method) { 399 $this->cacheStorage->delete($request->withMethod($method)); 400 } 401 402 return $response->withHeader(self::HEADER_INVALIDATION, true); 403 } 404 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body