diff --git a/composer.json b/composer.json index aa8bde7..52df585 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,11 @@ "Http\\Client\\Common\\Plugin\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "spec\\Http\\Client\\Common\\Plugin\\": "spec/" + } + }, "scripts": { "test": "vendor/bin/phpspec run", "test-ci": "vendor/bin/phpspec run -c phpspec.ci.yml" diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 808806d..97afd55 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -2,6 +2,7 @@ namespace spec\Http\Client\Common\Plugin; +use Prophecy\Argument; use Http\Message\StreamFactory; use Http\Promise\FulfilledPromise; use PhpSpec\ObjectBehavior; @@ -15,7 +16,10 @@ class CachePluginSpec extends ObjectBehavior { function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory) { - $this->beConstructedWith($pool, $streamFactory, ['default_ttl'=>60]); + $this->beConstructedWith($pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000 + ]); } function it_is_initializable(CacheItemPoolInterface $pool) @@ -39,14 +43,22 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i $request->getUri()->willReturn('/'); $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); - $response->getHeader('Cache-Control')->willReturn(array()); - $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('Cache-Control')->willReturn(array())->shouldBeCalled(); + $response->getHeader('Expires')->willReturn(array())->shouldBeCalled(); + $response->getHeader('ETag')->willReturn(array())->shouldBeCalled(); $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); - $item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled(); - $item->expiresAfter(60)->willReturn($item)->shouldBeCalled(); - $pool->save($item)->shouldBeCalled(); + $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()); @@ -100,13 +112,20 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $response->getHeader('Cache-Control')->willReturn(array('max-age=40')); $response->getHeader('Age')->willReturn(array('15')); $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('ETag')->willReturn(array()); $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); - // 40-15 should be 25 - $item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled(); - $item->expiresAfter(25)->willReturn($item)->shouldBeCalled(); + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => [] + ]))->willReturn($item)->shouldBeCalled(); + // 40-15 should be 25 + the default 1000 + $item->expiresAfter(1025)->willReturn($item)->shouldBeCalled(); $pool->save($item)->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { @@ -115,4 +134,171 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $this->handleRequest($request, $next, function () {}); } + + function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $httpBody = 'body'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn(array()); + $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('ETag')->willReturn(array('foo_etag')); + + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->expiresAfter(1060)->willReturn($item); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_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 () {}); + } + + function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + + $request->withHeader('If-Modified-Since', 'Thursday, 01-Jan-70 01:18:31 GMT')->shouldBeCalled()->willReturn($request); + $request->withHeader('If-None-Match', 'foo_etag')->shouldBeCalled()->willReturn($request); + + $response->getStatusCode()->willReturn(304); + + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true, false); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'] + ])->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_servces_a_cached_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream, StreamFactory $streamFactory) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => time()+1000000, //It is in the future + 'createdAt' => 4711, + 'etag' => [] + ])->shouldBeCalled(); + + // Make sure we add back the body + $response->withBody($stream)->willReturn($response)->shouldBeCalled(); + $streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream, StreamFactory $streamFactory) + { + $httpBody = 'body'; + + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + + $request->withHeader(Argument::any(), Argument::any())->willReturn($request); + $request->withHeader(Argument::any(), Argument::any())->willReturn($request); + + $response->getStatusCode()->willReturn(304); + $response->getHeader('Cache-Control')->willReturn(array()); + $response->getHeader('Expires')->willReturn(array())->shouldBeCalled(); + + // Make sure we add back the body + $response->withBody($stream)->willReturn($response)->shouldBeCalled(); + + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(true, true); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); + $item->get()->willReturn([ + 'response' => $response, + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 4711, + 'etag' => ['foo_etag'] + ])->shouldBeCalled(); + + $item->set($this->getCacheItemMatcher([ + 'response' => $response->getWrappedObject(), + 'body' => $httpBody, + 'expiresAt' => 0, + 'createdAt' => 0, + 'etag' => ['foo_etag'] + ]))->willReturn($item)->shouldBeCalled(); + $pool->save(Argument::any())->shouldBeCalled(); + + $streamFactory->createStream($httpBody)->shouldBeCalled()->willReturn($stream); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + + /** + * Private function to match cache item data. + * + * @param array $expectedData + * + * @return \Closure + */ + private function getCacheItemMatcher(array $expectedData) + { + return Argument::that(function(array $actualData) use ($expectedData) { + foreach ($expectedData as $key => $value) { + if (!isset($actualData[$key])) { + return false; + } + + if ($key === 'expiresAt' || $key === 'createdAt') { + // We do not need to validate the value of these fields. + continue; + } + + if ($actualData[$key] !== $value) { + return false; + } + } + return true; + }); + } } diff --git a/src/CachePlugin.php b/src/CachePlugin.php index f0bdbd4..c4fb6af 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -5,6 +5,7 @@ use Http\Client\Common\Plugin; use Http\Message\StreamFactory; use Http\Promise\FulfilledPromise; +use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -38,8 +39,12 @@ final class CachePlugin implements Plugin * @param array $config { * * @var bool $respect_cache_headers Whether to look at the cache directives or ignore them - * @var int $default_ttl If we do not respect cache headers or can't calculate a good ttl, use this value - * @var string $hash_algo The hashing algorithm to use when generating cache keys. + * @var int $default_ttl (seconds) If we do not respect cache headers or can't calculate a good ttl, use this + * value + * @var string $hash_algo The hashing algorithm to use when generating cache keys + * @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 that the server originally says it is valid for. + * We store a cache item for $cache_lifetime + max age of the response. * } */ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) @@ -58,7 +63,6 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamF public function handleRequest(RequestInterface $request, callable $next, callable $first) { $method = strtoupper($request->getMethod()); - // if the request not is cachable, move to $next if ($method !== 'GET' && $method !== 'HEAD') { return $next($request); @@ -69,15 +73,43 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $cacheItem = $this->pool->getItem($key); if ($cacheItem->isHit()) { - // return cached response $data = $cacheItem->get(); - $response = $data['response']; - $response = $response->withBody($this->streamFactory->createStream($data['body'])); + // The isset() is to be removed in 2.0. + if (isset($data['expiresAt']) && time() < $data['expiresAt']) { + // This item is still valid according to previous cache headers + return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem)); + } + + // Add headers to ask the server if this cache is still valid + if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); + } - return new FulfilledPromise($response); + if ($etag = $this->getETag($cacheItem)) { + $request = $request->withHeader('If-None-Match', $etag); + } } return $next($request)->then(function (ResponseInterface $response) use ($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 $response; + } + + // The cached response we have is still valid + $data = $cacheItem->get(); + $maxAge = $this->getMaxAge($response); + $data['expiresAt'] = time() + $maxAge; + $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime'] + $maxAge); + $this->pool->save($cacheItem); + + return $this->createResponseFromCacheItem($cacheItem); + } + if ($this->isCacheable($response)) { $bodyStream = $response->getBody(); $body = $bodyStream->__toString(); @@ -87,8 +119,17 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $response = $response->withBody($this->streamFactory->createStream($body)); } - $cacheItem->set(['response' => $response, 'body' => $body]) - ->expiresAfter($this->getMaxAge($response)); + $maxAge = $this->getMaxAge($response); + $currentTime = time(); + $cacheItem + ->expiresAfter($this->config['cache_lifetime'] + $maxAge) + ->set([ + 'response' => $response, + 'body' => $body, + 'expiresAt' => $currentTime + $maxAge, + 'createdAt' => $currentTime, + 'etag' => $response->getHeader('ETag'), + ]); $this->pool->save($cacheItem); } @@ -195,13 +236,78 @@ private function getMaxAge(ResponseInterface $response) private function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'cache_lifetime' => 86400 * 30, // 30 days 'default_ttl' => null, 'respect_cache_headers' => true, 'hash_algo' => 'sha1', ]); + $resolver->setAllowedTypes('cache_lifetime', 'int'); $resolver->setAllowedTypes('default_ttl', ['int', 'null']); $resolver->setAllowedTypes('respect_cache_headers', 'bool'); $resolver->setAllowedValues('hash_algo', hash_algos()); } + + /** + * @param CacheItemInterface $cacheItem + * + * @return ResponseInterface + */ + private function createResponseFromCacheItem(CacheItemInterface $cacheItem) + { + $data = $cacheItem->get(); + + /** @var ResponseInterface $response */ + $response = $data['response']; + $response = $response->withBody($this->streamFactory->createStream($data['body'])); + + return $response; + } + + /** + * Get the value of the "If-Modified-Since" header. + * + * @param CacheItemInterface $cacheItem + * + * @return string|null + */ + private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['createdAt'])) { + return; + } + + $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. + * + * @param CacheItemInterface $cacheItem + * + * @return string|null + */ + private function getETag(CacheItemInterface $cacheItem) + { + $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. + if (!isset($data['etag'])) { + return; + } + + if (!is_array($data['etag'])) { + return $data['etag']; + } + + foreach ($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } + } + } }