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.
This commit is contained in:
Jérémy Munsch 2026-08-12 17:40:18 +02:00
parent 699cdf7f8e
commit d9329aca92
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
13 changed files with 71 additions and 63 deletions

View file

@ -15,4 +15,8 @@ return function (Middleware $middleware): void {
$middleware->alias([
]);
$middleware->preventRequestsDuringMaintenance(except: [
'errors/*',
]);
};

View file

@ -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');

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Exceptions;
use Shared\Enums\BaseEnum;
enum ApiErrorCode: string
{
/** @use BaseEnum<string> */
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',
};
}
}

View file

@ -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);
}
}

View file

@ -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,
]);
}
}

View file

@ -0,0 +1,8 @@
@extends('errors.docs.layout')
@section('status', '503')
@section('doc-content')
<p>Le service est temporairement indisponible, généralement pour des raisons de maintenance.</p>
<p>Veuillez réessayer ultérieurement.</p>
@endsection

View file

@ -16,7 +16,7 @@
</head>
<body>
<span class="badge badge-@yield('status', '500')">HTTP @yield('status', '500')</span>
<h1>{{ $title }} ({{ $code->value }})</h1>
<h1>{{ $title }} ({{ $code }})</h1>
@yield('doc-content')

View file

@ -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 {