diff --git a/README.md b/README.md index 777d3db..cda7df0 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,25 @@ Requires Slim 3.0.0 or newer. ```php $app = new \Slim\App(); -// Register middleware -$app->add(new \Slim\HttpCache\Cache('public', 86400)); +// Register middleware to automatically set default cache headers +$app->add(new \Slim\Middleware\HttpCache\Cache('public', 86400)); // Fetch DI Container $container = $app->getContainer(); -// Register service provider -$container->register(new \Slim\HttpCache\CacheProvider); +// Register service provider to get access to the cache methods +$container->register(new \Slim\Middleware\HttpCache\CacheProvider); // Example route with ETag header $app->get('/foo', function ($req, $res, $args) { - $resWithEtag = $this['cache']->withEtag($res, 'abc'); + $resWithEtag = $this->cache->withEtag($res, 'abc'); + + // Optional: Return early if the Etag matches the Etag in the request + if ($this->cache->isStillValid($req, $resWithEtag)) { + return $resWithEtag->withStatus(304); + } + + $resWithEtag->getBody()->write('foo'); return $resWithEtag; }); diff --git a/composer.json b/composer.json index f1e0f34..efdea3b 100644 --- a/composer.json +++ b/composer.json @@ -18,16 +18,11 @@ "psr/http-message": "^1.0" }, "require-dev": { - "slim/slim": "dev-develop" + "slim/slim": "^3.0@dev" }, "autoload": { "psr-4": { - "Slim\\HttpCache\\": "src" - } - }, - "autoload-dev": { - "psr-4": { - "Slim\\HttpCache\\Tests\\": "tests" + "Slim\\Middleware\\HttpCache\\": "src" } } } diff --git a/src/Cache.php b/src/Cache.php index 948070b..aee7229 100644 --- a/src/Cache.php +++ b/src/Cache.php @@ -1,22 +1,25 @@ type = $type; $this->maxAge = $maxAge; + $this->cache = $cache ?: new CacheHelper(); } /** @@ -45,45 +50,22 @@ public function __construct($type = 'private', $maxAge = 86400) */ public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next) { + /** @var ResponseInterface $response */ $response = $next($request, $response); - // Cache-Control header - if (!$response->hasHeader('Cache-Control')) { - $response = $response->withHeader('Cache-Control', sprintf( - '%s, max-age=%s', - $this->type, - $this->maxAge - )); + // Don't add cache headers if the request method is not safe + if (!in_array($request->getMethod(), ['GET', 'HEAD'])) { + return $response; } - // Last-Modified header and conditional GET check - $lastModified = $response->getHeaderLine('Last-Modified'); - - if ($lastModified) { - if (!is_integer($lastModified)) { - $lastModified = strtotime($lastModified); - } - - $ifModifiedSince = $request->getHeaderLine('If-Modified-Since'); - - if ($ifModifiedSince && $lastModified <= strtotime($ifModifiedSince)) { - return $response->withStatus(304); - } + // Automatically add the default Cache-Control header + if (!$response->hasHeader('Cache-Control')) { + $response = $this->cache->allowCache($response, $this->type, $this->maxAge); } - // ETag header and conditional GET check - $etag = $response->getHeader('ETag'); - $etag = reset($etag); - - if ($etag) { - $ifNoneMatch = $request->getHeaderLine('If-None-Match'); - - if ($ifNoneMatch) { - $etagList = preg_split('@\s*,\s*@', $ifNoneMatch); - if (in_array($etag, $etagList) || in_array('*', $etagList)) { - return $response->withStatus(304); - } - } + // Check if the client cache is still valid + if ($response->getStatusCode() !== 304 && $this->cache->isStillValid($request, $response)) { + return $response->withStatus(304); } return $response; diff --git a/src/CacheHelper.php b/src/CacheHelper.php new file mode 100644 index 0000000..1b5d2d6 --- /dev/null +++ b/src/CacheHelper.php @@ -0,0 +1,153 @@ +withHeader('Cache-Control', $headerValue); + } + + /** + * Disable client-side HTTP caching + * + * @param ResponseInterface $response PSR7 response object + * + * @return ResponseInterface A new PSR7 response object with `Cache-Control` header + */ + public function denyCache(ResponseInterface $response) + { + return $response->withHeader('Cache-Control', 'no-store,no-cache'); + } + + /** + * Add `Expires` header to PSR7 response object + * + * @param ResponseInterface $response A PSR7 response object + * @param int|string $time A UNIX timestamp or a valid `strtotime()` string + * + * @return ResponseInterface A new PSR7 response object with `Expires` header + * @throws InvalidArgumentException if the expiration date cannot be parsed + */ + public function withExpires(ResponseInterface $response, $time) + { + if (!is_integer($time)) { + $time = strtotime($time); + if ($time === false) { + throw new InvalidArgumentException('Expiration value could not be parsed with `strtotime()`.'); + } + } + + return $response->withHeader('Expires', gmdate('D, d M Y H:i:s T', $time)); + } + + /** + * Add `ETag` header to PSR7 response object + * + * @param ResponseInterface $response A PSR7 response object + * @param string $value The ETag value + * @param string $type ETag type: "strong" or "weak" + * + * @return ResponseInterface A new PSR7 response object with `ETag` header + * @throws InvalidArgumentException if the etag type is invalid + */ + public function withEtag(ResponseInterface $response, $value, $type = 'strong') + { + if (!in_array($type, ['strong', 'weak'])) { + throw new InvalidArgumentException('Invalid etag type. Must be "strong" or "weak".'); + } + $value = '"' . $value . '"'; + if ($type === 'weak') { + $value = 'W/' . $value; + } + + return $response->withHeader('ETag', $value); + } + + /** + * Add `Last-Modified` header to PSR7 response object + * + * @param ResponseInterface $response A PSR7 response object + * @param int|string $time A UNIX timestamp or a valid `strtotime()` string + * + * @return ResponseInterface A new PSR7 response object with `Last-Modified` header + * @throws InvalidArgumentException if the last modified date cannot be parsed + */ + public function withLastModified(ResponseInterface $response, $time) + { + if (!is_integer($time)) { + $time = strtotime($time); + if ($time === false) { + throw new InvalidArgumentException('Last Modified value could not be parsed with `strtotime()`.'); + } + } + + return $response->withHeader('Last-Modified', gmdate('D, d M Y H:i:s T', $time)); + } + + /** + * Compares the request and the response to determine if the client still has a valid copy. + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + */ + public function isStillValid(RequestInterface $request, ResponseInterface $response) + { + // Last-Modified header and conditional GET check + $lastModified = $response->getHeaderLine('Last-Modified'); + + if ($lastModified) { + if (!is_integer($lastModified)) { + $lastModified = strtotime($lastModified); + } + + $ifModifiedSince = $request->getHeaderLine('If-Modified-Since'); + + if ($ifModifiedSince && $lastModified <= strtotime($ifModifiedSince)) { + return true; + } + } + + // ETag header and conditional GET check + $etag = $response->getHeaderLine('ETag'); + + if ($etag) { + $ifNoneMatch = $request->getHeaderLine('If-None-Match'); + + if ($ifNoneMatch) { + $etagList = preg_split('@\s*,\s*@', $ifNoneMatch); + if (in_array(str_replace('"', '', $etag), $etagList) || in_array('*', $etagList)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/CacheProvider.php b/src/CacheProvider.php index d7e52ed..5c8c172 100644 --- a/src/CacheProvider.php +++ b/src/CacheProvider.php @@ -1,10 +1,8 @@ withHeader('Cache-Control', $headerValue); - } - - /** - * Disable client-side HTTP caching - * - * @param ResponseInterface $response PSR7 response object - * - * @return ResponseInterface A new PSR7 response object with `Cache-Control` header - */ - public function denyCache(ResponseInterface $response) - { - return $response->withHeader('Cache-Control', 'no-store,no-cache'); - } - - /** - * Add `Expires` header to PSR7 response object - * - * @param ResponseInterface $response A PSR7 response object - * @param int|string $time A UNIX timestamp or a valid `strtotime()` string - * - * @return ResponseInterface A new PSR7 response object with `Expires` header - * @throws InvalidArgumentException if the expiration date cannot be parsed - */ - public function withExpires(ResponseInterface $response, $time) - { - if (!is_integer($time)) { - $time = strtotime($time); - if ($time === false) { - throw new InvalidArgumentException('Expiration value could not be parsed with `strtotime()`.'); - } - } - - return $response->withHeader('Expires', gmdate('D, d M Y H:i:s T', $time)); - } - - /** - * Add `ETag` header to PSR7 response object - * - * @param ResponseInterface $response A PSR7 response object - * @param string $value The ETag value - * @param string $type ETag type: "strong" or "weak" - * - * @return ResponseInterface A new PSR7 response object with `ETag` header - * @throws InvalidArgumentException if the etag type is invalid - */ - public function withEtag(ResponseInterface $response, $value, $type = 'strong') - { - if (!in_array($type, ['strong', 'weak'])) { - throw new InvalidArgumentException('Invalid etag type. Must be "strong" or "weak".'); - } - $value = '"' . $value . '"'; - if ($type === 'weak') { - $value = 'W/' . $value; - } - - return $response->withHeader('ETag', $value); - } - - /** - * Add `Last-Modified` header to PSR7 response object - * - * @param ResponseInterface $response A PSR7 response object - * @param int|string $time A UNIX timestamp or a valid `strtotime()` string - * - * @return ResponseInterface A new PSR7 response object with `Last-Modified` header - * @throws InvalidArgumentException if the last modified date cannot be parsed - */ - public function withLastModified(ResponseInterface $response, $time) - { - if (!is_integer($time)) { - $time = strtotime($time); - if ($time === false) { - throw new InvalidArgumentException('Last Modified value could not be parsed with `strtotime()`.'); - } - } - - return $response->withHeader('Last-Modified', gmdate('D, d M Y H:i:s T', $time)); + $container['cache'] = function () { + return new CacheHelper(); + }; } } diff --git a/tests/CacheHelperTest.php b/tests/CacheHelperTest.php new file mode 100644 index 0000000..770c694 --- /dev/null +++ b/tests/CacheHelperTest.php @@ -0,0 +1,153 @@ +cache = new CacheHelper(); + } + + public function testAllowCache() + { + $res = $this->cache->allowCache(new Response(), 'private', 43200); + + $cacheControl = $res->getHeaderLine('Cache-Control'); + + $this->assertEquals('private, max-age=43200', $cacheControl); + } + + public function testDenyCache() + { + $res = $this->cache->denyCache(new Response()); + + $cacheControl = $res->getHeaderLine('Cache-Control'); + + $this->assertEquals('no-store,no-cache', $cacheControl); + } + + public function testWithExpires() + { + $now = time(); + $res = $this->cache->withExpires(new Response(), $now); + + $expires = $res->getHeaderLine('Expires'); + + $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $expires); + } + + public function testWithETag() + { + $etag = 'abc'; + $res = $this->cache->withEtag(new Response(), $etag); + + $etagHeader = $res->getHeaderLine('ETag'); + + $this->assertEquals('"' . $etag . '"', $etagHeader); + } + + public function testWithETagWeak() + { + $etag = 'abc'; + $res = $this->cache->withEtag(new Response(), $etag, 'weak'); + + $etagHeader = $res->getHeaderLine('ETag'); + + $this->assertEquals('W/"' . $etag . '"', $etagHeader); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testWithETagInvalidType() + { + $etag = 'abc'; + $this->cache->withEtag(new Response(), $etag, 'bork'); + } + + public function testWithLastModified() + { + $now = time(); + $res = $this->cache->withLastModified(new Response(), $now); + + $lastModified = $res->getHeaderLine('Last-Modified'); + + $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $lastModified); + } + + /** + * @return array + */ + public function modifiedTimes() + { + return [ + 'same-time' => [0, true], + 'current-time-older' => [172800, true], + 'current-time-newer' => [-86400, false], + ]; + } + + /** + * @covers Slim\Middleware\HttpCache\CacheHelper::isStillValid + * @dataProvider modifiedTimes + * @param int $offsetLastRequest + * @param bool $valid + */ + public function testisValidWithLastModified($offsetLastRequest, $valid) + { + $now = time(); + $lastModified = gmdate('D, d M Y H:i:s T', $now); + $ifModifiedSince = gmdate('D, d M Y H:i:s T', $now + $offsetLastRequest); + + $req = $this->getMockBuilder('Slim\Http\Request')->disableOriginalConstructor()->getMock(); + $req->expects($this->once()) + ->method('getHeaderLine') + ->with('If-Modified-Since') + ->will($this->returnValue($ifModifiedSince)); + + $res = new Response(); + $res = $res->withHeader('Last-Modified', $lastModified); + + $this->assertSame($valid, $this->cache->isStillValid($req, $res)); + } + + /** + * @return array + */ + public function eTags() + { + return [ + 'hit' => ['abc', 'abc', true], + 'miss' => ['abc', 'xyz', false], + ]; + } + + /** + * @covers Slim\Middleware\HttpCache\CacheHelper::isStillValid + * @dataProvider eTags + * @param string $eTag + * @param string $ifNoneMatch + * @param bool $valid + */ + public function testIsValidWithETag($eTag, $ifNoneMatch, $valid) + { + $req = $this->getMockBuilder('Slim\Http\Request')->disableOriginalConstructor()->getMock(); + $req->expects($this->once()) + ->method('getHeaderLine') + ->with('If-None-Match') + ->will($this->returnValue($ifNoneMatch)); + + $res = new Response(); + $res = $res->withHeader('ETag', $eTag); + + $this->assertSame($valid, $this->cache->isStillValid($req, $res)); + } +} diff --git a/tests/CacheProviderTest.php b/tests/CacheProviderTest.php index ebaf082..6916e35 100644 --- a/tests/CacheProviderTest.php +++ b/tests/CacheProviderTest.php @@ -1,82 +1,20 @@ allowCache(new Response(), 'private', 43200); - - $cacheControl = $res->getHeaderLine('Cache-Control'); - - $this->assertEquals('private, max-age=43200', $cacheControl); - } - - public function testDenyCache() - { - $cacheProvider = new CacheProvider(); - $res = $cacheProvider->denyCache(new Response()); - - $cacheControl = $res->getHeaderLine('Cache-Control'); - - $this->assertEquals('no-store,no-cache', $cacheControl); - } - - public function testWithExpires() - { - $now = time(); - $cacheProvider = new CacheProvider(); - $res = $cacheProvider->withExpires(new Response(), $now); - - $expires = $res->getHeaderLine('Expires'); - - $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $expires); - } - - public function testWithETag() - { - $etag = 'abc'; - $cacheProvider = new CacheProvider(); - $res = $cacheProvider->withEtag(new Response(), $etag); - - $etagHeader = $res->getHeaderLine('ETag'); - - $this->assertEquals('"' . $etag . '"', $etagHeader); - } - - public function testWithETagWeak() - { - $etag = 'abc'; - $cacheProvider = new CacheProvider(); - $res = $cacheProvider->withEtag(new Response(), $etag, 'weak'); - - $etagHeader = $res->getHeaderLine('ETag'); - - $this->assertEquals('W/"' . $etag . '"', $etagHeader); - } - /** - * @expectedException \InvalidArgumentException + * @covers Slim\Middleware\HttpCache\CacheProvider::register */ - public function testWithETagInvalidType() + public function testRegister() { - $etag = 'abc'; - $cacheProvider = new CacheProvider(); - $cacheProvider->withEtag(new Response(), $etag, 'bork'); - } - - public function testWithLastModified() - { - $now = time(); - $cacheProvider = new CacheProvider(); - $res = $cacheProvider->withLastModified(new Response(), $now); - - $lastModified = $res->getHeaderLine('Last-Modified'); + $container = new Container(); + $container->register(new CacheProvider()); - $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $lastModified); + $this->assertTrue($container->offsetExists('cache')); + $this->assertInstanceOf('Slim\Middleware\HttpCache\CacheHelper', $container->offsetGet('cache')); } } diff --git a/tests/CacheTest.php b/tests/CacheTest.php index 38bc245..6a5c880 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -1,13 +1,12 @@ assertEquals('no-cache,no-store', $cacheControl); } - public function testLastModifiedWithCacheHit() + public function testCacheControlHeaderIgnoresUnsafeMethods() { - $now = time(); - $lastModified = gmdate('D, d M Y H:i:s T', $now + 86400); - $ifModifiedSince = gmdate('D, d M Y H:i:s T', $now + 86400); $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-Modified-Since', $ifModifiedSince); - $res = new Response(); - $next = function (Request $req, Response $res) use ($lastModified) { - return $res->withHeader('Last-Modified', $lastModified); - }; - $res = $cache($req, $res, $next); - - $this->assertEquals(304, $res->getStatusCode()); - } - - public function testLastModifiedWithCacheHitAndNewerDate() - { - $now = time(); - $lastModified = gmdate('D, d M Y H:i:s T', $now + 86400); - $ifModifiedSince = gmdate('D, d M Y H:i:s T', $now + 172800); // <-- Newer date - $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-Modified-Since', $ifModifiedSince); - $res = new Response(); - $next = function (Request $req, Response $res) use ($lastModified) { - return $res->withHeader('Last-Modified', $lastModified); - }; - $res = $cache($req, $res, $next); - - $this->assertEquals(304, $res->getStatusCode()); - } - - public function testLastModifiedWithCacheHitAndOlderDate() - { - $now = time(); - $lastModified = gmdate('D, d M Y H:i:s T', $now + 86400); - $ifModifiedSince = gmdate('D, d M Y H:i:s T', $now); // <-- Older date - $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-Modified-Since', $ifModifiedSince); - $res = new Response(); - $next = function (Request $req, Response $res) use ($lastModified) { - return $res->withHeader('Last-Modified', $lastModified); - }; - $res = $cache($req, $res, $next); - - $this->assertEquals(200, $res->getStatusCode()); - } + $req = $this->requestFactory(); + $req = $req->withMethod('POST'); - public function testLastModifiedWithCacheMiss() - { - $now = time(); - $lastModified = gmdate('D, d M Y H:i:s T', $now + 86400); - $ifModifiedSince = gmdate('D, d M Y H:i:s T', $now - 86400); - $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-Modified-Since', $ifModifiedSince); $res = new Response(); - $next = function (Request $req, Response $res) use ($lastModified) { - return $res->withHeader('Last-Modified', $lastModified); + $next = function (Request $req, Response $res) { + return $res; }; $res = $cache($req, $res, $next); - $this->assertEquals(200, $res->getStatusCode()); + $this->assertFalse($res->hasHeader('Cache-Control')); } - public function testETagWithCacheHit() + public function testSetsStatusTo304IfCacheStillValid() { - $etag = 'abc'; - $ifNoneMatch = 'abc'; - $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-None-Match', $ifNoneMatch); - $res = new Response(); - $next = function (Request $req, Response $res) use ($etag) { - return $res->withHeader('ETag', $etag); - }; - $res = $cache($req, $res, $next); + $cache = $this->getMock('Slim\Middleware\HttpCache\CacheHelper'); + $cache->expects($this->once())->method('isStillValid')->will($this->returnValue(true)); - $this->assertEquals(304, $res->getStatusCode()); - } + $middleware = new Cache('public', 86400, $cache); + $req = $this->requestFactory(); - public function testETagWithCacheMiss() - { - $etag = 'abc'; - $ifNoneMatch = 'xyz'; - $cache = new Cache('public', 86400); - $req = $this->requestFactory()->withHeader('If-None-Match', $ifNoneMatch); $res = new Response(); - $next = function (Request $req, Response $res) use ($etag) { - return $res->withHeader('ETag', $etag); + $res = $res->withHeader('Cache-Control', 'public'); + $next = function (Request $req, Response $res) { + return $res; }; - $res = $cache($req, $res, $next); + $res = $middleware($req, $res, $next); - $this->assertEquals(200, $res->getStatusCode()); + $this->assertSame(304, $res->getStatusCode()); } }