From 1568bea5f426955dc815cf67a3d10e99526152ed Mon Sep 17 00:00:00 2001 From: Jeroen Thora Date: Wed, 22 Feb 2017 08:29:06 +0100 Subject: [PATCH] Implement caching types for client and server caching. Fixes #3 --- CHANGELOG.md | 11 +++++ spec/CachePluginSpec.php | 48 ++++++++++++++++++++++ src/CachePlugin.php | 88 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df0c97..a5cec58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### Added - New `methods` setting which allows to configure the request methods which can be cached. +- New `respect_response_cache_directives` config setting to define specific cache directives to respect when handling responses. +- Introduced `CachePlugin::clientCache` and `CachePlugin::serverCache` factory methods to easily setup the plugin with +the correct config settigns for each usecase. + +### Changed + +- The `no-cache` directive is now respected by the plugin and will not cache the response + +### Deprecated + +- 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. ## 1.2.0 - 2016-08-16 diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index a3561fb..b8f6560 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -351,6 +351,54 @@ function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, Ca $this->handleRequest($request, $next, function () {}); } + function it_caches_private_responses_when_allowed( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + ResponseInterface $response, + StreamFactory $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedThrough('clientCache', [$pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + ]]); + + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn(['private'])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn(array())->shouldBeCalled(); + $response->getHeader('ETag')->willReturn(array())->shouldBeCalled(); + + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + /** * Private function to match cache item data. diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 66d38c6..b1ab73b 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -9,6 +9,7 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; /** @@ -33,6 +34,13 @@ final class CachePlugin implements Plugin */ private $config; + /** + * Cache directives indicating if a response can not be cached. + * + * @var array + */ + private $noCacheFlags = ['no-cache', 'private', 'no-store']; + /** * @param CacheItemPoolInterface $pool * @param StreamFactory $streamFactory @@ -45,7 +53,8 @@ final class CachePlugin implements Plugin * @var int $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. - * @var array $methods list of request methods which can be cached. + * @var array $methods list of request methods which can be cached + * @var array $respect_response_cache_directives list of cache directives this plugin will respect while caching responses. * } */ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) @@ -53,11 +62,57 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamF $this->pool = $pool; $this->streamFactory = $streamFactory; + if (isset($config['respect_cache_headers']) && isset($config['respect_response_cache_directives'])) { + 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); } + /** + * 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. + * + * @param CacheItemPoolInterface $pool + * @param StreamFactory $streamFactory + * @param array $config For all possible config options see the constructor docs + * + * @return CachePlugin + */ + public static function clientCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) + { + // Allow caching of private requests + if (isset($config['respect_response_cache_directives'])) { + $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); + } + + /** + * This method will setup the cachePlugin in server cache mode. This is the default caching behavior it refuses to + * cache responses with the `private`or `no-cache` directives. + * + * @param CacheItemPoolInterface $pool + * @param StreamFactory $streamFactory + * @param array $config For all possible config options see the constructor docs + * + * @return CachePlugin + */ + public static function serverCache(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) + { + return new self($pool, $streamFactory, $config); + } + /** * {@inheritdoc} */ @@ -183,11 +238,12 @@ protected function isCacheable(ResponseInterface $response) if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { return false; } - if (!$this->config['respect_cache_headers']) { - return true; - } - if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) { - 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; @@ -242,7 +298,7 @@ private function createCacheKey(RequestInterface $request) */ private function getMaxAge(ResponseInterface $response) { - if (!$this->config['respect_cache_headers']) { + if (!in_array('max-age', $this->config['respect_response_cache_directives'], true)) { return $this->config['default_ttl']; } @@ -276,9 +332,11 @@ private function configureOptions(OptionsResolver $resolver) $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' => true, 'hash_algo' => 'sha1', 'methods' => ['GET', 'HEAD'], + 'respect_response_cache_directives' => ['no-cache', 'private', 'max-age', 'no-store'], ]); $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); @@ -292,6 +350,22 @@ private function configureOptions(OptionsResolver $resolver) return empty($matches); }); + + $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 $value; + }); + + $resolver->setNormalizer('respect_response_cache_directives', function (Options $options, $value) { + if (false === $options['respect_cache_headers']) { + return []; + } + + return $value; + }); } /**