diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index 937e8c49dca..0b66dc4bb4d 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -44,6 +44,7 @@ use ApiPlatform\Laravel\Eloquent\State\PersistProcessor; use ApiPlatform\Laravel\Eloquent\State\RemoveProcessor; use ApiPlatform\Laravel\Exception\ErrorHandler; +use ApiPlatform\Laravel\Exception\ErrorRenderer; use ApiPlatform\Laravel\Metadata\CacheResourceCollectionMetadataFactory; use ApiPlatform\Laravel\Metadata\ParameterValidationResourceMetadataCollectionFactory; use ApiPlatform\Laravel\State\ParameterValidatorProvider; @@ -272,22 +273,35 @@ public function register(): void ); }); + $this->app->singleton(ErrorRenderer::class, static function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ErrorRenderer( + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ApiPlatformController::class), + $app->make(IdentifiersExtractorInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(Negotiator::class), + $config->get('api-platform.exception_to_status'), + $config->get('app.debug'), + $config->get('api-platform.error_formats'), + ); + }); + $this->app->extend( ExceptionHandler::class, static function (ExceptionHandler $decorated, Application $app) { /** @var ConfigRepository */ $config = $app['config']; + if (!$config->get('api-platform.error_handler.extend_laravel_handler', true)) { + return $decorated; + } + return new ErrorHandler( $app, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ApiPlatformController::class), - $app->make(IdentifiersExtractorInterface::class), - $app->make(ResourceClassResolverInterface::class), - $app->make(Negotiator::class), - $config->get('api-platform.exception_to_status'), - $config->get('app.debug'), - $config->get('api-platform.error_formats'), + $app->make(ErrorRenderer::class), $decorated ); } diff --git a/src/Laravel/Exception/ErrorHandler.php b/src/Laravel/Exception/ErrorHandler.php index 213ae49efb4..2c287b4a5c1 100644 --- a/src/Laravel/Exception/ErrorHandler.php +++ b/src/Laravel/Exception/ErrorHandler.php @@ -13,229 +13,41 @@ namespace ApiPlatform\Laravel\Exception; -use ApiPlatform\Laravel\ApiResource\Error; -use ApiPlatform\Laravel\Controller\ApiPlatformController; -use ApiPlatform\Metadata\Exception\InvalidUriVariableException; -use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; -use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface; -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\IdentifiersExtractorInterface; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\Util\ContentNegotiationTrait; -use ApiPlatform\State\Util\OperationRequestInitiatorTrait; -use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Auth\AuthenticationException; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionsHandler; -use Negotiation\Negotiator; -use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; -use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; class ErrorHandler extends ExceptionsHandler { - use ContentNegotiationTrait; - use OperationRequestInitiatorTrait; - - public static mixed $error; - - /** - * @param array $exceptionToStatus - * @param array $errorFormats - */ public function __construct( Container $container, - ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - private readonly ApiPlatformController $apiPlatformController, - private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, - private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, - ?Negotiator $negotiator = null, - private readonly ?array $exceptionToStatus = null, - private readonly ?bool $debug = false, - private readonly ?array $errorFormats = null, + private readonly ErrorRenderer $errorRenderer, private readonly ?ExceptionHandler $decorated = null, ) { - $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; - $this->negotiator = $negotiator; parent::__construct($container); } public function render($request, \Throwable $exception) { - $apiOperation = $this->initializeOperation($request); - - if (!$apiOperation) { - // For non-API operations, first check if any renderable callbacks on this - // ErrorHandler instance can handle the exception (issue #7466). - $response = $this->renderViaCallbacks($request, $exception); + if ($this->errorRenderer->shouldRender($request, $exception)) { + $response = $this->errorRenderer->render($request, $exception); if ($response) { return $response; } - - // If no callbacks handled it, delegate to the decorated handler if available - // to preserve custom exception handler classes (issue #7058). - return $this->decorated ? $this->decorated->render($request, $exception) : parent::render($request, $exception); - } - - $formats = $this->errorFormats ?? ['jsonproblem' => ['application/problem+json']]; - $format = $request->getRequestFormat() ?? $this->getRequestFormat($request, $formats, false); - - if ($this->resourceClassResolver->isResourceClass($exception::class)) { - $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); - - $operation = null; - foreach ($resourceCollection as $resource) { - foreach ($resource->getOperations() as $op) { - foreach ($op->getOutputFormats() ?? [] as $key => $value) { - if ($key === $format) { - $operation = $op; - break 3; - } - } - } - } - - // No operation found for the requested format, we take the first available - if (!$operation) { - $operation = $resourceCollection->getOperation(); - } - $errorResource = $exception; - if ($errorResource instanceof ProblemExceptionInterface && $operation instanceof HttpOperation) { - $statusCode = $this->getStatusCode($apiOperation, $operation, $exception); - $operation = $operation->withStatus($statusCode); - if ($errorResource instanceof StatusAwareExceptionInterface) { - $errorResource->setStatus($statusCode); - } - } - } else { - // Create a generic, rfc7807 compatible error according to the wanted format - $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format)); - // status code may be overridden by the exceptionToStatus option - $statusCode = 500; - if ($operation instanceof HttpOperation) { - $statusCode = $this->getStatusCode($apiOperation, $operation, $exception); - $operation = $operation->withStatus($statusCode); - } - - $errorResource = Error::createFromException($exception, $statusCode); } - /** @var HttpOperation $operation */ - if (!$operation->getProvider()) { - static::$error = $errorResource; - $operation = $operation->withProvider([self::class, 'provide']); - } - - // For our swagger Ui errors - if ('html' === $format) { - $operation = $operation->withOutputFormats(['html' => ['text/html']]); - } - - $identifiers = []; - try { - $identifiers = $this->identifiersExtractor?->getIdentifiersFromItem($errorResource, $operation) ?? []; - } catch (\Exception $e) { - } - - $normalizationContext = $operation->getNormalizationContext() ?? []; - if (!($normalizationContext['api_error_resource'] ?? false)) { - $normalizationContext += ['api_error_resource' => true]; - } - - if (!isset($normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES])) { - $normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES] = true === $this->debug ? [] : ['originalTrace']; - } - - $operation = $operation->withNormalizationContext($normalizationContext); - - $dup = $request->duplicate(null, null, []); - $dup->setMethod('GET'); - $dup->attributes->set('_api_resource_class', $operation->getClass()); - $dup->attributes->set('_api_previous_operation', $apiOperation); - $dup->attributes->set('_api_operation', $operation); - $dup->attributes->set('_api_operation_name', $operation->getName()); - $dup->attributes->set('exception', $exception); - // These are for swagger - $dup->attributes->set('_api_original_route', $request->attributes->get('_route')); - $dup->attributes->set('_api_original_uri_variables', $request->attributes->get('_api_uri_variables')); - $dup->attributes->set('_api_original_route_params', $request->attributes->get('_route_params')); - $dup->attributes->set('_api_requested_operation', $request->attributes->get('_api_requested_operation')); - - foreach ($identifiers as $name => $value) { - $dup->attributes->set($name, $value); - } - - try { - $response = $this->apiPlatformController->__invoke($dup); + // If it's not an API operation, or the renderer wasn't able to generate + // a response, first check if any renderable callbacks on this ErrorHandler + // instance can handle the exception (issue #7466). + $response = $this->renderViaCallbacks($request, $exception); + if ($response) { return $response; - } catch (\Throwable $e) { - return $this->decorated ? $this->decorated->render($request, $exception) : parent::render($request, $exception); - } - } - - private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $errorOperation, \Throwable $exception): int - { - $exceptionToStatus = array_merge( - $apiOperation ? $apiOperation->getExceptionToStatus() ?? [] : [], - $errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [], - $this->exceptionToStatus ?? [] - ); - - foreach ($exceptionToStatus as $class => $status) { - if (is_a($exception::class, $class, true)) { - return $status; - } - } - - if ($exception instanceof AuthenticationException) { - return 401; - } - - if ($exception instanceof AuthorizationException) { - return 403; - } - - if ($exception instanceof SymfonyHttpExceptionInterface) { - return $exception->getStatusCode(); - } - - if ($exception instanceof RequestExceptionInterface || $exception instanceof InvalidUriVariableException) { - return 400; - } - - // if ($exception instanceof ValidationException) { - // return 422; - // } - - if ($status = $errorOperation?->getStatus()) { - return $status; - } - - return 500; - } - - private function getFormatOperation(?string $format): string - { - return match ($format) { - 'json' => '_api_errors_problem', - 'jsonproblem' => '_api_errors_problem', - 'jsonld' => '_api_errors_hydra', - 'jsonapi' => '_api_errors_jsonapi', - 'html' => '_api_errors_problem', // This will be intercepted by the SwaggerUiProvider - default => '_api_errors_problem', - }; - } - - public static function provide(): mixed - { - if ($data = static::$error) { - return $data; } - throw new \LogicException(\sprintf('We could not find the thrown exception in the %s.', self::class)); + // If no callbacks handled it, delegate to the decorated handler if available + // to preserve custom exception handler classes (issue #7058). + return $this->decorated ? $this->decorated->render($request, $exception) : parent::render($request, $exception); } } diff --git a/src/Laravel/Exception/ErrorRenderer.php b/src/Laravel/Exception/ErrorRenderer.php new file mode 100644 index 00000000000..814859b51ab --- /dev/null +++ b/src/Laravel/Exception/ErrorRenderer.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Exception; + +use ApiPlatform\Laravel\ApiResource\Error; +use ApiPlatform\Laravel\Controller\ApiPlatformController; +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IdentifiersExtractorInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Auth\AuthenticationException; +use Negotiation\Negotiator; +use Symfony\Component\HttpFoundation\Exception\RequestExceptionInterface; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +class ErrorRenderer +{ + use ContentNegotiationTrait; + use OperationRequestInitiatorTrait; + + public static mixed $error; + + /** + * @param array $exceptionToStatus + * @param array $errorFormats + */ + public function __construct( + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private readonly ApiPlatformController $apiPlatformController, + private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, + private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, + ?Negotiator $negotiator = null, + private readonly ?array $exceptionToStatus = null, + private readonly ?bool $debug = false, + private readonly ?array $errorFormats = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->negotiator = $negotiator; + } + + public function shouldRender($request, \Throwable $throwable) + { + $apiOperation = $this->initializeOperation($request); + + return null !== $apiOperation; + } + + public function render($request, \Throwable $exception) + { + $apiOperation = $this->initializeOperation($request); + + $formats = $this->errorFormats ?? ['jsonproblem' => ['application/problem+json']]; + $format = $request->getRequestFormat() ?? $this->getRequestFormat($request, $formats, false); + + if ($this->resourceClassResolver->isResourceClass($exception::class)) { + $resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class); + + $operation = null; + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $op) { + foreach ($op->getOutputFormats() ?? [] as $key => $value) { + if ($key === $format) { + $operation = $op; + break 3; + } + } + } + } + + // No operation found for the requested format, we take the first available + if (!$operation) { + $operation = $resourceCollection->getOperation(); + } + $errorResource = $exception; + if ($errorResource instanceof ProblemExceptionInterface && $operation instanceof HttpOperation) { + $statusCode = $this->getStatusCode($apiOperation, $operation, $exception); + $operation = $operation->withStatus($statusCode); + if ($errorResource instanceof StatusAwareExceptionInterface) { + $errorResource->setStatus($statusCode); + } + } + } else { + // Create a generic, rfc7807 compatible error according to the wanted format + $operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format)); + // status code may be overridden by the exceptionToStatus option + $statusCode = 500; + if ($operation instanceof HttpOperation) { + $statusCode = $this->getStatusCode($apiOperation, $operation, $exception); + $operation = $operation->withStatus($statusCode); + } + + $errorResource = Error::createFromException($exception, $statusCode); + } + + /** @var HttpOperation $operation */ + if (!$operation->getProvider()) { + static::$error = $errorResource; + $operation = $operation->withProvider([self::class, 'provide']); + } + + // For our swagger Ui errors + if ('html' === $format) { + $operation = $operation->withOutputFormats(['html' => ['text/html']]); + } + + $identifiers = []; + try { + $identifiers = $this->identifiersExtractor?->getIdentifiersFromItem($errorResource, $operation) ?? []; + } catch (\Exception $e) { + } + + $normalizationContext = $operation->getNormalizationContext() ?? []; + if (!($normalizationContext['api_error_resource'] ?? false)) { + $normalizationContext += ['api_error_resource' => true]; + } + + if (!isset($normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES])) { + $normalizationContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES] = true === $this->debug ? [] : ['originalTrace']; + } + + $operation = $operation->withNormalizationContext($normalizationContext); + + $dup = $request->duplicate(null, null, []); + $dup->setMethod('GET'); + $dup->attributes->set('_api_resource_class', $operation->getClass()); + $dup->attributes->set('_api_previous_operation', $apiOperation); + $dup->attributes->set('_api_operation', $operation); + $dup->attributes->set('_api_operation_name', $operation->getName()); + $dup->attributes->set('exception', $exception); + // These are for swagger + $dup->attributes->set('_api_original_route', $request->attributes->get('_route')); + $dup->attributes->set('_api_original_uri_variables', $request->attributes->get('_api_uri_variables')); + $dup->attributes->set('_api_original_route_params', $request->attributes->get('_route_params')); + $dup->attributes->set('_api_requested_operation', $request->attributes->get('_api_requested_operation')); + + foreach ($identifiers as $name => $value) { + $dup->attributes->set($name, $value); + } + + try { + return $this->apiPlatformController->__invoke($dup); + } catch (\Throwable $e) { + return null; + } + } + + private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $errorOperation, \Throwable $exception): int + { + $exceptionToStatus = array_merge( + $apiOperation ? $apiOperation->getExceptionToStatus() ?? [] : [], + $errorOperation ? $errorOperation->getExceptionToStatus() ?? [] : [], + $this->exceptionToStatus ?? [] + ); + + foreach ($exceptionToStatus as $class => $status) { + if (is_a($exception::class, $class, true)) { + return $status; + } + } + + if ($exception instanceof AuthenticationException) { + return 401; + } + + if ($exception instanceof AuthorizationException) { + return 403; + } + + if ($exception instanceof SymfonyHttpExceptionInterface) { + return $exception->getStatusCode(); + } + + if ($exception instanceof RequestExceptionInterface || $exception instanceof InvalidUriVariableException) { + return 400; + } + + // if ($exception instanceof ValidationException) { + // return 422; + // } + + if ($status = $errorOperation?->getStatus()) { + return $status; + } + + return 500; + } + + private function getFormatOperation(?string $format): string + { + return match ($format) { + 'json' => '_api_errors_problem', + 'jsonproblem' => '_api_errors_problem', + 'jsonld' => '_api_errors_hydra', + 'jsonapi' => '_api_errors_jsonapi', + 'html' => '_api_errors_problem', // This will be intercepted by the SwaggerUiProvider + default => '_api_errors_problem', + }; + } + + public static function provide(): mixed + { + if ($data = static::$error) { + return $data; + } + + throw new \LogicException(\sprintf('We could not find the thrown exception in the %s.', self::class)); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 6a938e47b82..81411333ba8 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -168,4 +168,8 @@ // 'purger' => ApiPlatform\HttpCache\SouinPurger::class, // ], // ], + + 'error_handler' => [ + 'extend_laravel_handler' => true, + ], ];