diff --git a/bootstrap/middlewares.php b/bootstrap/middlewares.php index f0ef4fe..9ce7e00 100644 --- a/bootstrap/middlewares.php +++ b/bootstrap/middlewares.php @@ -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, ]); diff --git a/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php new file mode 100644 index 0000000..fcb5c2f --- /dev/null +++ b/src/Presentation/Api/V1/Http/Middlewares/EnsureApiV1AcceptsJson.php @@ -0,0 +1,51 @@ +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); + } +} diff --git a/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php new file mode 100644 index 0000000..82e55e6 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Middlewares/SetApiV1Etag.php @@ -0,0 +1,64 @@ +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; + } +}