Skip to content

Commit

Permalink
fix: exception to status on error resource (#5823)
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Sep 15, 2023
1 parent 231eba3 commit 3dedf6d
Show file tree
Hide file tree
Showing 16 changed files with 135 additions and 34 deletions.
7 changes: 7 additions & 0 deletions features/main/exception_to_status.feature
Expand Up @@ -31,3 +31,10 @@ Feature: Using exception_to_status config
Then the response status code should be 400
And the response should be in JSON
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"

@!mongodb
Scenario: Override validation exception status code from delete operation
When I add "Content-Type" header equal to "application/ld+json"
And I send a "DELETE" request to "/error_with_overriden_status/1"
Then the response status code should be 403
And the JSON node "status" should be equal to 403
2 changes: 1 addition & 1 deletion src/Action/EntrypointAction.php
Expand Up @@ -42,7 +42,7 @@ public function __invoke(Request $request = null)
{
if ($this->provider && $this->processor) {
$context = ['request' => $request];
$operation = new Get(class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create()));
$operation = new Get(read: true, serialize: true, class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create()));
$body = $this->provider->provide($operation, [], $context);

return $this->processor->process($body, $operation, [], $context);
Expand Down
7 changes: 6 additions & 1 deletion src/ApiResource/Error.php
Expand Up @@ -48,7 +48,7 @@ class Error extends \Exception implements ProblemExceptionInterface, HttpExcepti
public function __construct(
private readonly string $title,
private readonly string $detail,
#[ApiProperty(identifier: true)] private readonly int $status,
#[ApiProperty(identifier: true)] private int $status,
private readonly array $originalTrace,
private ?string $instance = null,
private string $type = 'about:blank',
Expand Down Expand Up @@ -132,6 +132,11 @@ public function getStatus(): ?int
return $this->status;
}

public function setStatus(int $status): void
{
$this->status = $status;
}

#[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])]
public function getDetail(): ?string
{
Expand Down
4 changes: 3 additions & 1 deletion src/Documentation/Action/DocumentationAction.php
Expand Up @@ -78,7 +78,7 @@ private function getOpenApiDocumentation(array $context, string $format, Request
{
if ($this->provider && $this->processor) {
$context['request'] = $request;
$operation = new Get(class: OpenApi::class, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null], outputFormats: $this->documentationFormats);
$operation = new Get(class: OpenApi::class, read: true, serialize: true, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null], outputFormats: $this->documentationFormats);
if ('html' === $format) {
$operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true);
}
Expand All @@ -104,6 +104,8 @@ private function getHydraDocumentation(array $context, Request $request): Docume
$context['request'] = $request;
$operation = new Get(
class: Documentation::class,
read: true,
serialize: true,
provider: fn () => new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version)
);

Expand Down
9 changes: 8 additions & 1 deletion src/Elasticsearch/State/CollectionProvider.php
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Elasticsearch\State;

use ApiPlatform\ApiResource\Error;
use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface;
use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata;
use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface;
Expand All @@ -22,6 +23,7 @@
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use Elastic\Elasticsearch\Client;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Response\Elasticsearch;
use Elasticsearch\Client as LegacyClient;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
Expand Down Expand Up @@ -72,7 +74,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
'body' => $body,
];

$documents = $this->client->search($params); // @phpstan-ignore-line
try {
$documents = $this->client->search($params); // @phpstan-ignore-line
} catch (ClientResponseException $e) {
$response = $e->getResponse();
throw new Error(status: $response->getStatusCode(), detail: (string) $response->getBody(), title: $response->getReasonPhrase(), originalTrace: $e->getTrace());
}

if ($documents instanceof Elasticsearch) {
$documents = $documents->asArray();
Expand Down
10 changes: 9 additions & 1 deletion src/Elasticsearch/State/ItemProvider.php
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Elasticsearch\State;

use ApiPlatform\ApiResource\Error;
use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata;
use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface;
use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
Expand Down Expand Up @@ -65,8 +66,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c

try {
$document = $this->client->get($params); // @phpstan-ignore-line
} catch (Missing404Exception|ClientResponseException) { // @phpstan-ignore-line
} catch (Missing404Exception) { // @phpstan-ignore-line
return null;
} catch (ClientResponseException $e) {
$response = $e->getResponse();
if (404 === $response->getStatusCode()) {
return null;
}

throw new Error(status: $response->getStatusCode(), detail: (string) $response->getBody(), title: $response->getReasonPhrase(), originalTrace: $e->getTrace());
}

if ($document instanceof Elasticsearch) {
Expand Down
3 changes: 2 additions & 1 deletion src/JsonLd/Action/ContextAction.php
Expand Up @@ -61,7 +61,8 @@ public function __invoke(string $shortName, Request $request = null): array|Resp
outputFormats: ['jsonld' => ['application/ld+json']],
validate: false,
provider: fn () => $this->getContext($shortName),
serialize: false
serialize: false,
read: true
);
$context = ['request' => $request];
$jsonLdContext = $this->provider->provide($operation, [], $context);
Expand Down
2 changes: 2 additions & 0 deletions src/Metadata/Exception/ProblemExceptionInterface.php
Expand Up @@ -28,6 +28,8 @@ public function getTitle(): ?string;

public function getStatus(): ?int;

public function setStatus(int $status): void;

public function getDetail(): ?string;

public function getInstance(): ?string;
Expand Down
3 changes: 2 additions & 1 deletion src/State/Provider/ReadProvider.php
Expand Up @@ -49,7 +49,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
}

