Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow defining exception_to_status per operation #3519

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599)
* **BC**: Use `@final` annotation in ORM filters (#4109)
* Allow defining `exception_to_status` per operation (#3519)
* Doctrine: Better exception to find which resource is linked to an exception (#3965)
* Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870)
* Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103)
Expand Down
35 changes: 33 additions & 2 deletions src/Action/ExceptionAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

namespace ApiPlatform\Core\Action;

use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Util\ErrorFormatGuesser;
use ApiPlatform\Core\Util\RequestAttributesExtractor;
use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -31,16 +33,18 @@ final class ExceptionAction
private $serializer;
private $errorFormats;
private $exceptionToStatus;
private $resourceMetadataFactory;

/**
* @param array $errorFormats A list of enabled error formats
* @param array $exceptionToStatus A list of exceptions mapped to their HTTP status code
*/
public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = [])
public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = [], ?ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
{
$this->serializer = $serializer;
$this->errorFormats = $errorFormats;
$this->exceptionToStatus = $exceptionToStatus;
$this->resourceMetadataFactory = $resourceMetadataFactory;
}

/**
Expand All @@ -53,7 +57,12 @@ public function __invoke($exception, Request $request): Response
$exceptionClass = $exception->getClass();
$statusCode = $exception->getStatusCode();

foreach ($this->exceptionToStatus as $class => $status) {
$exceptionToStatus = array_merge(
$this->exceptionToStatus,
$this->getOperationExceptionToStatus($request)
);

foreach ($exceptionToStatus as $class => $status) {
if (is_a($exceptionClass, $class, true)) {
$statusCode = $status;

Expand All @@ -69,4 +78,26 @@ public function __invoke($exception, Request $request): Response

return new Response($this->serializer->serialize($exception, $format['key'], ['statusCode' => $statusCode]), $statusCode, $headers);
}

private function getOperationExceptionToStatus(Request $request): array
{
$attributes = RequestAttributesExtractor::extractAttributes($request);

if ([] === $attributes || null === $this->resourceMetadataFactory) {
return [];
}

$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
$operationExceptionToStatus = $resourceMetadata->getOperationAttribute($attributes, 'exception_to_status', [], false);
$resourceExceptionToStatus = $resourceMetadata->getAttribute('exception_to_status', []);

if (!\is_array($operationExceptionToStatus) || !\is_array($resourceExceptionToStatus)) {
throw new \LogicException('"exception_to_status" attribute should be an array.');
}

return array_merge(
$resourceExceptionToStatus,
$operationExceptionToStatus
);
}
}
5 changes: 4 additions & 1 deletion src/Annotation/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
* @Attribute("swaggerContext", type="array"),
* @Attribute("urlGenerationStrategy", type="int"),
* @Attribute("validationGroups", type="mixed"),
* @Attribute("exceptionToStatus", type="array"),
* )
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
Expand Down Expand Up @@ -170,6 +171,7 @@ final class ApiResource
* @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
* @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups
* @param int $urlGenerationStrategy
* @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration
*
* @throws InvalidArgumentException
*/
Expand Down Expand Up @@ -219,7 +221,8 @@ public function __construct(
?array $swaggerContext = null,
?array $validationGroups = null,
?int $urlGenerationStrategy = null,
?bool $compositeIdentifier = null
?bool $compositeIdentifier = null,
?array $exceptionToStatus = null
) {
if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support
[$publicProperties, $configurableAttributes] = self::getConfigMetadata();
Expand Down
1 change: 1 addition & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@
<argument type="service" id="api_platform.serializer" />
<argument>%api_platform.error_formats%</argument>
<argument>%api_platform.exception_to_status%</argument>
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
</service>

<!-- Identifiers -->
Expand Down
150 changes: 150 additions & 0 deletions tests/Action/ExceptionActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

use ApiPlatform\Core\Action\ExceptionAction;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use ApiPlatform\Core\Tests\ProphecyTrait;
use DomainException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException;
use Symfony\Component\ErrorHandler\Exception\FlattenException;
Expand Down Expand Up @@ -57,6 +60,153 @@ public function testActionWithCatchableException()
$this->assertTrue($response->headers->contains('X-Frame-Options', 'deny'));
}

