diff --git a/packages/framework-extra-bundle/DependencyInjection/Integration/OpenApiIntegration.php b/packages/framework-extra-bundle/DependencyInjection/Integration/OpenApiIntegration.php index 9b5ef9c5c..298ed263c 100644 --- a/packages/framework-extra-bundle/DependencyInjection/Integration/OpenApiIntegration.php +++ b/packages/framework-extra-bundle/DependencyInjection/Integration/OpenApiIntegration.php @@ -27,6 +27,8 @@ use Draw\Component\OpenApi\Extraction\Extractor\PhpDoc\OperationExtractor; use Draw\Component\OpenApi\Extraction\Extractor\TypeSchemaExtractor; use Draw\Component\OpenApi\Extraction\ExtractorInterface; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\ConfigurableErrorToHttpCodeConverter; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\HttpExceptionToHttpCodeConverter; use Draw\Component\OpenApi\Naming\AliasesClassNamingFilter; use Draw\Component\OpenApi\OpenApi; use Draw\Component\OpenApi\Request\ValueResolver\RequestBody; @@ -330,6 +332,8 @@ private function configResponseExceptionHandler(array $config, PhpFileLoader $lo { if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition(ResponseApiExceptionListener::class); + $container->removeDefinition(ConfigurableErrorToHttpCodeConverter::class); + $container->removeDefinition(HttpExceptionToHttpCodeConverter::class); return; } @@ -351,15 +355,17 @@ private function configResponseExceptionHandler(array $config, PhpFileLoader $lo '$debug', new Parameter('kernel.debug') ) - ->setArgument( - '$errorCodes', - $codes - ) ->setArgument( '$violationKey', $config['violationKey'] ); + $container->getDefinition(ConfigurableErrorToHttpCodeConverter::class) + ->setArgument( + '$errorCodes', + $codes + ); + $operationExtractorDefinition = $container->getDefinition(OperationExtractor::class); foreach ($codes as $exceptionClass => $code) { diff --git a/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/OpenApiIntegrationTest.php b/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/OpenApiIntegrationTest.php index a2bf1d4eb..48d92f94b 100644 --- a/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/OpenApiIntegrationTest.php +++ b/packages/framework-extra-bundle/Tests/DependencyInjection/Integration/OpenApiIntegrationTest.php @@ -54,6 +54,8 @@ use Draw\Component\OpenApi\Extraction\Extractor\Symfony\RouteOperationExtractor; use Draw\Component\OpenApi\Extraction\Extractor\Symfony\RouterRootSchemaExtractor; use Draw\Component\OpenApi\Extraction\Extractor\TypeSchemaExtractor; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\ConfigurableErrorToHttpCodeConverter; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\HttpExceptionToHttpCodeConverter; use Draw\Component\OpenApi\Naming\AliasesClassNamingFilter; use Draw\Component\OpenApi\OpenApi; use Draw\Component\OpenApi\Request\ValueResolver\RequestBodyValueResolver; @@ -481,6 +483,18 @@ function (Definition $definition): void { 'draw.open_api.event_listener.request_validation_listener', [RequestValidationListener::class] ), + new ServiceConfiguration( + 'draw.open_api.http_foundation.error_to_http_code_converter.configurable_error_to_http_code_converter', + [ + ConfigurableErrorToHttpCodeConverter::class, + ] + ), + new ServiceConfiguration( + 'draw.open_api.http_foundation.error_to_http_code_converter.http_exception_to_http_code_converter', + [ + HttpExceptionToHttpCodeConverter::class, + ] + ), ], [ RouteDefaultApiRouteVersionMatcher::class => [ diff --git a/packages/open-api/EventListener/ResponseApiExceptionListener.php b/packages/open-api/EventListener/ResponseApiExceptionListener.php index 18a855f19..1bb25d561 100644 --- a/packages/open-api/EventListener/ResponseApiExceptionListener.php +++ b/packages/open-api/EventListener/ResponseApiExceptionListener.php @@ -4,25 +4,20 @@ use Draw\Component\OpenApi\Event\PreDumpRootSchemaEvent; use Draw\Component\OpenApi\Exception\ConstraintViolationListException; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\ConfigurableErrorToHttpCodeConverter; +use Draw\Component\OpenApi\HttpFoundation\ErrorToHttpCodeConverter\ErrorToHttpCodeConverterInterface; use Draw\Component\OpenApi\Schema\Response; use Draw\Component\OpenApi\Schema\Schema; +use Symfony\Component\DependencyInjection\Attribute\TaggedIterator; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Event\ExceptionEvent; -use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationInterface; use Symfony\Component\Validator\ConstraintViolationList; final class ResponseApiExceptionListener implements EventSubscriberInterface { - /** - * @var array - */ - private array $errorCodes; - - private const DEFAULT_STATUS_CODE = 500; - public static function getSubscribedEvents(): array { return [ @@ -32,11 +27,15 @@ public static function getSubscribedEvents(): array } public function __construct( + /** + * @var ErrorToHttpCodeConverterInterface[] + */ + #[TaggedIterator(ErrorToHttpCodeConverterInterface::class)] + private iterable $errorToHttpCodeConverters = [], private bool $debug = false, - array $errorCodes = [], private string $violationKey = 'errors', ) { - $this->errorCodes = array_filter($errorCodes); + $this->errorToHttpCodeConverters ??= new ConfigurableErrorToHttpCodeConverter(); } public function addErrorDefinition(PreDumpRootSchemaEvent $event): void @@ -183,22 +182,14 @@ private function getExceptionDetail(\Throwable $e): array return $result; } - private function getStatusCode(\Throwable $exception): int + private function getStatusCode(\Throwable $error): int { - if ($exception instanceof HttpException) { - return $exception->getStatusCode(); - } - - $exceptionClass = $exception::class; - - foreach ($this->errorCodes as $exceptionMapClass => $value) { - switch (true) { - case $exceptionClass === $exceptionMapClass: - case is_a($exception, $exceptionMapClass, true): - return $value; + foreach ($this->errorToHttpCodeConverters as $converter) { + if (null !== $statusCode = $converter->convertToHttpCode($error)) { + return $statusCode; } } - return self::DEFAULT_STATUS_CODE; + return 500; } } diff --git a/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverter.php b/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverter.php new file mode 100644 index 000000000..023b6b90c --- /dev/null +++ b/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverter.php @@ -0,0 +1,38 @@ + + */ + private array $errorCodes; + + public static function getDefaultPriority(): int + { + return -1000; + } + + public function __construct(array $errorCodes = []) + { + $this->errorCodes = array_filter($errorCodes); + } + + public function convertToHttpCode(\Throwable $error): int + { + $exceptionClass = $error::class; + + foreach ($this->errorCodes as $exceptionMapClass => $value) { + switch (true) { + case $exceptionClass === $exceptionMapClass: + case is_a($error, $exceptionMapClass, true): + return $value; + } + } + + return self::DEFAULT_STATUS_CODE; + } +} diff --git a/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ErrorToHttpCodeConverterInterface.php b/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ErrorToHttpCodeConverterInterface.php new file mode 100644 index 000000000..5486176cc --- /dev/null +++ b/packages/open-api/HttpFoundation/ErrorToHttpCodeConverter/ErrorToHttpCodeConverterInterface.php @@ -0,0 +1,13 @@ +getStatusCode(); + } + + return null; + } +} diff --git a/packages/open-api/Tests/EventListener/ResponseApiExceptionListenerTest.php b/packages/open-api/Tests/EventListener/ResponseApiExceptionListenerTest.php index 755613049..5b9436317 100644 --- a/packages/open-api/Tests/EventListener/ResponseApiExceptionListenerTest.php +++ b/packages/open-api/Tests/EventListener/ResponseApiExceptionListenerTest.php @@ -10,7 +10,6 @@ use Draw\Component\OpenApi\Schema\Response as OpenResponse; use Draw\Component\OpenApi\Schema\Root; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; @@ -26,6 +25,7 @@ class ResponseApiExceptionListenerTest extends TestCase { private ResponseApiExceptionListener $object; + private HttpKernelInterface $httpKernel; private \Exception $exception; private ExceptionEvent $exceptionEvent; @@ -146,7 +146,9 @@ public function testOnKernelExceptionDebugFalse(): void static::assertArrayNotHasKey( 'detail', json_decode( - $this->onKernelException(new ResponseApiExceptionListener(false))->getContent(), + $this->onKernelException( + new ResponseApiExceptionListener(debug: false) + )->getContent(), true, 512, \JSON_THROW_ON_ERROR @@ -170,7 +172,7 @@ public function testOnKernelExceptionDebugTrue(): void ); $responseData = json_decode( - $this->onKernelException(new ResponseApiExceptionListener(true))->getContent(), + $this->onKernelException(new ResponseApiExceptionListener(debug: true))->getContent(), true, 512, \JSON_THROW_ON_ERROR @@ -202,72 +204,6 @@ public function testOnKernelExceptionDebugTrue(): void ); } - public function testOnKernelExceptionDefaultStatusCode500(): void - { - static::assertSame( - 500, - $this->onKernelException()->getStatusCode() - ); - } - - public static function provideOnKernelExceptionStatusCode(): iterable - { - yield 'ChangeDefault' => [ - new \Exception(), - [\Exception::class => 400], - 400, - ]; - - yield 'FallbackOnDefault' => [ - new \Exception(), - [\RuntimeException::class => 400], - 500, - ]; - - yield 'MultipleConfiguration' => [ - new \Exception(), - [\RuntimeException::class => 400, \Exception::class => 300], - 300, - ]; - - yield 'Extend' => [ - new \OutOfBoundsException(), - [\RuntimeException::class => 400, \Exception::class => 300], - 400, - ]; - - $exception = new class() extends \Exception implements \JsonSerializable { - public function jsonSerialize(): void - { - } - }; - - yield 'Implements' => [ - $exception, - [\RuntimeException::class => 400, \JsonSerializable::class => 300], - 300, - ]; - } - - /** - * @param array $errorCodes - */ - #[DataProvider('provideOnKernelExceptionStatusCode')] - public function testOnKernelExceptionErrorCode(\Throwable $throwable, array $errorCodes, int $errorCode): void - { - $this->exceptionEvent = new ExceptionEvent( - $this->httpKernel, - $this->request, - HttpKernelInterface::MAIN_REQUEST, - $throwable - ); - - static::assertSame( - $errorCode, - $this->onKernelException(new ResponseApiExceptionListener(false, $errorCodes))->getStatusCode() - ); - } - private function createConstraintListExceptionEvent(?Constraint $constraint = null): void { $exception = new ConstraintViolationListException( @@ -299,7 +235,7 @@ public function testOnKernelExceptionPayload(): void $this->createConstraintListExceptionEvent($constraint = new NotNull(['payload' => uniqid('payload-')])); $value = json_decode( - $this->onKernelException(new ResponseApiExceptionListener(false, [], 'errors'))->getContent(), + $this->onKernelException(new ResponseApiExceptionListener())->getContent(), null, 512, \JSON_THROW_ON_ERROR diff --git a/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverterTest.php b/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverterTest.php new file mode 100644 index 000000000..d9f5890d1 --- /dev/null +++ b/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/ConfigurableErrorToHttpCodeConverterTest.php @@ -0,0 +1,85 @@ +errorToHttpCodeConverter = new ConfigurableErrorToHttpCodeConverter(); + } + + public function testConstruct(): void + { + static::assertInstanceOf( + ErrorToHttpCodeConverterInterface::class, + $this->errorToHttpCodeConverter + ); + } + + /** + * @param array $errorCodes + */ + #[DataProvider('provideConvertToHttpCode')] + public function testConvertToHttpCode(\Throwable $throwable, array $errorCodes, int $errorCode): void + { + $this->errorToHttpCodeConverter = new ConfigurableErrorToHttpCodeConverter($errorCodes); + + static::assertSame( + $errorCode, + $this->errorToHttpCodeConverter->convertToHttpCode($throwable) + ); + } + + public static function provideConvertToHttpCode(): iterable + { + yield 'Default' => [ + new \Exception(), + [], + 500, + ]; + + yield 'ChangeDefault' => [ + new \Exception(), + [\Exception::class => 400], + 400, + ]; + + yield 'FallbackOnDefault' => [ + new \Exception(), + [\RuntimeException::class => 400], + 500, + ]; + + yield 'MultipleConfiguration' => [ + new \Exception(), + [\RuntimeException::class => 400, \Exception::class => 300], + 300, + ]; + + yield 'Extend' => [ + new \OutOfBoundsException(), + [\RuntimeException::class => 400, \Exception::class => 300], + 400, + ]; + + $exception = new class() extends \Exception implements \JsonSerializable { + public function jsonSerialize(): void + { + } + }; + + yield 'Implements' => [ + $exception, + [\RuntimeException::class => 400, \JsonSerializable::class => 300], + 300, + ]; + } +} diff --git a/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/HttpExceptionToHttpCodeConverterTest.php b/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/HttpExceptionToHttpCodeConverterTest.php new file mode 100644 index 000000000..c9b0c91e3 --- /dev/null +++ b/packages/open-api/Tests/HttpFoundation/ErrorToHttpCodeConverter/HttpExceptionToHttpCodeConverterTest.php @@ -0,0 +1,55 @@ +httpExceptionToHttpCodeConverter = new HttpExceptionToHttpCodeConverter(); + } + + public function testConstruct(): void + { + static::assertInstanceOf( + ErrorToHttpCodeConverterInterface::class, + $this->httpExceptionToHttpCodeConverter + ); + } + + #[DataProvider('provideConvertToHttpCode')] + public function testConvertToHttpCode(\Throwable $throwable, ?int $expectedErrorCode): void + { + static::assertSame( + $expectedErrorCode, + $this->httpExceptionToHttpCodeConverter->convertToHttpCode($throwable) + ); + } + + public static function provideConvertToHttpCode(): iterable + { + yield 'Default' => [ + new \Exception(), + null, + ]; + + yield 'Base Class' => [ + new HttpException(400), + 400, + ]; + + yield 'Sub Class' => [ + new UnprocessableEntityHttpException(), + 422, + ]; + } +}