$request = ($context['request'] ?? null);
if (!($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request?->isMethodSafe())) {

if (!$operation->canRead()) {
return null;
}

Expand Down
13 changes: 13 additions & 0 deletions src/Symfony/Controller/MainController.php
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Api\UriVariablesConverterInterface;
use ApiPlatform\Exception\InvalidIdentifierException;
use ApiPlatform\Exception\InvalidUriVariableException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
Expand Down Expand Up @@ -62,6 +63,14 @@ public function __invoke(Request $request): Response
$operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE'));
}

if (null === $operation->canRead() && $operation instanceof HttpOperation) {
$operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe());
}

if (null === $operation->canDeserialize() && $operation instanceof HttpOperation) {
$operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true));
}

$body = $this->provider->provide($operation, $uriVariables, $context);

// The provider can change the Operation, extract it again from the Request attributes
Expand All @@ -84,6 +93,10 @@ public function __invoke(Request $request): Response
$operation = $operation->withWrite(!$request->isMethodSafe());
}

if (null === $operation->canSerialize()) {
$operation = $operation->withSerialize(true);
}

return $this->processor->process($body, $operation, $uriVariables, $context);
}
}
55 changes: 34 additions & 21 deletions src/Symfony/EventListener/ErrorListener.php
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Api\IdentifiersExtractorInterface;
use ApiPlatform\ApiResource\Error;
use ApiPlatform\Metadata\Error as ErrorOperation;
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
Expand Down Expand Up @@ -64,32 +65,44 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re
$apiOperation = $this->initializeOperation($request);
$format = $this->getRequestFormat($request, $this->errorFormats, 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;
if ($this->resourceMetadataCollectionFactory) {
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();
// 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, $request, $operation, $exception);
$operation = $operation->withStatus($statusCode);
$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 overriden by the exceptionToStatus option
$statusCode = 500;
if ($operation instanceof HttpOperation) {
$statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception);
$operation = $operation->withStatus($statusCode);
}

$errorResource = Error::createFromException($exception, $statusCode);
}
$errorResource = $exception;
} elseif ($this->resourceMetadataCollectionFactory) {
// Create a generic, rfc7807 compatible error according to the wanted format
/** @var HttpOperation $operation */
$operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format));
$operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception));
$errorResource = Error::createFromException($exception, $operation->getStatus());
} else {
/** @var HttpOperation $operation */
$operation = new ErrorOperation(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]);
Expand Down
7 changes: 3 additions & 4 deletions src/Symfony/State/DeserializeProvider.php
Expand Up @@ -51,10 +51,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
return $data;
}

if (
!($operation->canDeserialize() ?? true)
|| !\in_array($method = $operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)
) {
if (!$operation->canDeserialize()) {
return $data;
}

Expand All @@ -74,6 +71,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
throw new UnsupportedMediaTypeHttpException('Format not supported.');
}

$method = $operation->getMethod();

if (
null !== $data
&& (
Expand Down
2 changes: 1 addition & 1 deletion src/Symfony/State/SerializeProcessor.php
Expand Up @@ -36,7 +36,7 @@ public function __construct(private readonly ProcessorInterface $processor, priv

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
if ($data instanceof Response || !($operation->canSerialize() ?? true) || !($request = $context['request'] ?? null)) {
if ($data instanceof Response || !$operation->canSerialize() || !($request = $context['request'] ?? null)) {
return $this->processor->process($data, $operation, $uriVariables, $context);
}

Expand Down
9 changes: 8 additions & 1 deletion src/Symfony/Validator/Exception/ValidationException.php
Expand Up @@ -41,6 +41,8 @@
)]
final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface, \Stringable, ProblemExceptionInterface
{
private int $status = 422;

public function __construct(private readonly ConstraintViolationListInterface $constraintViolationList, string $message = '', int $code = 0, \Throwable $previous = null, string $errorTitle = null)
{
parent::__construct($message ?: $this->__toString(), $code, $previous, $errorTitle);
Expand Down Expand Up @@ -119,7 +121,12 @@ public function getDetail(): ?string
#[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])]
public function getStatus(): ?int
{
return 422;
return $this->status;
}

public function setStatus(int $status): void
{
$this->status = $status;
}

#[Groups(['jsonld', 'json'])]
Expand Down
34 changes: 34 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/ErrorWithOverridenStatus.php
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\ApiResource;

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
use Symfony\Component\Validator\ConstraintViolationList;

#[Delete(
uriTemplate: '/error_with_overriden_status/{id}',
read: true,
// To make it work with 3.1, remove in 4
uriVariables: ['id'],
provider: [ErrorWithOverridenStatus::class, 'throw'],
exceptionToStatus: [ValidationException::class => 403]
)]
class ErrorWithOverridenStatus
{
public static function throw(): void
{
throw new ValidationException(new ConstraintViolationList());
}
}
2 changes: 2 additions & 0 deletions tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php
Expand Up @@ -20,11 +20,13 @@
use ApiPlatform\Metadata\Put;
use ApiPlatform\Tests\Fixtures\TestBundle\Exception\NotFoundException;
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\RequiredFilter;
use ApiPlatform\Tests\Fixtures\TestBundle\State\DummyExceptionToStatusProvider;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

#[ApiResource(
exceptionToStatus: [NotFoundHttpException::class => 400],
provider: DummyExceptionToStatusProvider::class,
operations: [
new Get(uriTemplate: '/dummy_exception_to_statuses/{id}', exceptionToStatus: [NotFoundException::class => 404]),
new Put(uriTemplate: '/dummy_exception_to_statuses/{id}'),
Expand Down

0 comments on commit 3dedf6d

Please sign in to comment.