/**
* @dataProvider provideOperationExceptionToStatusCases
*/
public function testActionWithOperationExceptionToStatus(
array $globalExceptionToStatus,
?array $resourceExceptionToStatus,
?array $operationExceptionToStatus,
int $expectedStatusCode
) {
$exception = new DomainException();
$flattenException = FlattenException::create($exception);

$serializer = $this->prophesize(SerializerInterface::class);
$serializer->serialize($flattenException, 'jsonproblem', ['statusCode' => $expectedStatusCode])->willReturn();

$resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class);
$resourceMetadataFactory->create('Foo')->willReturn(new ResourceMetadata(
'Foo',
null,
null,
[
'operation' => null !== $operationExceptionToStatus ? ['exception_to_status' => $operationExceptionToStatus] : [],
],
null,
null !== $resourceExceptionToStatus ? ['exception_to_status' => $resourceExceptionToStatus] : []
));

$exceptionAction = new ExceptionAction(
$serializer->reveal(),
[
'jsonproblem' => ['application/problem+json'],
'jsonld' => ['application/ld+json'],
],
$globalExceptionToStatus,
$resourceMetadataFactory->reveal()
);

$request = new Request();
$request->setFormat('jsonproblem', 'application/problem+json');
$request->attributes->replace([
'_api_resource_class' => 'Foo',
'_api_item_operation_name' => 'operation',
]);

$response = $exceptionAction($flattenException, $request);

$this->assertSame('', $response->getContent());
$this->assertSame($expectedStatusCode, $response->getStatusCode());
$this->assertTrue($response->headers->contains('Content-Type', 'application/problem+json; charset=utf-8'));
$this->assertTrue($response->headers->contains('X-Content-Type-Options', 'nosniff'));
$this->assertTrue($response->headers->contains('X-Frame-Options', 'deny'));
}

public function provideOperationExceptionToStatusCases()
{
yield 'no mapping' => [
[],
null,
null,
500,
];

yield 'on global attributes' => [
[DomainException::class => 100],
null,
null,
100,
];

yield 'on global attributes with empty resource and operation attributes' => [
[DomainException::class => 100],
[],
[],
100,
];

yield 'on global attributes and resource attributes' => [
[DomainException::class => 100],
[DomainException::class => 200],
null,
200,
];

yield 'on global attributes and resource attributes with empty operation attributes' => [
[DomainException::class => 100],
[DomainException::class => 200],
[],
200,
];

yield 'on global attributes and operation attributes' => [
[DomainException::class => 100],
null,
[DomainException::class => 300],
300,
];

yield 'on global attributes and operation attributes with empty resource attributes' => [
[DomainException::class => 100],
[],
[DomainException::class => 300],
300,
];

yield 'on global, resource and operation attributes' => [
[DomainException::class => 100],
[DomainException::class => 200],
[DomainException::class => 300],
300,
];

yield 'on resource attributes' => [
[],
[DomainException::class => 200],
null,
200,
];

yield 'on resource attributes with empty operation attributes' => [
[],
[DomainException::class => 200],
[],
200,
];

yield 'on resource and operation attributes' => [
[],
[DomainException::class => 200],
[DomainException::class => 300],
300,
];

yield 'on operation attributes' => [
[],
null,
[DomainException::class => 300],
300,
];

yield 'on operation attributes with empty resource attributes' => [
[],
[],
[DomainException::class => 300],
300,
];
}

public function testActionWithUncatchableException()
{
$serializerException = $this->prophesize(ExceptionInterface::class);
Expand Down
6 changes: 6 additions & 0 deletions tests/Annotation/ApiResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ public function testConstructAttribute()
hydraContext: ['hydra' => 'foo'],
paginationViaCursor: ['foo'],
stateless: true,
exceptionToStatus: [
\DomainException::class => 400,
],
);
PHP
);
Expand Down Expand Up @@ -208,6 +211,9 @@ public function testConstructAttribute()
'pagination_via_cursor' => ['foo'],
'stateless' => true,
'composite_identifier' => null,
'exception_to_status' => [
\DomainException::class => 400,
],
], $resource->attributes);
}

Expand Down