From d9329aca92bf48cae72b63b2d63b7568e2760ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Wed, 12 Aug 2026 17:40:18 +0200 Subject: [PATCH] feat(errors): add detailed error documentation pages - Implemented views for error codes 400, 404, 422, 429, 500, and 503. - Updated error handling to use numeric codes instead of enum. - Enhanced error messages and resolution steps for better user guidance. --- bootstrap/middlewares.php | 4 ++ routes/web.php | 4 +- .../Api/V1/Exceptions/ApiErrorCode.php | 30 ----------- .../V1/Exceptions/ApiExceptionRenderer.php | 51 +++++++++++++------ .../ErrorDocumentationController.php | 25 +++++---- .../{bad-request.blade.php => 400.blade.php} | 0 .../{not-found.blade.php => 404.blade.php} | 0 ...lidation-error.blade.php => 422.blade.php} | 0 ...-many-requests.blade.php => 429.blade.php} | 0 ...internal-error.blade.php => 500.blade.php} | 0 .../resources/views/errors/docs/503.blade.php | 8 +++ .../views/errors/docs/layout.blade.php | 2 +- tests/Feature/ErrorDocumentationTest.php | 10 ++-- 13 files changed, 71 insertions(+), 63 deletions(-) delete mode 100644 src/Presentation/Api/V1/Exceptions/ApiErrorCode.php rename src/Presentation/Web/resources/views/errors/docs/{bad-request.blade.php => 400.blade.php} (100%) rename src/Presentation/Web/resources/views/errors/docs/{not-found.blade.php => 404.blade.php} (100%) rename src/Presentation/Web/resources/views/errors/docs/{validation-error.blade.php => 422.blade.php} (100%) rename src/Presentation/Web/resources/views/errors/docs/{too-many-requests.blade.php => 429.blade.php} (100%) rename src/Presentation/Web/resources/views/errors/docs/{internal-error.blade.php => 500.blade.php} (100%) create mode 100644 src/Presentation/Web/resources/views/errors/docs/503.blade.php diff --git a/bootstrap/middlewares.php b/bootstrap/middlewares.php index 66cb684..20ca07b 100644 --- a/bootstrap/middlewares.php +++ b/bootstrap/middlewares.php @@ -15,4 +15,8 @@ return function (Middleware $middleware): void { $middleware->alias([ ]); + + $middleware->preventRequestsDuringMaintenance(except: [ + 'errors/*', + ]); }; diff --git a/routes/web.php b/routes/web.php index 6e1adcc..954c1ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,4 +7,6 @@ use Presentation\Web\Http\Controllers\ErrorDocumentationController; use Presentation\Web\Http\Controllers\HomeController; Route::get('/', HomeController::class)->name('home'); -Route::get('/errors/{code}', ErrorDocumentationController::class)->name('errors.doc'); +Route::get('/errors/{code}', ErrorDocumentationController::class) + ->whereNumber('code') + ->name('errors.doc'); diff --git a/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php b/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php deleted file mode 100644 index 6c21eea..0000000 --- a/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php +++ /dev/null @@ -1,30 +0,0 @@ - */ - use BaseEnum; - - case NotFound = 'not-found'; - case ValidationError = 'validation-error'; - case BadRequest = 'bad-request'; - case TooManyRequests = 'too-many-requests'; - case InternalError = 'internal-error'; - - public function title(): string - { - return match ($this) { - self::NotFound => 'Resource Not Found', - self::ValidationError => 'Unprocessable Entity', - self::BadRequest => 'Bad Request', - self::TooManyRequests => 'Too Many Requests', - self::InternalError => 'Internal Server Error', - }; - } -} diff --git a/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php b/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php index bd569f0..3f91592 100644 --- a/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php +++ b/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php @@ -10,6 +10,9 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Config; use Illuminate\Http\Exceptions\ThrottleRequestsException; use Illuminate\Validation\ValidationException; +use League\Uri\Http; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Throwable; final readonly class ApiExceptionRenderer @@ -22,16 +25,16 @@ final readonly class ApiExceptionRenderer return match (true) { $e instanceof NotFoundException => response()->json([ - 'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::NotFound->value), - 'title' => ApiErrorCode::NotFound->title(), + 'type' => (string) $baseUri->withPath('/errors/404'), + 'title' => 'Resource Not Found', 'status' => 404, 'detail' => $e->getMessage(), 'instance' => $request->getPathInfo(), ], 404, ['Content-Type' => self::CONTENT_TYPE]), $e instanceof ValidationException => response()->json([ - 'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::ValidationError->value), - 'title' => ApiErrorCode::ValidationError->title(), + 'type' => (string) $baseUri->withPath('/errors/400'), + 'title' => 'Unprocessable Entity', 'status' => 400, 'detail' => $e->getMessage(), 'invalidParams' => array_values(array_map( @@ -57,8 +60,8 @@ final readonly class ApiExceptionRenderer $e instanceof \DomainException => response()->json([ - 'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::ValidationError->value), - 'title' => ApiErrorCode::ValidationError->title(), + 'type' => (string) $baseUri->withPath('/errors/422'), + 'title' => 'Unprocessable Entity', 'status' => 422, 'detail' => $e->getMessage(), 'instance' => $request->getPathInfo(), @@ -71,20 +74,38 @@ final readonly class ApiExceptionRenderer ], 422, ['Content-Type' => self::CONTENT_TYPE]), $e instanceof ThrottleRequestsException => response()->json([ - 'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::TooManyRequests->value), - 'title' => ApiErrorCode::TooManyRequests->title(), + 'type' => (string) $baseUri->withPath('/errors/429'), + 'title' => 'Too Many Requests', 'status' => 429, 'detail' => $e->getMessage(), 'instance' => $request->getPathInfo(), ], 429, array_merge(['Content-Type' => self::CONTENT_TYPE], $e->getHeaders())), - default => response()->json([ - 'type' => (string) $baseUri->withPath('/errors/' . ApiErrorCode::InternalError->value), - 'title' => ApiErrorCode::InternalError->title(), - 'status' => 500, - 'detail' => Config::boolean('app.debug') ? $e->getMessage() : 'An unexpected error occurred.', - 'instance' => $request->getPathInfo(), - ], 500, ['Content-Type' => self::CONTENT_TYPE]), + default => $this->renderDefault($e, $request, $baseUri), }; } + + private function renderDefault(Throwable $e, Request $request, Http $baseUri): JsonResponse + { + $status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500; + $title = Response::$statusTexts[$status] ?? 'Internal Server Error'; + + $detail = $title; + if ($e instanceof HttpExceptionInterface && $e->getMessage() !== '') { + $detail = $e->getMessage(); + } elseif (Config::boolean('app.debug')) { + $detail = $e->getMessage() ?: $title; + } + + $headers = $e instanceof HttpExceptionInterface ? $e->getHeaders() : []; + $headers['Content-Type'] = self::CONTENT_TYPE; + + return response()->json([ + 'type' => (string) $baseUri->withPath('/errors/' . $status), + 'title' => $title, + 'status' => $status, + 'detail' => $detail, + 'instance' => $request->getPathInfo(), + ], $status, $headers); + } } diff --git a/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php b/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php index 02f54c2..bd48f2c 100644 --- a/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php +++ b/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php @@ -5,23 +5,28 @@ declare(strict_types=1); namespace Presentation\Web\Http\Controllers; use Illuminate\Contracts\View\View; -use Presentation\Api\V1\Exceptions\ApiErrorCode; + +use Symfony\Component\HttpFoundation\Response; final class ErrorDocumentationController { - public function __invoke(ApiErrorCode $code): View + public function __invoke(int $code): View { - $viewName = match ($code) { - ApiErrorCode::NotFound => 'errors.docs.not-found', - ApiErrorCode::ValidationError => 'errors.docs.validation-error', - ApiErrorCode::BadRequest => 'errors.docs.bad-request', - ApiErrorCode::TooManyRequests => 'errors.docs.too-many-requests', - ApiErrorCode::InternalError => 'errors.docs.internal-error', - }; + if ($code <= 0) { + abort(404); + } + + $viewName = 'errors.docs.' . $code; + + if (!view()->exists($viewName)) { + abort(404); + } + + $title = Response::$statusTexts[$code] ?? 'Unknown Error'; return view($viewName, [ 'code' => $code, - 'title' => $code->title(), + 'title' => $title, ]); } } diff --git a/src/Presentation/Web/resources/views/errors/docs/bad-request.blade.php b/src/Presentation/Web/resources/views/errors/docs/400.blade.php similarity index 100% rename from src/Presentation/Web/resources/views/errors/docs/bad-request.blade.php rename to src/Presentation/Web/resources/views/errors/docs/400.blade.php diff --git a/src/Presentation/Web/resources/views/errors/docs/not-found.blade.php b/src/Presentation/Web/resources/views/errors/docs/404.blade.php similarity index 100% rename from src/Presentation/Web/resources/views/errors/docs/not-found.blade.php rename to src/Presentation/Web/resources/views/errors/docs/404.blade.php diff --git a/src/Presentation/Web/resources/views/errors/docs/validation-error.blade.php b/src/Presentation/Web/resources/views/errors/docs/422.blade.php similarity index 100% rename from src/Presentation/Web/resources/views/errors/docs/validation-error.blade.php rename to src/Presentation/Web/resources/views/errors/docs/422.blade.php diff --git a/src/Presentation/Web/resources/views/errors/docs/too-many-requests.blade.php b/src/Presentation/Web/resources/views/errors/docs/429.blade.php similarity index 100% rename from src/Presentation/Web/resources/views/errors/docs/too-many-requests.blade.php rename to src/Presentation/Web/resources/views/errors/docs/429.blade.php diff --git a/src/Presentation/Web/resources/views/errors/docs/internal-error.blade.php b/src/Presentation/Web/resources/views/errors/docs/500.blade.php similarity index 100% rename from src/Presentation/Web/resources/views/errors/docs/internal-error.blade.php rename to src/Presentation/Web/resources/views/errors/docs/500.blade.php diff --git a/src/Presentation/Web/resources/views/errors/docs/503.blade.php b/src/Presentation/Web/resources/views/errors/docs/503.blade.php new file mode 100644 index 0000000..6934e13 --- /dev/null +++ b/src/Presentation/Web/resources/views/errors/docs/503.blade.php @@ -0,0 +1,8 @@ +@extends('errors.docs.layout') + +@section('status', '503') + +@section('doc-content') +

