From 824b9aa08236d9a9aeb0ad640309c88dea345fe8 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Fri, 29 Jul 2016 14:37:41 +0200 Subject: [PATCH 01/19] Added support for Last-Modified and ETag --- src/CachePlugin.php | 110 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index f0bdbd4..d1ef3ef 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; @@ -58,7 +59,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 +69,42 @@ 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'])); + 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 ($modifiedAt = $this->getModifiedAt($cacheItem)) { + $modifiedAt = new \DateTime('@'.$modifiedAt); + $modifiedAt->setTimezone(new \DateTimeZone('GMT')); + $request = $request->withHeader( + 'If-Modified-Since', + sprintf('%s GMT', $modifiedAt->format('l, d-M-y H:i:s')) + ); + } - 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()) { + // The cached response we have is still valid + $data = $cacheItem->get(); + $data['expiresAt'] = time() + $this->getMaxAge($response); + $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime']); + $this->pool->save($cacheItem); + + return $this->createResponseFromCacheItem($cacheItem); + } + if ($this->isCacheable($response)) { $bodyStream = $response->getBody(); $body = $bodyStream->__toString(); @@ -87,8 +114,15 @@ 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)); + $cacheItem + ->expiresAfter($this->config['cache_lifetime']) + ->set([ + 'response' => $response, + 'body' => $body, + 'expiresAt' => time() + $this->getMaxAge($response), + 'createdAt' => time(), + 'etag' => $response->getHeader('ETag'), + ]); $this->pool->save($cacheItem); } @@ -195,13 +229,75 @@ private function getMaxAge(ResponseInterface $response) private function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ + 'cache_lifetime' => 2592000, // 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 timestamp when the cached response was stored. + * + * @param CacheItemInterface $cacheItem + * + * @return int|null + */ + private function getModifiedAt(CacheItemInterface $cacheItem) + { + $data = $cacheItem->get(); + if (!isset($data['createdAt'])) { + return null; + } + + return $data['createdAt']; + } + + /** + * Get the ETag from the cached response. + * + * @param CacheItemInterface $cacheItem + * + * @return string|null + */ + private function getETag(CacheItemInterface $cacheItem) + { + $data = $cacheItem->get(); + if (!isset($data['etag'])) { + return null; + } + + if (is_array($data['etag'])) { + foreach($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } + } + + return null; + } + + return $data['etag']; + } } From 3b2f72094401e61193d48b8f75d676013e7e7f6c Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Fri, 29 Jul 2016 14:44:10 +0200 Subject: [PATCH 02/19] style fix --- src/CachePlugin.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index d1ef3ef..0cdd066 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -99,7 +99,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl // The cached response we have is still valid $data = $cacheItem->get(); $data['expiresAt'] = time() + $this->getMaxAge($response); - $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime']); + $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime'] + $data['expiresAt']); $this->pool->save($cacheItem); return $this->createResponseFromCacheItem($cacheItem); @@ -114,12 +114,13 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $response = $response->withBody($this->streamFactory->createStream($body)); } + $expiresAt = time() + $this->getMaxAge($response); $cacheItem - ->expiresAfter($this->config['cache_lifetime']) + ->expiresAfter($this->config['cache_lifetime'] + $expiresAt) ->set([ 'response' => $response, 'body' => $body, - 'expiresAt' => time() + $this->getMaxAge($response), + 'expiresAt' => $expiresAt, 'createdAt' => time(), 'etag' => $response->getHeader('ETag'), ]); @@ -268,7 +269,7 @@ private function getModifiedAt(CacheItemInterface $cacheItem) { $data = $cacheItem->get(); if (!isset($data['createdAt'])) { - return null; + return; } return $data['createdAt']; @@ -285,17 +286,17 @@ private function getETag(CacheItemInterface $cacheItem) { $data = $cacheItem->get(); if (!isset($data['etag'])) { - return null; + return; } if (is_array($data['etag'])) { - foreach($data['etag'] as $etag) { + foreach ($data['etag'] as $etag) { if (!empty($etag)) { return $etag; } } - return null; + return; } return $data['etag']; From 3f36fb52ee36e0072135a42d29812674b5db4ce0 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Fri, 29 Jul 2016 14:56:33 +0200 Subject: [PATCH 03/19] Return the 304 response if we do not have a response in cache --- src/CachePlugin.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 0cdd066..6e9238c 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -96,6 +96,11 @@ public function handleRequest(RequestInterface $request, callable $next, callabl return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { if (304 === $response->getStatusCode()) { + if (!$cacheItem->isHit()) { + // We do not have the item in cache. We can return the cached response. + return $response; + } + // The cached response we have is still valid $data = $cacheItem->get(); $data['expiresAt'] = time() + $this->getMaxAge($response); From 39c0cfea4704b050a155515dcbc105a3d09378b4 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Fri, 29 Jul 2016 15:46:45 +0200 Subject: [PATCH 04/19] Bugfix and started on the tests --- spec/CachePluginSpec.php | 13 ++++++++++--- src/CachePlugin.php | 11 ++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 808806d..2a4f5a4 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -15,7 +15,7 @@ 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) @@ -41,11 +41,12 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i $response->getBody()->willReturn($stream); $response->getHeader('Cache-Control')->willReturn(array()); $response->getHeader('Expires')->willReturn(array()); + $response->getHeader('ETag')->willReturn(array()); $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(); + $item->set()->willReturn($item)->shouldBeCalled(); + $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); $pool->save($item)->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { @@ -53,6 +54,12 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i }; $this->handleRequest($request, $next, function () {}); + $item->get()->shouldHaveKeyWithValue('response', $response); + $item->get()->shouldHaveKeyWithValue('body', $httpBody); + $item->get()->shouldHaveKey('expiresAt'); + $item->get()->shouldHaveKey('createdAt'); + $item->get()->shouldHaveKey('etag'); + } function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 6e9238c..b30c8a3 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -103,8 +103,9 @@ public function handleRequest(RequestInterface $request, callable $next, callabl // The cached response we have is still valid $data = $cacheItem->get(); - $data['expiresAt'] = time() + $this->getMaxAge($response); - $cacheItem->set($data)->expiresAfter($this->config['cache_lifetime'] + $data['expiresAt']); + $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); @@ -119,13 +120,13 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $response = $response->withBody($this->streamFactory->createStream($body)); } - $expiresAt = time() + $this->getMaxAge($response); + $maxAge = $this->getMaxAge($response); $cacheItem - ->expiresAfter($this->config['cache_lifetime'] + $expiresAt) + ->expiresAfter($this->config['cache_lifetime'] + $maxAge) ->set([ 'response' => $response, 'body' => $body, - 'expiresAt' => $expiresAt, + 'expiresAt' => time() + $maxAge, 'createdAt' => time(), 'etag' => $response->getHeader('ETag'), ]); From 33c6e1c6fb8a8418e9aa8030211490c852b29226 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 31 Jul 2016 15:37:43 +0200 Subject: [PATCH 05/19] Style fixes --- src/CachePlugin.php | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index b30c8a3..6de0f1d 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -76,24 +76,17 @@ public function handleRequest(RequestInterface $request, callable $next, callabl } // Add headers to ask the server if this cache is still valid - if ($modifiedAt = $this->getModifiedAt($cacheItem)) { - $modifiedAt = new \DateTime('@'.$modifiedAt); - $modifiedAt->setTimezone(new \DateTimeZone('GMT')); - $request = $request->withHeader( - 'If-Modified-Since', - sprintf('%s GMT', $modifiedAt->format('l, d-M-y H:i:s')) - ); + if ($mod = $this->getModifiedAt($cacheItem)) { + $mod = new \DateTime('@'.$mod); + $mod->setTimezone(new \DateTimeZone('GMT')); + $request = $request->withHeader('If-Modified-Since', sprintf('%s GMT', $mod->format('l, d-M-y H:i:s'))); } if ($etag = $this->getETag($cacheItem)) { - $request = $request->withHeader( - 'If-None-Match', - $etag - ); + $request = $request->withHeader('If-None-Match', $etag); } } - return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { if (304 === $response->getStatusCode()) { if (!$cacheItem->isHit()) { From 83e22ce43935599b45221bedc73e7f6eb40f0ff4 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 31 Jul 2016 17:17:43 +0200 Subject: [PATCH 06/19] Tests are passing --- composer.json | 5 ++++ spec/CachePluginSpec.php | 59 ++++++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 14 deletions(-) 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 2a4f5a4..503617c 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -2,6 +2,8 @@ namespace spec\Http\Client\Common\Plugin; +use Prophecy\Argument; +use Prophecy\Comparator\Factory as ComparatorFactory; use Http\Message\StreamFactory; use Http\Promise\FulfilledPromise; use PhpSpec\ObjectBehavior; @@ -39,27 +41,28 @@ 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('ETag')->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()->willReturn($item)->shouldBeCalled(); $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); - $pool->save($item)->shouldBeCalled(); + + $item->set(Argument::that($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 () {}); - $item->get()->shouldHaveKeyWithValue('response', $response); - $item->get()->shouldHaveKeyWithValue('body', $httpBody); - $item->get()->shouldHaveKey('expiresAt'); - $item->get()->shouldHaveKey('createdAt'); - $item->get()->shouldHaveKey('etag'); - } function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) @@ -107,13 +110,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(Argument::that($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) { @@ -122,4 +132,25 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $this->handleRequest($request, $next, function () {}); } + + private function getCacheItemMatcher(array $expectedData) + { + return 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; + }; + } } From 370b0a0c77dc4b38eabd84f09fc03413293ec960 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 31 Jul 2016 17:19:05 +0200 Subject: [PATCH 07/19] Style fix --- spec/CachePluginSpec.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 503617c..1b43a52 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -49,13 +49,13 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i $item->isHit()->willReturn(false); $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); - $item->set(Argument::that($this->getCacheItemMatcher([ + $item->set($this->getCacheItemMatcher([ 'response' => $response->getWrappedObject(), 'body' => $httpBody, 'expiresAt' => 0, 'createdAt' => 0, 'etag' => [] - ])))->willReturn($item)->shouldBeCalled(); + ]))->willReturn($item)->shouldBeCalled(); $pool->save(Argument::any())->shouldBeCalled(); $next = function (RequestInterface $request) use ($response) { @@ -115,13 +115,13 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); - $item->set(Argument::that($this->getCacheItemMatcher([ + $item->set($this->getCacheItemMatcher([ 'response' => $response->getWrappedObject(), 'body' => $httpBody, 'expiresAt' => 0, 'createdAt' => 0, 'etag' => [] - ])))->willReturn($item)->shouldBeCalled(); + ]))->willReturn($item)->shouldBeCalled(); // 40-15 should be 25 + the default 1000 $item->expiresAfter(1025)->willReturn($item)->shouldBeCalled(); $pool->save($item)->shouldBeCalled(); @@ -133,9 +133,17 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $this->handleRequest($request, $next, function () {}); } + + /** + * Private function to match requests + * + * @param array $expectedData + * + * @return \Closure + */ private function getCacheItemMatcher(array $expectedData) { - return function(array $actualData) use ($expectedData) { + return Argument::that(function(array $actualData) use ($expectedData) { foreach ($expectedData as $key => $value) { if (!isset($actualData[$key])) { return false; @@ -151,6 +159,6 @@ private function getCacheItemMatcher(array $expectedData) } } return true; - }; + }); } } From d89d2336907ccdb8fce64132beba96a8162d5a5c Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 31 Jul 2016 17:50:56 +0200 Subject: [PATCH 08/19] Added tests --- spec/CachePluginSpec.php | 141 ++++++++++++++++++++++++++++++++++++++- src/CachePlugin.php | 2 +- 2 files changed, 140 insertions(+), 3 deletions(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 1b43a52..8515ee3 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -3,7 +3,6 @@ namespace spec\Http\Client\Common\Plugin; use Prophecy\Argument; -use Prophecy\Comparator\Factory as ComparatorFactory; use Http\Message\StreamFactory; use Http\Promise\FulfilledPromise; use PhpSpec\ObjectBehavior; @@ -133,9 +132,147 @@ 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('e3b717d5883a45ef9493d009741f7c64')->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('e3b717d5883a45ef9493d009741f7c64')->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('e3b717d5883a45ef9493d009741f7c64')->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('e3b717d5883a45ef9493d009741f7c64')->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 requests + * Private function to match cache item data. * * @param array $expectedData * diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 6de0f1d..76bd9d6 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -70,7 +70,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl if ($cacheItem->isHit()) { $data = $cacheItem->get(); - if (isset($data['expiresAt']) && time() > $data['expiresAt']) { + if (isset($data['expiresAt']) && time() < $data['expiresAt']) { // This item is still valid according to previous cache headers return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem)); } From 23d0cd4ba11015b4e3ef628157f1511b703e29ad Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 1 Aug 2016 12:47:23 +0200 Subject: [PATCH 09/19] Updated comment --- src/CachePlugin.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 76bd9d6..50685e9 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -90,7 +90,10 @@ public function handleRequest(RequestInterface $request, callable $next, callabl return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { if (304 === $response->getStatusCode()) { if (!$cacheItem->isHit()) { - // We do not have the item in cache. We can return the cached response. + /* + * 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; } From bf2e511347adb2aedcd623bb7a1549cad7b150a0 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 1 Aug 2016 06:47:28 -0400 Subject: [PATCH 10/19] Applied fixes from StyleCI [ci skip] [skip ci] --- src/CachePlugin.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 50685e9..eba2bbd 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -90,8 +90,8 @@ public function handleRequest(RequestInterface $request, callable $next, callabl 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 + /* + * 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; From eb2cd5816f6b44d95a8a1992fc05438a768f66d5 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 11:26:45 +0200 Subject: [PATCH 11/19] Made the code easier to read --- src/CachePlugin.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index eba2bbd..1c04712 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -76,10 +76,8 @@ public function handleRequest(RequestInterface $request, callable $next, callabl } // Add headers to ask the server if this cache is still valid - if ($mod = $this->getModifiedAt($cacheItem)) { - $mod = new \DateTime('@'.$mod); - $mod->setTimezone(new \DateTimeZone('GMT')); - $request = $request->withHeader('If-Modified-Since', sprintf('%s GMT', $mod->format('l, d-M-y H:i:s'))); + if ($modifiedSinceValue = $this->getModifiedSinceHeaderValue($cacheItem)) { + $request = $request->withHeader('If-Modified-Since', $modifiedSinceValue); } if ($etag = $this->getETag($cacheItem)) { @@ -117,13 +115,14 @@ public function handleRequest(RequestInterface $request, callable $next, callabl } $maxAge = $this->getMaxAge($response); + $currentTime = time(); $cacheItem ->expiresAfter($this->config['cache_lifetime'] + $maxAge) ->set([ 'response' => $response, 'body' => $body, - 'expiresAt' => time() + $maxAge, - 'createdAt' => time(), + 'expiresAt' => $currentTime + $maxAge, + 'createdAt' => $currentTime, 'etag' => $response->getHeader('ETag'), ]); $this->pool->save($cacheItem); @@ -232,7 +231,7 @@ private function getMaxAge(ResponseInterface $response) private function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ - 'cache_lifetime' => 2592000, // 30 days + 'cache_lifetime' => 86400 * 30, // 30 days 'default_ttl' => null, 'respect_cache_headers' => true, 'hash_algo' => 'sha1', @@ -261,20 +260,23 @@ private function createResponseFromCacheItem(CacheItemInterface $cacheItem) } /** - * Get the timestamp when the cached response was stored. + * Get the value of the "If-Modified-Since" header. * * @param CacheItemInterface $cacheItem * - * @return int|null + * @return string|null */ - private function getModifiedAt(CacheItemInterface $cacheItem) + private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) { $data = $cacheItem->get(); if (!isset($data['createdAt'])) { return; } - return $data['createdAt']; + $modified = new \DateTime('@'.$data['createdAt']); + $modified->setTimezone(new \DateTimeZone('GMT')); + + return sprintf('%s GMT', $modified->format('l, d-M-y H:i:s')); } /** From 0e6792af1433931f1135ac519e1e880589728d1e Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 11:39:35 +0200 Subject: [PATCH 12/19] Updated hash --- spec/CachePluginSpec.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 8515ee3..07472d2 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -147,7 +147,7 @@ function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, R $response->getHeader('Expires')->willReturn(array()); $response->getHeader('ETag')->willReturn(array('foo_etag')); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(false); $item->expiresAfter(1060)->willReturn($item); @@ -179,7 +179,7 @@ function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, $response->getStatusCode()->willReturn(304); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true, false); $item->get()->willReturn([ 'response' => $response, @@ -203,7 +203,7 @@ function it_servces_a_cached_response(CacheItemPoolInterface $pool, CacheItemInt $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true); $item->get()->willReturn([ 'response' => $response, @@ -241,7 +241,7 @@ function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, Ca // Make sure we add back the body $response->withBody($stream)->willReturn($response)->shouldBeCalled(); - $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true, true); $item->expiresAfter(1060)->willReturn($item)->shouldBeCalled(); $item->get()->willReturn([ From 8c5047f8416d0102f3d0a140d4c13a5f53b37dab Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 12:54:31 +0200 Subject: [PATCH 13/19] Minor --- spec/CachePluginSpec.php | 5 ++++- src/CachePlugin.php | 16 ++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 07472d2..355c989 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -16,7 +16,10 @@ class CachePluginSpec extends ObjectBehavior { function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory) { - $this->beConstructedWith($pool, $streamFactory, ['default_ttl'=>60, 'cache_lifetime'=>1000]); + $this->beConstructedWith($pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime'=>1000 + ]); } function it_is_initializable(CacheItemPoolInterface $pool) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 1c04712..796e836 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -293,16 +293,16 @@ private function getETag(CacheItemInterface $cacheItem) return; } - if (is_array($data['etag'])) { - foreach ($data['etag'] as $etag) { - if (!empty($etag)) { - return $etag; - } - } + if (!is_array($data['etag'])) { + return $data['etag']; + } - return; + foreach ($data['etag'] as $etag) { + if (!empty($etag)) { + return $etag; + } } - return $data['etag']; + return; } } From 68edac29b850e11ddb5c50ae583787e2cf199bdd Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 13:05:14 +0200 Subject: [PATCH 14/19] cs --- spec/CachePluginSpec.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 355c989..97afd55 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -18,7 +18,7 @@ function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory) { $this->beConstructedWith($pool, $streamFactory, [ 'default_ttl' => 60, - 'cache_lifetime'=>1000 + 'cache_lifetime' => 1000 ]); } From 4ffc484f2399ad3c895ed674ca9b2f4e6fb7b47e Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 13:09:01 +0200 Subject: [PATCH 15/19] Removed return statement --- src/CachePlugin.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 796e836..51b27b7 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -302,7 +302,5 @@ private function getETag(CacheItemInterface $cacheItem) return $etag; } } - - return; } } From 91a031a11c8327c5133ce85fbe497c2ca6a8ace0 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 13:18:04 +0200 Subject: [PATCH 16/19] cs --- src/CachePlugin.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 51b27b7..c9bf429 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -119,12 +119,12 @@ public function handleRequest(RequestInterface $request, callable $next, callabl $cacheItem ->expiresAfter($this->config['cache_lifetime'] + $maxAge) ->set([ - 'response' => $response, - 'body' => $body, - 'expiresAt' => $currentTime + $maxAge, - 'createdAt' => $currentTime, - 'etag' => $response->getHeader('ETag'), - ]); + 'response' => $response, + 'body' => $body, + 'expiresAt' => $currentTime + $maxAge, + 'createdAt' => $currentTime, + 'etag' => $response->getHeader('ETag'), + ]); $this->pool->save($cacheItem); } From 75db148ffa4631cadeb327e6b000471061c89384 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 15:45:00 +0200 Subject: [PATCH 17/19] Added comment --- src/CachePlugin.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index c9bf429..7bc7f4d 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -39,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 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 = []) From dfa5f178f7343bf064e583dd40d7a069d5744698 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 15:46:03 +0200 Subject: [PATCH 18/19] Removed period --- src/CachePlugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 7bc7f4d..5f50b57 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -41,7 +41,7 @@ final class CachePlugin implements Plugin * @var bool $respect_cache_headers Whether to look at the cache directives or ignore them * @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 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. From cc8ac7578a5a895b948b9de12e42460270d10abc Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Tue, 2 Aug 2016 17:58:25 +0200 Subject: [PATCH 19/19] Added docs about isset --- src/CachePlugin.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 5f50b57..c4fb6af 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -74,6 +74,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl if ($cacheItem->isHit()) { $data = $cacheItem->get(); + // 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)); @@ -273,6 +274,7 @@ private function createResponseFromCacheItem(CacheItemInterface $cacheItem) private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) { $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. if (!isset($data['createdAt'])) { return; } @@ -293,6 +295,7 @@ private function getModifiedSinceHeaderValue(CacheItemInterface $cacheItem) private function getETag(CacheItemInterface $cacheItem) { $data = $cacheItem->get(); + // The isset() is to be removed in 2.0. if (!isset($data['etag'])) { return; }