Add Cache API helper (#36)

* Add new CacheMiddleware cache helper + add getUri method

* Example of cache http middleware added

* remove getUri method from RequestWrapper + add generateEtag method to CacheMiddleware

* fix etag

* Standardising the http cache example

* Add better exemple for http cache

* Implement CacheMiddleware tests and refactoring

* rename example

---------

Co-authored-by: Aymane <aymane@cashflowpositifi.fr>
Co-authored-by: Jamie Barton <jamie@notrab.dev>
This commit is contained in:
Amano 2024-09-08 10:23:35 +02:00 committed by GitHub
parent bdfc84289d
commit aa6ab9934e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 290 additions and 0 deletions

View File

@ -0,0 +1,44 @@
# Http Cache Example
This example demonstrates how to use the http cache middleware in Dumbo
## Running the Example
1. Install dependencies:
```bash
composer install
```
2. Start the server:
```bash
composer start
```
3. To test the cached route, use the curl command to send a GET request to http://localhost:8000/cached/greet. This
route has the CacheMiddleware applied to it, so it should return cache control headers. Here's the command:
```bash
curl -i http://localhost:8000/cached/greet
```
Look for the ETag and Cache-Control headers in the response. The ETag header is a unique identifier for the version
of
the resource, and the Cache-Control header indicates the caching policy
4. To test the cache functionality, you can send the same request again, but this time include the If-None-Match header
with the ETag value from the previous response. If the resource hasn't changed, the server should return a 304 Not
Modified status. Here's the command:
```bash
curl -i -H 'If-None-Match: $ETAG' http://localhost:8000/cached/greet
# curl -i -H 'If-None-Match: W/"..."' http://localhost:8000/cached/greet
```
5. To test the uncached route, send a GET request to http://localhost:8000/uncached/greet. This route does not have the
CacheMiddleware applied to it, so it should not return cache control headers. Here's the command:
```bash
curl -i http://localhost:8000/uncached/greet
```

View File

@ -0,0 +1,14 @@
{
"require": {
"notrab/dumbo": "@dev"
},
"repositories": [
{
"type": "path",
"url": "../../"
}
],
"scripts": {
"start": "php -S localhost:8000 index.php"
}
}

View File

@ -0,0 +1,40 @@
<?php
use Dumbo\Dumbo;
use Dumbo\Middleware\CacheMiddleware;
require "vendor/autoload.php";
$app = new Dumbo();
/**
* CacheMiddleware is a middleware that adds cache control headers to the HTTP response.
* This middleware is applied only to routes that begin with "/cached",
* meaning that only these routes will have cache control headers.
*/
$app->use('/cached', CacheMiddleware::withHeaders(
type: "public",
mustRevalidate: true,
maxAge: 3600,
strictEtag: true
));
$app->get("/cached/greet", function ($c) {
sleep(5);
return $c->json([
"message" => "Welcome cached route!",
]);
});
$app->get('/uncached/greet', function ($c) {
sleep(5);
return $c->json([
'message' => "Uncached route!",
]);
});
$app->run();

View File

@ -0,0 +1,75 @@
<?php
namespace Dumbo\Middleware;
use Dumbo\Context;
use Psr\Http\Message\ResponseInterface;
class CacheMiddleware
{
const HTTP_NOT_MODIFIED = 304;
public static function withHeaders(
string $type = 'private',
bool $mustRevalidate = false,
int $maxAge = 86400,
bool $strictEtag = false,
): callable
{
return function (Context $ctx, callable $next) use (
$type,
$mustRevalidate,
$maxAge,
$strictEtag
): ResponseInterface {
$request = $ctx->req;
if ($request->method() != 'GET') {
return $next($ctx);
}
$etag = self::generateEtag($ctx, $strictEtag);
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
$cacheControlHeader = sprintf('%s, max-age=%d%s', $type, $maxAge, $mustRevalidate ? ', must-revalidate' : '');
$ifNoneMatch = $request->header('If-None-Match');
$ifModifiedSince = $request->header('If-Modified-Since');
if ($ifNoneMatch && $etag === $ifNoneMatch) {
return $ctx->getResponse()
->withStatus(self::HTTP_NOT_MODIFIED)
->withHeader('Cache-Control', $cacheControlHeader)
->withHeader('ETag', $etag)
->withHeader('Last-Modified', $lastModified);
}
if ($ifModifiedSince && strtotime($ifModifiedSince) >= strtotime($lastModified)) {
return $ctx->getResponse()
->withStatus(self::HTTP_NOT_MODIFIED)
->withHeader('Cache-Control', $cacheControlHeader)
->withHeader('ETag', $etag)
->withHeader('Last-Modified', $lastModified);
}
$response = $next($ctx);
if (!$response->hasHeader('Cache-Control')) {
$response = $response->withHeader('Cache-Control', $cacheControlHeader);
}
return $response
->withHeader('ETag', $etag)
->withHeader('Last-Modified', $lastModified);
};
}
private static function generateEtag(Context $ctx, bool $strict): string
{
$identifier = $strict
? $ctx->req->method() . $ctx->req->path() . serialize($ctx->req->query())
: $ctx->req->path();
return sprintf('W/"%s"', md5($identifier));
}
}

View File

@ -218,4 +218,5 @@ class RequestWrapper implements RequestWrapperInterface
{
return "/" . trim($uri, "/");
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace Dumbo\Tests;
use Dumbo\Context;
use Dumbo\Middleware\CacheMiddleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
class CacheMiddlewareTest extends TestCase
{
private function createMockContext($method = 'GET', $headers = [], $path = '/', $queryParams = []): Context
{
$request = $this->createMock(ServerRequestInterface::class);
$request->method('getMethod')->willReturn($method);
$uri = $this->createMock(UriInterface::class);
$uri->method('getPath')->willReturn($path);
$request->method('getUri')->willReturn($uri);
$request->method('getHeader')
->willReturnCallback(function ($headerName) use ($headers) {
return $headers[$headerName] ?? [];
});
$request->method('getHeaders')->willReturn($headers);
$request->method('getQueryParams')->willReturn($queryParams);
return new Context($request, [], $path);
}
public function testHeadersAreModifiedByMiddleware()
{
$middleware = CacheMiddleware::withHeaders('public', true, 60);
$context = $this->createMockContext();
$next = function ($ctx) {
return new Response(200, [], 'Response body');
};
$response = $middleware($context, $next);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEquals('public, max-age=60, must-revalidate', $response->getHeaderLine('Cache-Control'));
$this->assertNotEmpty($response->getHeaderLine('ETag'));
$this->assertNotEmpty($response->getHeaderLine('Last-Modified'));
}
public function testReturns304WhenETagMatches()
{
$etag = 'W/"' . md5('/') . '"';
$middleware = CacheMiddleware::withHeaders('public', true, 60);
$context = $this->createMockContext('GET', ['If-None-Match' => [$etag]]);
$next = function ($ctx) {
return new Response(200, [], 'Response body');
};
$response = $middleware($context, $next);
$this->assertEquals(304, $response->getStatusCode());
$this->assertEquals($etag, $response->getHeaderLine('ETag'));
}
public function testReturns304WhenIfModifiedSinceMatches()
{
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
$middleware = CacheMiddleware::withHeaders('public', true, 60);
$context = $this->createMockContext('GET', ['If-Modified-Since' => [$lastModified]]);
$next = function ($ctx) use ($lastModified) {
return (new Response(200, [], 'Response body'))
->withHeader('Last-Modified', $lastModified);
};
$response = $middleware($context, $next);
$this->assertEquals(304, $response->getStatusCode());
$this->assertEquals($lastModified, $response->getHeaderLine('Last-Modified'));
}
public function testDoesNotCacheNonGetRequests()
{
$middleware = CacheMiddleware::withHeaders('public', true, 60);
$context = $this->createMockContext('POST');
$next = function ($ctx) {
return new Response(200, [], 'Response body');
};
$response = $middleware($context, $next);
$this->assertEquals(200, $response->getStatusCode());
$this->assertEmpty($response->getHeaderLine('Cache-Control'));
}
public function testGeneratesCorrectETagForStrictMode()
{
$middleware = CacheMiddleware::withHeaders('public', true, 60, true);
$context = $this->createMockContext('GET', [], '/test', ['name' => 'Dumbo']);
$next = function ($ctx) {
return new Response(200, [], 'Response body');
};
$expectedEtag = 'W/"' . md5('GET/test' . serialize(['name' => 'Dumbo'])) . '"';
$response = $middleware($context, $next);
$this->assertEquals($expectedEtag, $response->getHeaderLine('ETag'));
}
}