feat(middlewares): add EnsureApiV1AcceptsJson and SetApiV1Etag middlewares

Introduce new middlewares to enforce JSON acceptance and manage ETag headers for API responses.
This commit is contained in:
Jérémy Munsch 2026-08-14 16:47:52 +02:00
parent 8f82f86966
commit 0a1d56fc69
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 119 additions and 0 deletions

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use Illuminate\Foundation\Configuration\Middleware;
use Presentation\Api\V1\Http\Middlewares\EnsureApiV1AcceptsJson;
use Presentation\Api\V1\Http\Middlewares\SetApiV1Etag;
use Presentation\Api\V1\Http\Middlewares\TransformApiV1Response;
return function (Middleware $middleware): void {
@ -13,7 +15,9 @@ return function (Middleware $middleware): void {
$middleware->api(append: []);
$middleware->group('api.v1', [
EnsureApiV1AcceptsJson::class,
'throttle:api.v1',
SetApiV1Etag::class,
TransformApiV1Response::class,
]);

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class EnsureApiV1AcceptsJson
{
public function handle(Request $request, Closure $next): Response
{
// * Bypass for OpenAPI YAML spec route
if ($request->routeIs('api.v1.openapi')) {
/** @var Response */
return $next($request);
}
$acceptHeader = $request->header('Accept');
if ($acceptHeader !== null && $acceptHeader !== '' && $acceptHeader !== '*/*') {
$acceptableTypes = $request->getAcceptableContentTypes();
$acceptsJson = false;
foreach ($acceptableTypes as $type) {
if ($type === '*/*' || $type === 'application/*' || str_contains($type, 'json')) {
$acceptsJson = true;
break;
}
}
if (! $acceptsJson) {
return response()->json([
'type' => $request->schemeAndHttpHost() . '/errors/406',
'title' => 'Not Acceptable',
'status' => 406,
'detail' => 'The requested media type is not supported by this endpoint. This API only serves application/json.',
'instance' => $request->getPathInfo(),
], 406, ['Content-Type' => 'application/problem+json']);
}
}
$request->headers->set('Accept', 'application/json');
/** @var Response */
return $next($request);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
final class SetApiV1Etag
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
if (! $request->isMethodCacheable() || $response->getStatusCode() !== 200) {
return $response;
}
$etag = null;
if ($response instanceof BinaryFileResponse) {
$path = $response->getFile()->getPathname();
if (file_exists($path)) {
$etag = '"' . hash_file('sha256', $path) . '"';
}
} else {
$content = $response->getContent();
if ($content !== false && $content !== '') {
$etag = '"' . hash('sha256', $content) . '"';
}
}
if ($etag === null) {
return $response;
}
$response->headers->set('ETag', $etag);
$ifNoneMatch = $request->header('If-None-Match');
if ($ifNoneMatch !== null && $this->matchesEtag($ifNoneMatch, $etag)) {
$response->setStatusCode(304);
$response->setContent('');
}
return $response;
}
private function matchesEtag(string $ifNoneMatch, string $etag): bool
{
$candidates = array_map('trim', explode(',', $ifNoneMatch));
foreach ($candidates as $candidate) {
if ($candidate === '*' || $candidate === $etag || trim($candidate, '"') === trim($etag, '"')) {
return true;
}
}
return false;
}
}