diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c3ed3..7df0c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Change Log +## 1.3.0 - unreleased +### Added + +- New `methods` setting which allows to configure the request methods which can be cached. ## 1.2.0 - 2016-08-16 ### Changed -- The default value for `default_ttl` is changed from `null` to `0`. +- The default value for `default_ttl` is changed from `null` to `0`. ### Fixed diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php index 97afd55..a3561fb 100644 --- a/spec/CachePluginSpec.php +++ b/spec/CachePluginSpec.php @@ -41,6 +41,8 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); + $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); $response->getHeader('Cache-Control')->willReturn(array())->shouldBeCalled(); @@ -71,6 +73,8 @@ function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheIte { $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); + $response->getStatusCode()->willReturn(400); $response->getHeader('Cache-Control')->willReturn(array()); $response->getHeader('Expires')->willReturn(array()); @@ -85,7 +89,7 @@ function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheIte $this->handleRequest($request, $next, function () {}); } - function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + function it_doesnt_store_post_requests_by_default(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) { $request->getMethod()->willReturn('POST'); $request->getUri()->willReturn('/'); @@ -97,6 +101,74 @@ function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemIn $this->handleRequest($request, $next, function () {}); } + function it_stores_post_requests_when_allowed( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + ResponseInterface $response, + StreamFactory $streamFactory, + StreamInterface $stream + ) { + $this->beConstructedWith($pool, $streamFactory, [ + 'default_ttl' => 60, + 'cache_lifetime' => 1000, + 'methods' => ['GET', 'HEAD', 'POST'] + ]); + + $httpBody = 'hello=world'; + $stream->__toString()->willReturn($httpBody); + $stream->isSeekable()->willReturn(true); + $stream->rewind()->shouldBeCalled(); + + $request->getMethod()->willReturn('POST'); + $request->getUri()->willReturn('/post'); + $request->getBody()->willReturn($stream); + + $response->getStatusCode()->willReturn(200); + $response->getBody()->willReturn($stream); + $response->getHeader('Cache-Control')->willReturn([])->shouldBeCalled(); + $response->getHeader('Expires')->willReturn([])->shouldBeCalled(); + $response->getHeader('ETag')->willReturn([])->shouldBeCalled(); + + $pool->getItem('e4311a9af932c603b400a54efab21b6d7dea7a90')->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 () {}); + } + + function it_does_not_allow_invalid_request_methods( + CacheItemPoolInterface $pool, + CacheItemInterface $item, + RequestInterface $request, + ResponseInterface $response, + StreamFactory $streamFactory, + StreamInterface $stream + ) { + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'HEAD', 'POST ']]]); + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'HEAD"', 'POST']]]); + $this + ->shouldThrow("Symfony\Component\OptionsResolver\Exception\InvalidOptionsException") + ->during('__construct', [$pool, $streamFactory, ['methods' => ['GET', 'head', 'POST']]]); + } function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response, StreamInterface $stream) { @@ -107,6 +179,8 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); + $response->getStatusCode()->willReturn(200); $response->getBody()->willReturn($stream); $response->getHeader('Cache-Control')->willReturn(array('max-age=40')); @@ -141,6 +215,7 @@ function it_saves_etag(CacheItemPoolInterface $pool, CacheItemInterface $item, R $stream->__toString()->willReturn($httpBody); $stream->isSeekable()->willReturn(true); $stream->rewind()->shouldBeCalled(); + $request->getBody()->shouldBeCalled(); $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); @@ -176,6 +251,7 @@ function it_adds_etag_and_modfied_since_to_request(CacheItemPoolInterface $pool, $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); $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); @@ -205,6 +281,7 @@ function it_servces_a_cached_response(CacheItemPoolInterface $pool, CacheItemInt $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); $pool->getItem('d20f64acc6e70b6079845f2fe357732929550ae1')->shouldBeCalled()->willReturn($item); $item->isHit()->willReturn(true); @@ -233,6 +310,7 @@ function it_serves_and_resaved_expired_response(CacheItemPoolInterface $pool, Ca $request->getMethod()->willReturn('GET'); $request->getUri()->willReturn('/'); + $request->getBody()->shouldBeCalled(); $request->withHeader(Argument::any(), Argument::any())->willReturn($request); $request->withHeader(Argument::any(), Argument::any())->willReturn($request); diff --git a/src/CachePlugin.php b/src/CachePlugin.php index 5053aad..66d38c6 100644 --- a/src/CachePlugin.php +++ b/src/CachePlugin.php @@ -45,6 +45,7 @@ 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. * } */ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamFactory, array $config = []) @@ -64,7 +65,7 @@ public function handleRequest(RequestInterface $request, callable $next, callabl { $method = strtoupper($request->getMethod()); // if the request not is cachable, move to $next - if ($method !== 'GET' && $method !== 'HEAD') { + if (!in_array($method, $this->config['methods'])) { return $next($request); } @@ -205,7 +206,6 @@ private function getCacheControlDirective(ResponseInterface $response, $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]; @@ -225,7 +225,12 @@ private function getCacheControlDirective(ResponseInterface $response, $name) */ private function createCacheKey(RequestInterface $request) { - return hash($this->config['hash_algo'], $request->getMethod().' '.$request->getUri()); + $body = (string) $request->getBody(); + if (!empty($body)) { + $body = ' '.$body; + } + + return hash($this->config['hash_algo'], $request->getMethod().' '.$request->getUri().$body); } /** @@ -273,12 +278,20 @@ private function configureOptions(OptionsResolver $resolver) 'default_ttl' => 0, 'respect_cache_headers' => true, 'hash_algo' => 'sha1', + 'methods' => ['GET', 'HEAD'], ]); $resolver->setAllowedTypes('cache_lifetime', ['int', 'null']); $resolver->setAllowedTypes('default_ttl', ['int', 'null']); $resolver->setAllowedTypes('respect_cache_headers', 'bool'); + $resolver->setAllowedTypes('methods', '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); + }); } /**