Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion docs/3.advanced/5.middleware.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Middleware
description: JSON:API content type middleware.
description: JSON:API content type and ETag caching middleware.
---

# Middleware
Expand All @@ -19,3 +19,55 @@ Route::middleware([ForceJsonApiResponse::class])->group(function () {
```

This ensures every response from your API routes has the correct content type header, even for error responses.

## ETag

Adds automatic `ETag` headers to GET responses and returns `304 Not Modified` when the client sends a matching `If-None-Match` header. This reduces bandwidth for API consumers that cache responses.

```php
// routes/api.php
use BlueBeetle\ApiToolkit\Http\Middleware\ETag;

Route::middleware([ETag::class])->group(function () {
Route::apiResource('products', ProductController::class);
});
```

### How It Works

1. The middleware generates an ETag from the response content (MD5 hash)
2. The ETag is added to the response headers
3. On subsequent requests, if the client sends `If-None-Match` with the same ETag, a `304 Not Modified` response is returned with an empty body

### When It Applies

The middleware only processes responses that meet all of these conditions:

- The request method is safe (GET, HEAD)
- The response status is successful (2xx)
- The response body is not empty

POST, PUT, PATCH, DELETE requests and error responses are passed through unchanged.

### Example Flow

```
# First request - full response with ETag
GET /api/products
→ 200 OK
→ ETag: "a1b2c3d4..."
→ {"data": [...]}

# Second request - client sends cached ETag
GET /api/products
If-None-Match: "a1b2c3d4..."
→ 304 Not Modified
→ (empty body)

# Data changed - ETag no longer matches
GET /api/products
If-None-Match: "a1b2c3d4..."
→ 200 OK
→ ETag: "e5f6g7h8..."
→ {"data": [...]}
```
62 changes: 62 additions & 0 deletions src/Http/Middleware/ETag.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types = 1);

namespace BlueBeetle\ApiToolkit\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final class ETag
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);

if (! $this->shouldProcess($request, $response)) {
return $response;
}

$etag = $this->generateETag($response);
$response->headers->set('ETag', $etag);

if ($this->isNotModified($request, $etag)) {
$response->setStatusCode(304);
$response->setContent('');
}

return $response;
}

private function shouldProcess(Request $request, Response $response): bool
{
if (! $request->isMethodSafe()) {
return false;
}

if (! $response->isSuccessful()) {
return false;
}

$content = $response->getContent();

return $content !== false && $content !== '';
}

private function generateETag(Response $response): string
{
return '"'.md5($response->getContent()).'"';
}

private function isNotModified(Request $request, string $etag): bool
{
$ifNoneMatch = $request->headers->get('If-None-Match');

if ($ifNoneMatch === null) {
return false;
}

return $ifNoneMatch === $etag;
}
}
80 changes: 80 additions & 0 deletions tests/Feature/Http/Middleware/ETagTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types = 1);

namespace BlueBeetle\ApiToolkit\Tests\Feature\Http\Middleware;

use BlueBeetle\ApiToolkit\Http\Middleware\ETag;
use Illuminate\Support\Facades\Route;

beforeEach(function () {
Route::middleware(ETag::class)->get('/test', fn () => response()->json(['data' => 'hello']));
Route::middleware(ETag::class)->post('/test', fn () => response()->json(['created' => true], 201));
Route::middleware(ETag::class)->get('/error', fn () => response()->json(['error' => 'fail'], 500));
Route::middleware(ETag::class)->get('/empty', fn () => response('', 204));
});

it('adds etag header to GET responses', function () {
$response = $this->getJson('/test');

$response->assertOk();
$response->assertHeader('ETag');

$etag = $response->headers->get('ETag');
expect($etag)->toStartWith('"');
expect($etag)->toEndWith('"');
});

it('returns 304 when If-None-Match matches', function () {
$first = $this->getJson('/test');
$etag = $first->headers->get('ETag');

$second = $this->getJson('/test', ['If-None-Match' => $etag]);

$second->assertStatus(304);
expect($second->getContent())->toBe('');
});

it('returns full response when If-None-Match does not match', function () {
$response = $this->getJson('/test', ['If-None-Match' => '"stale-etag"']);

$response->assertOk();
$response->assertHeader('ETag');
expect($response->json('data'))->toBe('hello');
});

it('returns full response without If-None-Match header', function () {
$response = $this->getJson('/test');

$response->assertOk();
$response->assertHeader('ETag');
expect($response->json('data'))->toBe('hello');
});

it('does not process non-safe methods', function () {
$response = $this->postJson('/test');

$response->assertStatus(201);
expect($response->headers->has('ETag'))->toBeFalse();
});

it('does not process error responses', function () {
$response = $this->getJson('/error');

$response->assertStatus(500);
expect($response->headers->has('ETag'))->toBeFalse();
});

it('does not process empty responses', function () {
$response = $this->get('/empty');

$response->assertStatus(204);
expect($response->headers->has('ETag'))->toBeFalse();
});

it('generates consistent etags for same content', function () {
$first = $this->getJson('/test');
$second = $this->getJson('/test');

expect($first->headers->get('ETag'))->toBe($second->headers->get('ETag'));
});