Le service est temporairement indisponible, généralement pour des raisons de maintenance.

+

Veuillez réessayer ultérieurement.

+@endsection diff --git a/src/Presentation/Web/resources/views/errors/docs/layout.blade.php b/src/Presentation/Web/resources/views/errors/docs/layout.blade.php index 2ddcea5..b168a67 100644 --- a/src/Presentation/Web/resources/views/errors/docs/layout.blade.php +++ b/src/Presentation/Web/resources/views/errors/docs/layout.blade.php @@ -16,7 +16,7 @@ HTTP @yield('status', '500') -

{{ $title }} ({{ $code->value }})

+

{{ $title }} ({{ $code }})

@yield('doc-content') diff --git a/tests/Feature/ErrorDocumentationTest.php b/tests/Feature/ErrorDocumentationTest.php index cb3e6ba..3abc3ea 100644 --- a/tests/Feature/ErrorDocumentationTest.php +++ b/tests/Feature/ErrorDocumentationTest.php @@ -4,22 +4,20 @@ declare(strict_types=1); namespace Tests\Feature; -use Presentation\Api\V1\Exceptions\ApiErrorCode; - describe('Error Documentation Pages', function (): void { it('renders the succinct documentation view for valid error codes', function (): void { // GIVEN /** @var \Tests\TestCase $test */ $test = $this; - $code = ApiErrorCode::NotFound; + $code = 404; // WHEN - $response = $test->get("/errors/{$code->value}"); + $response = $test->get("/errors/{$code}"); // THEN $response->assertStatus(200); - $response->assertSee('Resource Not Found'); - $response->assertSee('not-found'); + $response->assertSee('Not Found'); + $response->assertSee('404'); }); it('returns a 404 for an invalid error code slug', function (): void {