mirror of
https://github.com/notrab/dumbo.git
synced 2025-01-16 13:50:03 +01:00
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:
parent
bdfc84289d
commit
aa6ab9934e
44
examples/http-cache/README.md
Normal file
44
examples/http-cache/README.md
Normal 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
|
||||
```
|
14
examples/http-cache/composer.json
Normal file
14
examples/http-cache/composer.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"require": {
|
||||
"notrab/dumbo": "@dev"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../../"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"start": "php -S localhost:8000 index.php"
|
||||
}
|
||||
}
|
40
examples/http-cache/index.php
Normal file
40
examples/http-cache/index.php
Normal 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();
|
75
src/Middleware/CacheMiddleware.php
Normal file
75
src/Middleware/CacheMiddleware.php
Normal 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));
|
||||
}
|
||||
}
|
@ -218,4 +218,5 @@ class RequestWrapper implements RequestWrapperInterface
|
||||
{
|
||||
return "/" . trim($uri, "/");
|
||||
}
|
||||
|
||||
}
|
||||
|
116
tests/CacheMiddlewareTest.php
Normal file
116
tests/CacheMiddlewareTest.php
Normal 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'));
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user