From 0d1473f5b616c97bb08d8e3edb4ff34986cd2fe4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 20 Jan 2025 16:38:56 +0100 Subject: [PATCH] feat(openapi): document error outputs using json-schemas --- src/JsonApi/JsonSchema/SchemaFactory.php | 26 +- src/JsonSchema/ResourceMetadataTrait.php | 13 +- .../EloquentPropertyMetadataFactory.php | 4 + src/Metadata/ApiProperty.php | 54 ++-- src/Metadata/ApiResource.php | 57 ++-- src/Metadata/HttpOperation.php | 54 ++-- src/Metadata/Operation.php | 14 +- src/Metadata/Parameter.php | 4 +- src/OpenApi/Factory/OpenApiFactory.php | 199 +++++++++----- .../Tests/Factory/OpenApiFactoryTest.php | 246 ++++++++++++------ .../Serializer/OpenApiNormalizerTest.php | 5 + src/State/ApiResource/Error.php | 71 ++++- .../Exception/ValidationException.php | 44 ++-- .../Fixtures/TestBundle/ApiResource/Crud.php | 22 ++ tests/Functional/OpenApiTest.php | 70 +++++ .../Command/JsonSchemaGenerateCommandTest.php | 5 +- 16 files changed, 624 insertions(+), 264 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Crud.php create mode 100644 tests/Functional/OpenApiTest.php diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 6cd4d7e1e86..0410b4cf1c6 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ApiResource\Error; /** * Decorator factory which adds JSON:API properties to the JSON Schema document. @@ -31,6 +32,14 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + + /** + * As JSON:API recommends using [includes](https://jsonapi.org/format/#fetching-includes) instead of groups + * this flag allows to force using groups to generate the JSON:API JSONSchema. Defaults to true, use it in + * a serializer context. + */ + public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; + private const LINKS_PROPS = [ 'type' => 'object', 'properties' => [ @@ -124,14 +133,27 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin } // We don't use the serializer context here as JSON:API doesn't leverage serializer groups for related resources. // That is done by query parameter. @see https://jsonapi.org/format/#fetching-includes - $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, [], $forceCollection); + $serializerContext ??= $this->getSerializerContext($operation ?? $this->findOperation($className, $type, $operation, $serializerContext, $format), $type); + $jsonApiSerializerContext = !($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true) ? $serializerContext : []; + $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); if (($key = $schema->getRootDefinitionKey()) || ($key = $schema->getItemsDefinitionKey())) { $definitions = $schema->getDefinitions(); $properties = $definitions[$key]['properties'] ?? []; + if (Error::class === $className && !isset($properties['errors'])) { + $definitions[$key]['properties'] = [ + 'errors' => [ + 'type' => 'object', + 'properties' => $properties, + ], + ]; + + return $schema; + } + // Prevent reapplying - if (isset($properties['id'], $properties['type']) || isset($properties['data'])) { + if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) { return $schema; } diff --git a/src/JsonSchema/ResourceMetadataTrait.php b/src/JsonSchema/ResourceMetadataTrait.php index 51232c33a34..64e47fe640c 100644 --- a/src/JsonSchema/ResourceMetadataTrait.php +++ b/src/JsonSchema/ResourceMetadataTrait.php @@ -36,7 +36,7 @@ private function findOutputClass(string $className, string $type, Operation $ope return $forceSubschema ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); } - private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext): Operation + private function findOperation(string $className, string $type, ?Operation $operation, ?array $serializerContext, ?string $format = null): Operation { if (null === $operation) { if (null === $this->resourceMetadataFactory) { @@ -54,7 +54,7 @@ private function findOperation(string $className, string $type, ?Operation $oper $operation = new HttpOperation(); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); } // The best here is to use an Operation when calling `buildSchema`, we try to do a smart guess otherwise @@ -65,13 +65,13 @@ private function findOperation(string $className, string $type, ?Operation $oper return $resourceMetadataCollection->getOperation($operation->getName()); } - return $this->findOperationForType($resourceMetadataCollection, $type, $operation); + return $this->findOperationForType($resourceMetadataCollection, $type, $operation, $format); } return $operation; } - private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation + private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation, ?string $format = null): Operation { // Find the operation and use the first one that matches criterias foreach ($resourceMetadataCollection as $resourceMetadata) { @@ -85,6 +85,11 @@ private function findOperationForType(ResourceMetadataCollection $resourceMetada $operation = $op; break 2; } + + if ($format && Schema::TYPE_OUTPUT === $type && \array_key_exists($format, $op->getOutputFormats() ?? [])) { + $operation = $op; + break 2; + } } } diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index d4210674e69..971651a016a 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -46,6 +46,10 @@ public function __construct( */ public function create(string $resourceClass, string $property, array $options = []): ApiProperty { + if (!is_a($resourceClass, Model::class, true)) { + return $this->decorated?->create($resourceClass, $property, $options) ?? new ApiProperty(); + } + try { $refl = new \ReflectionClass($resourceClass); $model = $refl->newInstanceWithoutConstructor(); diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 879465d0369..d8f3d4efa74 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -231,7 +231,7 @@ public function getProperty(): ?string return $this->property; } - public function withProperty(string $property): self + public function withProperty(string $property): static { $self = clone $this; $self->property = $property; @@ -244,7 +244,7 @@ public function getDescription(): ?string return $this->description; } - public function withDescription(string $description): self + public function withDescription(string $description): static { $self = clone $this; $self->description = $description; @@ -257,7 +257,7 @@ public function isReadable(): ?bool return $this->readable; } - public function withReadable(bool $readable): self + public function withReadable(bool $readable): static { $self = clone $this; $self->readable = $readable; @@ -270,7 +270,7 @@ public function isWritable(): ?bool return $this->writable; } - public function withWritable(bool $writable): self + public function withWritable(bool $writable): static { $self = clone $this; $self->writable = $writable; @@ -283,7 +283,7 @@ public function isReadableLink(): ?bool return $this->readableLink; } - public function withReadableLink(bool $readableLink): self + public function withReadableLink(bool $readableLink): static { $self = clone $this; $self->readableLink = $readableLink; @@ -296,7 +296,7 @@ public function isWritableLink(): ?bool return $this->writableLink; } - public function withWritableLink(bool $writableLink): self + public function withWritableLink(bool $writableLink): static { $self = clone $this; $self->writableLink = $writableLink; @@ -309,7 +309,7 @@ public function isRequired(): ?bool return $this->required; } - public function withRequired(bool $required): self + public function withRequired(bool $required): static { $self = clone $this; $self->required = $required; @@ -322,7 +322,7 @@ public function isIdentifier(): ?bool return $this->identifier; } - public function withIdentifier(bool $identifier): self + public function withIdentifier(bool $identifier): static { $self = clone $this; $self->identifier = $identifier; @@ -335,7 +335,7 @@ public function getDefault() return $this->default; } - public function withDefault($default): self + public function withDefault($default): static { $self = clone $this; $self->default = $default; @@ -348,7 +348,7 @@ public function getExample(): mixed return $this->example; } - public function withExample(mixed $example): self + public function withExample(mixed $example): static { $self = clone $this; $self->example = $example; @@ -361,7 +361,7 @@ public function getDeprecationReason(): ?string return $this->deprecationReason; } - public function withDeprecationReason($deprecationReason): self + public function withDeprecationReason($deprecationReason): static { $self = clone $this; $self->deprecationReason = $deprecationReason; @@ -374,7 +374,7 @@ public function isFetchable(): ?bool return $this->fetchable; } - public function withFetchable($fetchable): self + public function withFetchable($fetchable): static { $self = clone $this; $self->fetchable = $fetchable; @@ -387,7 +387,7 @@ public function getFetchEager(): ?bool return $this->fetchEager; } - public function withFetchEager($fetchEager): self + public function withFetchEager($fetchEager): static { $self = clone $this; $self->fetchEager = $fetchEager; @@ -400,7 +400,7 @@ public function getJsonldContext(): ?array return $this->jsonldContext; } - public function withJsonldContext($jsonldContext): self + public function withJsonldContext($jsonldContext): static { $self = clone $this; $self->jsonldContext = $jsonldContext; @@ -413,7 +413,7 @@ public function getOpenapiContext(): ?array return $this->openapiContext; } - public function withOpenapiContext($openapiContext): self + public function withOpenapiContext($openapiContext): static { $self = clone $this; $self->openapiContext = $openapiContext; @@ -426,7 +426,7 @@ public function getJsonSchemaContext(): ?array return $this->jsonSchemaContext; } - public function withJsonSchemaContext($jsonSchemaContext): self + public function withJsonSchemaContext($jsonSchemaContext): static { $self = clone $this; $self->jsonSchemaContext = $jsonSchemaContext; @@ -439,7 +439,7 @@ public function getPush(): ?bool return $this->push; } - public function withPush($push): self + public function withPush($push): static { $self = clone $this; $self->push = $push; @@ -452,7 +452,7 @@ public function getSecurity(): ?string return $this->security instanceof \Stringable ? (string) $this->security : $this->security; } - public function withSecurity($security): self + public function withSecurity($security): static { $self = clone $this; $self->security = $security; @@ -465,7 +465,7 @@ public function getSecurityPostDenormalize(): ?string return $this->securityPostDenormalize instanceof \Stringable ? (string) $this->securityPostDenormalize : $this->securityPostDenormalize; } - public function withSecurityPostDenormalize($securityPostDenormalize): self + public function withSecurityPostDenormalize($securityPostDenormalize): static { $self = clone $this; $self->securityPostDenormalize = $securityPostDenormalize; @@ -481,7 +481,7 @@ public function getTypes(): ?array /** * @param string[]|string $types */ - public function withTypes(array|string $types = []): self + public function withTypes(array|string $types = []): static { $self = clone $this; $self->types = (array) $types; @@ -500,7 +500,7 @@ public function getBuiltinTypes(): ?array /** * @param Type[] $builtinTypes */ - public function withBuiltinTypes(array $builtinTypes = []): self + public function withBuiltinTypes(array $builtinTypes = []): static { $self = clone $this; $self->builtinTypes = $builtinTypes; @@ -513,7 +513,7 @@ public function getSchema(): ?array return $this->schema; } - public function withSchema(array $schema = []): self + public function withSchema(array $schema = []): static { $self = clone $this; $self->schema = $schema; @@ -521,7 +521,7 @@ public function withSchema(array $schema = []): self return $self; } - public function withInitializable(bool $initializable): self + public function withInitializable(?bool $initializable): static { $self = clone $this; $self->initializable = $initializable; @@ -539,7 +539,7 @@ public function getExtraProperties(): ?array return $this->extraProperties; } - public function withExtraProperties(array $extraProperties = []): self + public function withExtraProperties(array $extraProperties = []): static { $self = clone $this; $self->extraProperties = $extraProperties; @@ -560,7 +560,7 @@ public function getIris() * * @param string|string[] $iris */ - public function withIris(string|array $iris): self + public function withIris(string|array $iris): static { $metadata = clone $this; $metadata->iris = (array) $iris; @@ -576,7 +576,7 @@ public function getGenId() return $this->genId; } - public function withGenId(bool $genId): self + public function withGenId(bool $genId): static { $metadata = clone $this; $metadata->genId = $genId; @@ -594,7 +594,7 @@ public function getUriTemplate(): ?string return $this->uriTemplate; } - public function withUriTemplate(?string $uriTemplate): self + public function withUriTemplate(?string $uriTemplate): static { $metadata = clone $this; $metadata->uriTemplate = $uriTemplate; diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index c420e97c644..d061d937275 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -1027,7 +1027,7 @@ public function getOperations(): ?Operations return $this->operations; } - public function withOperations(Operations $operations): self + public function withOperations(Operations $operations): static { $self = clone $this; $self->operations = $operations; @@ -1041,7 +1041,7 @@ public function getUriTemplate(): ?string return $this->uriTemplate; } - public function withUriTemplate(string $uriTemplate): self + public function withUriTemplate(string $uriTemplate): static { $self = clone $this; $self->uriTemplate = $uriTemplate; @@ -1057,7 +1057,7 @@ public function getTypes(): ?array /** * @param string[]|string $types */ - public function withTypes(array|string $types): self + public function withTypes(array|string $types): static { $self = clone $this; $self->types = (array) $types; @@ -1073,7 +1073,7 @@ public function getFormats() return $this->formats; } - public function withFormats(mixed $formats): self + public function withFormats(mixed $formats): static { $self = clone $this; $self->formats = $formats; @@ -1092,7 +1092,7 @@ public function getInputFormats() /** * @param mixed|null $inputFormats */ - public function withInputFormats($inputFormats): self + public function withInputFormats($inputFormats): static { $self = clone $this; $self->inputFormats = $inputFormats; @@ -1111,7 +1111,7 @@ public function getOutputFormats() /** * @param mixed|null $outputFormats */ - public function withOutputFormats($outputFormats): self + public function withOutputFormats($outputFormats): static { $self = clone $this; $self->outputFormats = $outputFormats; @@ -1130,7 +1130,7 @@ public function getUriVariables() /** * @param array|array|string[]|string|null $uriVariables */ - public function withUriVariables($uriVariables): self + public function withUriVariables($uriVariables): static { $self = clone $this; $self->uriVariables = $uriVariables; @@ -1143,7 +1143,7 @@ public function getRoutePrefix(): ?string return $this->routePrefix; } - public function withRoutePrefix(string $routePrefix): self + public function withRoutePrefix(string $routePrefix): static { $self = clone $this; $self->routePrefix = $routePrefix; @@ -1156,7 +1156,7 @@ public function getDefaults(): ?array return $this->defaults; } - public function withDefaults(array $defaults): self + public function withDefaults(array $defaults): static { $self = clone $this; $self->defaults = $defaults; @@ -1169,7 +1169,7 @@ public function getRequirements(): ?array return $this->requirements; } - public function withRequirements(array $requirements): self + public function withRequirements(array $requirements): static { $self = clone $this; $self->requirements = $requirements; @@ -1182,7 +1182,7 @@ public function getOptions(): ?array return $this->options; } - public function withOptions(array $options): self + public function withOptions(array $options): static { $self = clone $this; $self->options = $options; @@ -1195,7 +1195,7 @@ public function getStateless(): ?bool return $this->stateless; } - public function withStateless(bool $stateless): self + public function withStateless(bool $stateless): static { $self = clone $this; $self->stateless = $stateless; @@ -1208,7 +1208,7 @@ public function getSunset(): ?string return $this->sunset; } - public function withSunset(string $sunset): self + public function withSunset(string $sunset): static { $self = clone $this; $self->sunset = $sunset; @@ -1221,7 +1221,7 @@ public function getAcceptPatch(): ?string return $this->acceptPatch; } - public function withAcceptPatch(string $acceptPatch): self + public function withAcceptPatch(string $acceptPatch): static { $self = clone $this; $self->acceptPatch = $acceptPatch; @@ -1234,7 +1234,10 @@ public function getStatus(): ?int return $this->status; } - public function withStatus($status): self + /** + * @param int $status + */ + public function withStatus($status): static { $self = clone $this; $self->status = $status; @@ -1247,7 +1250,7 @@ public function getHost(): ?string return $this->host; } - public function withHost(string $host): self + public function withHost(string $host): static { $self = clone $this; $self->host = $host; @@ -1260,7 +1263,7 @@ public function getSchemes(): ?array return $this->schemes; } - public function withSchemes(array $schemes): self + public function withSchemes(array $schemes): static { $self = clone $this; $self->schemes = $schemes; @@ -1273,7 +1276,7 @@ public function getCondition(): ?string return $this->condition; } - public function withCondition(string $condition): self + public function withCondition(string $condition): static { $self = clone $this; $self->condition = $condition; @@ -1286,7 +1289,7 @@ public function getController(): ?string return $this->controller; } - public function withController(string $controller): self + public function withController(string $controller): static { $self = clone $this; $self->controller = $controller; @@ -1299,7 +1302,7 @@ public function getHeaders(): ?array return $this->headers; } - public function withHeaders(array $headers): self + public function withHeaders(array $headers): static { $self = clone $this; $self->headers = $headers; @@ -1312,7 +1315,7 @@ public function getCacheHeaders(): ?array return $this->cacheHeaders; } - public function withCacheHeaders(array $cacheHeaders): self + public function withCacheHeaders(array $cacheHeaders): static { $self = clone $this; $self->cacheHeaders = $cacheHeaders; @@ -1328,7 +1331,7 @@ public function getHydraContext(): ?array return $this->hydraContext; } - public function withHydraContext(array $hydraContext): self + public function withHydraContext(array $hydraContext): static { $self = clone $this; $self->hydraContext = $hydraContext; @@ -1341,7 +1344,7 @@ public function getOpenapi(): bool|OpenApiOperation|null return $this->openapi; } - public function withOpenapi(bool|OpenApiOperation $openapi): self + public function withOpenapi(bool|OpenApiOperation $openapi): static { $self = clone $this; $self->openapi = $openapi; @@ -1354,7 +1357,7 @@ public function getPaginationViaCursor(): ?array return $this->paginationViaCursor; } - public function withPaginationViaCursor(array $paginationViaCursor): self + public function withPaginationViaCursor(array $paginationViaCursor): static { $self = clone $this; $self->paginationViaCursor = $paginationViaCursor; @@ -1367,7 +1370,7 @@ public function getExceptionToStatus(): ?array return $this->exceptionToStatus; } - public function withExceptionToStatus(array $exceptionToStatus): self + public function withExceptionToStatus(array $exceptionToStatus): static { $self = clone $this; $self->exceptionToStatus = $exceptionToStatus; @@ -1383,7 +1386,7 @@ public function getGraphQlOperations(): ?array return $this->graphQlOperations; } - public function withGraphQlOperations(array $graphQlOperations): self + public function withGraphQlOperations(array $graphQlOperations): static { $self = clone $this; $self->graphQlOperations = $graphQlOperations; @@ -1399,7 +1402,7 @@ public function getLinks(): ?array /** * @param Link[] $links */ - public function withLinks(array $links): self + public function withLinks(array $links): static { $self = clone $this; $self->links = $links; diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index ab4f4d8f04a..531030554ff 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -270,7 +270,7 @@ public function getMethod(): string return $this->method; } - public function withMethod(string $method): self + public function withMethod(string $method): static { $self = clone $this; $self->method = $method; @@ -299,7 +299,7 @@ public function getTypes(): ?array /** * @param string[]|string $types */ - public function withTypes($types): self + public function withTypes($types): static { $self = clone $this; $self->types = (array) $types; @@ -312,7 +312,7 @@ public function getFormats() return $this->formats; } - public function withFormats($formats = null): self + public function withFormats($formats = null): static { $self = clone $this; $self->formats = $formats; @@ -325,7 +325,7 @@ public function getInputFormats() return $this->inputFormats; } - public function withInputFormats($inputFormats = null): self + public function withInputFormats($inputFormats = null): static { $self = clone $this; $self->inputFormats = $inputFormats; @@ -338,7 +338,7 @@ public function getOutputFormats() return $this->outputFormats; } - public function withOutputFormats($outputFormats = null): self + public function withOutputFormats($outputFormats = null): static { $self = clone $this; $self->outputFormats = $outputFormats; @@ -351,7 +351,7 @@ public function getUriVariables() return $this->uriVariables; } - public function withUriVariables($uriVariables): self + public function withUriVariables($uriVariables): static { $self = clone $this; $self->uriVariables = $uriVariables; @@ -364,7 +364,7 @@ public function getRoutePrefix(): ?string return $this->routePrefix; } - public function withRoutePrefix(string $routePrefix): self + public function withRoutePrefix(string $routePrefix): static { $self = clone $this; $self->routePrefix = $routePrefix; @@ -377,7 +377,7 @@ public function getRouteName(): ?string return $this->routeName; } - public function withRouteName(?string $routeName): self + public function withRouteName(?string $routeName): static { $self = clone $this; $self->routeName = $routeName; @@ -390,7 +390,7 @@ public function getDefaults(): ?array return $this->defaults; } - public function withDefaults(array $defaults): self + public function withDefaults(array $defaults): static { $self = clone $this; $self->defaults = $defaults; @@ -403,7 +403,7 @@ public function getRequirements(): ?array return $this->requirements; } - public function withRequirements(array $requirements): self + public function withRequirements(array $requirements): static { $self = clone $this; $self->requirements = $requirements; @@ -416,7 +416,7 @@ public function getOptions(): ?array return $this->options; } - public function withOptions(array $options): self + public function withOptions(array $options): static { $self = clone $this; $self->options = $options; @@ -429,7 +429,7 @@ public function getStateless(): ?bool return $this->stateless; } - public function withStateless($stateless): self + public function withStateless($stateless): static { $self = clone $this; $self->stateless = $stateless; @@ -442,7 +442,7 @@ public function getSunset(): ?string return $this->sunset; } - public function withSunset(string $sunset): self + public function withSunset(string $sunset): static { $self = clone $this; $self->sunset = $sunset; @@ -455,7 +455,7 @@ public function getAcceptPatch(): ?string return $this->acceptPatch; } - public function withAcceptPatch(string $acceptPatch): self + public function withAcceptPatch(string $acceptPatch): static { $self = clone $this; $self->acceptPatch = $acceptPatch; @@ -468,7 +468,7 @@ public function getStatus(): ?int return $this->status; } - public function withStatus(int $status): self + public function withStatus(int $status): static { $self = clone $this; $self->status = $status; @@ -481,7 +481,7 @@ public function getHost(): ?string return $this->host; } - public function withHost(string $host): self + public function withHost(string $host): static { $self = clone $this; $self->host = $host; @@ -494,7 +494,7 @@ public function getSchemes(): ?array return $this->schemes; } - public function withSchemes(array $schemes): self + public function withSchemes(array $schemes): static { $self = clone $this; $self->schemes = $schemes; @@ -507,7 +507,7 @@ public function getCondition(): ?string return $this->condition; } - public function withCondition(string $condition): self + public function withCondition(string $condition): static { $self = clone $this; $self->condition = $condition; @@ -520,7 +520,7 @@ public function getController(): ?string return $this->controller; } - public function withController(string $controller): self + public function withController(string $controller): static { $self = clone $this; $self->controller = $controller; @@ -533,7 +533,7 @@ public function getHeaders(): ?array return $this->headers; } - public function withHeaders(array $headers): self + public function withHeaders(array $headers): static { $self = clone $this; $self->headers = $headers; @@ -546,7 +546,7 @@ public function getCacheHeaders(): ?array return $this->cacheHeaders; } - public function withCacheHeaders(array $cacheHeaders): self + public function withCacheHeaders(array $cacheHeaders): static { $self = clone $this; $self->cacheHeaders = $cacheHeaders; @@ -559,7 +559,7 @@ public function getPaginationViaCursor(): ?array return $this->paginationViaCursor; } - public function withPaginationViaCursor(array $paginationViaCursor): self + public function withPaginationViaCursor(array $paginationViaCursor): static { $self = clone $this; $self->paginationViaCursor = $paginationViaCursor; @@ -572,7 +572,7 @@ public function getHydraContext(): ?array return $this->hydraContext; } - public function withHydraContext(array $hydraContext): self + public function withHydraContext(array $hydraContext): static { $self = clone $this; $self->hydraContext = $hydraContext; @@ -585,7 +585,7 @@ public function getOpenapi(): bool|OpenApiOperation|Webhook|null return $this->openapi; } - public function withOpenapi(bool|OpenApiOperation|Webhook $openapi): self + public function withOpenapi(bool|OpenApiOperation|Webhook $openapi): static { $self = clone $this; $self->openapi = $openapi; @@ -598,7 +598,7 @@ public function getExceptionToStatus(): ?array return $this->exceptionToStatus; } - public function withExceptionToStatus(array $exceptionToStatus): self + public function withExceptionToStatus(array $exceptionToStatus): static { $self = clone $this; $self->exceptionToStatus = $exceptionToStatus; @@ -614,7 +614,7 @@ public function getLinks(): ?array /** * @param WebLink[] $links */ - public function withLinks(array $links): self + public function withLinks(array $links): static { $self = clone $this; $self->links = $links; @@ -630,7 +630,7 @@ public function getErrors(): ?array /** * @param class-string[] $errors */ - public function withErrors(array $errors): self + public function withErrors(array $errors): static { $self = clone $this; $self->errors = $errors; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 94d321ff786..185048ca4af 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -874,7 +874,7 @@ public function canRead(): ?bool return $this->read; } - public function withRead(bool $read = true): self + public function withRead(bool $read = true): static { $self = clone $this; $self->read = $read; @@ -887,7 +887,7 @@ public function canDeserialize(): ?bool return $this->deserialize; } - public function withDeserialize(bool $deserialize = true): self + public function withDeserialize(bool $deserialize = true): static { $self = clone $this; $self->deserialize = $deserialize; @@ -900,7 +900,7 @@ public function canValidate(): ?bool return $this->validate; } - public function withValidate(bool $validate = true): self + public function withValidate(bool $validate = true): static { $self = clone $this; $self->validate = $validate; @@ -913,7 +913,7 @@ public function canWrite(): ?bool return $this->write; } - public function withWrite(bool $write = true): self + public function withWrite(bool $write = true): static { $self = clone $this; $self->write = $write; @@ -926,7 +926,7 @@ public function canSerialize(): ?bool return $this->serialize; } - public function withSerialize(bool $serialize = true): self + public function withSerialize(bool $serialize = true): static { $self = clone $this; $self->serialize = $serialize; @@ -939,7 +939,7 @@ public function getPriority(): ?int return $this->priority; } - public function withPriority(int $priority = 0): self + public function withPriority(int $priority = 0): static { $self = clone $this; $self->priority = $priority; @@ -952,7 +952,7 @@ public function getName(): ?string return $this->name; } - public function withName(string $name = ''): self + public function withName(string $name = ''): static { $self = clone $this; $self->name = $name; diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 3aadd249b38..815a68d405c 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -248,7 +248,7 @@ public function withConstraints(mixed $constraints): static return $self; } - public function withSecurity(string|\Stringable|null $security): self + public function withSecurity(string|\Stringable|null $security): static { $self = clone $this; $self->security = $security; @@ -256,7 +256,7 @@ public function withSecurity(string|\Stringable|null $security): self return $self; } - public function withSecurityMessage(?string $securityMessage): self + public function withSecurityMessage(?string $securityMessage): static { $self = clone $this; $self->securityMessage = $securityMessage; diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index caeb5be9731..f5103ebfbe0 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; @@ -51,7 +52,9 @@ use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; +use ApiPlatform\State\ApiResource\Error as ApiResourceError; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Routing\RouteCollection; @@ -71,7 +74,14 @@ final class OpenApiFactory implements OpenApiFactoryInterface private readonly PaginationOptions $paginationOptions; private ?RouteCollection $routeCollection = null; private ?ContainerInterface $filterLocator = null; + /** + * @var array + */ + private array $localErrorResourceCache = []; + /** + * @param array $formats + */ public function __construct( private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, @@ -322,7 +332,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { - $openapiOperation = $this->addOperationErrors($openapiOperation, $errors, $responseMimeTypes, $resourceMetadataCollection, $schema, $schemas); + /** @var array */ + $errorOperations = []; + foreach ($errors as $error) { + $errorOperations[$error] = $this->getErrorResource($error); + } + + $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation); } if ($overrideResponses || !$existingResponses) { @@ -334,40 +350,39 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection break; case 'POST': $successStatus = (string) $operation->getStatus() ?: 201; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); + $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); 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->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - if (!isset($existingResponses[400])) { - $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); - } - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); + $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); break; case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation); - break; } } - if (true === $overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { - $openapiOperation = $openapiOperation->withResponse(403, new Response('Forbidden')); + if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(403)->withDescription('Forbidden'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); } - if (true === $overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { - if (!isset($existingResponses[404])) { - $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); - } + if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404])) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(404)->withDescription('Not found'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); } if (!$openapiOperation->getResponses()) { @@ -398,9 +413,22 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($openapiAttribute instanceof Webhook) { $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); - } else { - $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + continue; + } + + // We merge content types for errors, maybe that this logic could be applied to every resources at some point + if ($operation instanceof Error && ($existingPathItem = $paths->getPath($path)) && ($existingOperation = $existingPathItem->getGet()) && ($currentResponse = $openapiOperation->getResponses()[200] ?? null)) { + $errorResponse = $existingOperation->getResponses()[200]; + $currentResponseContent = $currentResponse->getContent(); + + foreach ($errorResponse->getContent() as $mime => $content) { + $currentResponseContent[$mime] = $content; + } + + $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent)); } + + $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); } } @@ -421,6 +449,9 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat } /** + * @param array $responseMimeTypes + * @param array $operationSchemas + * * @return \ArrayObject */ private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject @@ -545,7 +576,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } if ($operationUriVariables[$parameterName]->getIdentifiers() === $uriVariableDefinition->getIdentifiers() && $operationUriVariables[$parameterName]->getFromClass() === $uriVariableDefinition->getFromClass()) { - $parameters[$parameterName] = '$request.path.'.$uriVariableDefinition->getIdentifiers()[0]; + $parameters[$parameterName] = '$request.path.'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id'); } } @@ -555,7 +586,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } if ($uriVariableDefinition->getFromClass() === $currentOperation->getClass()) { - $parameters[$parameterName] = '$response.body#/'.$uriVariableDefinition->getIdentifiers()[0]; + $parameters[$parameterName] = '$response.body#/'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id'); } } @@ -610,6 +641,9 @@ private function getFilterClass(HttpOperation $operation): ?string return $entityClass; } + /** + * @param array $description + */ private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter { if (isset($description['swagger'])) { @@ -756,6 +790,10 @@ private function getSecuritySchemes(): array return $securitySchemes; } + /** + * @param \ArrayObject $schemas + * @param \ArrayObject $definitions + */ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void { foreach ($definitions as $key => $value) { @@ -779,18 +817,20 @@ private function hasParameter(Operation $operation, Parameter $parameter): ?arra private function mergeParameter(Parameter $actual, Parameter $defined): Parameter { - foreach ([ - 'name', - 'in', - 'description', - 'required', - 'deprecated', - 'allowEmptyValue', - 'style', - 'explode', - 'allowReserved', - 'example', - ] as $method) { + foreach ( + [ + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method + ) { $newValue = $defined->{"get$method"}(); if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { $actual = $actual->{"with$method"}($newValue); @@ -808,20 +848,55 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete } /** - * @param string[] $errors - * @param array $responseMimeTypes + * @param array $errors + * @param \ArrayObject $schemas */ - private function addOperationErrors(Operation $operation, array $errors, array $responseMimeTypes, ResourceMetadataCollection $resourceMetadataCollection, Schema $schema, \ArrayObject $schemas): Operation - { - $existingResponses = null; - foreach ($errors as $error) { - if (!is_a($error, ProblemExceptionInterface::class, true)) { - throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + private function addOperationErrors( + Operation $operation, + array $errors, + ResourceMetadataCollection $resourceMetadataCollection, + Schema $schema, + \ArrayObject $schemas, + HttpOperation $originalOperation, + ): Operation { + $defaultFormat = ['json' => ['application/problem+json']]; + foreach ($errors as $error => $errorResource) { + $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $defaultFormat); + foreach ($errorResource->getOperations() as $errorOperation) { + if (false === $errorOperation->getOpenApi()) { + continue; + } + + $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $defaultFormat); } - $status = null; - $description = null; + $operationErrorSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } + 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)); + } + + $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + } + + return $operation; + } + + /** + * @param string|class-string $error + */ + private function getErrorResource(string $error, ?int $status = null, ?string $description = null): ErrorResource + { + if ($this->localErrorResourceCache[$error] ?? null) { + return $this->localErrorResourceCache[$error]; + } + + if (is_a($error, ProblemExceptionInterface::class, true)) { try { /** @var ProblemExceptionInterface $exception */ $exception = new $error(); @@ -829,32 +904,32 @@ private function addOperationErrors(Operation $operation, array $errors, array $ $description = $exception->getTitle(); } catch (\TypeError) { } + } elseif (class_exists($error)) { + throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + } - try { - $errorOperation = $this->resourceMetadataFactory->create($error)->getOperation(); - if (!is_a($errorOperation, Error::class)) { - throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); - } - } catch (ResourceClassNotFoundException|OperationNotFoundException) { - $errorOperation = null; + try { + $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + if (!($errorResource instanceof ErrorResource)) { + throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); } - $status ??= $errorOperation?->getStatus(); - $description ??= $errorOperation?->getDescription(); - if (!$status) { - 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)); + // Here we want the exception status and expression to override the resource one when available + if ($status) { + $errorResource = $errorResource->withStatus($status); } - $operationErrorSchemas = []; - foreach ($responseMimeTypes as $operationFormat) { - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($error, $operationFormat, Schema::TYPE_OUTPUT, null, $schema); - $operationErrorSchemas[$operationFormat] = $operationErrorSchema; - $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + if ($description) { + $errorResource = $errorResource->withDescription($description); } + } catch (ResourceClassNotFoundException|OperationNotFoundException) { + $errorResource = new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + } - $operation = $this->buildOpenApiResponse($existingResponses ??= $operation->getResponses() ?: [], $status, $description ?? '', $operation, $errorOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + if (!$errorResource->getClass()) { + $errorResource = $errorResource->withClass($error); } - return $operation; + return $this->localErrorResourceCache[$error] = $errorResource; } } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 2a7fd031c09..7fd600de488 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Error as ErrorOperation; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; @@ -60,8 +61,10 @@ use ApiPlatform\OpenApi\Tests\Fixtures\DummyFilter; use ApiPlatform\OpenApi\Tests\Fixtures\Issue6872\Diamond; use ApiPlatform\OpenApi\Tests\Fixtures\OutputDto; +use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; +use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -285,14 +288,17 @@ public function testInvoke(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); - $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ApiResource(operations: [new ErrorOperation(name: 'err', description: 'nice one!')])])); + $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ErrorResource(operations: [new ErrorOperation(name: 'err', description: 'nice one!')])])); $resourceCollectionMetadataFactoryProphecy->create(WithParameter::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(WithParameter::class, [$parameterResource])); $resourceCollectionMetadataFactoryProphecy->create(Diamond::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Diamond::class, [$diamondResource])); + $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); $propertyNameCollectionFactoryProphecy->create(DummyErrorResource::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['type', 'title', 'status', 'detail', 'instance'])); $propertyNameCollectionFactoryProphecy->create(OutputDto::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + $propertyNameCollectionFactoryProphecy->create(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['type', 'title', 'status', 'detail', 'instance'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -407,59 +413,62 @@ public function testInvoke(): void ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'type', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error type.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'title', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error title.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'status', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) - ->withDescription('This is an error status.') - ->withReadable(true) - ->withWritable(false) - ->withIdentifier(true) - ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'detail', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error detail.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'instance', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error instance.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) - ); + + foreach ([DummyErrorResource::class, Error::class] as $cl) { + $propertyMetadataFactoryProphecy->create($cl, 'type', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error type.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'title', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error title.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'status', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an error status.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) + ); + $propertyMetadataFactoryProphecy->create($cl, 'detail', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error detail.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'instance', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error instance.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) + ); + } $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $filters = [ @@ -610,6 +619,8 @@ public function testInvoke(): void ]), ], ])); + $errorSchema = clone $dummyErrorSchema->getDefinitions(); + $errorSchema['description'] = ''; $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -634,8 +645,9 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), - 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Parameter' => $parameterSchema, + 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), + 'Error' => $errorSchema, ])); $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ @@ -703,8 +715,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -735,7 +755,11 @@ public function testInvoke(): void 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), ]) ), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Retrieves a Dummy resource.', 'Retrieves a Dummy resource.', @@ -755,9 +779,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -777,7 +813,11 @@ public function testInvoke(): void ['Dummy'], [ '204' => new Response('Dummy resource deleted'), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Removes the Dummy resource.', 'Removes the Dummy resource.', @@ -800,7 +840,11 @@ public function testInvoke(): void 'Foo' => ['$ref' => '#/components/schemas/Dummy'], ])), '205' => new Response(), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Dummy', 'Custom description', @@ -843,9 +887,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -952,8 +1008,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -996,8 +1060,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1033,8 +1105,16 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -1070,7 +1150,11 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1128,8 +1212,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1160,11 +1252,11 @@ public function testInvoke(): void '418' => new Response( 'A Teapot Exception', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/problem+json' => new MediaType(new \ArrayObject(new \ArrayObject([ '$ref' => '#/components/schemas/DummyErrorResource', ]))), ]), - links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(), null, 'This is a dummy')]) + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), ], 'Retrieves the collection of Dummy resources.', diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index defc4ad12f7..23b7d7af780 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -43,7 +43,9 @@ use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\OpenApi\Tests\Fixtures\Dummy; +use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -100,6 +102,7 @@ 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']) @@ -141,6 +144,8 @@ public function testNormalize(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); + $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( diff --git a/src/State/ApiResource/Error.php b/src/State/ApiResource/Error.php index a69c7e6f97a..2f9cbeb1c08 100644 --- a/src/State/ApiResource/Error.php +++ b/src/State/ApiResource/Error.php @@ -25,7 +25,6 @@ use Symfony\Component\WebLink\Link; #[ErrorResource( - openapi: false, uriVariables: ['status'], uriTemplate: '/errors/{status}', operations: [ @@ -37,15 +36,17 @@ normalizationContext: [ 'groups' => ['jsonproblem'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), new Operation( name: '_api_errors_hydra', routeName: 'api_errors', - outputFormats: ['jsonld' => ['application/problem+json']], + outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], normalizationContext: [ 'groups' => ['jsonld'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), @@ -55,32 +56,46 @@ hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ + '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', routeName: 'api_errors', hideHydraOperation: true, + openapi: false ), ], provider: 'api_platform.state.error_provider', - graphQlOperations: [] + graphQlOperations: [], + description: 'A representation of common errors.' )] #[ApiProperty(property: 'traceAsString', hydra: false)] #[ApiProperty(property: 'string', hydra: false)] class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface { + private ?string $id = null; + public function __construct( private string $title, private string $detail, - #[ApiProperty(identifier: true, writable: false, initializable: false)] private int $status, + #[ApiProperty( + description: 'The HTTP status code applicable to this problem.', + identifier: true, + writable: false, + initializable: false, + schema: ['type' => 'number', 'example' => 404, 'default' => 400] + )] private int $status, ?array $originalTrace = null, private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], ?\Throwable $previous = null, + private ?array $meta = null, + private ?array $source = null, ) { parent::__construct($title, $status, $previous); @@ -98,7 +113,28 @@ public function __construct( #[Groups(['jsonapi'])] public function getId(): string { - return (string) $this->status; + return $this->id ?? ((string) $this->status); + } + + #[Groups(['jsonapi'])] + #[ApiProperty(schema: ['type' => 'object'])] + public function getMeta(): ?array + { + return $this->meta; + } + + #[Groups(['jsonapi'])] + #[ApiProperty(schema: [ + 'type' => 'object', + 'properties' => [ + 'pointer' => ['type' => 'string'], + 'parameter' => ['type' => 'string'], + 'header' => ['type' => 'string'], + ], + ])] + public function getSource(): ?array + { + return $this->source; } #[SerializedName('trace')] @@ -142,7 +178,7 @@ public function setHeaders(array $headers): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A URI reference that identifies the problem type')] public function getType(): string { return $this->type; @@ -154,7 +190,7 @@ public function setType(string $type): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A short, human-readable summary of the problem.')] public function getTitle(): ?string { return $this->title; @@ -177,7 +213,7 @@ public function setStatus(int $status): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A human-readable explanation specific to this occurrence of the problem.')] public function getDetail(): ?string { return $this->detail; @@ -188,8 +224,8 @@ public function setDetail(?string $detail = null): void $this->detail = $detail; } - #[Groups(['jsonld', 'jsonproblem'])] - #[ApiProperty(writable: false, initializable: false)] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + #[ApiProperty(writable: false, initializable: false, description: 'A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.')] public function getInstance(): ?string { return $this->instance; @@ -199,4 +235,19 @@ public function setInstance(?string $instance = null): void { $this->instance = $instance; } + + public function setId(?string $id = null): void + { + $this->id = $id; + } + + public function setMeta(?array $meta = null): void + { + $this->meta = $meta; + } + + public function setSource(?array $source = null): void + { + $this->source = $source; + } } diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index 82631b92b6d..6bce2f38987 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -23,6 +23,7 @@ use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\Validator\ConstraintViolationList; use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\WebLink\Link; @@ -34,32 +35,39 @@ #[ErrorResource( uriTemplate: '/validation_errors/{id}', status: 422, - openapi: false, uriVariables: ['id'], provider: 'api_platform.validator.state.error_provider', shortName: 'ConstraintViolation', + description: 'Unprocessable entity', operations: [ new ErrorOperation( name: '_api_validation_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: [ 'groups' => ['json'], + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], 'skip_null_values' => true, ] ), new ErrorOperation( name: '_api_validation_errors_hydra', - outputFormats: ['jsonld' => ['application/problem+json']], + outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], normalizationContext: [ 'groups' => ['jsonld'], + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], 'skip_null_values' => true, ] ), new ErrorOperation( name: '_api_validation_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], - normalizationContext: ['groups' => ['jsonapi'], 'skip_null_values' => true] + normalizationContext: [ + 'disable_json_schema_serializer_groups' => false, + 'groups' => ['jsonapi'], + 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], + ] ), ], graphQlOperations: [] @@ -70,21 +78,13 @@ class ValidationException extends RuntimeException implements ConstraintViolatio { private int $status = 422; protected ?string $errorTitle = null; - private ConstraintViolationListInterface $constraintViolationList; + private array|ConstraintViolationListInterface $constraintViolationList = []; - public function __construct(string|ConstraintViolationListInterface $message = '', string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) + public function __construct(ConstraintViolationListInterface $message = new ConstraintViolationList(), string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) { $this->errorTitle = $errorTitle; - - if ($message instanceof ConstraintViolationListInterface) { - $this->constraintViolationList = $message; - parent::__construct($this->__toString(), $code ?? 0, $previous); - - return; - } - - trigger_deprecation('api_platform/core', '3.3', \sprintf('The "%s" exception will have a "%s" first argument in 4.x.', self::class, ConstraintViolationListInterface::class)); - parent::__construct($message ?: $this->__toString(), $code ?? 0, $previous); + $this->constraintViolationList = $message; + parent::__construct($this->__toString(), $code ?? 0, $previous); } /** @@ -166,7 +166,19 @@ public function getInstance(): ?string #[SerializedName('violations')] #[Groups(['json', 'jsonld'])] - #[ApiProperty(jsonldContext: ['@type' => 'ConstraintViolationList'])] + #[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 getConstraintViolationList(): ConstraintViolationListInterface { return $this->constraintViolationList; diff --git a/tests/Fixtures/TestBundle/ApiResource/Crud.php b/tests/Fixtures/TestBundle/ApiResource/Crud.php new file mode 100644 index 00000000000..165aebf73b0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Crud.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource] +class Crud +{ + public string $id; +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php new file mode 100644 index 00000000000..692d2cd3dec --- /dev/null +++ b/tests/Functional/OpenApiTest.php @@ -0,0 +1,70 @@ + + * + * 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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Crud; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class OpenApiTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Crud::class]; + } + + public function testErrorsAreDocumented(): void + { + $container = static::getContainer(); + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $res = $response->toArray(); + $this->assertTrue(isset($res['paths']['/cruds/{id}']['patch']['responses'])); + $responses = $res['paths']['/cruds/{id}']['patch']['responses']; + + foreach ($responses as $status => $response) { + if ($status < 400) { + continue; + } + + $this->assertArrayHasKey('application/problem+json', $response['content']); + $this->assertArrayHasKey('application/ld+json', $response['content']); + $this->assertArrayHasKey('application/vnd.api+json', $response['content']); + + match ($status) { + 422 => $this->assertStringStartsWith('#/components/schemas/ConstraintViolation', $response['content']['application/problem+json']['schema']['$ref']), + default => $this->assertStringStartsWith('#/components/schemas/Error', $response['content']['application/problem+json']['schema']['$ref']), + }; + } + + // 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']); + } + + foreach (['title', 'detail', 'instance', 'type', 'status', '@id', '@type', '@context'] as $key) { + $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonld-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']); + } + } +} diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index cd34b6bfb11..260124a1e3a 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\ApplicationTester; @@ -378,9 +379,7 @@ public function testGenId(): void $this->assertArrayNotHasKey('@id', $json['definitions']['DisableIdGenerationItem.jsonld']['properties']); } - /** - * @dataProvider arrayPropertyTypeSyntaxProvider - */ + #[DataProvider('arrayPropertyTypeSyntaxProvider')] public function testOpenApiSchemaGenerationForArrayProperty(string $propertyName, array $expectedProperties): void { $this->tester->run([