diff --git a/features/main/exception_to_status.feature b/features/main/exception_to_status.feature index c77c6d59edb..a182ea848a8 100644 --- a/features/main/exception_to_status.feature +++ b/features/main/exception_to_status.feature @@ -45,8 +45,3 @@ Feature: Using exception_to_status config 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 - - Scenario: Show error page - When I add "Accept" header equal to "text/html" - And I send a "GET" request to "/errors/404" - Then the response status code should be 200 diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 9cfbb7deb07..26d3a91852b 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -50,8 +50,8 @@ public function create(string $className, string $format = 'json', ?string $inpu } $definitionName = $serializerContext[SchemaFactory::OPENAPI_DEFINITION_NAME] ?? null; - if ($definitionName) { - $name = \sprintf('%s-%s', $prefix, $definitionName); + if (null !== $definitionName) { + $name = \sprintf('%s%s', $prefix, $definitionName ? '-'.$definitionName : $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); $name = $groups ? \sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; diff --git a/src/JsonSchema/ResourceMetadataTrait.php b/src/JsonSchema/ResourceMetadataTrait.php index 64e47fe640c..52d10e0b930 100644 --- a/src/JsonSchema/ResourceMetadataTrait.php +++ b/src/JsonSchema/ResourceMetadataTrait.php @@ -54,7 +54,7 @@ private function findOperation(string $className, string $type, ?Operation $oper $operation = new HttpOperation(); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $forceSubschema ? null : $format); } // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index d59c7e3dfca..0ff047692b6 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -61,7 +61,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $inputOrOutputClass = $className; $serializerContext ??= []; } else { - $operation = $this->findOperation($className, $type, $operation, $serializerContext); + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); $serializerContext ??= $this->getSerializerContext($operation, $type); } @@ -74,7 +74,6 @@ public function buildSchema(string $className, string $format = 'json', string $ $validationGroups = $operation ? $this->getValidationGroups($operation) : []; $version = $schema->getVersion(); $definitionName = $this->definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext); - $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if (!$operation) { $method = Schema::TYPE_INPUT === $type ? 'POST' : 'GET'; @@ -291,6 +290,10 @@ private function getFactoryOptions(array $serializerContext, array $validationGr $options['validation_groups'] = $validationGroups; } + if ($operation && ($ignoredAttributes = $operation->getNormalizationContext()['ignored_attributes'] ?? null)) { + $options['ignored_attributes'] = $ignoredAttributes; + } + return $options; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 38b86202169..d276dd449bd 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -77,6 +77,7 @@ use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Laravel\ApiResource\Error; +use ApiPlatform\Laravel\ApiResource\ValidationError; use ApiPlatform\Laravel\Controller\ApiPlatformController; use ApiPlatform\Laravel\Controller\DocumentationController; use ApiPlatform\Laravel\Controller\EntrypointController; @@ -177,6 +178,7 @@ use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; +use ApiPlatform\State\ErrorProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\State\ParameterProviderInterface; @@ -773,6 +775,9 @@ public function register(): void licenseUrl: $config->get('api-platform.swagger_ui.license.url', ''), persistAuthorization: $config->get('api-platform.swagger_ui.persist_authorization', false), httpAuth: $config->get('api-platform.swagger_ui.http_auth', []), + tags: $config->get('api-platform.openapi.tags', []), + errorResourceClass: Error::class, + validationErrorResourceClass: ValidationError::class ); }); @@ -846,7 +851,9 @@ public function register(): void null, $config->get('api-platform.formats'), $app->make(Options::class), - $app->make(PaginationOptions::class), // ?PaginationOptions $paginationOptions = null, + $app->make(PaginationOptions::class), + null, + $config->get('api-platform.error_formats'), // ?RouterInterface $router = null ); }); @@ -1216,6 +1223,18 @@ private function registerGraphQl(Application $app): void }); $app->alias(GraphQlReadProvider::class, 'api_platform.graphql.state_provider.read'); + $app->singleton(ErrorProvider::class, function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + + return new ErrorProvider( + $config->get('app.debug'), + $app->make(ResourceClassResolver::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + ); + }); + $app->tag([ErrorProvider::class], ProviderInterface::class); + $app->singleton(ResolverProvider::class, function (Application $app) { $resolvers = iterator_to_array($app->tagged('api_platform.graphql.resolver')); $taggedItemResolvers = iterator_to_array($app->tagged(QueryItemResolverInterface::class)); @@ -1314,7 +1333,7 @@ private function registerGraphQl(Application $app): void /** @var ConfigRepository */ $config = $app['config']; - return new Executor($config->get('api-platform.graphql.introspection.enabled') ?? false, $config->get('api-platform.graphql.max_query_complexity'), $config->get('api-platform.graphql.max_query_depth')); + return new Executor($config->get('api-platform.graphql.introspection.enabled') ?? false, $config->get('api-platform.graphql.max_query_complexity') ?? 500, $config->get('api-platform.graphql.max_query_depth') ?? 200); }); $app->singleton(GraphiQlController::class, function (Application $app) { diff --git a/src/Laravel/ApiResource/Error.php b/src/Laravel/ApiResource/Error.php index fef7088b17c..e013746f8fc 100644 --- a/src/Laravel/ApiResource/Error.php +++ b/src/Laravel/ApiResource/Error.php @@ -13,12 +13,14 @@ namespace ApiPlatform\Laravel\ApiResource; -use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as Operation; use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\State\ErrorProvider; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Ignore; @@ -26,39 +28,66 @@ use Symfony\Component\WebLink\Link; #[ErrorResource( - types: ['hydra:Error'], + uriTemplate: '/errors/{status}{._format}', openapi: false, uriVariables: ['status'], operations: [ new Operation( + errors: [], name: '_api_errors_problem', - outputFormats: ['json' => ['application/problem+json']], + routeName: '_api_errors', + outputFormats: ['json' => ['application/problem+json', 'application/json']], + hideHydraOperation: true, normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonproblem'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], - uriTemplate: '/errors/{status}' ), new Operation( + errors: [], name: '_api_errors_hydra', - outputFormats: ['jsonld' => ['application/problem+json']], + routeName: '_api_errors', + outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonld'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], - links: [new Link(rel: ContextBuilderInterface::JSONLD_NS.'error', href: 'http://www.w3.org/ns/hydra/error')], - uriTemplate: '/errors/{status}.jsonld' + links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), new Operation( + errors: [], name: '_api_errors_jsonapi', + routeName: '_api_errors', + hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], - normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true], - uriTemplate: '/errors/{status}.jsonapi' + normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', + 'disable_json_schema_serializer_groups' => false, + 'groups' => ['jsonapi'], + 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], + ], + ), + new Operation( + name: '_api_errors', + hideHydraOperation: true, + extraProperties: ['_api_disable_swagger_provider' => true], + outputFormats: ['html' => ['text/html'], 'jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], ), ], - graphQlOperations: [] + outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], + provider: ErrorProvider::class, + graphQlOperations: [], + description: 'A representation of common errors.', )] -class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface +#[ApiProperty(property: 'previous', hydra: false, readable: false)] +#[ApiProperty(property: 'traceAsString', hydra: false, readable: false)] +#[ApiProperty(property: 'string', hydra: false, readable: false)] +class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface, ErrorResourceInterface { /** * @var array @@ -73,7 +102,7 @@ public function __construct( private readonly string $title, private readonly string $detail, #[ApiProperty(identifier: true)] private int $status, - array $originalTrace, + array $originalTrace = [], private readonly ?string $instance = null, private string $type = 'about:blank', private array $headers = [], diff --git a/src/Laravel/ApiResource/ValidationError.php b/src/Laravel/ApiResource/ValidationError.php index 92197a74e2b..aa2117f49ab 100644 --- a/src/Laravel/ApiResource/ValidationError.php +++ b/src/Laravel/ApiResource/ValidationError.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Laravel\ApiResource; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; @@ -32,35 +33,40 @@ uriTemplate: '/validation_errors/{id}', status: 422, openapi: false, + outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], uriVariables: ['id'], shortName: 'ValidationError', operations: [ new ErrorOperation( + routeName: 'api_validation_errors', name: '_api_validation_errors_problem', outputFormats: ['json' => ['application/problem+json']], - normalizationContext: ['groups' => ['json'], + normalizationContext: [ + 'groups' => ['json'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], - uriTemplate: '/validation_errors/{id}' ), new ErrorOperation( name: '_api_validation_errors_hydra', + routeName: 'api_validation_errors', outputFormats: ['jsonld' => ['application/problem+json']], links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], normalizationContext: [ 'groups' => ['jsonld'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], - uriTemplate: '/validation_errors/{id}.jsonld' ), new ErrorOperation( name: '_api_validation_errors_jsonapi', + routeName: 'api_validation_errors', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ 'groups' => ['jsonapi'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], - uriTemplate: '/validation_errors/{id}.jsonapi' ), ], graphQlOperations: [] @@ -139,6 +145,19 @@ public function getInstance(): ?string */ #[SerializedName('violations')] #[Groups(['json', 'jsonld', 'jsonapi'])] + #[ApiProperty( + jsonldContext: ['@type' => 'ConstraintViolationList'], + schema: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'propertyPath' => ['type' => 'string', 'description' => 'The property path of the violation'], + 'message' => ['type' => 'string', 'description' => 'The message associated with the violation'], + ], + ], + ] + )] public function getViolations(): array { return $this->violations; diff --git a/src/Laravel/Exception/ErrorHandler.php b/src/Laravel/Exception/ErrorHandler.php index 60578341b10..db795818011 100644 --- a/src/Laravel/Exception/ErrorHandler.php +++ b/src/Laravel/Exception/ErrorHandler.php @@ -113,8 +113,6 @@ public function register(): void /** @var HttpOperation $operation */ if (!$operation->getProvider()) { - // TODO: validation - // static::$error = 'jsonapi' === $format && $errorResource instanceof ConstraintViolationListAwareExceptionInterface ? $errorResource->getConstraintViolationList() : $errorResource; static::$error = $errorResource; $operation = $operation->withProvider([self::class, 'provide']); } @@ -147,7 +145,7 @@ public function register(): void $dup->attributes->set('_api_previous_operation', $apiOperation); $dup->attributes->set('_api_operation', $operation); $dup->attributes->set('_api_operation_name', $operation->getName()); - $dup->attributes->remove('exception'); + $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')); diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index ad1e6f1b78b..460db550e9a 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -168,7 +168,9 @@ private function generateRoute(object|string $resource, int $referenceType = Url } try { - return $this->router->generate($operation->getName(), $identifiers, $operation->getUrlGenerationStrategy() ?? $referenceType); + $routeName = $operation instanceof HttpOperation ? ($operation->getRouteName() ?? $operation->getName()) : $operation->getName(); + + return $this->router->generate($routeName, $identifiers, $operation->getUrlGenerationStrategy() ?? $referenceType); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(\sprintf('Unable to generate an IRI for the item of type "%s"', $operation->getClass()), $e->getCode(), $e); } diff --git a/src/Laravel/State/SwaggerUiProvider.php b/src/Laravel/State/SwaggerUiProvider.php index a3bef7d424c..a465e5c1748 100644 --- a/src/Laravel/State/SwaggerUiProvider.php +++ b/src/Laravel/State/SwaggerUiProvider.php @@ -53,6 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c || !($request = $context['request'] ?? null) || 'html' !== $request->getRequestFormat() || !$this->swaggerUiEnabled + || true === ($operation->getExtraProperties()['_api_disable_swagger_provider'] ?? false) ) { return $this->decorated->provide($operation, $uriVariables, $context); } @@ -64,7 +65,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c // We need to call our operation provider just in case it fails // when it fails we'll get an Error, and we'll fix the status accordingly // @see features/main/content_negotiation.feature:119 - // DocumentationAction has no content negotiation as well we want HTML so render swagger ui + // When requesting DocumentationAction or EntrypointAction with Accept: text/html we render SwaggerUi if (!$operation instanceof Error && Documentation::class !== $operation->getClass()) { $this->decorated->provide($operation, $uriVariables, $context); } diff --git a/src/Laravel/Tests/JsonProblemTest.php b/src/Laravel/Tests/JsonProblemTest.php index ea91b2f84ee..f2dc6cb09d8 100644 --- a/src/Laravel/Tests/JsonProblemTest.php +++ b/src/Laravel/Tests/JsonProblemTest.php @@ -18,6 +18,7 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class JsonProblemTest extends TestCase { @@ -31,8 +32,8 @@ public function testNotFound(): void $response->assertHeader('content-type', 'application/problem+json; charset=utf-8'); $response->assertJsonFragment([ '@context' => '/api/contexts/Error', - '@id' => '/api/errors/404.jsonld', - '@type' => 'hydra:Error', + '@id' => '/api/errors/404', + '@type' => 'Error', 'type' => '/errors/404', 'title' => 'An error occurred', 'status' => 404, @@ -58,4 +59,80 @@ public function testProductionError(): void $this->assertArrayNotHasKey('line', $content); $this->assertArrayNotHasKey('file', $content); } + + /** + * @param list}> $expected + */ + #[DefineEnvironment('useProductionMode')] + #[DataProvider('formatsProvider')] + public function testRetrieveError(string $format, string $status, array $expected): void + { + $response = $this->get('/api/errors/'.$status, ['accept' => $format]); + $this->assertEquals($expected, $response->json()); + } + + #[DefineEnvironment('useProductionMode')] + public function testRetrieveErrorHtml(): void + { + $response = $this->get('/api/errors/403', ['accept' => 'text/html']); + $this->assertEquals(' + + + + Error 403 + +

Error 403

Forbidden +', $response->getContent()); + } + + /** + * @return list}> + */ + public static function formatsProvider(): array + { + return [ + [ + 'application/vnd.api+json', + '401', + [ + 'errors' => [ + [ + 'id' => '/api/errors/401', + 'detail' => 'Unauthorized', + 'type' => 'about:blank', + 'title' => 'Error 401', + 'status' => 401, + 'code' => '401', + 'links' => [ + 'type' => 'about:blank', + ], + ], + ], + ], + ], + [ + 'application/ld+json', + '401', + [ + '@context' => '/api/contexts/Error', + '@type' => 'Error', + '@id' => '/api/errors/401', + 'detail' => 'Unauthorized', + 'title' => 'Error 401', + 'status' => 401, + 'type' => 'about:blank', + ], + ], + [ + 'application/json', + '401', + [ + 'type' => 'about:blank', + 'detail' => 'Unauthorized', + 'title' => 'Error 401', + 'status' => 401, + ], + ], + ]; + } } diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index e0ba5060ee1..63cf6ce0dcb 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -109,6 +109,10 @@ //] ], + // 'openapi' => [ + // 'tags' => [] + // ], + 'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH, 'serializer' => [ diff --git a/src/Laravel/routes/api.php b/src/Laravel/routes/api.php index f69b0c3196d..17e8688fb28 100644 --- a/src/Laravel/routes/api.php +++ b/src/Laravel/routes/api.php @@ -35,6 +35,10 @@ foreach ($resourceNameCollectionFactory->create() as $resourceClass) { foreach ($resourceMetadataFactory->create($resourceClass) as $resourceMetadata) { foreach ($resourceMetadata->getOperations() as $operation) { + if ($operation->getRouteName()) { + continue; + } + /* @var HttpOperation $operation */ Route::addRoute($operation->getMethod(), Str::replace('{._format}', '{_format?}', $operation->getUriTemplate()), ApiPlatformController::class) ->prefix($operation->getRoutePrefix()) @@ -55,6 +59,10 @@ ->middleware(ApiPlatformMiddleware::class) ->name('api_jsonld_context'); + Route::get('/validation_errors/{id}', fn () => throw new NotExposedHttpException('Not exposed.')) + ->name('api_validation_errors') + ->middleware(ApiPlatformMiddleware::class); + Route::get('/docs{_format?}', DocumentationController::class) ->middleware(ApiPlatformMiddleware::class) ->name('api_doc'); diff --git a/src/Metadata/ErrorResourceInterface.php b/src/Metadata/ErrorResourceInterface.php new file mode 100644 index 00000000000..2d41c185e8f --- /dev/null +++ b/src/Metadata/ErrorResourceInterface.php @@ -0,0 +1,19 @@ + + * + * 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\Metadata; + +interface ErrorResourceInterface +{ + public static function createFromException(\Exception|\Throwable $exception, int $status): self; +} diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index e539a8906da..d41d1c0561e 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -52,12 +52,14 @@ public function create(string $resourceClass, string $property, array $options = if ($denormalizationGroups && !\is_array($denormalizationGroups)) { $denormalizationGroups = [$denormalizationGroups]; } + + $ignoredAttributes = $options['ignored_attributes'] ?? []; } catch (ResourceClassNotFoundException) { // TODO: for input/output classes, the serializer groups must be read from the actual resource class return $propertyMetadata; } - $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups); + $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $ignoredAttributes); $types = $propertyMetadata->getBuiltinTypes() ?? []; if (!$this->isResourceClass($resourceClass) && $types) { @@ -80,8 +82,12 @@ public function create(string $resourceClass, string $property, array $options = * @param string[]|null $normalizationGroups * @param string[]|null $denormalizationGroups */ - private function transformReadWrite(ApiProperty $propertyMetadata, string $resourceClass, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null): ApiProperty + private function transformReadWrite(ApiProperty $propertyMetadata, string $resourceClass, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, array $ignoredAttributes = []): ApiProperty { + if (\in_array($propertyName, $ignoredAttributes, true)) { + return $propertyMetadata->withWritable(false)->withReadable(false); + } + $serializerAttributeMetadata = $this->getSerializerAttributeMetadata($resourceClass, $propertyName); $groups = $serializerAttributeMetadata ? $serializerAttributeMetadata->getGroups() : []; $ignored = $serializerAttributeMetadata && $serializerAttributeMetadata->isIgnored(); diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 9d1cd0ec841..fc4abf3277d 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -95,6 +95,7 @@ public function __construct( ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null, + private readonly array $errorFormats = [], ) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); @@ -122,7 +123,6 @@ public function __invoke(array $context = []): OpenApi foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); - foreach ($resourceMetadataCollection as $resourceMetadata) { $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context); } @@ -164,6 +164,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection return; } + $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class); + $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity'); + // This filters on our extension x-apiplatform-tag as the openapi operation tag is used for ordering operations $filteredTags = $context['filter_tags'] ?? []; if (!\is_array($filteredTags)) { @@ -359,6 +362,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withParameters($openapiParameters); $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + $errors = null; if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { /** @var array */ $errorOperations = []; @@ -380,19 +384,24 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $successStatus = (string) $operation->getStatus() ?: 201; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), - ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed - ], $resourceMetadataCollection, $schema, $schemas, $operation); + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } break; case 'PATCH': case 'PUT': $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), - ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed - ], $resourceMetadataCollection, $schema, $schemas, $operation); + + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } break; case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; @@ -403,13 +412,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(403)->withDescription('Forbidden'), + $defaultError->withStatus(403)->withDescription('Forbidden'), ], $resourceMetadataCollection, $schema, $schemas, $operation); } - if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404])) { + if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(404)->withDescription('Not found'), + $defaultError->withStatus(404)->withDescription('Not found'), ], $resourceMetadataCollection, $schema, $schemas, $operation); } @@ -876,8 +885,8 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete } /** - * @param array $errors - * @param \ArrayObject $schemas + * @param ErrorResource[] $errors + * @param \ArrayObject $schemas */ private function addOperationErrors( Operation $operation, @@ -887,15 +896,20 @@ private function addOperationErrors( \ArrayObject $schemas, HttpOperation $originalOperation, ): Operation { - $defaultFormat = ['json' => ['application/problem+json']]; - foreach ($errors as $error => $errorResource) { - $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $defaultFormat); + foreach ($errors as $errorResource) { + $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats); foreach ($errorResource->getOperations() as $errorOperation) { if (false === $errorOperation->getOpenApi()) { continue; } - $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $defaultFormat); + $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $this->errorFormats); + } + + foreach ($responseMimeTypes as $mime => $format) { + if (!isset($this->errorFormats[$format])) { + unset($responseMimeTypes[$mime]); + } } $operationErrorSchemas = []; @@ -906,7 +920,7 @@ private function addOperationErrors( } if (!$status = $errorResource->getStatus()) { - throw new RuntimeException(\sprintf('The error class %s has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $error)); + throw new RuntimeException(\sprintf('The error class "%s" has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $errorResource->getClass())); } $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); @@ -936,8 +950,10 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); } + $defaultErrorResourceClass = $this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class; + try { - $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); if (!($errorResource instanceof ErrorResource)) { throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); } @@ -951,7 +967,7 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d $errorResource = $errorResource->withDescription($description); } } catch (ResourceClassNotFoundException|OperationNotFoundException) { - $errorResource = new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + $errorResource = new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); } if (!$errorResource->getClass()) { diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index 175b7005eaa..a6f003542d7 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -18,7 +18,9 @@ final readonly class Options { /** - * @param Tag[] $tags + * @param Tag[] $tags + * @param class-string $errorResourceClass + * @param class-string $validationErrorResourceClass */ public function __construct( private string $title, @@ -42,6 +44,8 @@ public function __construct( private bool $persistAuthorization = false, private array $httpAuth = [], private array $tags = [], + private ?string $errorResourceClass = null, + private ?string $validationErrorResourceClass = null, ) { } @@ -152,4 +156,20 @@ public function getTags(): array { return $this->tags; } + + /** + * @return class-string|null + */ + public function getErrorResourceClass(): ?string + { + return $this->errorResourceClass; + } + + /** + * @return class-string|null + */ + public function getValidationErrorResourceClass(): ?string + { + return $this->validationErrorResourceClass; + } } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 7fd600de488..18c02e47998 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -551,7 +551,9 @@ public function testInvoke(): void 'scheme' => 'basic', ], ]), - new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination'), + null, + ['json' => ['application/problem+json']] ); $dummySchema = new Schema('openapi'); diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index 23b7d7af780..ccde6517d52 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -102,7 +102,6 @@ public function testNormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); $propertyNameCollectionFactoryProphecy->create('Zorro', Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); - $propertyNameCollectionFactoryProphecy->create(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection([])); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) diff --git a/src/State/ApiResource/Error.php b/src/State/ApiResource/Error.php index 2f9cbeb1c08..0593719f0f8 100644 --- a/src/State/ApiResource/Error.php +++ b/src/State/ApiResource/Error.php @@ -13,9 +13,11 @@ namespace ApiPlatform\State\ApiResource; +use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as Operation; use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; @@ -26,24 +28,30 @@ #[ErrorResource( uriVariables: ['status'], - uriTemplate: '/errors/{status}', + requirements: ['status' => '\d+'], + uriTemplate: '/errors/{status}{._format}', + openapi: false, operations: [ new Operation( + errors: [], name: '_api_errors_problem', - routeName: 'api_errors', - outputFormats: ['json' => ['application/problem+json']], + routeName: '_api_errors', + outputFormats: ['json' => ['application/problem+json', 'application/json']], hideHydraOperation: true, normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonproblem'], 'skip_null_values' => true, 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), new Operation( + errors: [], name: '_api_errors_hydra', - routeName: 'api_errors', + routeName: '_api_errors', outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonld'], 'skip_null_values' => true, 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], @@ -51,11 +59,13 @@ links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), new Operation( + errors: [], name: '_api_errors_jsonapi', - routeName: 'api_errors', + routeName: '_api_errors', hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'disable_json_schema_serializer_groups' => false, 'groups' => ['jsonapi'], 'skip_null_values' => true, @@ -64,18 +74,25 @@ ), new Operation( name: '_api_errors', - routeName: 'api_errors', hideHydraOperation: true, - openapi: false + extraProperties: ['_api_disable_swagger_provider' => true], + outputFormats: [ + 'html' => ['text/html'], + 'jsonapi' => ['application/vnd.api+json'], + 'jsonld' => ['application/ld+json'], + 'json' => ['application/problem+json', 'application/json'], + ], ), ], + outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], provider: 'api_platform.state.error_provider', graphQlOperations: [], - description: 'A representation of common errors.' + description: 'A representation of common errors.', )] -#[ApiProperty(property: 'traceAsString', hydra: false)] -#[ApiProperty(property: 'string', hydra: false)] -class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface +#[ApiProperty(property: 'previous', hydra: false, readable: false)] +#[ApiProperty(property: 'traceAsString', hydra: false, readable: false)] +#[ApiProperty(property: 'string', hydra: false, readable: false)] +class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface, ErrorResourceInterface { private ?string $id = null; diff --git a/src/State/ErrorProvider.php b/src/State/ErrorProvider.php index e85a7f3194f..a6ebb62426f 100644 --- a/src/State/ErrorProvider.php +++ b/src/State/ErrorProvider.php @@ -13,36 +13,85 @@ namespace ApiPlatform\State; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @internal */ final class ErrorProvider implements ProviderInterface { - public function __construct(private readonly bool $debug = false, private ?ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(private readonly bool $debug = false, private ?ResourceClassResolverInterface $resourceClassResolver = null, private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { } + /** + * @param array{status?: int} $uriVariables + */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object { - if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation || null === ($exception = $request->attributes->get('exception'))) { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { throw new \RuntimeException('Not an HTTP request'); } + if (!($exception = $request->attributes->get('exception'))) { + $status = $uriVariables['status'] ?? null; + + // We change the operation to get our normalization context according to the format + if ($this->resourceMetadataCollectionFactory) { + $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $name => $operation) { + if (isset($operation->getOutputFormats()[$request->getRequestFormat()])) { + $request->attributes->set('_api_operation', $operation); + $request->attributes->set('_api_operation_nme', $name); + break 2; + } + } + } + } + + $text = Response::$statusTexts[$status] ?? throw new NotFoundHttpException(); + + $cl = $operation->getClass(); + + return match ($request->getRequestFormat()) { + 'html' => $this->renderError((int) $status, $text), + default => new $cl("Error $status", $text, (int) $status), + }; + } + if ($this->resourceClassResolver?->isResourceClass($exception::class)) { return $exception; } $status = $operation->getStatus() ?? 500; - $error = Error::createFromException($exception, $status); - if (!$this->debug && $status >= 500) { + $cl = is_a($operation->getClass(), ErrorResourceInterface::class, true) ? $operation->getClass() : Error::class; + $error = $cl::createFromException($exception, $status); + if (!$this->debug && $status >= 500 && method_exists($error, 'setDetail')) { $error->setDetail('Internal Server Error'); } return $error; } + + private function renderError(int $status, string $text): Response + { + return new Response(<< + + + + Error $status + +

Error $status

$text + +HTML); + } } diff --git a/src/Symfony/Action/ErrorPageAction.php b/src/Symfony/Action/ErrorPageAction.php deleted file mode 100644 index e1d8aab1ca7..00000000000 --- a/src/Symfony/Action/ErrorPageAction.php +++ /dev/null @@ -1,38 +0,0 @@ - - * - * 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\Symfony\Action; - -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; - -final class ErrorPageAction -{ - public function __invoke(Request $request): Response - { - $status = $request->attributes->get('status'); - $text = Response::$statusTexts[$status] ?? throw new NotFoundHttpException(); - - return new Response(<< - - - - Error $status - -

Error $status

$text - -HTML); - } -} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 2ce985cb1b6..4e273866bdb 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -115,7 +115,7 @@ public function load(array $configs, ContainerBuilder $container): void $jsonSchemaFormats = $config['jsonschema_formats']; if (!$jsonSchemaFormats) { - foreach (array_keys($formats) as $f) { + foreach (array_merge(array_keys($formats), array_keys($errorFormats)) as $f) { // Distinct JSON-based formats must have names that start with 'json' if (str_starts_with($f, 'json')) { $jsonSchemaFormats[$f] = true; diff --git a/src/Symfony/Bundle/Resources/config/api.xml b/src/Symfony/Bundle/Resources/config/api.xml index 7bce6a8f3a8..9ec2d2bb2fc 100644 --- a/src/Symfony/Bundle/Resources/config/api.xml +++ b/src/Symfony/Bundle/Resources/config/api.xml @@ -13,7 +13,6 @@ - @@ -166,6 +165,7 @@ %kernel.debug% + diff --git a/src/Symfony/Bundle/Resources/config/openapi.xml b/src/Symfony/Bundle/Resources/config/openapi.xml index ceab653f0ee..7bf673b90e2 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Symfony/Bundle/Resources/config/openapi.xml @@ -93,6 +93,7 @@ + %api_platform.error_formats% diff --git a/src/Symfony/Bundle/Resources/config/routing/errors.xml b/src/Symfony/Bundle/Resources/config/routing/errors.xml index bcdaa358d30..f1413706ae4 100644 --- a/src/Symfony/Bundle/Resources/config/routing/errors.xml +++ b/src/Symfony/Bundle/Resources/config/routing/errors.xml @@ -5,12 +5,6 @@ xsi:schemaLocation="http://symfony.com/schema/routing http://symfony.com/schema/routing/routing-1.0.xsd"> - - api_platform.action.error_page - - \d+ - - api_platform.action.not_exposed diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php index 43273d4a4ec..f2c526e90e6 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProvider.php @@ -74,6 +74,7 @@ class: OpenApi::class, // save our operation $request->attributes->set('_api_operation', $swaggerUiOperation); + $data = $this->openApiFactory->__invoke([ 'base_url' => $request->getBaseUrl() ?: '/', 'filter_tags' => $request->query->all('filter_tags'), diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index beafc6922cd..7e9cc8cdfe9 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Symfony\Controller; -use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\RuntimeException; @@ -48,12 +47,13 @@ public function __construct( public function __invoke(Request $request): Response { $operation = $this->initializeOperation($request); + if (!$operation) { throw new RuntimeException('Not an API operation.'); } $uriVariables = []; - if (!$operation instanceof Error) { + if (!$request->attributes->has('exception')) { try { $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); $request->attributes->set('_api_uri_variables', $uriVariables); @@ -86,7 +86,7 @@ public function __invoke(Request $request): Response if ($request->attributes->get('_api_operation') !== $operation) { $operation = $this->initializeOperation($request); - if (!$operation instanceof Error) { + if (!$request->attributes->has('exception')) { try { $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); } catch (InvalidIdentifierException|InvalidUriVariableException $e) { diff --git a/src/Symfony/EventListener/ReadListener.php b/src/Symfony/EventListener/ReadListener.php index 475c2da59d1..c43ba52d554 100644 --- a/src/Symfony/EventListener/ReadListener.php +++ b/src/Symfony/EventListener/ReadListener.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Symfony\EventListener; -use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\HttpOperation; @@ -73,7 +72,7 @@ public function onKernelRequest(RequestEvent $event): void } $uriVariables = []; - if (!$operation instanceof Error && $operation instanceof HttpOperation) { + if (!$request->attributes->has('exception') && $operation instanceof HttpOperation) { try { $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); } catch (InvalidIdentifierException|InvalidUriVariableException $e) { diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index 6bce2f38987..fc6c9a8b250 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -36,6 +36,8 @@ uriTemplate: '/validation_errors/{id}', status: 422, uriVariables: ['id'], + openapi: false, + outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], provider: 'api_platform.validator.state.error_provider', shortName: 'ConstraintViolation', description: 'Unprocessable entity', diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1b8fd8e874e..00d325a3748 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -91,7 +91,7 @@ api_platform: include_type: true services: - test.clienL: + test.client: class: ApiPlatform\Tests\Fixtures\TestBundle\BrowserKit\Client shared: false public: true diff --git a/tests/Functional/ErrorTest.php b/tests/Functional/ErrorTest.php new file mode 100644 index 00000000000..97209c64999 --- /dev/null +++ b/tests/Functional/ErrorTest.php @@ -0,0 +1,80 @@ + + * + * 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\Functional; + +use ApiPlatform\State\ApiResource\Error; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class ErrorTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Error::class]; + } + + #[DataProvider('formatsProvider')] + public function testRetrieveError(string $format, string $status, $expected): void + { + self::createClient()->request('GET', '/errors/'.$status, ['headers' => ['accept' => $format]]); + $this->assertJsonContains($expected); + } + + public function testRetrieveErrorHtml(): void + { + $response = self::createClient()->request('GET', '/errors/403', ['headers' => ['accept' => 'text/html']]); + $this->assertEquals(' + + + + Error 403 + +

Error 403

Forbidden +', $response->getContent()); + } + + public static function formatsProvider(): array + { + return [ + [ + 'application/vnd.api+json', + '401', + [ + 'errors' => [['id' => '/errors/401', 'detail' => 'Unauthorized']], + ], + ], + [ + 'application/ld+json', + '401', + [ + '@type' => 'hydra:Error', + 'description' => 'Unauthorized', + ], + ], + [ + 'application/json', + '401', + [ + 'detail' => 'Unauthorized', + ], + ], + ]; + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index b44a02a3a17..30628e1d95b 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -57,14 +57,14 @@ public function testErrorsAreDocumented(): void // problem detail https://datatracker.ietf.org/doc/html/rfc7807#section-3.1 foreach (['title', 'detail', 'instance', 'type', 'status'] as $key) { - $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonld-jsonproblem']['properties']); + $this->assertArrayHasKey($key, $res['components']['schemas']['Error']['properties']); } foreach (['title', 'detail', 'instance', 'type', 'status', '@id', '@type', '@context'] as $key) { - $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonld-jsonld']['properties']); + $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonld']['properties']); } foreach (['id', 'title', 'detail', 'instance', 'type', 'status', 'meta', 'source'] as $key) { - $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonapi-jsonapi']['properties']['errors']['properties']); + $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonapi']['properties']['errors']['properties']); } } diff --git a/tests/Symfony/Controller/MainControllerTest.php b/tests/Symfony/Controller/MainControllerTest.php index 64209b0bc0a..de9faeb0c49 100644 --- a/tests/Symfony/Controller/MainControllerTest.php +++ b/tests/Symfony/Controller/MainControllerTest.php @@ -112,6 +112,7 @@ public function testControllerErrorWithUriVariables(): void $body = new \stdClass(); $response = new Response(); $request = new Request(); + $request->attributes->set('exception', new \Exception()); $request->attributes->set('_api_operation', new Error(uriVariables: ['id' => new Link()])); $provider->expects($this->once()) @@ -135,6 +136,7 @@ public function testControllerErrorWithUriVariablesDuringProvider(): void $response = new Response(); $request = new Request(); + $request->attributes->set('exception', new \Exception()); $request->attributes->set('_api_operation', new Get(uriVariables: ['id' => new Link()])); $request->attributes->set('id', '1'); diff --git a/tests/Symfony/Routing/ApiLoaderTest.php b/tests/Symfony/Routing/ApiLoaderTest.php index 0c9ba60f018..6766527825f 100644 --- a/tests/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Symfony/Routing/ApiLoaderTest.php @@ -213,7 +213,6 @@ public function testApiLoaderWithPrefix(): void $prefixedPath = $prefix.$path; - $this->assertNotNull($routeCollection->get('api_errors')); $this->assertEquals( $this->getRoute( $prefixedPath,