diff --git a/CHANGELOG.md b/CHANGELOG.md index 49d3a30..2e6f6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # Version 2 +## 2.1.0 - TBD + +### Added + +- Added `EtagCachePlugin` to cache only ETag-backed responses and always revalidate them before serving cached bodies. + ## 2.0.2 - 2025-12-01 - Support Symfony 8. diff --git a/README.md b/README.md index f4f9f43..537660a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,14 @@ composer require php-http/cache-plugin Please see the [official documentation](http://docs.php-http.org/en/latest/plugins/cache.html). +To only cache ETag-backed responses and always revalidate cached bodies with `If-None-Match`, use `EtagCachePlugin`: + +``` php +$plugin = EtagCachePlugin::clientCache($pool, $streamFactory); +``` + +Responses without an `ETag` header are not cached. Cached responses are only returned after the origin replies with `304 Not Modified`. + ## Testing diff --git a/src/AbstractCachePlugin.php b/src/AbstractCachePlugin.php new file mode 100644 index 0000000..3192499 --- /dev/null +++ b/src/AbstractCachePlugin.php @@ -0,0 +1,473 @@ +pool = $pool; + $this->streamFactory = $streamFactory; + + if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { + throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); + } + + $optionsResolver = new OptionsResolver(); + self::configureOptions($optionsResolver); + $this->config = $optionsResolver->resolve($config); + + if (null === $this->config['cache_key_generator']) { + $this->config['cache_key_generator'] = new SimpleGenerator(); + } + } + + /** + * @param mixed[] $config + * + * @return mixed[] + */ + protected static function prepareClientCacheConfig(array $config): array + { + // Allow caching of private requests + if (\array_key_exists('respect_response_cache_directives', $config)) { + $config['respect_response_cache_directives'][] = 'no-cache'; + $config['respect_response_cache_directives'][] = 'max-age'; + $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); + } else { + $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; + } + + return $config; + } + + /** + * {@inheritdoc} + * + * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) + */ + protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) + { + $method = strtoupper($request->getMethod()); + // if the request not is cachable, move to $next + if (!in_array($method, $this->config['methods'])) { + return $next($request)->then(function (ResponseInterface $response) use ($request) { + $response = $this->handleCacheListeners($request, $response, false, null); + + return $response; + }); + } + + // If we can cache the request + $key = $this->createCacheKey($request); + $cacheItem = $this->pool->getItem($key); + + if ($cacheItem->isHit()) { + $data = $cacheItem->get(); + if (is_array($data)) { + if (static::shouldUseCachedResponse($data)) { + // This item is still valid according to previous cache headers + $response = $this->createResponseFromCacheItem($cacheItem); + $response = $this->handleCacheListeners($request, $response, true, $cacheItem); + + return new FulfilledPromise($response); + } + + $request = static::withCacheValidationHeaders($request, $cacheItem); + } + } + + return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { + if (304 === $response->getStatusCode()) { + if (!static::canUseCacheItemForNotModifiedResponse($cacheItem)) { + /* + * We do not have the item in cache. This plugin did not add If-Modified-Since + * or If-None-Match headers. Return the response from server. + */ + return $this->handleCacheListeners($request, $response, false, $cacheItem); + } + + // The cached response we have is still valid + $data = $cacheItem->get(); + $maxAge = $this->getMaxAge($response); + $data['expiresAt'] = static::calculateResponseExpiresAt($maxAge); + $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); + $this->pool->save($cacheItem); + + return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); + } + + if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { + /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. + * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. + .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. + */ + $bodyStream = $response->getBody(); + $body = $bodyStream->__toString(); + $bodyStream->detach(); + + $maxAge = $this->getMaxAge($response); + $cacheItem + ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) + ->set([ + 'response' => $response, + 'body' => $body, + 'expiresAt' => static::calculateResponseExpiresAt($maxAge), + 'createdAt' => time(), + 'etag' => $response->getHeader('ETag'), + ]); + $this->pool->save($cacheItem); + + $bodyStream = $this->streamFactory->createStream($body); + if ($bodyStream->isSeekable()) { + $bodyStream->rewind(); + } + + $response = $response->withBody($bodyStream); + } + + return $this->handleCacheListeners($request, $response, false, $cacheItem); + }); + } + + /** + * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be + * returned is $maxAge. + * + * @return int|null Unix system time passed to the PSR-6 cache + */ + private function calculateCacheItemExpiresAfter(?int $maxAge): ?int + { + if (null === $this->config['cache_lifetime'] && null === $maxAge) { + return null; + } + + return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); + } + + /** + * Calculate the timestamp when a response expires. After that timestamp, we need to send a + * If-Modified-Since / If-None-Match request to validate the response. + * + * @return int|null Unix system time. A null value means that the response expires when the cache item expires + */ + protected static function calculateResponseExpiresAt(?int $maxAge) + { + if (null === $maxAge) { + return null; + } + + return time() + $maxAge; + } + + /** + * Verify that we can cache this response. + * + * @return bool + */ + protected function isCacheable(ResponseInterface $response) + { + if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { + return false; + } + + $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); + foreach ($nocacheDirectives as $nocacheDirective) { + if (self::getCacheControlDirective($response, $nocacheDirective)) { + return false; + } + } + + return true; + } + + /** + * Verify that we can cache this request. + */ + private function isCacheableRequest(RequestInterface $request): bool + { + $uri = $request->getUri()->__toString(); + foreach ($this->config['blacklisted_paths'] as $regex) { + if (1 === preg_match($regex, $uri)) { + return false; + } + } + + return true; + } + + /** + * Get the value of a parameter in the cache control header. + * + * @param string $name The field of Cache-Control to fetch + * + * @return bool|string The value of the directive, true if directive without value, false if directive not present + */ + private static function getCacheControlDirective(ResponseInterface $response, string $name) + { + $headers = $response->getHeader('Cache-Control'); + foreach ($headers as $header) { + if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { + // return the value for $name if it exists + if (isset($matches[1])) { + return $matches[1]; + } + + return true; + } + } + + return false; + } + + private function createCacheKey(RequestInterface $request): string + { + $key = $this->config['cache_key_generator']->generate($request); + + return hash($this->config['hash_algo'], $key); + } + + /** + * Get a ttl in seconds. + * + * Returns null if we do not respect cache headers and got no defaultTtl. + */ + private function getMaxAge(ResponseInterface $response): ?int + { + if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { + return $this->config['default_ttl']; + } + + // check for max age in the Cache-Control header + $maxAge = self::getCacheControlDirective($response, 'max-age'); + if (!is_bool($maxAge)) { + $ageHeaders = $response->getHeader('Age'); + foreach ($ageHeaders as $age) { + return ((int) $maxAge) - ((int) $age); + } + + return (int) $maxAge; + } + + // check for ttl in the Expires header + $headers = $response->getHeader('Expires'); + foreach ($headers as $header) { + return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); + } + + return $this->config['default_ttl']; + } + + /** + * Configure an options resolver. + */ + private static function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'cache_lifetime' => 86400 * 30, // 30 days + 'default_ttl' => 0, + // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead + 'respect_cache_headers' => null, + 'hash_algo' => 'sha1', + 'methods' => ['GET', 'HEAD'], + 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], + 'cache_key_generator' => null, + 'cache_listeners' => [], + 'blacklisted_paths' => [], + ]); + + $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); + $resolver->setAllowedTypes('default_ttl', ['int', 'null']); + $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); + $resolver->setAllowedTypes('methods', 'array'); + $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); + $resolver->setAllowedTypes('blacklisted_paths', 'array'); + $resolver->setAllowedValues('hash_algo', hash_algos()); + $resolver->setAllowedValues('methods', function ($value) { + /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ + $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); + + return empty($matches); + }); + $resolver->setAllowedTypes('cache_listeners', ['array']); + + $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { + if (null !== $value) { + @trigger_error('The option "respect_cache_headers" is deprecated since version 1.3 and will be removed in 3.0. Use "respect_response_cache_directives" instead.', E_USER_DEPRECATED); + } + + return null === $value ? true : $value; + }); + + $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { + if (false === $options['respect_cache_headers']) { + return []; + } + + return $value; + }); + } + + private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface + { + $data = $cacheItem->get(); + + /** @var ResponseInterface $response */ + $response = $data['response']; + $stream = $this->streamFactory->createStream($data['body']); + + try { + $stream->rewind(); + } catch (\Exception $e) { + throw new RewindStreamException('Cannot rewind stream.', 0, $e); + } + + return $response->withBody($stream); + } + + protected static function responseHasETag(ResponseInterface $response): bool + { + foreach ($response->getHeader('ETag') as $etag) { + if ('' !== trim($etag)) { + return true; + } + } + + return false; + } + + /** + * Get the value for the "If-Modified-Since" header. + */ + private static function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 3.0. + if (!isset($data['createdAt'])) { + return null; + } + + $modified = new \DateTime('@'.$data['createdAt']); + $modified->setTimezone(new \DateTimeZone('GMT')); + + return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); + } + + /** + * Get the ETag from the cached response. + */ + protected static function getETag(CacheItemInterface $cacheItem): ?string + { + $data = $cacheItem->get(); + // The isset() is to be removed in 3.0. + if (!isset($data['etag'])) { + return null; + } + + foreach ($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } + } + + return null; + } + + /** + * Call the registered cache listeners. + */ + private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface + { + foreach ($this->config['cache_listeners'] as $cacheListener) { + $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); + } + + return $response; + } + + /** + * @param mixed[] $data + */ + protected static function shouldUseCachedResponse(array $data): bool + { + // The array_key_exists() is to be removed in 3.0. + return array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt']); + } + + protected static function withCacheValidationHeaders(RequestInterface $request, CacheItemInterface $cacheItem): RequestInterface + { + // Add headers to ask the server if this cache is still valid + if ($modifiedSinceValue = self::getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); + } + + if ($etag = static::getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } + + return $request; + } + + protected static function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + { + return $cacheItem->isHit(); + } +} diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 6afb03e..bd36225 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -2,19 +2,8 @@ namespace Http\Client\Common\Plugin; -use Http\Client\Common\Plugin; -use Http\Client\Common\Plugin\Exception\RewindStreamException; -use Http\Client\Common\Plugin\Cache\Generator\CacheKeyGenerator; -use Http\Client\Common\Plugin\Cache\Generator\SimpleGenerator; -use Http\Promise\FulfilledPromise; -use Http\Promise\Promise; -use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; -use Symfony\Component\OptionsResolver\Options; -use Symfony\Component\OptionsResolver\OptionsResolver; /** * Allow for caching a response with a PSR-6 compatible caching engine. @@ -23,67 +12,8 @@ * * @author Tobias Nyholm */ -final class CachePlugin implements Plugin +final class CachePlugin extends AbstractCachePlugin { - use VersionBridgePlugin; - - /** - * @var CacheItemPoolInterface - */ - private $pool; - - /** - * @var StreamFactoryInterface - */ - private $streamFactory; - - /** - * @var mixed[] - */ - private $config; - - /** - * Cache directives indicating if a response can not be cached. - * - * @var string[] - */ - private $noCacheFlags = ['no-cache', 'private', 'no-store']; - - /** - * @param mixed[] $config - * - * bool respect_cache_headers: Whether to look at the cache directives or ignore them - * int default_ttl: (seconds) If we do not respect cache headers or can't calculate a good ttl, use this value - * string hash_algo: The hashing algorithm to use when generating cache keys - * int|null cache_lifetime: (seconds) To support serving a previous stale response when the server answers 304 - * we have to store the cache for a longer time than the server originally says it is valid for. - * We store a cache item for $cache_lifetime + max age of the response. - * string[] methods: list of request methods which can be cached - * string[] blacklisted_paths: list of regex for URLs explicitly not to be cached - * string[] respect_response_cache_directives: list of cache directives this plugin will respect while caching responses - * CacheKeyGenerator cache_key_generator: an object to generate the cache key. Defaults to a new instance of SimpleGenerator - * CacheListener[] cache_listeners: an array of objects to act on the response based on the results of the cache check. - * Defaults to an empty array - * } - */ - public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) - { - $this->pool = $pool; - $this->streamFactory = $streamFactory; - - if (\array_key_exists('respect_cache_headers', $config) && \array_key_exists('respect_response_cache_directives', $config)) { - throw new \InvalidArgumentException('You can\'t provide config option "respect_cache_headers" and "respect_response_cache_directives". Use "respect_response_cache_directives" instead.'); - } - - $optionsResolver = new OptionsResolver(); - $this->configureOptions($optionsResolver); - $this->config = $optionsResolver->resolve($config); - - if (null === $this->config['cache_key_generator']) { - $this->config['cache_key_generator'] = new SimpleGenerator(); - } - } - /** * This method will setup the cachePlugin in client cache mode. When using the client cache mode the plugin will * cache responses with `private` cache directive. @@ -94,16 +24,7 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactoryInterface */ public static function clientCache(CacheItemPoolInterface $pool, StreamFactoryInterface $streamFactory, array $config = []) { - // Allow caching of private requests - if (\array_key_exists('respect_response_cache_directives', $config)) { - $config['respect_response_cache_directives'][] = 'no-cache'; - $config['respect_response_cache_directives'][] = 'max-age'; - $config['respect_response_cache_directives'] = array_unique($config['respect_response_cache_directives']); - } else { - $config['respect_response_cache_directives'] = ['no-cache', 'max-age']; - } - - return new self($pool, $streamFactory, $config); + return new self($pool, $streamFactory, self::prepareClientCacheConfig($config)); } /** @@ -118,345 +39,4 @@ public static function serverCache(CacheItemPoolInterface $pool, StreamFactoryIn { return new self($pool, $streamFactory, $config); } - - /** - * {@inheritdoc} - * - * @return Promise Resolves a PSR-7 Response or fails with an Http\Client\Exception (The same as HttpAsyncClient) - */ - protected function doHandleRequest(RequestInterface $request, callable $next, callable $first) - { - $method = strtoupper($request->getMethod()); - // if the request not is cachable, move to $next - if (!in_array($method, $this->config['methods'])) { - return $next($request)->then(function (ResponseInterface $response) use ($request) { - $response = $this->handleCacheListeners($request, $response, false, null); - - return $response; - }); - } - - // If we can cache the request - $key = $this->createCacheKey($request); - $cacheItem = $this->pool->getItem($key); - - if ($cacheItem->isHit()) { - $data = $cacheItem->get(); - if (is_array($data)) { - // The array_key_exists() is to be removed in 2.0. - if (array_key_exists('expiresAt', $data) && (null === $data['expiresAt'] || time() < $data['expiresAt'])) { - // This item is still valid according to previous cache headers - $response = $this->createResponseFromCacheItem($cacheItem); - $response = $this->handleCacheListeners($request, $response, true, $cacheItem); - - return new FulfilledPromise($response); - } - - // Add headers to ask the server if this cache is still valid - if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { - $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); - } - - if ($etag = $this->getETag($cacheItem)) { - $request = $request->withHeader('If-None-Match', $etag); - } - } - } - - return $next($request)->then(function (ResponseInterface $response) use ($request, $cacheItem) { - if (304 === $response->getStatusCode()) { - if (!$cacheItem->isHit()) { - /* - * We do not have the item in cache. This plugin did not add If-Modified-Since - * or If-None-Match headers. Return the response from server. - */ - return $this->handleCacheListeners($request, $response, false, $cacheItem); - } - - // The cached response we have is still valid - $data = $cacheItem->get(); - $maxAge = $this->getMaxAge($response); - $data['expiresAt'] = $this->calculateResponseExpiresAt($maxAge); - $cacheItem->set($data)->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)); - $this->pool->save($cacheItem); - - return $this->handleCacheListeners($request, $this->createResponseFromCacheItem($cacheItem), true, $cacheItem); - } - - if ($this->isCacheable($response) && $this->isCacheableRequest($request)) { - /* The PSR-7 response body is a stream. We can't expect that the response implements Serializable and handles the body. - * Therefore we store the body separately and detach the stream to avoid attempting to serialize a resource. - .* Our implementation still makes the assumption that the response object apart from the body can be serialized and deserialized. - */ - $bodyStream = $response->getBody(); - $body = $bodyStream->__toString(); - $bodyStream->detach(); - - $maxAge = $this->getMaxAge($response); - $cacheItem - ->expiresAfter($this->calculateCacheItemExpiresAfter($maxAge)) - ->set([ - 'response' => $response, - 'body' => $body, - 'expiresAt' => $this->calculateResponseExpiresAt($maxAge), - 'createdAt' => time(), - 'etag' => $response->getHeader('ETag'), - ]); - $this->pool->save($cacheItem); - - $bodyStream = $this->streamFactory->createStream($body); - if ($bodyStream->isSeekable()) { - $bodyStream->rewind(); - } - - $response = $response->withBody($bodyStream); - } - - return $this->handleCacheListeners($request, $response, false, $cacheItem); - }); - } - - /** - * Calculate the timestamp when this cache item should be dropped from the cache. The lowest value that can be - * returned is $maxAge. - * - * @return int|null Unix system time passed to the PSR-6 cache - */ - private function calculateCacheItemExpiresAfter(?int $maxAge): ?int - { - if (null === $this->config['cache_lifetime'] && null === $maxAge) { - return null; - } - - return ($this->config['cache_lifetime'] ?: 0) + ($maxAge ?: 0); - } - - /** - * Calculate the timestamp when a response expires. After that timestamp, we need to send a - * If-Modified-Since / If-None-Match request to validate the response. - * - * @return int|null Unix system time. A null value means that the response expires when the cache item expires - */ - private function calculateResponseExpiresAt(?int $maxAge): ?int - { - if (null === $maxAge) { - return null; - } - - return time() + $maxAge; - } - - /** - * Verify that we can cache this response. - * - * @return bool - */ - protected function isCacheable(ResponseInterface $response) - { - if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { - return false; - } - - $nocacheDirectives = array_intersect($this->config['respect_response_cache_directives'], $this->noCacheFlags); - foreach ($nocacheDirectives as $nocacheDirective) { - if ($this->getCacheControlDirective($response, $nocacheDirective)) { - return false; - } - } - - return true; - } - - /** - * Verify that we can cache this request. - */ - private function isCacheableRequest(RequestInterface $request): bool - { - $uri = $request->getUri()->__toString(); - foreach ($this->config['blacklisted_paths'] as $regex) { - if (1 === preg_match($regex, $uri)) { - return false; - } - } - - return true; - } - - /** - * Get the value of a parameter in the cache control header. - * - * @param string $name The field of Cache-Control to fetch - * - * @return bool|string The value of the directive, true if directive without value, false if directive not present - */ - private function getCacheControlDirective(ResponseInterface $response, string $name) - { - $headers = $response->getHeader('Cache-Control'); - foreach ($headers as $header) { - if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { - // return the value for $name if it exists - if (isset($matches[1])) { - return $matches[1]; - } - - return true; - } - } - - return false; - } - - private function createCacheKey(RequestInterface $request): string - { - $key = $this->config['cache_key_generator']->generate($request); - - return hash($this->config['hash_algo'], $key); - } - - /** - * Get a ttl in seconds. - * - * Returns null if we do not respect cache headers and got no defaultTtl. - */ - private function getMaxAge(ResponseInterface $response): ?int - { - if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { - return $this->config['default_ttl']; - } - - // check for max age in the Cache-Control header - $maxAge = $this->getCacheControlDirective($response, 'max-age'); - if (!is_bool($maxAge)) { - $ageHeaders = $response->getHeader('Age'); - foreach ($ageHeaders as $age) { - return ((int) $maxAge) - ((int) $age); - } - - return (int) $maxAge; - } - - // check for ttl in the Expires header - $headers = $response->getHeader('Expires'); - foreach ($headers as $header) { - return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); - } - - return $this->config['default_ttl']; - } - - /** - * Configure an options resolver. - */ - private function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([ - 'cache_lifetime' => 86400 * 30, // 30 days - 'default_ttl' => 0, - // Deprecated as of v1.3, to be removed in v2.0. Use respect_response_cache_directives instead - 'respect_cache_headers' => null, - 'hash_algo' => 'sha1', - 'methods' => ['GET', 'HEAD'], - 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], - 'cache_key_generator' => null, - 'cache_listeners' => [], - 'blacklisted_paths' => [], - ]); - - $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); - $resolver->setAllowedTypes('default_ttl', ['int', 'null']); - $resolver->setAllowedTypes('respect_cache_headers', ['bool', 'null']); - $resolver->setAllowedTypes('methods', 'array'); - $resolver->setAllowedTypes('cache_key_generator', ['null', CacheKeyGenerator::class]); - $resolver->setAllowedTypes('blacklisted_paths', 'array'); - $resolver->setAllowedValues('hash_algo', hash_algos()); - $resolver->setAllowedValues('methods', function ($value) { - /* RFC7230 sections 3.1.1 and 3.2.6 except limited to uppercase characters. */ - $matches = preg_grep('/[^A-Z0-9!#$%&\'*+\-.^_`|~]/', $value); - - return empty($matches); - }); - $resolver->setAllowedTypes('cache_listeners', ['array']); - - $resolver->setNormalizer('respect_cache_headers', function (Options $options, $value) { - if (null !== $value) { - @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); - } - - return null === $value ? true : $value; - }); - - $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { - if (false === $options['respect_cache_headers']) { - return []; - } - - return $value; - }); - } - - private function createResponseFromCacheItem(CacheItemInterface $cacheItem): ResponseInterface - { - $data = $cacheItem->get(); - - /** @var ResponseInterface $response */ - $response = $data['response']; - $stream = $this->streamFactory->createStream($data['body']); - - try { - $stream->rewind(); - } catch (\Exception $e) { - throw new RewindStreamException('Cannot rewind stream.', 0, $e); - } - - return $response->withBody($stream); - } - - /** - * Get the value for the "If-Modified-Since" header. - */ - private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem): ?string - { - $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. - if (!isset($data['createdAt'])) { - return null; - } - - $modified = new \DateTime('@'.$data['createdAt']); - $modified->setTimezone(new \DateTimeZone('GMT')); - - return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); - } - - /** - * Get the ETag from the cached response. - */ - private function getETag(CacheItemInterface $cacheItem): ?string - { - $data = $cacheItem->get(); - // The isset() is to be removed in 2.0. - if (!isset($data['etag'])) { - return null; - } - - foreach ($data['etag'] as $etag) { - if (!empty($etag)) { - return $etag; - } - } - - return null; - } - - /** - * Call the registered cache listeners. - */ - private function handleCacheListeners(RequestInterface $request, ResponseInterface $response, bool $cacheHit, ?CacheItemInterface $cacheItem): ResponseInterface - { - foreach ($this->config['cache_listeners'] as $cacheListener) { - $response = $cacheListener->onCacheResponse($request, $response, $cacheHit, $cacheItem); - } - - return $response; - } } diff --git a/src/EtagCachePlugin.php b/src/EtagCachePlugin.php new file mode 100644 index 0000000..09fda1c --- /dev/null +++ b/src/EtagCachePlugin.php @@ -0,0 +1,78 @@ +withHeader('If-None-Match', $etag); + } + + return $request; + } + + protected static function canUseCacheItemForNotModifiedResponse(CacheItemInterface $cacheItem): bool + { + return $cacheItem->isHit() && null !== self::getETag($cacheItem); + } +} diff --git a/tests/Cache/EtagCachePluginTest.php b/tests/Cache/EtagCachePluginTest.php new file mode 100644 index 0000000..bc09b33 --- /dev/null +++ b/tests/Cache/EtagCachePluginTest.php @@ -0,0 +1,314 @@ + 60, + 'cache_lifetime' => 1000, + ]; + + return new EtagCachePlugin($pool, $streamFactory, array_merge($defaults, $config)); + } + + private function cacheItemConstraint(array $expected): Callback + { + return $this->callback(function ($actual) use ($expected) { + if (!is_array($actual)) { + return false; + } + + foreach ($expected as $key => $value) { + if (!array_key_exists($key, $actual)) { + return false; + } + + if (in_array($key, ['expiresAt', 'createdAt'], true)) { + continue; + } + + if ($actual[$key] !== $value) { + return false; + } + } + + return true; + }); + } + + private function createFulfilledNext(ResponseInterface $response): callable + { + return function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response); + }; + } + + public function testInterface(): void + { + $plugin = $this->createPlugin( + $this->createMock(CacheItemPoolInterface::class), + $this->createMock(StreamFactoryInterface::class) + ); + + self::assertInstanceOf(Plugin::class, $plugin); + } + + public function testDoesNotCacheResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testCachesResponseWithEtag(): void + { + $httpBody = 'body'; + + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn($httpBody); + $stream->method('isSeekable')->willReturn(true); + $stream->expects($this->once())->method('rewind'); + $stream->expects($this->once())->method('detach'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($stream); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getBody')->willReturn($stream); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($stream); + $response->method('getHeader')->willReturnCallback(function ($header) { + if ('ETag' === $header) { + return ['foo_etag']; + } + + return []; + }); + $response->expects($this->once())->method('withBody')->with($stream)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(false); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testAlwaysRevalidatesCachedResponse(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } + + public function testServesCachedResponseAfterNotModified(): void + { + $httpBody = 'body'; + + $requestBody = $this->createMock(StreamInterface::class); + $requestBody->method('__toString')->willReturn(''); + + $cachedBody = $this->createMock(StreamInterface::class); + $cachedBody->expects($this->once())->method('rewind'); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->once())->method('createStream')->with($httpBody)->willReturn($cachedBody); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($requestBody); + $request->expects($this->once())->method('withHeader')->with('If-None-Match', 'foo_etag')->willReturnSelf(); + + $notModifiedResponse = $this->createMock(ResponseInterface::class); + $notModifiedResponse->method('getStatusCode')->willReturn(304); + $notModifiedResponse->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + $cachedResponse->expects($this->once())->method('withBody')->with($cachedBody)->willReturnSelf(); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'], + ]); + $item->expects($this->once())->method('expiresAfter')->with(1060)->willReturnSelf(); + $item->expects($this->once())->method('set')->with($this->cacheItemConstraint([ + 'response' => $cachedResponse, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'], + ]))->willReturnSelf(); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->once())->method('save')->with($item); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($notModifiedResponse), function () { + })->wait(); + + self::assertSame($cachedResponse, $result); + } + + public function testIgnoresCachedResponseWithoutEtag(): void + { + $stream = $this->createMock(StreamInterface::class); + $stream->method('__toString')->willReturn(''); + + $streamFactory = $this->createMock(StreamFactoryInterface::class); + $streamFactory->expects($this->never())->method('createStream'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('__toString')->willReturn('https://example.com/'); + + $request = $this->createMock(RequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + $request->method('getBody')->willReturn($stream); + $request->expects($this->never())->method('withHeader'); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getHeader')->willReturn([]); + + $cachedResponse = $this->createMock(ResponseInterface::class); + + $item = $this->createMock(CacheItemInterface::class); + $item->method('isHit')->willReturn(true); + $item->method('get')->willReturn([ + 'response' => $cachedResponse, + 'body' => 'cached', + 'expiresAt' => time() + 1000000, + 'createdAt' => 4711, + 'etag' => [], + ]); + $item->expects($this->never())->method('set'); + + $pool = $this->createMock(CacheItemPoolInterface::class); + $pool->expects($this->once())->method('getItem')->willReturn($item); + $pool->expects($this->never())->method('save'); + + $plugin = $this->createPlugin($pool, $streamFactory); + + $result = $plugin->handleRequest($request, $this->createFulfilledNext($response), function () { + })->wait(); + + self::assertSame($response, $result); + } +}