From dff915df98f19ac3b48d408acbde83b7c5f66988 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 26 Oct 2023 16:16:28 +0200 Subject: [PATCH] fix: use error normalizers Use defaults.extra_properties.skip_deprecated_exception_normalizers to skip these normalizers. fixes #5921 --- features/main/exception_to_status.feature | 7 +++ src/Hydra/Serializer/ErrorNormalizer.php | 11 ++-- src/JsonApi/Serializer/ErrorNormalizer.php | 11 +++- ...ationResourceMetadataCollectionFactory.php | 17 ++++-- src/Problem/Serializer/ErrorNormalizer.php | 14 +++-- src/Serializer/SerializerContextBuilder.php | 2 +- .../Tests/SerializerContextBuilderTest.php | 18 +++---- src/State/Processor/RespondProcessor.php | 7 +++ .../ApiPlatformExtension.php | 4 ++ src/Symfony/Bundle/Resources/config/hydra.xml | 2 + .../Bundle/Resources/config/jsonapi.xml | 2 + .../Bundle/Resources/config/problem.xml | 2 + .../Issue5921/ExceptionResource.php | 27 ++++++++++ .../ApiResource/Issue5924/TooManyRequests.php | 27 ++++++++++ .../TestBundle/Exception/TestException.php | 18 +++++++ .../TestBundle/Serializer/ErrorNormalizer.php | 52 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 4 ++ .../Serializer/ErrorNormalizerTest.php | 5 +- tests/State/RespondProcessorTest.php | 16 ++++++ tests/Symfony/Bundle/Test/ApiTestCaseTest.php | 21 ++++++++ 20 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue5921/ExceptionResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue5924/TooManyRequests.php create mode 100644 tests/Fixtures/TestBundle/Exception/TestException.php create mode 100644 tests/Fixtures/TestBundle/Serializer/ErrorNormalizer.php diff --git a/features/main/exception_to_status.feature b/features/main/exception_to_status.feature index d95a6112d4e..a182ea848a8 100644 --- a/features/main/exception_to_status.feature +++ b/features/main/exception_to_status.feature @@ -38,3 +38,10 @@ Feature: Using exception_to_status config 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 + + @!mongodb + Scenario: Get HTTP Exception headers + When I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/issue5924" + Then the response status code should be 429 + Then the header "retry-after" should be equal to 32 diff --git a/src/Hydra/Serializer/ErrorNormalizer.php b/src/Hydra/Serializer/ErrorNormalizer.php index af59a428958..febf2908d99 100644 --- a/src/Hydra/Serializer/ErrorNormalizer.php +++ b/src/Hydra/Serializer/ErrorNormalizer.php @@ -24,7 +24,7 @@ /** * Converts {@see \Exception} or {@see FlattenException} to a Hydra error representation. * - * @deprecated + * @deprecated we use ItemNormalizer instead * * @author Kévin Dunglas * @author Samuel ROZE @@ -37,7 +37,7 @@ final class ErrorNormalizer implements NormalizerInterface, CacheableSupportsMet public const TITLE = 'title'; private array $defaultContext = [self::TITLE => 'An error occurred']; - public function __construct(private readonly UrlGeneratorInterface|LegacyUrlGeneratorInterface $urlGenerator, private readonly bool $debug = false, array $defaultContext = []) + public function __construct(private readonly UrlGeneratorInterface|LegacyUrlGeneratorInterface $urlGenerator, private readonly bool $debug = false, array $defaultContext = [], private readonly ?NormalizerInterface $itemNormalizer = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -47,7 +47,12 @@ public function __construct(private readonly UrlGeneratorInterface|LegacyUrlGene */ public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource. We fallback on "api_platform.serializer.normalizer.item".', __CLASS__)); + + if ($this->itemNormalizer) { + return $this->itemNormalizer->normalize($object, $format, $context); + } + $data = [ '@context' => $this->urlGenerator->generate('api_jsonld_context', ['shortName' => 'Error']), '@type' => 'hydra:Error', diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 32ca637faa9..835a9ec0da4 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -22,6 +22,8 @@ /** * Converts {@see \Exception} or {@see FlattenException} or to a JSON API error representation. * + * @deprecated we use ItemNormalizer instead + * * @author Héctor Hurtarte */ final class ErrorNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface @@ -34,7 +36,7 @@ final class ErrorNormalizer implements NormalizerInterface, CacheableSupportsMet self::TITLE => 'An error occurred', ]; - public function __construct(private readonly bool $debug = false, array $defaultContext = []) + public function __construct(private readonly bool $debug = false, array $defaultContext = [], private readonly ?NormalizerInterface $itemNormalizer = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -44,7 +46,12 @@ public function __construct(private readonly bool $debug = false, array $default */ public function normalize(mixed $object, string $format = null, array $context = []): array { - trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource. We fallback on "api_platform.serializer.normalizer.item".', __CLASS__)); + + if ($this->itemNormalizer) { + return $this->itemNormalizer->normalize($object, $format, $context); + } + $data = [ 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], 'description' => $this->getErrorMessage($object, $context, $this->debug), diff --git a/src/Metadata/Resource/Factory/DeprecationResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/DeprecationResourceMetadataCollectionFactory.php index eebbaf4aaca..30df81e872c 100644 --- a/src/Metadata/Resource/Factory/DeprecationResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/DeprecationResourceMetadataCollectionFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -37,12 +38,22 @@ public function create(string $resourceClass): ResourceMetadataCollection { $resourceMetadataCollection = $this->decorated->create($resourceClass); - foreach ($resourceMetadataCollection as $resourceMetadata) { - foreach ($resourceMetadata->getOperations() as $operation) { - if ($operation instanceof Put && null === ($operation->getExtraProperties()['standard_put'] ?? null)) { + foreach ($resourceMetadataCollection as $i => $resourceMetadata) { + $newOperations = []; + foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + $extraProperties = $operation->getExtraProperties(); + if ($operation instanceof Put && null === ($extraProperties['standard_put'] ?? null)) { $this->triggerDeprecationOnce($operation, 'extraProperties["standard_put"]', 'In API Platform 4 PUT will always replace the data, use extraProperties["standard_put"] to "true" on every operation to avoid breaking PUT\'s behavior. Use PATCH to use the old behavior.'); } + + if (null === ($extraProperties['skip_deprecated_exception_normalizers'] ?? null)) { + $operation = $operation->withExtraProperties(['skip_deprecated_exception_normalizers' => false] + $extraProperties); + } + + $newOperations[$operationName] = $operation; } + + $resourceMetadataCollection[$i] = $resourceMetadata->withOperations(new Operations($newOperations)); } return $resourceMetadataCollection; diff --git a/src/Problem/Serializer/ErrorNormalizer.php b/src/Problem/Serializer/ErrorNormalizer.php index 39093aec65f..c1da53e12c1 100644 --- a/src/Problem/Serializer/ErrorNormalizer.php +++ b/src/Problem/Serializer/ErrorNormalizer.php @@ -22,6 +22,7 @@ * Normalizes errors according to the API Problem spec (RFC 7807). * * @see https://tools.ietf.org/html/rfc7807 + * @deprecated we use ItemNormalizer instead * * @author Kévin Dunglas */ @@ -36,7 +37,7 @@ final class ErrorNormalizer implements NormalizerInterface, CacheableSupportsMet self::TITLE => 'An error occurred', ]; - public function __construct(private readonly bool $debug = false, array $defaultContext = []) + public function __construct(private readonly bool $debug = false, array $defaultContext = [], private readonly ?NormalizerInterface $itemNormalizer = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -46,7 +47,12 @@ public function __construct(private readonly bool $debug = false, array $default */ public function normalize(mixed $object, string $format = null, array $context = []): array { - trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource.', __CLASS__)); + trigger_deprecation('api-platform', '3.2', sprintf('The class "%s" is deprecated in favor of using an Error resource. We fallback on "api_platform.serializer.normalizer.item".', __CLASS__)); + + if ($this->itemNormalizer) { + return $this->itemNormalizer->normalize($object, $format, $context); + } + $data = [ 'type' => $context[self::TYPE] ?? $this->defaultContext[self::TYPE], 'title' => $context[self::TITLE] ?? $this->defaultContext[self::TITLE], @@ -69,12 +75,12 @@ public function supportsNormalization(mixed $data, string $format = null, array return false; } - return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); + return (self::FORMAT === $format || 'json' === $format) && ($data instanceof \Exception || $data instanceof FlattenException); } public function getSupportedTypes($format): array { - if (self::FORMAT === $format) { + if (self::FORMAT === $format || 'json' === $format) { return [ \Exception::class => false, FlattenException::class => false, diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index b77f40dec0a..02cace61ab1 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -62,7 +62,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ $context['uri'] = $request->getUri(); $context['input'] = $operation->getInput(); $context['output'] = $operation->getOutput(); - $context['skip_deprecated_exception_normalizers'] = true; + $context['skip_deprecated_exception_normalizers'] = $operation->getExtraProperties()['skip_deprecated_exception_normalizers'] ?? false; // Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) { diff --git a/src/Serializer/Tests/SerializerContextBuilderTest.php b/src/Serializer/Tests/SerializerContextBuilderTest.php index b95ed38bc8b..04a0bb929ac 100644 --- a/src/Serializer/Tests/SerializerContextBuilderTest.php +++ b/src/Serializer/Tests/SerializerContextBuilderTest.php @@ -67,42 +67,42 @@ public function testCreateFromRequest(): void { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['foo' => 'bar', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get_collection', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['foo' => 'bar', 'operation_name' => 'get_collection', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('get_collection'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation->withName('post'), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => (new Put(name: 'put'))->withOperation($this->operation), 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foowithpatch/1', 'PATCH'); $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); - $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false, 'operation' => $this->patchOperation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', 'id' => '1']); - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false, 'operation' => $this->operation, 'skip_null_values' => true, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -115,7 +115,7 @@ public function testThrowExceptionOnInvalidRequest(): void public function testReuseExistingAttributes(): void { - $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => true]; + $expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation, 'exclude_from_cache_key' => ['root_operation', 'operation'], 'skip_deprecated_exception_normalizers' => false]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'operation_name' => 'get'])); } diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 99e68360e03..b58f97239c0 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Processor; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; @@ -24,6 +25,7 @@ use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\ProcessorInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * Serializes data. @@ -64,6 +66,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'X-Frame-Options' => 'deny', ]; + $exception = $request->attributes->get('exception'); + if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { + $headers = array_merge($headers, $exceptionHeaders); + } + $status = $operation->getStatus(); if ($sunset = $operation->getSunset()) { diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 6866edd35e4..0cba66d2e30 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -127,6 +127,10 @@ public function load(array $configs, ContainerBuilder $container): void $errorFormats['json'] = ['application/problem+json', 'application/json']; } + if (!isset($errorFormats['jsonproblem'])) { + $errorFormats['jsonproblem'] = ['application/problem+json']; + } + if ($this->isConfigEnabled($container, $config['graphql']) && !isset($formats['json'])) { trigger_deprecation('api-platform/core', '3.2', 'Add the "json" format to the configuration to use GraphQL.'); $formats['json'] = ['application/json']; diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index d568ef82693..c6dbdda6827 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -49,6 +49,8 @@ %kernel.debug% + + diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Symfony/Bundle/Resources/config/jsonapi.xml index bb2796046ac..b17ca9e3ce8 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Symfony/Bundle/Resources/config/jsonapi.xml @@ -73,6 +73,8 @@ %kernel.debug% + + diff --git a/src/Symfony/Bundle/Resources/config/problem.xml b/src/Symfony/Bundle/Resources/config/problem.xml index c253f393dba..549d7f5ab10 100644 --- a/src/Symfony/Bundle/Resources/config/problem.xml +++ b/src/Symfony/Bundle/Resources/config/problem.xml @@ -22,6 +22,8 @@ %kernel.debug% + + diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5921/ExceptionResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue5921/ExceptionResource.php new file mode 100644 index 00000000000..b12a9a3c377 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5921/ExceptionResource.php @@ -0,0 +1,27 @@ + + * + * 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\Issue5921; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Exception\TestException; + +#[Get(uriTemplate: 'issue5921{._format}', read: true, provider: [ExceptionResource::class, 'provide'])] +class ExceptionResource +{ + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): void + { + throw new TestException(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5924/TooManyRequests.php b/tests/Fixtures/TestBundle/ApiResource/Issue5924/TooManyRequests.php new file mode 100644 index 00000000000..757e282da32 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5924/TooManyRequests.php @@ -0,0 +1,27 @@ + + * + * 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\Issue5924; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; + +#[Get(uriTemplate: 'issue5924{._format}', read: true, provider: [TooManyRequests::class, 'provide'])] +class TooManyRequests +{ + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): void + { + throw new TooManyRequestsHttpException(32); + } +} diff --git a/tests/Fixtures/TestBundle/Exception/TestException.php b/tests/Fixtures/TestBundle/Exception/TestException.php new file mode 100644 index 00000000000..141719a15db --- /dev/null +++ b/tests/Fixtures/TestBundle/Exception/TestException.php @@ -0,0 +1,18 @@ + + * + * 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\Exception; + +class TestException extends \Exception +{ +} diff --git a/tests/Fixtures/TestBundle/Serializer/ErrorNormalizer.php b/tests/Fixtures/TestBundle/Serializer/ErrorNormalizer.php new file mode 100644 index 00000000000..1871a869fd7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Serializer/ErrorNormalizer.php @@ -0,0 +1,52 @@ + + * + * 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\Serializer; + +use ApiPlatform\Tests\Fixtures\TestBundle\Exception\TestException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class ErrorNormalizer implements NormalizerInterface +{ + public function __construct(private readonly NormalizerInterface $decorated) + { + } + + public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject + { + $a = $this->decorated->normalize($object, $format, $context); + $a['hello'] = 'world'; + + return $a; + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + if (\is_object($data) && $data instanceof TestException) { + return true; + } + + return $this->decorated->supportsNormalization($data, $format); + } + + public function hasCacheableSupportsMethod(): bool + { + return false; + } + + public function getSupportedTypes(?string $format): array + { + // @phpstan-ignore-next-line + return $this->decorated->getSupportedTypes($format); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 71fe2a590bb..9e0fb6597a2 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -462,3 +462,7 @@ services: decorates: 'api_platform.json_schema.type_factory' arguments: $decorated: '@.inner' + + ApiPlatform\Tests\Fixtures\TestBundle\Serializer\ErrorNormalizer: + decorates: 'api_platform.problem.normalizer.error' + arguments: [ '@.inner' ] diff --git a/tests/Problem/Serializer/ErrorNormalizerTest.php b/tests/Problem/Serializer/ErrorNormalizerTest.php index 07c1e3ef8ac..db477fe584d 100644 --- a/tests/Problem/Serializer/ErrorNormalizerTest.php +++ b/tests/Problem/Serializer/ErrorNormalizerTest.php @@ -38,11 +38,14 @@ public function testSupportNormalization(): void $this->assertTrue($normalizer->supportsNormalization(new FlattenException(), ErrorNormalizer::FORMAT)); $this->assertFalse($normalizer->supportsNormalization(new FlattenException(), 'xml')); $this->assertFalse($normalizer->supportsNormalization(new \stdClass(), ErrorNormalizer::FORMAT)); - $this->assertEmpty($normalizer->getSupportedTypes('json')); $this->assertSame([ \Exception::class => false, FlattenException::class => false, ], $normalizer->getSupportedTypes($normalizer::FORMAT)); + $this->assertSame([ + \Exception::class => false, + FlattenException::class => false, + ], $normalizer->getSupportedTypes('json')); // note: jsonproblem is the default for json if (!method_exists(Serializer::class, 'getSupportedTypes')) { $this->assertFalse($normalizer->hasCacheableSupportsMethod()); diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 449215ab155..935d4109640 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -25,6 +25,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; class RespondProcessorTest extends TestCase { @@ -97,4 +98,19 @@ public function testRedirectToOperation(): void $this->assertSame(200, $response->getStatusCode()); $this->assertNull($response->headers->get('Location')); } + + public function testAddsExceptionHeaders(): void + { + $operation = new Get(); + + /** @var ProcessorInterface $respondProcessor */ + $respondProcessor = new RespondProcessor(); + $req = new Request(); + $req->attributes->set('exception', new TooManyRequestsHttpException(32)); + $response = $respondProcessor->process('content', new Get(), context: [ + 'request' => $req, + ]); + + $this->assertSame('32', $response->headers->get('retry-after')); + } } diff --git a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php index 5a2984a59c5..4143e7385cf 100644 --- a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -24,9 +24,12 @@ use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Tools\SchemaTool; use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; class ApiTestCaseTest extends ApiTestCase { + use ExpectDeprecationTrait; + public function testAssertJsonContains(): void { self::createClient()->request('GET', '/'); @@ -268,6 +271,24 @@ public function testGetMercureMessages(): void ); } + /** + * @group legacy + */ + public function testExceptionNormalizer(): void + { + $this->expectDeprecation('Since api-platform 3.2: The class "ApiPlatform\Problem\Serializer\ErrorNormalizer" is deprecated in favor of using an Error resource. We fallback on "api_platform.serializer.normalizer.item".'); + + $response = self::createClient()->request('GET', '/issue5921', [ + 'headers' => [ + 'accept' => 'application/json', + ], + ]); + + $data = $response->toArray(false); + $this->assertArrayHasKey('hello', $data); + $this->assertEquals($data['hello'], 'world'); + } + private function recreateSchema(array $options = []): void { self::bootKernel($options);