diff --git a/bootstrap/exceptions.php b/bootstrap/exceptions.php new file mode 100644 index 0000000..2a96e0d --- /dev/null +++ b/bootstrap/exceptions.php @@ -0,0 +1,35 @@ +reportable(function (\Throwable $e): void { + if (app()->bound(HubInterface::class)) { + resolve(HubInterface::class)->captureException($e); + } + }); + + $exceptions->render(function (\Throwable $e, Request $request) { + $apiDispatcher = resolve(ApiExceptionDispatcher::class); + if ($apiResponse = $apiDispatcher->render($e, $request)) { + return $apiResponse; + } + + $webRenderer = resolve(WebExceptionRenderer::class); + if ($webResponse = $webRenderer->render($e, $request)) { + return $webResponse; + } + + return null; + }); + + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*'), + ); +}; diff --git a/bootstrap/middlewares.php b/bootstrap/middlewares.php index b332cd9..66cb684 100644 --- a/bootstrap/middlewares.php +++ b/bootstrap/middlewares.php @@ -5,6 +5,14 @@ declare(strict_types=1); use Illuminate\Foundation\Configuration\Middleware; return function (Middleware $middleware): void { + $middleware->web(append: [ + 'throttle:web', + ]); + + $middleware->api(append: [ + 'throttle:api.v1', + ]); + $middleware->alias([ ]); }; diff --git a/config/throttles.php b/config/throttles.php new file mode 100644 index 0000000..ea6d234 --- /dev/null +++ b/config/throttles.php @@ -0,0 +1,10 @@ + (int) env('THROTTLE_WEB', 120), + 'api' => [ + 'v1' => (int) env('THROTTLE_API_V1', 60), + ], +]; diff --git a/src/Infra/Providers/AppServiceProvider.php b/src/Infra/Providers/AppServiceProvider.php index 3db49f3..ad91020 100644 --- a/src/Infra/Providers/AppServiceProvider.php +++ b/src/Infra/Providers/AppServiceProvider.php @@ -12,13 +12,29 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request as IlluminateRequest; use League\Uri\Http; use Shared\Pagination; class AppServiceProvider extends ServiceProvider { + public function register(): void + { + $this->app->singleton('clientIp', function () { + if (app()->environment('local')) { + return request()->getClientIp(); + } + + return request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp(); + }); + } + public function boot(): void { + $this->configureRateLimiting(); + ini_set('assert.active', '1'); ini_set('assert.exception', '1'); @@ -63,6 +79,18 @@ class AppServiceProvider extends ServiceProvider }); } + protected function configureRateLimiting(): void + { + RateLimiter::for('web', function (IlluminateRequest $request) { + return Limit::perMinute(Config::integer('throttles.web'))->by(resolve('clientIp')); + }); + + RateLimiter::for('api.v1', function (IlluminateRequest $request) { + return Limit::perMinute(Config::integer('throttles.api.v1'))->by(resolve('clientIp')); + }); + } + + public function toLocalInput(CarbonInterface | \DateTimeInterface | int | float | string | null $date): ?string { if ($date === null) { diff --git a/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php b/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php index 986883e..6c21eea 100644 --- a/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php +++ b/src/Presentation/Api/V1/Exceptions/ApiErrorCode.php @@ -14,6 +14,7 @@ enum ApiErrorCode: string 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 @@ -22,6 +23,7 @@ enum ApiErrorCode: string 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 06416de..bd569f0 100644 --- a/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php +++ b/src/Presentation/Api/V1/Exceptions/ApiExceptionRenderer.php @@ -8,6 +8,7 @@ use Domain\Exceptions\NotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Config; +use Illuminate\Http\Exceptions\ThrottleRequestsException; use Illuminate\Validation\ValidationException; use Throwable; @@ -69,6 +70,14 @@ 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(), + '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(), diff --git a/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php b/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php index b1bb1cb..02f54c2 100644 --- a/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php +++ b/src/Presentation/Web/Http/Controllers/ErrorDocumentationController.php @@ -11,7 +11,15 @@ final class ErrorDocumentationController { public function __invoke(ApiErrorCode $code): View { - return view('errors.docs.' . $code->value, [ + $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', + }; + + return view($viewName, [ 'code' => $code, 'title' => $code->title(), ]); 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 e4cce30..2ddcea5 100644 --- a/src/Presentation/Web/resources/views/errors/docs/layout.blade.php +++ b/src/Presentation/Web/resources/views/errors/docs/layout.blade.php @@ -1,20 +1,20 @@ -@extends('layouts.layout') - -@section('title', $title) - -@push('styles') - -@endpush - -@section('content') + + + + + @yield('title', 'Error Documentation') + + @stack('styles') + + HTTP @yield('status', '500')

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

@@ -22,4 +22,5 @@

Conforme à la spécification RFC 7807 Problem Details.

-@endsection + + diff --git a/src/Presentation/Web/resources/views/errors/docs/too-many-requests.blade.php b/src/Presentation/Web/resources/views/errors/docs/too-many-requests.blade.php new file mode 100644 index 0000000..6641c78 --- /dev/null +++ b/src/Presentation/Web/resources/views/errors/docs/too-many-requests.blade.php @@ -0,0 +1,8 @@ +@extends('errors.docs.layout') + +@section('status', '429') + +@section('doc-content') +

Trop de requêtes ont été envoyées dans un laps de temps donné. Vous avez dépassé la limite de requêtes autorisée.

+

Veuillez attendre avant de réessayer.

+@endsection