diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7579f74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +composer.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cfbeff0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# How to Contribute + +## Pull Requests + +1. Fork this repository +2. Create a new branch for each feature or improvement +3. Send a pull request from each feature branch + +It is very important to separate new features or improvements into separate feature branches, and to send a pull request for each branch. This allows me to review and pull in new features or improvements individually. + +## Style Guide + +All pull requests must adhere to the [PSR-2 standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md). + +## Unit Testing + +All pull requests must be accompanied by passing unit tests and complete code coverage. The Slim Framework uses phpunit for testing. + +[Learn about PHPUnit](https://github.com/sebastianbergmann/phpunit/) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..130902a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) 2012-2015 Josh Lockhart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..353778a --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Slim Framework HTTP Cache + +This repository contains a Slim Framework HTTP cache middleware and service provider. + +## Install + +Via Composer + +``` bash +$ composer require slim/httpcache +``` + +Requires Slim 3.0.0 or newer. + +## Usage + +```php +$app = new \Slim\App(); + +// Register middleware +$app->add(new \Slim\HttpCache\Cache('public', 86400)); + +// Register service provider +$app->register(new \Slim\HttpCache\CacheProvider); + +// Example route with ETag header +$app->get('/foo', function ($req, $res, $args) { + $resWithEtag = $this['cache']->withEtag($res, 'abc'); + + return $resWithEtag; +}); + +$app->run(); +``` + +## Testing + +``` bash +$ phpunit +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security + +If you discover any security related issues, please email security@slimframework.com instead of using the issue tracker. + +## Credits + +- [Josh Lockhart](https://github.com/codeguy) +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..bc18ce9 --- /dev/null +++ b/composer.json @@ -0,0 +1,33 @@ +{ + "name": "slim/http-cache", + "type": "library", + "description": "Slim Framework HTTP cache middleware and service provider", + "keywords": ["slim","framework","middleware","cache"], + "homepage": "http://slimframework.com", + "license": "MIT", + "authors": [ + { + "name": "Josh Lockhart", + "email": "hello@joshlockhart.com", + "homepage": "http://joshlockhart.com" + } + ], + "require": { + "php": ">=5.4.0", + "pimple/pimple": "~3.0", + "psr/http-message": "~0.9" + }, + "require-dev": { + "slim/slim": "dev-develop" + }, + "autoload": { + "psr-4": { + "Slim\\HttpCache\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Slim\\HttpCache\\Tests\\": "tests" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..8259dc4 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,25 @@ + + + + + + tests/ + + + + + + src/ + + + diff --git a/src/Cache.php b/src/Cache.php new file mode 100644 index 0000000..9fb223b --- /dev/null +++ b/src/Cache.php @@ -0,0 +1,83 @@ +type = $type; + $this->maxAge = $maxAge; + } + + /** + * Invoke cache middleware + * + * @param RequestInterface $request A PSR7 request object + * @param ResponseInterface $response A PSR7 response object + * @param callable $next The next middleware callable + * + * @return ResponseInterface A PSR7 response object + */ + public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next) + { + $response = $next($request, $response); + + // Cache-Control header + $response = $response->withHeader('Cache-Control', sprintf( + '%s, max-age=%s', + $this->type, + $this->maxAge + )); + + // Last-Modified header and conditional GET check + $lastModified = $response->getHeader('Last-Modified'); + if ($lastModified) { + if (!is_integer($lastModified)) { + $lastModified = strtotime($lastModified); + } + $ifModifiedSince = $request->getHeader('If-Modified-Since'); + if ($ifModifiedSince && $lastModified === strtotime($ifModifiedSince)) { + return $response->withStatus(304); + } + } + + // ETag header and conditional GET check + $etag = $response->getHeader('ETag'); + if ($etag) { + $ifNoneMatch = $request->getHeader('If-None-Match'); + if ($ifNoneMatch) { + $etagList = preg_split('@\s*,\s*@', $ifNoneMatch); + if (in_array($etag, $etagList) || in_array('*', $etagList)) { + return $response->withStatus(304); + } + } + } + + return $response; + } +} diff --git a/src/CacheProvider.php b/src/CacheProvider.php new file mode 100644 index 0000000..693d7bc --- /dev/null +++ b/src/CacheProvider.php @@ -0,0 +1,81 @@ +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 + */ + 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 + */ + 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)); + } +} diff --git a/tests/CacheProviderTest.php b/tests/CacheProviderTest.php new file mode 100644 index 0000000..d2112d9 --- /dev/null +++ b/tests/CacheProviderTest.php @@ -0,0 +1,54 @@ +withExpires(new Response(), $now); + + $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $res->getHeader('Expires')); + } + + public function testWithETag() + { + $etag = 'abc'; + $cacheProvider = new CacheProvider(); + $res = $cacheProvider->withEtag(new Response(), $etag); + + $this->assertEquals('"' . $etag . '"', $res->getHeader('ETag')); + } + + public function testWithETagWeak() + { + $etag = 'abc'; + $cacheProvider = new CacheProvider(); + $res = $cacheProvider->withEtag(new Response(), $etag, 'weak'); + + $this->assertEquals('W/"' . $etag . '"', $res->getHeader('ETag')); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testWithETagInvalidType() + { + $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); + + $this->assertEquals(gmdate('D, d M Y H:i:s T', $now), $res->getHeader('Last-Modified')); + } +} diff --git a/tests/CacheTest.php b/tests/CacheTest.php new file mode 100644 index 0000000..2cdd207 --- /dev/null +++ b/tests/CacheTest.php @@ -0,0 +1,99 @@ +requestFactory(); + $res = new Response(); + $next = function (Request $req, Response $res) { + return $res; + }; + $res = $cache($req, $res, $next); + + $this->assertEquals('public, max-age=86400', $res->getHeader('Cache-Control')); + } + + public function testLastModifiedWithCacheHit() + { + $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 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); + }; + $res = $cache($req, $res, $next); + + $this->assertEquals(200, $res->getStatusCode()); + } + + public function testETagWithCacheHit() + { + $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); + + $this->assertEquals(304, $res->getStatusCode()); + } + + 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 = $cache($req, $res, $next); + + $this->assertEquals(200, $res->getStatusCode()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..5330160 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,2 @@ +