From 7f0448ab4b76e321d7699e49586a290cc0153130 Mon Sep 17 00:00:00 2001 From: Craig Smith <952595+phpsa@users.noreply.github.com> Date: Wed, 12 Feb 2025 20:20:11 +1300 Subject: [PATCH] Added support for `Symfony\Component\HttpKernel\Exception\*` documentation when annotated in exceptions (#674) * fix: Symfony\Component\HttpKernel\Exception\ types * added test and removed notfound as is handled seperatly * fix formatting * fix formatting and updated logic * formatting fix * undo formatting --------- Co-authored-by: Roman Lytvynenko --- .../HttpExceptionToResponseExtension.php | 72 ++++++++++++++++++- tests/ErrorsResponsesTest.php | 59 ++++++++++++++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/Support/ExceptionToResponseExtensions/HttpExceptionToResponseExtension.php b/src/Support/ExceptionToResponseExtensions/HttpExceptionToResponseExtension.php index eb93b847..2343dc92 100644 --- a/src/Support/ExceptionToResponseExtensions/HttpExceptionToResponseExtension.php +++ b/src/Support/ExceptionToResponseExtensions/HttpExceptionToResponseExtension.php @@ -10,7 +10,23 @@ use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\Type; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\GoneHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\LengthRequiredHttpException; +use Symfony\Component\HttpKernel\Exception\LockedHttpException; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException; +use Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; class HttpExceptionToResponseExtension extends ExceptionToResponseExtension { @@ -34,7 +50,8 @@ public function toResponse(Type $type) ? ($type->templateTypes[7] ?? null) : ($type->templateTypes[0] ?? null); - if (! $codeType instanceof LiteralIntegerType) { + $responseCode = $this->getResponseCode($codeType, $type); + if ($responseCode === null) { return null; } @@ -51,11 +68,60 @@ public function toResponse(Type $type) ) ->setRequired(['message']); - return Response::make($codeType->value) - ->description('An error') + return Response::make($responseCode) + ->description($this->getDescription($type)) ->setContent( 'application/json', Schema::fromType($responseBodyType) ); } + + protected function getResponseCode(?Type $codeType, Type $type): ?int + { + if (! $codeType instanceof LiteralIntegerType) { + return match (true) { + $type->isInstanceOf(AccessDeniedHttpException::class) => 403, + $type->isInstanceOf(BadRequestHttpException::class) => 400, + $type->isInstanceOf(ConflictHttpException::class) => 409, + $type->isInstanceOf(GoneHttpException::class) => 410, + $type->isInstanceOf(LengthRequiredHttpException::class) => 411, + $type->isInstanceOf(LockedHttpException::class) => 423, + $type->isInstanceOf(MethodNotAllowedHttpException::class) => 405, + $type->isInstanceOf(NotAcceptableHttpException::class) => 406, + $type->isInstanceOf(PreconditionFailedHttpException::class) => 412, + $type->isInstanceOf(PreconditionRequiredHttpException::class) => 428, + $type->isInstanceOf(ServiceUnavailableHttpException::class) => 503, + $type->isInstanceOf(TooManyRequestsHttpException::class) => 429, + $type->isInstanceOf(UnauthorizedHttpException::class) => 401, + $type->isInstanceOf(UnprocessableEntityHttpException::class) => 422, + $type->isInstanceOf(UnsupportedMediaTypeHttpException::class) => 415, + default => null, + }; + } + + return $codeType->value; + } + + protected function getDescription(Type $type): string + { + return match (true) { + $type->isInstanceOf(AccessDeniedHttpException::class) => 'Access denied', + $type->isInstanceOf(BadRequestHttpException::class) => 'Bad request', + $type->isInstanceOf(ConflictHttpException::class) => 'Conflict', + $type->isInstanceOf(GoneHttpException::class) => 'Gone', + $type->isInstanceOf(LengthRequiredHttpException::class) => 'Length required', + $type->isInstanceOf(LockedHttpException::class) => 'Locked', + $type->isInstanceOf(MethodNotAllowedHttpException::class) => 'Method not allowed', + $type->isInstanceOf(NotAcceptableHttpException::class) => 'Not acceptable', + $type->isInstanceOf(NotFoundHttpException::class) => 'Not found', + $type->isInstanceOf(PreconditionFailedHttpException::class) => 'Precondition failed', + $type->isInstanceOf(PreconditionRequiredHttpException::class) => 'Precondition required', + $type->isInstanceOf(ServiceUnavailableHttpException::class) => 'Service unavailable', + $type->isInstanceOf(TooManyRequestsHttpException::class) => 'Too many requests', + $type->isInstanceOf(UnauthorizedHttpException::class) => 'Unauthorized', + $type->isInstanceOf(UnprocessableEntityHttpException::class) => 'Unprocessable entity', + $type->isInstanceOf(UnsupportedMediaTypeHttpException::class) => 'Unsupported media type', + default => 'An error', + }; + } } diff --git a/tests/ErrorsResponsesTest.php b/tests/ErrorsResponsesTest.php index 8d8f53a8..065f3645 100644 --- a/tests/ErrorsResponsesTest.php +++ b/tests/ErrorsResponsesTest.php @@ -7,7 +7,21 @@ use Illuminate\Routing\Controller; use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as RouteFacade; - +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\GoneHttpException; +use Symfony\Component\HttpKernel\Exception\LengthRequiredHttpException; +use Symfony\Component\HttpKernel\Exception\LockedHttpException; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException; +use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException; +use Symfony\Component\HttpKernel\Exception\PreconditionRequiredHttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use function Spatie\Snapshots\assertMatchesSnapshot; it('adds validation error response', function () { @@ -92,6 +106,42 @@ expect($openApiDocument['paths']['/test']['get']['responses'][409])->toHaveKey('content.application/json.schema.type', 'object'); }); + + +it('adds http error response exception extending sympony HTTP exception is thrown', function () { + $openApiDocument = generateForRoute(fn() => RouteFacade::get('api/test', [ErrorsResponsesTest_Controller::class, 'symfony_http_exception_response'])); + + // AccessDeniedHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][403])->toHaveKey('content.application/json.schema.type', 'object'); + // BadRequestHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][400])->toHaveKey('content.application/json.schema.type', 'object'); + // ConflictHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][409])->toHaveKey('content.application/json.schema.type', 'object'); + // GoneHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][410])->toHaveKey('content.application/json.schema.type', 'object'); + // LengthRequiredHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][411])->toHaveKey('content.application/json.schema.type', 'object'); + // LockedHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][423])->toHaveKey('content.application/json.schema.type', 'object'); + // MethodNotAllowedHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][405])->toHaveKey('content.application/json.schema.type', 'object'); + // NotAcceptableHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][406])->toHaveKey('content.application/json.schema.type', 'object'); + // PreconditionFailedHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][412])->toHaveKey('content.application/json.schema.type', 'object'); + // PreconditionRequiredHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][428])->toHaveKey('content.application/json.schema.type', 'object'); + // ServiceUnavailableHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][503])->toHaveKey('content.application/json.schema.type', 'object'); + // TooManyRequestsHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][429])->toHaveKey('content.application/json.schema.type', 'object'); + // UnauthorizedHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][401])->toHaveKey('content.application/json.schema.type', 'object'); + // UnprocessableEntityHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][422])->toHaveKey('content.application/json.schema.type', 'object'); + // UnsupportedMediaTypeHttpException + expect($openApiDocument['paths']['/test']['get']['responses'][415])->toHaveKey('content.application/json.schema.type', 'object'); +}); class ErrorsResponsesTest_Controller extends Controller { use AuthorizesRequests, DispatchesJobs, ValidatesRequests; @@ -129,6 +179,13 @@ public function custom_exception_response(Illuminate\Http\Request $request) { throw new BusinessException('The business error'); } + + /** + * @throws AccessDeniedHttpException|BadRequestHttpException|ConflictHttpException|GoneHttpException|LengthRequiredHttpException|LockedHttpException|MethodNotAllowedHttpException|NotAcceptableHttpException|PreconditionFailedHttpException|PreconditionRequiredHttpException|ServiceUnavailableHttpException|TooManyRequestsHttpException|UnauthorizedHttpException|UnprocessableEntityHttpException|UnsupportedMediaTypeHttpException + */ + public function symfony_http_exception_response(Illuminate\Http\Request $request) + { + } } class BusinessException extends \Symfony\Component\HttpKernel\Exception\HttpException