From e04892df8a99aec3755c068e5691d61cc13d903c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 May 2025 16:23:50 +0200 Subject: [PATCH 1/4] test: openapi fixes note that the test in docs.feature has been moved to phpunit --- .../related-resouces-inclusion.feature | 4 + features/openapi/docs.feature | 109 +----------------- tests/Fixtures/TestBundle/Document/User.php | 2 +- .../Entity/Issue5793/BagOfTests.php | 2 +- 4 files changed, 7 insertions(+), 110 deletions(-) diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index ba171bece72..2a6663d3fd1 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -54,7 +54,9 @@ Feature: JSON API Inclusion of Related Resources } """ + @createSchema Scenario: Request inclusion of a non existing related resource + Given there are 3 dummy property objects When I send a "GET" request to "/dummy_properties/1?include=foo" Then the response status code should be 200 And the response should be in JSON @@ -87,7 +89,9 @@ Feature: JSON API Inclusion of Related Resources } """ + @createSchema Scenario: Request inclusion of a related resource keeping main object properties unfiltered + Given there are 3 dummy property objects When I send a "GET" request to "/dummy_properties/1?include=group&fields[group]=id,foo&fields[DummyProperty]=bar,baz" Then the response status code should be 200 And the response should be in JSON diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 5d5d802c1dc..a1ac98752de 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -153,10 +153,9 @@ Feature: Documentation support And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 6 elements # Subcollection - check schema - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.allOf[1].properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" # Deprecations - And the JSON node "paths./dummies.get.deprecated" should be false And the JSON node "paths./deprecated_resources.get.deprecated" should be true And the JSON node "paths./deprecated_resources.post.deprecated" should be true And the JSON node "paths./deprecated_resources/{id}.get.deprecated" should be true @@ -166,111 +165,6 @@ Feature: Documentation support # Formats And the OpenAPI class "Dummy.jsonld" exists - And the "@id" property exists for the OpenAPI class "Dummy.jsonld" - And the JSON node "paths./dummies.get.responses.200.content.application/ld+json" should be equal to: - """ - { - "schema": { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Dummy.jsonld" - } - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": { - "type": "string", - "format": "iri-reference" - }, - "@type": { - "type": "string" - }, - "hydra:first": { - "type": "string", - "format": "iri-reference" - }, - "hydra:last": { - "type": "string", - "format": "iri-reference" - }, - "hydra:previous": { - "type": "string", - "format": "iri-reference" - }, - "hydra:next": { - "type": "string", - "format": "iri-reference" - } - }, - "example": { - "@id": "string", - "type": "string", - "hydra:first": "string", - "hydra:last": "string", - "hydra:previous": "string", - "hydra:next": "string" - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "hydra:template": { - "type": "string" - }, - "hydra:variableRepresentation": { - "type": "string" - }, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "variable": { - "type": "string" - }, - "property": { - "type": ["string", "null"] - }, - "required": { - "type": "boolean" - } - } - } - } - } - } - }, - "required": [ - "hydra:member" - ] - } - } - """ - And the JSON node "paths./dummies.get.responses.200.content.application/json" should be equal to: - """ - { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Dummy" - } - } - } - """ And the JSON node "paths./override_open_api_responses.post.responses" should be equal to: """ { @@ -322,7 +216,6 @@ Feature: Documentation support And the "resourceRelated" property for the OpenAPI class "Resource" should be equal to: """ { - "readOnly": true, "anyOf": [ { "$ref": "#/components/schemas/ResourceRelated" diff --git a/tests/Fixtures/TestBundle/Document/User.php b/tests/Fixtures/TestBundle/Document/User.php index 874b5411c79..1dd93390db8 100644 --- a/tests/Fixtures/TestBundle/Document/User.php +++ b/tests/Fixtures/TestBundle/Document/User.php @@ -34,7 +34,7 @@ * @author Théo FIDRY * @author Kévin Dunglas */ -#[ApiResource(operations: [new Get(), new Put(), new Delete(), new Put(input: RecoverPasswordInput::class, output: RecoverPasswordOutput::class, uriTemplate: 'users/recover/{id}'), new Post(), new GetCollection(), new Post(uriTemplate: '/users/password_reset_request', messenger: 'input', input: PasswordResetRequest::class, output: PasswordResetRequestResult::class, normalizationContext: ['groups' => ['user_password_reset_request']], denormalizationContext: ['groups' => ['user_password_reset_request']])], normalizationContext: ['groups' => ['user', 'user-read']], denormalizationContext: ['groups' => ['user', 'user-write']])] +#[ApiResource(openapi: false, operations: [new Get(), new Put(), new Delete(), new Put(input: RecoverPasswordInput::class, output: RecoverPasswordOutput::class, uriTemplate: 'users/recover/{id}'), new Post(), new GetCollection(), new Post(uriTemplate: '/users/password_reset_request', messenger: 'input', input: PasswordResetRequest::class, output: PasswordResetRequestResult::class, normalizationContext: ['groups' => ['user_password_reset_request']], denormalizationContext: ['groups' => ['user_password_reset_request']])], normalizationContext: ['groups' => ['user', 'user-read']], denormalizationContext: ['groups' => ['user', 'user-write']])] #[ODM\Document(collection: 'user_test')] class User implements UserInterface, PasswordAuthenticatedUserInterface { diff --git a/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php b/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php index 17283e34de7..74494409068 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php +++ b/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php @@ -49,7 +49,7 @@ class BagOfTests #[Groups(['read', 'write'])] private Collection $nonResourceTests; - #[ORM\ManyToOne(targetEntity: TestEntity::class)] + #[ORM\ManyToOne(targetEntity: TestEntity::class, cascade: ['persist'])] #[ORM\JoinColumn(name: 'type', referencedColumnName: 'id', nullable: false)] #[Groups(['read', 'write'])] protected ?TestEntity $type = null; From b0b3fe40f19c5957ec9fe2fc580a7f01d1492439 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 May 2025 16:24:52 +0200 Subject: [PATCH 2/4] cs(metadata): nullable operator --- .../Property/Factory/SerializerPropertyMetadataFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index f79e6213961..0166806eb14 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -103,8 +103,8 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou } $serializerAttributeMetadata = $this->getSerializerAttributeMetadata($resourceClass, $propertyName); - $groups = $serializerAttributeMetadata ? $serializerAttributeMetadata->getGroups() : []; - $ignored = $serializerAttributeMetadata && $serializerAttributeMetadata->isIgnored(); + $groups = $serializerAttributeMetadata?->getGroups() ?? []; + $ignored = $serializerAttributeMetadata?->isIgnored() ?? false; if (false !== $propertyMetadata->isReadable()) { $propertyMetadata = $propertyMetadata->withReadable(!$ignored && (null === $normalizationGroups || array_intersect($normalizationGroups, $groups))); From 67ee6cea29d1a515ef203ce864b4e329b5e1d788 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 13 May 2025 16:36:41 +0200 Subject: [PATCH 3/4] fix(json-schema): share invariable sub-schemas --- features/openapi/docs.feature | 2 +- src/Hal/JsonSchema/SchemaFactory.php | 170 +++++++---- src/Hydra/JsonSchema/SchemaFactory.php | 289 ++++++++++++------ src/JsonApi/JsonSchema/SchemaFactory.php | 162 +++++++--- src/JsonSchema/DefinitionNameFactory.php | 10 +- .../Factory/SchemaPropertyMetadataFactory.php | 23 +- src/JsonSchema/ResourceMetadataTrait.php | 6 +- src/JsonSchema/Schema.php | 7 +- src/JsonSchema/SchemaFactory.php | 24 +- src/JsonSchema/SchemaFactoryInterface.php | 4 + src/JsonSchema/SchemaUriPrefixTrait.php | 28 ++ src/OpenApi/Factory/OpenApiFactory.php | 29 +- .../ApiPlatformExtension.php | 7 +- src/Symfony/Bundle/Resources/config/hal.xml | 2 + src/Symfony/Bundle/Resources/config/hydra.xml | 2 + .../Bundle/Resources/config/json_schema.xml | 6 +- ...ropertySchemaCollectionRestrictionTest.php | 2 +- ...opertySchemaGreaterThanRestrictionTest.php | 4 + .../PropertySchemaLessThanRestrictionTest.php | 4 + .../ValidatorPropertyMetadataFactoryTest.php | 17 +- .../PropertySchemaGreaterThanRestriction.php | 1 + .../PropertySchemaLessThanRestriction.php | 1 + .../Exception/ValidationException.php | 4 + tests/Behat/OpenApiContext.php | 13 +- .../JsonSchema/JsonApiJsonSchemaTest.php | 163 ++++++++++ .../JsonSchema/JsonLdJsonSchemaTest.php | 171 +++++++++++ .../Functional/JsonSchema/JsonSchemaTest.php | 184 +++++++++++ tests/Functional/OpenApiTest.php | 81 ++++- tests/Hal/JsonSchema/SchemaFactoryTest.php | 41 ++- .../Command/JsonSchemaGenerateCommandTest.php | 207 +------------ tests/OpenApi/Command/OpenApiCommandTest.php | 6 +- 31 files changed, 1200 insertions(+), 470 deletions(-) create mode 100644 src/JsonSchema/SchemaUriPrefixTrait.php create mode 100644 tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php create mode 100644 tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php create mode 100644 tests/Functional/JsonSchema/JsonSchemaTest.php diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index a1ac98752de..32ec1db1d04 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -80,7 +80,7 @@ Feature: Documentation support And the JSON node "paths./api/custom-call/{id}.put" should exist # Properties And the "id" property exists for the OpenAPI class "Dummy" - And the "name" property is required for the OpenAPI class "Dummy" + And the "name" property is required for the OpenAPI class "Dummy.jsonld" And the "genderType" property exists for the OpenAPI class "Person" And the "genderType" property for the OpenAPI class "Person" should be equal to: """ diff --git a/src/Hal/JsonSchema/SchemaFactory.php b/src/Hal/JsonSchema/SchemaFactory.php index 32433f3da55..8075bd40f81 100644 --- a/src/Hal/JsonSchema/SchemaFactory.php +++ b/src/Hal/JsonSchema/SchemaFactory.php @@ -13,10 +13,15 @@ namespace ApiPlatform\Hal\JsonSchema; +use ApiPlatform\JsonSchema\DefinitionNameFactory; +use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; +use ApiPlatform\JsonSchema\ResourceMetadataTrait; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; /** * Decorator factory which adds HAL properties to the JSON Schema document. @@ -26,6 +31,11 @@ */ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { + use ResourceMetadataTrait; + use SchemaUriPrefixTrait; + + private const COLLECTION_BASE_SCHEMA_NAME = 'HalCollectionBaseSchema'; + private const HREF_PROP = [ 'href' => [ 'type' => 'string', @@ -44,8 +54,12 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ], ]; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory) + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private ?DefinitionNameFactoryInterface $definitionNameFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null) { + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory(); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -56,79 +70,131 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto */ public function buildSchema(string $className, string $format = 'jsonhal', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema { - $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); if ('jsonhal' !== $format) { - return $schema; + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + } + + if (!$this->isResourceClass($className)) { + $operation = null; + $inputOrOutputClass = null; + $serializerContext ??= []; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); } + if (null === $inputOrOutputClass) { + // input or output disabled + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + } + + $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $serializerContext, $forceCollection); $definitions = $schema->getDefinitions(); - if ($key = $schema->getRootDefinitionKey()) { - $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + $definitionName = $this->definitionNameFactory->create($className, $format, $className, $operation, $serializerContext); + $prefix = $this->getSchemaUriPrefix($schema->getVersion()); + $collectionKey = $schema->getItemsDefinitionKey(); + + // Already computed + if (!$collectionKey && isset($definitions[$definitionName])) { + $schema['$ref'] = $prefix.$definitionName; return $schema; } - if ($key = $schema->getItemsDefinitionKey()) { - $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + + $key = $schema->getRootDefinitionKey() ?? $collectionKey; + + $definitions[$definitionName] = [ + 'allOf' => [ + ['type' => 'object', 'properties' => self::BASE_PROPS], + ['$ref' => $prefix.$key], + ], + ]; + + if (isset($definitions[$key]['description'])) { + $definitions[$definitionName]['description'] = $definitions[$key]['description']; + } + + if (!$collectionKey) { + $schema['$ref'] = $prefix.$definitionName; + + return $schema; } if (($schema['type'] ?? '') === 'array') { - $items = $schema['items']; - unset($schema['items']); - - $schema['type'] = 'object'; - $schema['properties'] = [ - '_embedded' => [ - 'anyOf' => [ - [ + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) { + $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + 'type' => 'object', + 'properties' => [ + '_embedded' => [ + 'anyOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + 'type' => 'array', + ], + ], + ], + ['type' => 'object'], + ], + ], + 'totalItems' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'itemsPerPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + '_links' => [ 'type' => 'object', 'properties' => [ - 'item' => [ - 'type' => 'array', - 'items' => $items, + 'self' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'first' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'last' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'next' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, + ], + 'previous' => [ + 'type' => 'object', + 'properties' => self::HREF_PROP, ], ], ], - ['type' => 'object'], ], - ], - 'totalItems' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - 'itemsPerPage' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - '_links' => [ + 'required' => ['_links', '_embedded'], + ]; + } + + unset($schema['items']); + unset($schema['type']); + + $schema['description'] = "$definitionName collection."; + $schema['allOf'] = [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + [ 'type' => 'object', 'properties' => [ - 'self' => [ - 'type' => 'object', - 'properties' => self::HREF_PROP, - ], - 'first' => [ - 'type' => 'object', - 'properties' => self::HREF_PROP, - ], - 'last' => [ - 'type' => 'object', - 'properties' => self::HREF_PROP, - ], - 'next' => [ - 'type' => 'object', - 'properties' => self::HREF_PROP, - ], - 'previous' => [ - 'type' => 'object', - 'properties' => self::HREF_PROP, + '_embedded' => [ + 'additionalProperties' => [ + 'type' => 'array', + 'items' => ['$ref' => $prefix.$definitionName], + ], ], ], ], ]; - $schema['required'] = [ - '_links', - '_embedded', - ]; return $schema; } diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index 515cf723a94..a03ad0c479c 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -15,10 +15,15 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; +use ApiPlatform\JsonSchema\DefinitionNameFactory; +use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; +use ApiPlatform\JsonSchema\ResourceMetadataTrait; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; /** * Decorator factory which adds Hydra properties to the JSON Schema document. @@ -28,39 +33,67 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use HydraPrefixTrait; + use ResourceMetadataTrait; + use SchemaUriPrefixTrait; + + private const ITEM_BASE_SCHEMA_NAME = 'HydraItemBaseSchema'; + private const ITEM_BASE_SCHEMA_OUTPUT_NAME = 'HydraOutputBaseSchema'; + private const COLLECTION_BASE_SCHEMA_NAME = 'HydraCollectionBaseSchema'; private const BASE_PROP = [ - 'readOnly' => true, 'type' => 'string', ]; private const BASE_PROPS = [ '@id' => self::BASE_PROP, '@type' => self::BASE_PROP, ]; - private const BASE_ROOT_PROPS = [ - '@context' => [ - 'readOnly' => true, - 'oneOf' => [ - ['type' => 'string'], - [ - 'type' => 'object', - 'properties' => [ - '@vocab' => [ - 'type' => 'string', - ], - 'hydra' => [ - 'type' => 'string', - 'enum' => [ContextBuilder::HYDRA_NS], + private const ITEM_BASE_SCHEMA = [ + 'type' => 'object', + 'properties' => [ + '@context' => [ + 'oneOf' => [ + ['type' => 'string'], + [ + 'type' => 'object', + 'properties' => [ + '@vocab' => [ + 'type' => 'string', + ], + 'hydra' => [ + 'type' => 'string', + 'enum' => [ContextBuilder::HYDRA_NS], + ], ], + 'required' => ['@vocab', 'hydra'], + 'additionalProperties' => true, ], - 'required' => ['@vocab', 'hydra'], - 'additionalProperties' => true, ], - ], + ] + self::BASE_PROPS, ], - ] + self::BASE_PROPS; + ]; + + private const ITEM_BASE_SCHEMA_OUTPUT = [ + 'required' => ['@id', '@type'], + ] + self::ITEM_BASE_SCHEMA; + + /** + * @var array + */ + private array $transformed = []; + + /** + * @param array $defaultContext + */ + public function __construct( + private readonly SchemaFactoryInterface $schemaFactory, + private readonly array $defaultContext = [], + private ?DefinitionNameFactoryInterface $definitionNameFactory = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ) { + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory(); + } + $this->resourceMetadataFactory = $resourceMetadataFactory; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly array $defaultContext = []) - { if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -71,30 +104,79 @@ public function __construct(private readonly SchemaFactoryInterface $schemaFacto */ public function buildSchema(string $className, string $format = 'jsonld', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema { - $schema = $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); - if ('jsonld' !== $format) { - return $schema; + if ('jsonld' !== $format || 'input' === $type) { + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + } + if (!$this->isResourceClass($className)) { + $operation = null; + $inputOrOutputClass = null; + $serializerContext ??= []; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); } - if ('input' === $type) { - return $schema; + if (null === $inputOrOutputClass) { + // input or output disabled + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); } + $definitionName = $this->definitionNameFactory->create($className, $format, $className, $operation, $serializerContext); + + // JSON-LD is slightly different then JSON:API or HAL + // All the references that are resources must also be in JSON-LD therefore combining + // the HydraItemBaseSchema and the JSON schema is harder (unless we loop again through all relationship) + // The less intensive path is to compute the jsonld schemas, then to combine in an allOf + $schema = $this->schemaFactory->buildSchema($className, 'jsonld', $type, $operation, $schema, $serializerContext, $forceCollection); $definitions = $schema->getDefinitions(); - if ($key = $schema->getRootDefinitionKey()) { - $definitions[$key]['properties'] = self::BASE_ROOT_PROPS + ($definitions[$key]['properties'] ?? []); + + $prefix = $this->getSchemaUriPrefix($schema->getVersion()); + $collectionKey = $schema->getItemsDefinitionKey(); + $key = $schema->getRootDefinitionKey() ?? $collectionKey; + $name = Schema::TYPE_OUTPUT === $type ? self::ITEM_BASE_SCHEMA_NAME : self::ITEM_BASE_SCHEMA_OUTPUT_NAME; + + if (!isset($definitions[$name])) { + $definitions[$name] = Schema::TYPE_OUTPUT === $type ? self::ITEM_BASE_SCHEMA_OUTPUT : self::ITEM_BASE_SCHEMA; + } + + if (isset($definitions[$key]['description'])) { + $definitions[$definitionName]['description'] = $definitions[$key]['description']; + } + + if (!$collectionKey) { + if ($this->transformed[$definitionName] ?? false) { + return $schema; + } + + $allOf = new \ArrayObject(['allOf' => [ + ['$ref' => $prefix.$name], + $definitions[$key], + ]]); + + if (isset($definitions[$key]['description'])) { + $allOf['description'] = $definitions[$key]['description']; + } + + $definitions[$definitionName] = $allOf; + + unset($definitions[$definitionName]['allOf'][1]['description']); + + $schema['$ref'] = $prefix.$definitionName; + + $this->transformed[$definitionName] = true; return $schema; } - if ($key = $schema->getItemsDefinitionKey()) { - $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + + // handle hydra:Collection + if (($schema['type'] ?? '') !== 'array') { + return $schema; } - if (($schema['type'] ?? '') === 'array') { - // hydra:collection - $items = $schema['items']; - unset($schema['items']); + $hydraPrefix = $this->getHydraPrefix($serializerContext + $this->defaultContext); + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) { switch ($schema->getVersion()) { // JSON Schema + OpenAPI 3.1 case Schema::VERSION_OPENAPI: @@ -107,81 +189,96 @@ public function buildSchema(string $className, string $format = 'jsonld', string break; } - $hydraPrefix = $this->getHydraPrefix(($serializerContext ?? []) + $this->defaultContext); - $schema['type'] = 'object'; - $schema['properties'] = [ - $hydraPrefix.'member' => [ - 'type' => 'array', - 'items' => $items, + $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + 'type' => 'object', + 'required' => [ + $hydraPrefix.'member', ], - $hydraPrefix.'totalItems' => [ - 'type' => 'integer', - 'minimum' => 0, - ], - $hydraPrefix.'view' => [ - 'type' => 'object', - 'properties' => [ - '@id' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - '@type' => [ - 'type' => 'string', - ], - $hydraPrefix.'first' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - $hydraPrefix.'last' => [ - 'type' => 'string', - 'format' => 'iri-reference', - ], - $hydraPrefix.'previous' => [ - 'type' => 'string', - 'format' => 'iri-reference', + 'properties' => [ + $hydraPrefix.'member' => [ + 'type' => 'array', + ], + $hydraPrefix.'totalItems' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + $hydraPrefix.'view' => [ + 'type' => 'object', + 'properties' => [ + '@id' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + '@type' => [ + 'type' => 'string', + ], + $hydraPrefix.'first' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + $hydraPrefix.'last' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + $hydraPrefix.'previous' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], + $hydraPrefix.'next' => [ + 'type' => 'string', + 'format' => 'iri-reference', + ], ], - $hydraPrefix.'next' => [ + 'example' => [ + '@id' => 'string', 'type' => 'string', - 'format' => 'iri-reference', + $hydraPrefix.'first' => 'string', + $hydraPrefix.'last' => 'string', + $hydraPrefix.'previous' => 'string', + $hydraPrefix.'next' => 'string', ], ], - 'example' => [ - '@id' => 'string', - 'type' => 'string', - $hydraPrefix.'first' => 'string', - $hydraPrefix.'last' => 'string', - $hydraPrefix.'previous' => 'string', - $hydraPrefix.'next' => 'string', - ], - ], - $hydraPrefix.'search' => [ - 'type' => 'object', - 'properties' => [ - '@type' => ['type' => 'string'], - $hydraPrefix.'template' => ['type' => 'string'], - $hydraPrefix.'variableRepresentation' => ['type' => 'string'], - $hydraPrefix.'mapping' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - '@type' => ['type' => 'string'], - 'variable' => ['type' => 'string'], - 'property' => $nullableStringDefinition, - 'required' => ['type' => 'boolean'], + $hydraPrefix.'search' => [ + 'type' => 'object', + 'properties' => [ + '@type' => ['type' => 'string'], + $hydraPrefix.'template' => ['type' => 'string'], + $hydraPrefix.'variableRepresentation' => ['type' => 'string'], + $hydraPrefix.'mapping' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + '@type' => ['type' => 'string'], + 'variable' => ['type' => 'string'], + 'property' => $nullableStringDefinition, + 'required' => ['type' => 'boolean'], + ], ], ], ], ], ], ]; - $schema['required'] = [ - $hydraPrefix.'member', - ]; - - return $schema; } + unset($schema['items']); + + $schema['type'] = 'object'; + $schema['description'] = "$definitionName collection."; + $schema['allOf'] = [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + [ + 'type' => 'object', + 'properties' => [ + $hydraPrefix.'member' => [ + 'type' => 'array', + 'items' => ['$ref' => $prefix.$definitionName], + ], + ], + ], + ]; + return $schema; } diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index 312e64704b6..a78325cd3f5 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -13,11 +13,13 @@ namespace ApiPlatform\JsonApi\JsonSchema; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\DefinitionNameFactoryInterface; use ApiPlatform\JsonSchema\ResourceMetadataTrait; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface; use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\JsonSchema\SchemaUriPrefixTrait; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -37,6 +39,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + use SchemaUriPrefixTrait; /** * As JSON:API recommends using [includes](https://jsonapi.org/format/#fetching-includes) instead of groups @@ -45,6 +48,8 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI */ public const DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS = 'disable_json_schema_serializer_groups'; + private const COLLECTION_BASE_SCHEMA_NAME = 'JsonApiCollectionBaseSchema'; + private const LINKS_PROPS = [ 'type' => 'object', 'properties' => [ @@ -119,8 +124,11 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ], ]; - public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(private readonly SchemaFactoryInterface $schemaFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { + if (!$definitionNameFactory) { + $this->definitionNameFactory = new DefinitionNameFactory(); + } if ($this->schemaFactory instanceof SchemaFactoryAwareInterface) { $this->schemaFactory->setSchemaFactory($this); } @@ -136,56 +144,110 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin if ('jsonapi' !== $format) { return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); } + + if (!$this->isResourceClass($className)) { + $operation = null; + $inputOrOutputClass = null; + $serializerContext ??= []; + } else { + $operation = $this->findOperation($className, $type, $operation, $serializerContext, $format); + $inputOrOutputClass = $this->findOutputClass($className, $type, $operation, $serializerContext); + $serializerContext ??= $this->getSerializerContext($operation, $type); + } + + if (null === $inputOrOutputClass) { + // input or output disabled + return $this->schemaFactory->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + } + // 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 - $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, + $jsonApiSerializerContext = $serializerContext; + if (false === ($serializerContext[self::DISABLE_JSON_SCHEMA_SERIALIZER_GROUPS] ?? true)) { + unset($jsonApiSerializerContext['groups']); + } + + $schema = $this->schemaFactory->buildSchema($className, 'json', $type, $operation, $schema, $jsonApiSerializerContext, $forceCollection); + $definitionName = $this->definitionNameFactory->create($className, $format, $className, $operation, $serializerContext); + $prefix = $this->getSchemaUriPrefix($schema->getVersion()); + $definitions = $schema->getDefinitions(); + $collectionKey = $schema->getItemsDefinitionKey(); + + // Already computed + if (!$collectionKey && isset($definitions[$definitionName])) { + $schema['$ref'] = $prefix.$definitionName; + + return $schema; + } + + $key = $schema->getRootDefinitionKey() ?? $collectionKey; + $properties = $definitions[$definitionName]['properties'] ?? []; + + // Prevent reapplying + if (isset($definitions[$key]['description'])) { + $definitions[$definitionName]['description'] = $definitions[$key]['description']; + } + + if (Error::class === $className && !isset($properties['errors'])) { + $definitions[$definitionName]['properties'] = [ + 'errors' => [ + 'type' => 'array', + 'items' => [ + 'allOf' => [ + ['$ref' => $prefix.$key], + ['type' => 'object', 'properties' => ['source' => ['type' => 'object'], 'status' => ['type' => 'string']]], + ], ], - ]; + ], + ]; - return $schema; - } + $schema['$ref'] = $prefix.$definitionName; - // Prevent reapplying - if (isset($properties['id'], $properties['type']) || isset($properties['data']) || isset($properties['errors'])) { - return $schema; - } + return $schema; + } - $definitions[$key]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + if (!$collectionKey) { + $definitions[$definitionName]['properties'] = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + $schema['$ref'] = $prefix.$definitionName; - if ($schema->getRootDefinitionKey()) { - return $schema; - } + return $schema; } if (($schema['type'] ?? '') === 'array') { - // data - $items = $schema['items']; - unset($schema['items']); + if (!isset($definitions[self::COLLECTION_BASE_SCHEMA_NAME])) { + $definitions[self::COLLECTION_BASE_SCHEMA_NAME] = [ + 'type' => 'object', + 'properties' => [ + 'links' => self::LINKS_PROPS, + 'meta' => self::META_PROPS, + 'data' => [ + 'type' => 'array', + ], + ], + 'required' => ['data'], + ]; + } - $schema['type'] = 'object'; - $schema['properties'] = [ - 'links' => self::LINKS_PROPS, - 'meta' => self::META_PROPS, - 'data' => [ - 'type' => 'array', - 'items' => $items, + unset($schema['items']); + unset($schema['type']); + + $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); + $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; + + $definitions[$definitionName] = [ + 'description' => "$definitionName collection.", + 'allOf' => [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + ['type' => 'object', 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => $properties['data'], + ], + ]], ], ]; - $schema['required'] = [ - 'data', - ]; + + $schema['$ref'] = $prefix.$definitionName; return $schema; } @@ -217,11 +279,11 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, continue; } - $operation = $this->findOperation($relatedClassName, $type, $operation, $serializerContext); + $operation = $this->findOperation($relatedClassName, $type, null, $serializerContext); $inputOrOutputClass = $this->findOutputClass($relatedClassName, $type, $operation, $serializerContext); $serializerContext ??= $this->getSerializerContext($operation, $type); $definitionName = $this->definitionNameFactory->create($relatedClassName, $format, $inputOrOutputClass, $operation, $serializerContext); - $ref = Schema::VERSION_OPENAPI === $schema->getVersion() ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; + $ref = $this->getSchemaUriPrefix($schema->getVersion()).$definitionName; $refs[$ref] = '$ref'; } $relatedDefinitions[$propertyName] = array_flip($refs); @@ -235,15 +297,18 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, ]; continue; } + if ('id' === $propertyName) { + // should probably be renamed "lid" and moved to the above node $attributes['_id'] = $property; continue; } $attributes[$propertyName] = $property; } + $currentRef = $this->getSchemaUriPrefix($schema->getVersion()).$schema->getRootDefinitionKey(); $replacement = self::PROPERTY_PROPS; - $replacement['attributes']['properties'] = $attributes; + $replacement['attributes'] = ['$ref' => $currentRef]; $included = []; if (\count($relationships) > 0) { @@ -267,15 +332,20 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, } if ($required = $definitions[$key]['required'] ?? null) { - foreach ($required as $require) { - if (isset($replacement['attributes']['properties'][$require])) { - $replacement['attributes']['required'][] = $require; - continue; - } + foreach ($required as $i => $require) { if (isset($relationships[$require])) { $replacement['relationships']['required'][] = $require; + unset($required[$i]); } } + + $replacement['attributes'] = [ + 'allOf' => [ + $replacement['attributes'], + ['type' => 'object', 'required' => $required], + ], + ]; + unset($definitions[$key]['required']); } diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 26d3a91852b..29838f2b3f0 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -24,8 +24,11 @@ final class DefinitionNameFactory implements DefinitionNameFactoryInterface private const GLUE = '.'; private array $prefixCache = []; - public function __construct(private ?array $distinctFormats) + public function __construct(private ?array $distinctFormats = null) { + if ($distinctFormats) { + trigger_deprecation('api-platform/json-schema', '4.2', 'The distinctFormats argument is deprecated and will be removed in 5.0.'); + } } public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string @@ -44,7 +47,10 @@ public function create(string $className, string $format = 'json', ?string $inpu $prefix .= self::GLUE.$shortName; } - if ('json' !== $format && ($this->distinctFormats[$format] ?? false)) { + // TODO: remove in 5.0 + $v = $this->distinctFormats ? ($this->distinctFormats[$format] ?? false) : true; + + if ('json' !== $format && $v) { // JSON is the default, and so isn't included in the definition name $prefix .= self::GLUE.$format; } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index d7ee5b847d8..163be408ff7 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -72,10 +72,6 @@ public function create(string $resourceClass, string $property, array $options = $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink(); $propertySchema = $propertyMetadata->getSchema() ?? []; - if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { - $propertySchema['readOnly'] = true; - } - if (!\array_key_exists('writeOnly', $propertySchema) && false === $propertyMetadata->isReadable()) { $propertySchema['writeOnly'] = true; } @@ -340,8 +336,13 @@ private function getClassSchemaDefinition(?string $className, ?bool $readableLin private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, ?bool $link): array { $types = $propertyMetadata->getBuiltinTypes() ?? []; + $className = ($types[0] ?? null)?->getClassName() ?? null; - if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!\count($types) || null === ($className = $types[0]->getClassName()) || !$this->isResourceClass($className))) { + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!$className || !$this->isResourceClass($className))) { if ($default instanceof \BackedEnum) { $default = $default->value; } @@ -365,6 +366,14 @@ private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $prope return $propertySchema; } + if ($propertyMetadata->getUriTemplate()) { + return $propertySchema + [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ]; + } + $valueSchema = []; foreach ($types as $type) { // Temp fix for https://github.com/symfony/symfony/pull/52699 @@ -455,6 +464,8 @@ private function legacyTypeToArray(LegacyType $type, ?bool $readableLink = null) * Note: if the class is not part of exceptions listed above, any class is considered as a resource. * * @throws PropertyNotFoundException + * + * @return array */ private function getLegacyClassType(?string $className, bool $nullable, ?bool $readableLink): array { @@ -521,6 +532,8 @@ private function getLegacyClassType(?string $className, bool $nullable, ?bool $r ]; } + // When this is set, we compute the schema at SchemaFactory::buildPropertySchema as it + // will end up being a $ref to another class schema, we don't have enough informations here return ['type' => Schema::UNKNOWN_TYPE]; } diff --git a/src/JsonSchema/ResourceMetadataTrait.php b/src/JsonSchema/ResourceMetadataTrait.php index 52d10e0b930..71988820c76 100644 --- a/src/JsonSchema/ResourceMetadataTrait.php +++ b/src/JsonSchema/ResourceMetadataTrait.php @@ -73,12 +73,12 @@ private function findOperation(string $className, string $type, ?Operation $oper private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation, ?string $format = null): Operation { + $lookForCollection = $operation instanceof CollectionOperationInterface; // Find the operation and use the first one that matches criterias foreach ($resourceMetadataCollection as $resourceMetadata) { foreach ($resourceMetadata->getOperations() ?? [] as $op) { - if ($operation instanceof CollectionOperationInterface && $op instanceof CollectionOperationInterface) { - $operation = $op; - break 2; + if (!$lookForCollection && $op instanceof CollectionOperationInterface) { + continue; } if (Schema::TYPE_INPUT === $type && \in_array($op->getMethod(), ['POST', 'PATCH', 'PUT'], true)) { diff --git a/src/JsonSchema/Schema.php b/src/JsonSchema/Schema.php index 9d7aa40a966..9b686022d59 100644 --- a/src/JsonSchema/Schema.php +++ b/src/JsonSchema/Schema.php @@ -30,6 +30,7 @@ final class Schema extends \ArrayObject public const VERSION_JSON_SCHEMA = 'json-schema'; public const VERSION_OPENAPI = 'openapi'; public const VERSION_SWAGGER = 'swagger'; + public const VERSION_LOCAL = 'local'; public const UNKNOWN_TYPE = 'unknown_type'; public function __construct(private readonly string $version = self::VERSION_JSON_SCHEMA) @@ -123,7 +124,11 @@ private function removeDefinitionKeyPrefix(string $definitionKey): string { // strlen('#/definitions/') = 14 // strlen('#/components/schemas/') = 21 - $prefix = self::VERSION_OPENAPI === $this->version ? 21 : 14; + $prefix = match ($this->version) { + self::VERSION_LOCAL => 9, + self::VERSION_OPENAPI => 21, + default => 14, + }; return substr($definitionKey, $prefix); } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 574ec26a6d2..3fa18dfb8d2 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -40,16 +40,16 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; + use SchemaUriPrefixTrait; private ?SchemaFactoryInterface $schemaFactory = null; // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object - public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private readonly ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if (!$definitionNameFactory) { - $this->definitionNameFactory = new DefinitionNameFactory($this->distinctFormats); + $this->definitionNameFactory = new DefinitionNameFactory($distinctFormats); } $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -92,7 +92,7 @@ public function buildSchema(string $className, string $format = 'json', string $ } if (!isset($schema['$ref']) && !isset($schema['type'])) { - $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; + $ref = $this->getSchemaUriPrefix($version).$definitionName; if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { $schema['type'] = 'array'; $schema['items'] = ['$ref' => $ref]; @@ -110,7 +110,9 @@ public function buildSchema(string $className, string $format = 'json', string $ /** @var \ArrayObject $definition */ $definition = new \ArrayObject(['type' => 'object']); $definitions[$definitionName] = $definition; - $definition['description'] = $operation ? ($operation->getDescription() ?? '') : ''; + if ($description = $operation?->getDescription()) { + $definition['description'] = $description; + } // additionalProperties are allowed by default, so it does not need to be set explicitly, unless allow_extra_attributes is false // See https://json-schema.org/understanding-json-schema/reference/object.html#properties @@ -121,8 +123,6 @@ public function buildSchema(string $className, string $format = 'json', string $ // see https://github.com/json-schema-org/json-schema-spec/pull/737 if (Schema::VERSION_SWAGGER !== $version && $operation && $operation->getDeprecationReason()) { $definition['deprecated'] = true; - } else { - $definition['deprecated'] = false; } // externalDocs is an OpenAPI specific extension, but JSON Schema allows additional keys, so we always add it @@ -134,6 +134,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null); foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options); + if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) { continue; } @@ -195,6 +196,7 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); + // Scalar properties if ( !$isUnknown && ( [] === $types @@ -203,6 +205,10 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) ) ) { + if (isset($propertySchema['$ref'])) { + unset($propertySchema['type']); + } + $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); return; @@ -259,7 +265,8 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam $refs[] = ['type' => 'null']; } - if (($c = \count($refs)) > 1) { + $c = \count($refs); + if ($c > 1) { $propertySchema['anyOf'] = $refs; unset($propertySchema['type']); } elseif (1 === $c) { @@ -320,6 +327,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // complete property schema with resource reference ($ref) if it's related to an object/resource $refs = []; $isNullable = $type?->isNullable() ?? false; + $hasClassName = false; if ($type) { foreach ($type instanceof CompositeTypeInterface ? $type->getTypes() : [$type] as $t) { diff --git a/src/JsonSchema/SchemaFactoryInterface.php b/src/JsonSchema/SchemaFactoryInterface.php index ec992908a50..f495b36c7e4 100644 --- a/src/JsonSchema/SchemaFactoryInterface.php +++ b/src/JsonSchema/SchemaFactoryInterface.php @@ -22,6 +22,10 @@ */ interface SchemaFactoryInterface { + public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; + public const SUBSCHEMA_FORMAT = '_api_subschema_format'; + public const COMPUTE_REFERENCES = '_api_subschema_compute_references'; + /** * Builds the JSON Schema document corresponding to the given PHP class. */ diff --git a/src/JsonSchema/SchemaUriPrefixTrait.php b/src/JsonSchema/SchemaUriPrefixTrait.php new file mode 100644 index 00000000000..de5efd96c10 --- /dev/null +++ b/src/JsonSchema/SchemaUriPrefixTrait.php @@ -0,0 +1,28 @@ + + * + * 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\JsonSchema; + +/** + * @internal + */ +trait SchemaUriPrefixTrait +{ + public function getSchemaUriPrefix(string $version): string + { + return match ($version) { + Schema::VERSION_OPENAPI => '#/components/schemas/', + default => '#/definitions/', + }; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 43894a6b347..e5fa4c1f27f 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -242,14 +242,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], requestBody: $openapiOperation->getRequestBody(), callbacks: $openapiOperation->getCallbacks(), - deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(), + deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : ($operation->getDeprecationReason() ? true : null), security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null, servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null, extensionProperties: $openapiOperation->getExtensionProperties(), ); foreach ($openapiOperation->getTags() as $v) { - $tags[$v] = new Tag(name: $v, description: $resource->getDescription()); + $tags[$v] = new Tag(name: $v, description: $resource->getDescription() ?? "Resource '$v' operations."); } [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); @@ -267,9 +267,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationOutputSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $operationOutputSchema = null; + if (str_starts_with($operationFormat, 'json')) { + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); + } + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; - $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); } // Set up parameters @@ -454,9 +458,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if (null === $content) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchema = null; + if (str_starts_with($operationFormat, 'json')) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + } + $operationInputSchemas[$operationFormat] = $operationInputSchema; - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); } @@ -517,7 +525,7 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { - $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + $content[$mimeType] = isset($operationSchemas[$format]) ? new MediaType(schema: new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))) : new \ArrayObject(); } return $content; @@ -980,9 +988,12 @@ private function addOperationErrors( $operationErrorSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchema = null; + if (str_starts_with($operationFormat, 'json')) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } $operationErrorSchemas[$operationFormat] = $operationErrorSchema; - $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); } if (!$status = $errorResource->getStatus()) { diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 71b2c16122b..2b88aa9099b 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -113,6 +113,7 @@ public function load(array $configs, ContainerBuilder $container): void $patchFormats = $this->getFormats($config['patch_formats']); $errorFormats = $this->getFormats($config['error_formats']); $docsFormats = $this->getFormats($config['docs_formats']); + if (!$config['enable_docs']) { // JSON-LD documentation format is mandatory, even if documentation is disabled. $docsFormats = isset($formats['jsonld']) ? ['jsonld' => ['application/ld+json']] : []; @@ -144,7 +145,7 @@ public function load(array $configs, ContainerBuilder $container): void $patchFormats['jsonapi'] = ['application/vnd.api+json']; } - $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats, $jsonSchemaFormats); + $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats, $docsFormats); $this->registerMetadataConfiguration($container, $config, $loader); $this->registerOAuthConfiguration($container, $config); $this->registerOpenApiConfiguration($container, $config, $loader); @@ -189,7 +190,7 @@ public function load(array $configs, ContainerBuilder $container): void } } - private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats, array $jsonSchemaFormats): void + private function registerCommonConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader, array $formats, array $patchFormats, array $errorFormats, array $docsFormats): void { $loader->load('state/state.xml'); $loader->load('symfony/symfony.xml'); @@ -230,7 +231,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); - $container->setParameter('api_platform.jsonschema_formats', $jsonSchemaFormats); + $container->setParameter('api_platform.jsonschema_formats', []); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); $container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']); diff --git a/src/Symfony/Bundle/Resources/config/hal.xml b/src/Symfony/Bundle/Resources/config/hal.xml index df615a9d8fc..7ca544aea59 100644 --- a/src/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Symfony/Bundle/Resources/config/hal.xml @@ -9,6 +9,8 @@ + + diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 6fb156141ab..b4c7de74ca1 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -8,6 +8,8 @@ %api_platform.serializer.default_context% + + diff --git a/src/Symfony/Bundle/Resources/config/json_schema.xml b/src/Symfony/Bundle/Resources/config/json_schema.xml index aa2ba09cc50..d905b14c7cb 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.xml +++ b/src/Symfony/Bundle/Resources/config/json_schema.xml @@ -13,7 +13,7 @@ - %api_platform.jsonschema_formats% + @@ -33,9 +33,7 @@ - - %api_platform.jsonschema_formats% - + diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php index ebeeae39211..3aad08f4627 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaCollectionRestrictionTest.php @@ -104,7 +104,7 @@ public static function createProvider(): \Generator 'name' => new \ArrayObject(), 'email' => ['minLength' => 2, 'maxLength' => 255, 'format' => 'email'], 'phone' => ['pattern' => '^([+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], - 'age' => ['exclusiveMinimum' => 0], + 'age' => ['exclusiveMinimum' => 0, 'minimum' => 0], 'social' => ['type' => 'object', 'properties' => new \ArrayObject(['githubUsername' => new \ArrayObject()]), 'additionalProperties' => false, 'required' => ['githubUsername']], ]); $required = ['name', 'email', 'social']; diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index 6a83464feab..0bf23802096 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -79,6 +79,7 @@ public function testCreate(): void { self::assertEquals([ 'exclusiveMinimum' => 10, + 'minimum' => 10 ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); } @@ -86,14 +87,17 @@ public function testCreateWithNativeType(): void { self::assertEquals([ 'exclusiveMinimum' => 10, + 'minimum' => 10 ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMinimum' => 0, + 'minimum' => 0 ], $this->propertySchemaGreaterThanRestriction->create(new Positive(), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMinimum' => 10.99, + 'minimum' => 10.99 ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10.99]), (new ApiProperty())->withNativeType(Type::float()))); } } diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index b636079f869..371c936b0d0 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -79,6 +79,7 @@ public function testCreate(): void { self::assertEquals([ 'exclusiveMaximum' => 10, + 'maximum' => 10 ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); } @@ -86,14 +87,17 @@ public function testCreateWithNativeType(): void { self::assertEquals([ 'exclusiveMaximum' => 10, + 'maximum' => 10 ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMaximum' => 0, + 'maximum' => 0 ], $this->propertySchemaLessThanRestriction->create(new Negative(), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMaximum' => 10.99, + 'maximum' => 10.99 ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10.99]), (new ApiProperty())->withNativeType(Type::float()))); } } diff --git a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 8f1cb99bced..8cdb7925046 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -712,6 +712,7 @@ public function testCreateWithPropertyCollectionRestriction(): void 'phone' => ['pattern' => '^([+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], 'age' => [ 'exclusiveMinimum' => 0, + 'minimum' => 0 ], 'social' => [ 'type' => 'object', @@ -766,7 +767,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), 'property' => 'greaterThanMe', - 'expectedSchema' => ['exclusiveMinimum' => 10], + 'expectedSchema' => ['exclusiveMinimum' => 10, 'minimum' => 10], ]; yield [ @@ -778,7 +779,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), 'property' => 'lessThanMe', - 'expectedSchema' => ['exclusiveMaximum' => 99], + 'expectedSchema' => ['exclusiveMaximum' => 99, 'maximum' => 99], ]; yield [ @@ -790,7 +791,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), 'property' => 'positive', - 'expectedSchema' => ['exclusiveMinimum' => 0], + 'expectedSchema' => ['exclusiveMinimum' => 0, 'minimum' => 0], ]; yield [ @@ -802,7 +803,7 @@ public static function provideNumericConstraintCases(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), 'property' => 'negative', - 'expectedSchema' => ['exclusiveMaximum' => 0], + 'expectedSchema' => ['exclusiveMaximum' => 0, 'maximum' => 0], ]; yield [ @@ -849,7 +850,7 @@ public static function provideNumericConstraintCasesWithNativeType(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withNativeType(Type::int()), 'property' => 'greaterThanMe', - 'expectedSchema' => ['exclusiveMinimum' => 10], + 'expectedSchema' => ['exclusiveMinimum' => 10, 'minimum' => 10], ]; yield [ @@ -861,7 +862,7 @@ public static function provideNumericConstraintCasesWithNativeType(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withNativeType(Type::int()), 'property' => 'lessThanMe', - 'expectedSchema' => ['exclusiveMaximum' => 99], + 'expectedSchema' => ['exclusiveMaximum' => 99, 'maximum' => 99], ]; yield [ @@ -873,7 +874,7 @@ public static function provideNumericConstraintCasesWithNativeType(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withNativeType(Type::int()), 'property' => 'positive', - 'expectedSchema' => ['exclusiveMinimum' => 0], + 'expectedSchema' => ['exclusiveMinimum' => 0, 'minimum' => 0], ]; yield [ @@ -885,7 +886,7 @@ public static function provideNumericConstraintCasesWithNativeType(): \Generator yield [ 'propertyMetadata' => (new ApiProperty())->withNativeType(Type::int()), 'property' => 'negative', - 'expectedSchema' => ['exclusiveMaximum' => 0], + 'expectedSchema' => ['exclusiveMaximum' => 0, 'maximum' => 0], ]; yield [ diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index 97918da97e3..53296c035e0 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -34,6 +34,7 @@ final class PropertySchemaGreaterThanRestriction implements PropertySchemaRestri public function create(Constraint $constraint, ApiProperty $propertyMetadata): array { return [ + 'minimum' => $constraint->value, 'exclusiveMinimum' => $constraint->value, ]; } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index f19648a8620..3ea103a8f3d 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -34,6 +34,7 @@ final class PropertySchemaLessThanRestriction implements PropertySchemaRestricti public function create(Constraint $constraint, ApiProperty $propertyMetadata): array { return [ + 'maximum' => $constraint->value, 'exclusiveMaximum' => $constraint->value, ]; } diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index b80297ca083..558179b5520 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Validator\Exception; +use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\ErrorResource; @@ -46,6 +47,7 @@ name: '_api_validation_errors_problem', outputFormats: ['json' => ['application/problem+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['json'], 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], 'skip_null_values' => true, @@ -56,6 +58,7 @@ 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: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonld'], 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], 'skip_null_values' => true, @@ -65,6 +68,7 @@ name: '_api_validation_errors_jsonapi', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'disable_json_schema_serializer_groups' => false, 'groups' => ['jsonapi'], 'skip_null_values' => true, diff --git a/tests/Behat/OpenApiContext.php b/tests/Behat/OpenApiContext.php index 7b3fbf73ed4..0d60eb407a5 100644 --- a/tests/Behat/OpenApiContext.php +++ b/tests/Behat/OpenApiContext.php @@ -97,7 +97,18 @@ public function assertThePropertyExistForTheOpenApiClass(string $propertyName, s */ public function assertThePropertyIsRequiredForTheOpenAPIClass(string $propertyName, string $className): void { - if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) { + $ok = false; + $schema = $this->getClassInfo($className); + if (isset($schema->allOf)) { + foreach ($schema->allOf as $schema) { + if (isset($schema->required) && \in_array($propertyName, $schema->required, true)) { + $ok = true; + break; + } + } + } + + if (!$ok) { throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" should be required', $propertyName, $className)); } } diff --git a/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php b/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php new file mode 100644 index 00000000000..f598471ec75 --- /dev/null +++ b/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php @@ -0,0 +1,163 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Animal; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AnimalObservation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6317\Issue6317; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Species; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class JsonApiJsonSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected SchemaFactoryInterface $schemaFactory; + protected static ?bool $alwaysBootKernel = false; + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + AnimalObservation::class, + Animal::class, + Species::class, + Question::class, + Answer::class, + Issue6317::class, + ]; + } + + public function testJsonApi(): void + { + $speciesSchema = $this->schemaFactory->buildSchema(Issue6317::class, 'jsonapi', Schema::TYPE_OUTPUT); + $this->assertEquals('#/definitions/Issue6317.jsonapi', $speciesSchema['$ref']); + $this->assertEquals([ + 'properties' => [ + 'data' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + '$ref' => '#/definitions/Issue6317', + ], + ], + 'required' => [ + 'type', + 'id', + ], + ], + ], + ], $speciesSchema['definitions']['Issue6317.jsonapi']); + } + + public function testJsonApiIncludesSchemaForQuestion(): void + { + $questionSchema = $this->schemaFactory->buildSchema(Question::class, 'jsonapi', Schema::TYPE_OUTPUT); + $json = $questionSchema->getDefinitions(); + $properties = $json['Question.jsonapi']['properties']['data']['properties']; + $included = $json['Question.jsonapi']['properties']['included']; + + $this->assertArrayHasKey('answer', $properties['relationships']['properties']); + $this->assertArrayHasKey('anyOf', $included['items']); + $this->assertCount(1, $included['items']['anyOf']); + $this->assertArrayHasKey('$ref', $included['items']['anyOf'][0]); + $this->assertSame('#/definitions/Answer.jsonapi', $included['items']['anyOf'][0]['$ref']); + } + + public function testJsonApiIncludesSchemaForAnimalObservation(): void + { + $animalObservationSchema = $this->schemaFactory->buildSchema(AnimalObservation::class, 'jsonapi', Schema::TYPE_OUTPUT); + $json = $animalObservationSchema->getDefinitions(); + $properties = $json['AnimalObservation.jsonapi']['properties']['data']['properties']; + $included = $json['AnimalObservation.jsonapi']['properties']['included']; + + $this->assertArrayHasKey('individuals', $properties['relationships']['properties']); + $this->assertArrayHasKey('anyOf', $included['items']); + $this->assertCount(1, $included['items']['anyOf']); + $this->assertSame('#/definitions/Animal.jsonapi', $included['items']['anyOf'][0]['$ref']); + } + + public function testJsonApiIncludesSchemaForAnimal(): void + { + $animalSchema = $this->schemaFactory->buildSchema(Animal::class, 'jsonapi', Schema::TYPE_OUTPUT); + $json = $animalSchema->getDefinitions(); + $properties = $json['Animal.jsonapi']['properties']['data']['properties']; + $included = $json['Animal.jsonapi']['properties']['included']; + + $this->assertArrayHasKey('species', $properties['relationships']['properties']); + $this->assertArrayHasKey('anyOf', $included['items']); + $this->assertCount(1, $included['items']['anyOf']); + $this->assertSame('#/definitions/Species.jsonapi', $included['items']['anyOf'][0]['$ref']); + } + + public function testJsonApiIncludesSchemaForSpecies(): void + { + $speciesSchema = $this->schemaFactory->buildSchema(Species::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $this->assertEquals('#/definitions/Species.jsonapi', $speciesSchema['$ref']); + $this->assertEquals( + [ + 'description' => 'Species.jsonapi collection.', + 'allOf' => [ + ['$ref' => '#/definitions/JsonApiCollectionBaseSchema'], + [ + 'type' => 'object', + 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + ], + 'type' => [ + 'type' => 'string', + ], + 'attributes' => [ + '$ref' => '#/definitions/Species', + ], + ], + 'required' => [ + 'type', + 'id', + ], + ], + ], + ], + ], + ], + ], + $speciesSchema->getDefinitions()['Species.jsonapi'] + ); + } +} diff --git a/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php new file mode 100644 index 00000000000..9fe737e8e72 --- /dev/null +++ b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php @@ -0,0 +1,171 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6212\Nest; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class JsonLdJsonSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected SchemaFactoryInterface $schemaFactory; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [RelatedDummy::class, ThirdLevel::class, RelatedToDummyFriend::class]; + } + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + } + + public function testSubSchemaJsonLd(): void + { + $schema = $this->schemaFactory->buildSchema(BagOfTests::class, 'jsonld'); + + $expectedBagOfTestsSchema = new \ArrayObject([ + 'allOf' => [ + [ + '$ref' => '#/definitions/HydraItemBaseSchema', + ], + new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + ]), + 'description' => new \ArrayObject([ + 'maxLength' => 255, + ]), + 'tests' => new \ArrayObject([ + 'type' => 'string', + 'foo' => 'bar', + ]), + 'nonResourceTests' => new \ArrayObject([ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/NonResourceTestEntity.jsonld-read', + ], + ]), + 'type' => new \ArrayObject([ + '$ref' => '#/definitions/TestEntity.jsonld-read', + ]), + ], + ]), + ], + ]); + + $expectedNonResourceTestEntitySchema = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + ]), + 'nullableString' => new \ArrayObject([ + 'type' => [ + 'string', + 'null', + ], + ]), + 'nullableInt' => new \ArrayObject([ + 'type' => [ + 'integer', + 'null', + ], + ]), + ], + ]); + + $expectedTestEntitySchema = new \ArrayObject([ + 'allOf' => [ + [ + '$ref' => '#/definitions/HydraItemBaseSchema', + ], + new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + ]), + 'nullableString' => new \ArrayObject([ + 'type' => [ + 'string', + 'null', + ], + ]), + 'nullableInt' => new \ArrayObject([ + 'type' => [ + 'integer', + 'null', + ], + ]), + ], + ]), + ], + ]); + + $this->assertArrayHasKey('definitions', $schema); + $this->assertArrayHasKey('BagOfTests.jsonld-read', $schema['definitions']); + $this->assertArrayHasKey('NonResourceTestEntity.jsonld-read', $schema['definitions']); + $this->assertArrayHasKey('TestEntity.jsonld-read', $schema['definitions']); + + $this->assertEquals($expectedBagOfTestsSchema, $schema['definitions']['BagOfTests.jsonld-read']); + $this->assertEquals($expectedNonResourceTestEntitySchema, $schema['definitions']['NonResourceTestEntity.jsonld-read']); + $this->assertEquals($expectedTestEntitySchema, $schema['definitions']['TestEntity.jsonld-read']); + + $this->assertEquals('#/definitions/BagOfTests.jsonld-read', $schema['$ref']); + } + + public function testSchemaJsonLdCollection(): void + { + $schema = $this->schemaFactory->buildSchema(BagOfTests::class, 'jsonld', forceCollection: true); + + $this->assertArrayHasKey('definitions', $schema); + $this->assertArrayHasKey('BagOfTests.jsonld-read', $schema['definitions']); + $this->assertArrayHasKey('NonResourceTestEntity.jsonld-read', $schema['definitions']); + $this->assertArrayHasKey('TestEntity.jsonld-read', $schema['definitions']); + $this->assertArrayHasKey('HydraItemBaseSchema', $schema['definitions']); + $this->assertArrayHasKey('HydraCollectionBaseSchema', $schema['definitions']); + + $this->assertEquals(['$ref' => '#/definitions/HydraCollectionBaseSchema'], $schema['allOf'][0]); + $this->assertEquals(['$ref' => '#/definitions/BagOfTests.jsonld-read'], $schema['allOf'][1]['properties']['hydra:member']['items']); + } + + public function testArraySchemaWithMultipleUnionTypes(): void + { + $schema = $this->schemaFactory->buildSchema(Nest::class, 'jsonld', 'output'); + + $this->assertEquals($schema['definitions']['Nest.jsonld']['allOf'][1]['properties']['owner']['anyOf'], [ + ['$ref' => '#/definitions/Robin.jsonld'], + ['$ref' => '#/definitions/Wren.jsonld'], + ['type' => 'null'], + ]); + + $this->assertArrayHasKey('Nest.jsonld', $schema['definitions']); + } +} diff --git a/tests/Functional/JsonSchema/JsonSchemaTest.php b/tests/Functional/JsonSchema/JsonSchemaTest.php new file mode 100644 index 00000000000..5f303bd8600 --- /dev/null +++ b/tests/Functional/JsonSchema/JsonSchemaTest.php @@ -0,0 +1,184 @@ + + * + * 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\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\Related; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithEnumProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\TestEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6212\Nest; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use JsonSchema\Validator; +use PHPUnit\Framework\Attributes\DataProvider; + +class JsonSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected SchemaFactoryInterface $schemaFactory; + protected static ?bool $alwaysBootKernel = false; + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + BagOfTests::class, + TestEntity::class, + BrokenDocs::class, + Related::class, + Nest::class, + ResourceWithEnumProperty::class, + ]; + } + + #[DataProvider('getInvalidSchemas')] + public function testSchemaIsNotValid(string $json, array $args): void + { + $schema = $this->schemaFactory->buildSchema(...$args); + $validator = new Validator(); + $json = json_decode($json, null, 512, \JSON_THROW_ON_ERROR); + $validator->validate($json, $schema->getArrayCopy()); + $this->assertFalse($validator->isValid()); + } + + /** + * @return array + */ + public static function getInvalidSchemas(): array + { + return [ + 'json-ld' => [ + '{"@context":"/contexts/BagOfTests","@id":"/bag_of_tests/1","@type":"BagOfTests","id":1,"description":"string","tests":"a string","nonResourceTests":[{"id":1,"nullableString":"string","nullableInt":0}],"type":{"@type":"TestEntity","id":1,"nullableString":"string","nullableInt":0}}', + [BagOfTests::class, 'jsonld'], + ], + ]; + } + + #[DataProvider('getSchemas')] + public function testSchemaIsValid(string $json, array $args): void + { + $schema = $this->schemaFactory->buildSchema(...$args); + $validator = new Validator(); + $json = json_decode($json, null, 512, \JSON_THROW_ON_ERROR); + $validator->validate($json, $schema->getArrayCopy()); + $this->assertTrue($validator->isValid()); + } + + /** + * @return array + */ + public static function getSchemas(): array + { + return [ + 'json-ld' => [ + '{"@context":"/contexts/BagOfTests","@id":"/bag_of_tests/1","@type":"BagOfTests","id":1,"description":"string","tests":"a string","nonResourceTests":[{"id":1,"nullableString":"string","nullableInt":0}],"type":{"@id":"/test_entities/1","@type":"TestEntity","id":1,"nullableString":"string","nullableInt":0}}', + [BagOfTests::class, 'jsonld'], + ], + 'json-ld Collection' => [ + '{"@context":"/contexts/BagOfTests","@id":"/bag_of_tests","@type":"hydra:Collection","hydra:totalItems":1,"hydra:member":[{"@id":"/bag_of_tests/1","@type":"BagOfTests","id":1,"description":"string","nonResourceTests":[],"type":{"@id":"/test_entities/1","@type":"TestEntity","id":1,"nullableString":"string","nullableInt":0}}]}', + [BagOfTests::class, 'jsonld', 'forceCollection' => true], + ], + ]; + } + + /** + * Test issue #5501, the locations relation inside BrokenDocs is a Resource (named Related) but its only operation is a NotExposed. + * Still, serializer groups are set, and therefore it is a "readableLink" so we actually want to compute the schema, even if it's not accessible + * directly, it is accessible through that relation. + */ + public function testExecuteWithNotExposedResourceAndReadableLink(): void + { + $schema = $this->schemaFactory->buildSchema(BrokenDocs::class, 'jsonld'); + + $this->assertArrayHasKey('Related.jsonld-location.read_collection', $schema['definitions']); + } + + public function testArraySchemaWithReference(): void + { + $schema = $this->schemaFactory->buildSchema(BagOfTests::class, 'jsonld', Schema::TYPE_INPUT); + + $this->assertEquals($schema['definitions']['BagOfTests.jsonld-write']['properties']['tests'], new \ArrayObject([ + 'type' => 'string', + 'foo' => 'bar', + ])); + + $this->assertEquals($schema['definitions']['BagOfTests.jsonld-write']['properties']['nonResourceTests'], new \ArrayObject([ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/NonResourceTestEntity.jsonld-write', + ], + ])); + + $this->assertEquals($schema['definitions']['BagOfTests.jsonld-write']['properties']['description'], new \ArrayObject([ + 'maxLength' => 255, + ])); + + $this->assertEquals($schema['definitions']['BagOfTests.jsonld-write']['properties']['type'], new \ArrayObject([ + '$ref' => '#/definitions/TestEntity.jsonld-write', + ])); + } + + public function testResourceWithEnumPropertiesSchema(): void + { + $json = $this->schemaFactory->buildSchema(ResourceWithEnumProperty::class, 'jsonld', Schema::TYPE_OUTPUT); + $properties = $json['definitions']['ResourceWithEnumProperty.jsonld']['allOf'][1]['properties']; + + $this->assertEquals( + new \ArrayObject([ + 'type' => ['integer', 'null'], + 'enum' => [1, 2, 3, null], + ]), + $properties['intEnum'] + ); + $this->assertEquals( + new \ArrayObject([ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['yes', 'no', 'maybe'], + ], + ]), + $properties['stringEnum'] + ); + $this->assertEquals( + new \ArrayObject([ + 'type' => ['string', 'null'], + 'enum' => ['male', 'female', null], + ]), + $properties['gender'] + ); + $this->assertEquals( + new \ArrayObject([ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['male', 'female'], + ], + ]), + $properties['genders'] + ); + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index aaf5fcff365..4e68edf8d51 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -63,10 +63,66 @@ public function testErrorsAreDocumented(): void } foreach (['title', 'detail', 'instance', 'type', 'status', '@id', '@type', '@context'] as $key) { - $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonld']['properties']); + $this->assertSame(['allOf' => [ + ['$ref' => '#/components/schemas/HydraItemBaseSchema'], + [ + 'type' => 'object', + 'properties' => [ + 'title' => [ + 'description' => 'A short, human-readable summary of the problem.', + 'type' => [ + 0 => 'string', + 1 => 'null', + ], + ], + 'detail' => [ + 'description' => 'A human-readable explanation specific to this occurrence of the problem.', + 'type' => [ + 0 => 'string', + 1 => 'null', + ], + ], + 'status' => [ + 'type' => 'number', + 'examples' => [ + 0 => 404, + ], + 'default' => 400, + ], + 'instance' => [ + 'description' => 'A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.', + 'type' => [ + 0 => 'string', + 1 => 'null', + ], + ], + 'type' => [ + 'description' => 'A URI reference that identifies the problem type', + 'type' => 'string', + ], + 'description' => [ + 'type' => [ + 0 => 'string', + 1 => 'null', + ], + ], + ], + ], + ], 'description' => 'A representation of common errors.'], $res['components']['schemas']['Error.jsonld']); } + foreach (['id', 'title', 'detail', 'instance', 'type', 'status', 'meta', 'source'] as $key) { - $this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonapi']['properties']['errors']['properties']); + $this->assertSame(['allOf' => [ + ['$ref' => '#/components/schemas/Error'], + ['type' => 'object', 'properties' => [ + 'source' => [ + 'type' => 'object', + ], + 'status' => [ + 'type' => 'string', + ], + ]], + ]], $res['components']['schemas']['Error.jsonapi']['properties']['errors']['items']); } } @@ -98,4 +154,25 @@ public function testFilterExtensionTags(): void $this->assertArrayHasKey('/crud_open_api_api_platform_tags/{id}', $res['paths']); $this->assertEquals([['name' => 'Crud', 'description' => 'A resource used for OpenAPI tests.'], ['name' => 'CrudOpenApiApiPlatformTag', 'description' => 'Something nice']], $res['tags']); } + + public function testHasSchemasForMultipleFormats(): void + { + $response = self::createClient()->request('GET', '/docs?filter_tags[]=internal', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $res = $response->toArray(); + $this->assertArrayHasKey('Crud.jsonld', $res['components']['schemas']); + $this->assertSame(['allOf' => [ + ['$ref' => '#/components/schemas/HydraItemBaseSchema'], + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + ], + ], + ], + ], 'description' => 'A resource used for OpenAPI tests.'], $res['components']['schemas']['Crud.jsonld']); + } } diff --git a/tests/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Hal/JsonSchema/SchemaFactoryTest.php index 8868ab3d0cf..0fc749a8591 100644 --- a/tests/Hal/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hal/JsonSchema/SchemaFactoryTest.php @@ -50,7 +50,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -87,8 +87,8 @@ public function testHasRootDefinitionKeyBuildSchema(): void $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties'])); - $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + $this->assertTrue(isset($definitions[$rootDefinitionKey]['allOf'][0]['properties'])); + $properties = $resultSchema['definitions'][$rootDefinitionKey]['allOf'][0]['properties']; $this->assertArrayHasKey('_links', $properties); $this->assertEquals( [ @@ -109,29 +109,26 @@ public function testHasRootDefinitionKeyBuildSchema(): void ); } - public function testSchemaTypeBuildSchema(): void + public function testCollection(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonhal', Schema::TYPE_OUTPUT, new GetCollection()); - $definitionName = 'Dummy.jsonhal'; - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('_embedded', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']); - $this->assertArrayHasKey('_links', $resultSchema['properties']); - $properties = $resultSchema['definitions'][$definitionName]['properties']; - $this->assertArrayHasKey('_links', $properties); - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonhal', Schema::TYPE_OUTPUT, null, null, null, true); + $this->assertTrue(isset($resultSchema['definitions']['Dummy.jsonhal'])); + $this->assertTrue(isset($resultSchema['definitions']['HalCollectionBaseSchema'])); + $this->assertTrue(isset($resultSchema['definitions']['Dummy.jsonhal'])); - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('_embedded', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']); - $this->assertArrayHasKey('_links', $resultSchema['properties']); - $properties = $resultSchema['definitions'][$definitionName]['properties']; - $this->assertArrayHasKey('_links', $properties); + foreach ($resultSchema['allOf'] as $schema) { + if (isset($schema['$ref'])) { + $this->assertEquals($schema['$ref'], '#/definitions/HalCollectionBaseSchema'); + continue; + } + + $this->assertArrayHasKey('_embedded', $schema['properties']); + $this->assertEquals('#/definitions/Dummy.jsonhal', $schema['properties']['_embedded']['additionalProperties']['items']['$ref']); + } + + $forceCollectionSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonhal', Schema::TYPE_OUTPUT, null, null, null, true); + $this->assertEquals($forceCollectionSchema, $resultSchema); } } diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 03fdc10396e..ae400607ff2 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -17,12 +17,10 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\AnimalObservation; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumIntegerResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumStringResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6299\Issue6299; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6317\Issue6317; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6800\TestApiDocHashmapArrayObjectIssue; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithEnumProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Species; use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DocumentDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -30,7 +28,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5998\Issue5998Product; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5998\ProductCode; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5998\SaveProduct; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6212\Nest; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -67,8 +64,6 @@ public static function getResources(): array { return [ Dummy::class, - BrokenDocs::class, - Nest::class, BagOfTests::class, ResourceWithEnumProperty::class, Issue6299::class, @@ -77,7 +72,6 @@ public static function getResources(): array Answer::class, AnimalObservation::class, Animal::class, - Species::class, Issue6317::class, ProductCode::class, Issue5998Product::class, @@ -127,92 +121,6 @@ public function testExecuteWithJsonldTypeInput(): void $this->assertStringNotContainsString('@type', $result); } - /** - * Test issue #5501, the locations relation inside BrokenDocs is a Resource (named Related) but its only operation is a NotExposed. - * Still, serializer groups are set, and therefore it is a "readableLink" so we actually want to compute the schema, even if it's not accessible - * directly, it is accessible through that relation. - */ - public function testExecuteWithNotExposedResourceAndReadableLink(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => BrokenDocs::class, '--type' => 'output']); - $result = $this->tester->getDisplay(); - - $this->assertStringContainsString('Related.jsonld-location.read_collection', $result); - } - - /** - * When serializer groups are present the Schema should have an embed resource. #5470 breaks array references when serializer groups are present. - */ - #[\PHPUnit\Framework\Attributes\Group('orm')] - public function testArraySchemaWithReference(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => BagOfTests::class, '--type' => 'input']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['tests'], [ - 'type' => 'string', - 'foo' => 'bar', - ]); - - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['nonResourceTests'], [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/definitions/NonResourceTestEntity.jsonld-write', - ], - ]); - - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['description'], [ - 'maxLength' => 255, - ]); - - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['type'], [ - '$ref' => '#/definitions/TestEntity.jsonld-write', - ]); - } - - public function testArraySchemaWithMultipleUnionTypesJsonLd(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Nest::class, '--type' => 'output', '--format' => 'jsonld']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - - $this->assertContains(['$ref' => '#/definitions/Robin.jsonld'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); - $this->assertContains(['$ref' => '#/definitions/Wren.jsonld'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); - $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonld']['properties']['owner']['anyOf']); - - $this->assertArrayHasKey('Wren.jsonld', $json['definitions']); - $this->assertArrayHasKey('Robin.jsonld', $json['definitions']); - } - - public function testArraySchemaWithMultipleUnionTypesJsonApi(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Nest::class, '--type' => 'output', '--format' => 'jsonapi']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - - $this->assertContains(['$ref' => '#/definitions/Robin.jsonapi'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); - $this->assertContains(['$ref' => '#/definitions/Wren.jsonapi'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); - $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonapi']['properties']['data']['properties']['attributes']['properties']['owner']['anyOf']); - - $this->assertArrayHasKey('Wren.jsonapi', $json['definitions']); - $this->assertArrayHasKey('Robin.jsonapi', $json['definitions']); - } - - public function testArraySchemaWithMultipleUnionTypesJsonHal(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Nest::class, '--type' => 'output', '--format' => 'jsonhal']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - - $this->assertContains(['$ref' => '#/definitions/Robin.jsonhal'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); - $this->assertContains(['$ref' => '#/definitions/Wren.jsonhal'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); - $this->assertContains(['type' => 'null'], $json['definitions']['Nest.jsonhal']['properties']['owner']['anyOf']); - - $this->assertArrayHasKey('Wren.jsonhal', $json['definitions']); - $this->assertArrayHasKey('Robin.jsonhal', $json['definitions']); - } - /** * Test issue #5998. */ @@ -238,67 +146,6 @@ public function testOpenApiResourceRefIsNotOverwritten(): void $this->assertEquals('#/definitions/DummyDate', $json['definitions']['Issue6299.Issue6299OutputDto.jsonld']['properties']['collectionDto']['items']['$ref']); } - /** - * Test related Schema keeps json-ld context. - */ - #[\PHPUnit\Framework\Attributes\Group('orm')] - public function testSubSchemaJsonLd(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => RelatedDummy::class, '--type' => 'output', '--format' => 'jsonld']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - - $this->assertArrayHasKey('@id', $json['definitions']['ThirdLevel.jsonld-friends']['properties']); - } - - #[\PHPUnit\Framework\Attributes\Group('orm')] - public function testJsonApiIncludesSchema(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Question::class, '--type' => 'output', '--format' => 'jsonapi']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - $properties = $json['definitions']['Question.jsonapi']['properties']['data']['properties']; - $included = $json['definitions']['Question.jsonapi']['properties']['included']; - - $this->assertArrayHasKey('answer', $properties['relationships']['properties']); - $this->assertArrayHasKey('anyOf', $included['items']); - $this->assertCount(1, $included['items']['anyOf']); - $this->assertArrayHasKey('$ref', $included['items']['anyOf'][0]); - $this->assertSame('#/definitions/Answer.jsonapi', $included['items']['anyOf'][0]['$ref']); - - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => AnimalObservation::class, '--type' => 'output', '--format' => 'jsonapi']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - $properties = $json['definitions']['AnimalObservation.jsonapi']['properties']['data']['properties']; - $included = $json['definitions']['AnimalObservation.jsonapi']['properties']['included']; - - $this->assertArrayHasKey('individuals', $properties['relationships']['properties']); - $this->assertArrayNotHasKey('individuals', $properties['attributes']['properties']); - $this->assertArrayHasKey('anyOf', $included['items']); - $this->assertCount(1, $included['items']['anyOf']); - $this->assertSame('#/definitions/Animal.jsonapi', $included['items']['anyOf'][0]['$ref']); - - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Animal::class, '--type' => 'output', '--format' => 'jsonapi']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - $properties = $json['definitions']['Animal.jsonapi']['properties']['data']['properties']; - $included = $json['definitions']['Animal.jsonapi']['properties']['included']; - - $this->assertArrayHasKey('species', $properties['relationships']['properties']); - $this->assertArrayNotHasKey('species', $properties['attributes']['properties']); - $this->assertArrayHasKey('anyOf', $included['items']); - $this->assertCount(1, $included['items']['anyOf']); - $this->assertSame('#/definitions/Species.jsonapi', $included['items']['anyOf'][0]['$ref']); - - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Species::class, '--type' => 'output', '--format' => 'jsonapi']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - $properties = $json['definitions']['Species.jsonapi']['properties']['data']['properties']; - - $this->assertArrayHasKey('kingdom', $properties['attributes']['properties']); - $this->assertArrayHasKey('phylum', $properties['attributes']['properties']); - } - /** * Test issue #6317. */ @@ -307,61 +154,13 @@ public function testBackedEnumExamplesAreNotLost(): void $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => Issue6317::class, '--type' => 'output', '--format' => 'jsonld']); $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $properties = $json['definitions']['Issue6317.jsonld']['properties']; - + $properties = $json['definitions']['Issue6317.jsonld']['allOf'][1]['properties']; $this->assertArrayHasKey('example', $properties['id']); $this->assertArrayHasKey('example', $properties['name']); - // jsonldContext $this->assertArrayNotHasKey('example', $properties['ordinal']); - // openapiContext $this->assertArrayNotHasKey('example', $properties['cardinal']); } - public function testResourceWithEnumPropertiesSchema(): void - { - $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => ResourceWithEnumProperty::class, '--type' => 'output', '--format' => 'jsonld']); - $result = $this->tester->getDisplay(); - $json = json_decode($result, associative: true); - $properties = $json['definitions']['ResourceWithEnumProperty.jsonld']['properties']; - - $this->assertSame( - [ - 'type' => ['string', 'null'], - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ], - $properties['intEnum'] - ); - $this->assertSame( - [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ], - ], - $properties['stringEnum'] - ); - $this->assertSame( - [ - 'type' => ['string', 'null'], - 'enum' => ['male', 'female', null], - ], - $properties['gender'] - ); - $this->assertSame( - [ - 'type' => 'array', - 'items' => [ - 'type' => 'string', - 'enum' => ['male', 'female'], - ], - ], - $properties['genders'] - ); - } - /** * Test feature #6716. */ @@ -387,9 +186,11 @@ public function testOpenApiSchemaGenerationForArrayProperty(string $propertyName $result = $this->tester->getDisplay(); $json = json_decode($result, true); $definitions = $json['definitions']; - $ressourceDefinitions = $definitions['TestApiDocHashmapArrayObjectIssue.jsonld']; $this->assertArrayHasKey('TestApiDocHashmapArrayObjectIssue.jsonld', $definitions); + + $ressourceDefinitions = $definitions['TestApiDocHashmapArrayObjectIssue.jsonld']['allOf'][1]; + $this->assertEquals('object', $ressourceDefinitions['type']); $this->assertEquals($expectedProperties, $ressourceDefinitions['properties'][$propertyName]); } diff --git a/tests/OpenApi/Command/OpenApiCommandTest.php b/tests/OpenApi/Command/OpenApiCommandTest.php index faf4ffbf3d8..f9d49a778bd 100644 --- a/tests/OpenApi/Command/OpenApiCommandTest.php +++ b/tests/OpenApi/Command/OpenApiCommandTest.php @@ -147,9 +147,9 @@ public function testBackedEnumExamplesAreNotLost(): void }; $assertExample($json['components']['schemas']['Issue6317']['properties'], 'id'); - $assertExample($json['components']['schemas']['Issue6317.jsonld']['properties'], 'id'); - $assertExample($json['components']['schemas']['Issue6317.jsonapi']['properties']['data']['properties']['attributes']['properties'], '_id'); - $assertExample($json['components']['schemas']['Issue6317.jsonhal']['properties'], 'id'); + $assertExample($json['components']['schemas']['Issue6317.jsonld']['allOf'][1]['properties'], 'id'); + $this->assertEquals($json['components']['schemas']['Issue6317.jsonhal']['allOf'][1]['$ref'], '#/components/schemas/Issue6317'); + // $this->assertEquals($json['components']['schemas']['Issue6317.jsonapi']['allOf'][1]['properties']['data']['items'][]); } private function assertYaml(string $data): void From 7a7a13526850abf1b22127cb93cdd9933272736d Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 14 May 2025 16:09:30 +0200 Subject: [PATCH 4/4] fix(json-schema): share invariable sub-schemas --- src/Hydra/JsonSchema/SchemaFactory.php | 4 +- .../Tests/JsonSchema/SchemaFactoryTest.php | 81 ++++++++--------- src/JsonApi/JsonSchema/SchemaFactory.php | 22 ++--- .../Tests/JsonSchema/SchemaFactoryTest.php | 89 ++++++++----------- .../Factory/SchemaPropertyMetadataFactory.php | 8 +- src/JsonSchema/SchemaFactory.php | 2 +- .../Tests}/DefinitionNameFactoryTest.php | 14 +-- .../NamespaceA/Module/DummyClass.php | 2 +- .../NamespaceB/Module/DummyClass.php | 2 +- .../NamespaceC/Module/DummyClass.php | 2 +- src/JsonSchema/Tests/SchemaFactoryTest.php | 16 ++-- .../Tests/Factory/OpenApiFactoryTest.php | 48 +++++----- .../Serializer/OpenApiNormalizerTest.php | 2 +- ...opertySchemaGreaterThanRestrictionTest.php | 8 +- .../PropertySchemaLessThanRestrictionTest.php | 8 +- .../ValidatorPropertyMetadataFactoryTest.php | 2 +- .../JsonSchema/JsonApiJsonSchemaTest.php | 9 +- .../JsonSchema/JsonLdJsonSchemaTest.php | 8 +- .../Functional/JsonSchema/JsonSchemaTest.php | 4 + 19 files changed, 151 insertions(+), 180 deletions(-) rename {tests/JsonSchema => src/JsonSchema/Tests}/DefinitionNameFactoryTest.php (85%) rename {tests/JsonSchema/Dummy => src/JsonSchema/Tests/Fixtures/DefinitionNameFactory}/NamespaceA/Module/DummyClass.php (76%) rename {tests/JsonSchema/Dummy => src/JsonSchema/Tests/Fixtures/DefinitionNameFactory}/NamespaceB/Module/DummyClass.php (76%) rename {tests/JsonSchema/Dummy => src/JsonSchema/Tests/Fixtures/DefinitionNameFactory}/NamespaceC/Module/DummyClass.php (76%) diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index a03ad0c479c..8f2a13594b5 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -67,8 +67,8 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'additionalProperties' => true, ], ], - ] + self::BASE_PROPS, - ], + ], + ] + self::BASE_PROPS, ]; private const ITEM_BASE_SCHEMA_OUTPUT = [ diff --git a/src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php b/src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php index e8d735ab68d..25f1a2ffda7 100644 --- a/src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/Hydra/Tests/JsonSchema/SchemaFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory as BaseSchemaFactory; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -28,6 +29,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; class SchemaFactoryTest extends TestCase @@ -48,10 +50,12 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection(['id', 'name'])); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn(new ApiProperty(identifier: true)); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn(new ApiProperty()); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryCollection->reveal(), @@ -60,7 +64,12 @@ protected function setUp(): void definitionNameFactory: $definitionNameFactory, ); - $this->schemaFactory = new SchemaFactory($baseSchemaFactory); + $this->schemaFactory = new SchemaFactory( + $baseSchemaFactory, + [], + $definitionNameFactory, + $resourceMetadataFactoryCollection->reveal(), + ); } public function testBuildSchema(): void @@ -86,12 +95,13 @@ public function testHasRootDefinitionKeyBuildSchema(): void $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertTrue(isset($definitions[$rootDefinitionKey]['properties'])); - $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; + $this->assertTrue(isset($definitions[$rootDefinitionKey]['allOf'][1]['properties'])); + $this->assertEquals($definitions[$rootDefinitionKey]['allOf'][0], ['$ref' => '#/definitions/HydraItemBaseSchema']); + + $properties = $definitions['HydraItemBaseSchema']['properties']; $this->assertArrayHasKey('@context', $properties); $this->assertEquals( [ - 'readOnly' => true, 'oneOf' => [ ['type' => 'string'], [ @@ -119,55 +129,34 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection()); - $definitionName = 'Dummy.jsonld'; - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertTrue(isset($resultSchema['properties']['hydra:member'])); - $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); - $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); - $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][$definitionName]['properties']; + $hydraCollectionSchema = $resultSchema['definitions']['HydraCollectionBaseSchema']; + $properties = $hydraCollectionSchema['properties']; + $this->assertTrue(isset($properties['hydra:member'])); + $this->assertArrayHasKey('hydra:totalItems', $properties); + $this->assertArrayHasKey('hydra:view', $properties); + $this->assertArrayHasKey('hydra:search', $properties); $this->assertArrayNotHasKey('@context', $properties); - $this->assertArrayHasKey('@type', $properties); - $this->assertArrayHasKey('@id', $properties); - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, null, null, true); + $this->assertTrue(isset($properties['hydra:view'])); + $this->assertArrayHasKey('properties', $properties['hydra:view']); + $this->assertArrayHasKey('hydra:first', $properties['hydra:view']['properties']); + $this->assertArrayHasKey('hydra:last', $properties['hydra:view']['properties']); + $this->assertArrayHasKey('hydra:previous', $properties['hydra:view']['properties']); + $this->assertArrayHasKey('hydra:next', $properties['hydra:view']['properties']); - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertTrue(isset($resultSchema['properties']['hydra:member'])); - $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); - $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); - $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][$definitionName]['properties']; - $this->assertArrayNotHasKey('@context', $properties); - $this->assertArrayHasKey('@type', $properties); - $this->assertArrayHasKey('@id', $properties); - } - - public function testHasHydraViewNavigationBuildSchema(): void - { - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection()); - - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertTrue(isset($resultSchema['properties']['hydra:view'])); - $this->assertArrayHasKey('properties', $resultSchema['properties']['hydra:view']); - $this->assertArrayHasKey('hydra:first', $resultSchema['properties']['hydra:view']['properties']); - $this->assertArrayHasKey('hydra:last', $resultSchema['properties']['hydra:view']['properties']); - $this->assertArrayHasKey('hydra:previous', $resultSchema['properties']['hydra:view']['properties']); - $this->assertArrayHasKey('hydra:next', $resultSchema['properties']['hydra:view']['properties']); + $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, null, null, true); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } public function testSchemaTypeBuildSchemaWithoutPrefix(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection(), null, [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false]); $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertTrue(isset($resultSchema['properties']['member'])); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']); - $this->assertArrayHasKey('view', $resultSchema['properties']); - $this->assertArrayHasKey('search', $resultSchema['properties']); + $hydraCollectionSchema = $resultSchema['definitions']['HydraCollectionBaseSchema']; + $properties = $hydraCollectionSchema['properties']; + $this->assertArrayHasKey('totalItems', $properties); + $this->assertArrayHasKey('view', $properties); + $this->assertArrayHasKey('search', $properties); } } diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index a78325cd3f5..616d747dc18 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -234,21 +234,17 @@ public function buildSchema(string $className, string $format = 'jsonapi', strin $properties = $this->buildDefinitionPropertiesSchema($key, $className, $format, $type, $operation, $schema, []); $properties['data']['properties']['attributes']['$ref'] = $prefix.$key; - $definitions[$definitionName] = [ - 'description' => "$definitionName collection.", - 'allOf' => [ - ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], - ['type' => 'object', 'properties' => [ - 'data' => [ - 'type' => 'array', - 'items' => $properties['data'], - ], - ]], - ], + $schema['description'] = "$definitionName collection."; + $schema['allOf'] = [ + ['$ref' => $prefix.self::COLLECTION_BASE_SCHEMA_NAME], + ['type' => 'object', 'properties' => [ + 'data' => [ + 'type' => 'array', + 'items' => $properties['data'], + ], + ]], ]; - $schema['$ref'] = $prefix.$definitionName; - return $schema; } diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 7a642739297..37d1b2a7a55 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -45,12 +45,13 @@ protected function setUp(): void (new ApiResource())->withOperations(new Operations([ 'get' => (new Get())->withName('get'), ])), - ])); + ]) + ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -60,6 +61,7 @@ protected function setUp(): void ); $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $this->schemaFactory = new SchemaFactory( schemaFactory: $baseSchemaFactory, @@ -107,9 +109,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void 'type' => 'string', ], 'attributes' => [ - 'type' => 'object', - 'properties' => [ - ], + '$ref' => '#/definitions/Dummy', ], ], 'required' => [ @@ -124,58 +124,39 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, new GetCollection()); - $definitionName = 'Dummy.jsonapi'; $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('links', $resultSchema['properties']); - $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); - - $this->assertArrayHasKey('meta', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); - - $this->assertArrayHasKey('data', $resultSchema['properties']); - $this->assertArrayHasKey('items', $resultSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); - - $properties = $resultSchema['definitions'][$definitionName]['properties']; + $this->assertTrue(isset($resultSchema['allOf'][0]['$ref'])); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], '#/definitions/JsonApiCollectionBaseSchema'); + + $jsonApiCollectionBaseSchema = $resultSchema['definitions']['JsonApiCollectionBaseSchema']; + $this->assertTrue(isset($jsonApiCollectionBaseSchema['properties'])); + $this->assertArrayHasKey('links', $jsonApiCollectionBaseSchema['properties']); + $this->assertArrayHasKey('self', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('first', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('prev', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('next', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + $this->assertArrayHasKey('last', $jsonApiCollectionBaseSchema['properties']['links']['properties']); + + $this->assertArrayHasKey('meta', $jsonApiCollectionBaseSchema['properties']); + $this->assertArrayHasKey('totalItems', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('itemsPerPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + $this->assertArrayHasKey('currentPage', $jsonApiCollectionBaseSchema['properties']['meta']['properties']); + + $objectSchema = $resultSchema['allOf'][1]; + $this->assertArrayHasKey('data', $objectSchema['properties']); + + $this->assertArrayHasKey('items', $objectSchema['properties']['data']); + $this->assertArrayHasKey('$ref', $objectSchema['properties']['data']['items']['properties']['attributes']); + + $properties = $objectSchema['properties']; $this->assertArrayHasKey('data', $properties); - $this->assertArrayHasKey('properties', $properties['data']); - $this->assertArrayHasKey('id', $properties['data']['properties']); - $this->assertArrayHasKey('type', $properties['data']['properties']); - $this->assertArrayHasKey('attributes', $properties['data']['properties']); - - $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $this->assertArrayHasKey('items', $properties['data']); + $this->assertArrayHasKey('id', $properties['data']['items']['properties']); + $this->assertArrayHasKey('type', $properties['data']['items']['properties']); + $this->assertArrayHasKey('attributes', $properties['data']['items']['properties']); - $this->assertNull($resultSchema->getRootDefinitionKey()); - $this->assertTrue(isset($resultSchema['properties'])); - $this->assertArrayHasKey('links', $resultSchema['properties']); - $this->assertArrayHasKey('self', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('first', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('prev', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('next', $resultSchema['properties']['links']['properties']); - $this->assertArrayHasKey('last', $resultSchema['properties']['links']['properties']); - - $this->assertArrayHasKey('meta', $resultSchema['properties']); - $this->assertArrayHasKey('totalItems', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('itemsPerPage', $resultSchema['properties']['meta']['properties']); - $this->assertArrayHasKey('currentPage', $resultSchema['properties']['meta']['properties']); - - $this->assertArrayHasKey('data', $resultSchema['properties']); - $this->assertArrayHasKey('items', $resultSchema['properties']['data']); - $this->assertArrayHasKey('$ref', $resultSchema['properties']['data']['items']); - - $properties = $resultSchema['definitions'][$definitionName]['properties']; - $this->assertArrayHasKey('data', $properties); - $this->assertArrayHasKey('properties', $properties['data']); - $this->assertArrayHasKey('id', $properties['data']['properties']); - $this->assertArrayHasKey('type', $properties['data']['properties']); - $this->assertArrayHasKey('attributes', $properties['data']['properties']); + $forcedCollection = $this->schemaFactory->buildSchema(Dummy::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); + $this->assertEquals($resultSchema['allOf'][0]['$ref'], $forcedCollection['allOf'][0]['$ref']); } } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 163be408ff7..72b7a38608a 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -102,11 +102,17 @@ private function getTypeSchema(ApiProperty $propertyMetadata, array $propertySch { $type = $propertyMetadata->getNativeType(); + $className = null; $typeIsResourceClass = function (Type $type) use (&$className): bool { return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); }; + $isResourceClass = $type?->isSatisfiedBy($typeIsResourceClass); - if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && !$type?->isSatisfiedBy($typeIsResourceClass)) { + if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { + $propertySchema['readOnly'] = true; + } + + if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && !$isResourceClass) { if ($default instanceof \BackedEnum) { $default = $default->value; } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 3fa18dfb8d2..ee96a386bfa 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -135,7 +135,7 @@ public function buildSchema(string $className, string $format = 'json', string $ foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options); - if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) { + if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { continue; } diff --git a/tests/JsonSchema/DefinitionNameFactoryTest.php b/src/JsonSchema/Tests/DefinitionNameFactoryTest.php similarity index 85% rename from tests/JsonSchema/DefinitionNameFactoryTest.php rename to src/JsonSchema/Tests/DefinitionNameFactoryTest.php index b02159e12a4..e50764ea76e 100644 --- a/tests/JsonSchema/DefinitionNameFactoryTest.php +++ b/src/JsonSchema/Tests/DefinitionNameFactoryTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\JsonSchema; +namespace ApiPlatform\JsonSchema\Tests; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\SchemaFactory; @@ -65,33 +65,33 @@ public static function providerDefinitions(): iterable #[\PHPUnit\Framework\Attributes\DataProvider('providerDefinitions')] public function testCreate(string $expected, string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): void { - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); static::assertSame($expected, $definitionNameFactory->create($className, $format, $inputOrOutputClass, $operation, $serializerContext)); } public function testCreateDifferentPrefixesForClassesWithTheSameShortName(): void { - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true]); + $definitionNameFactory = new DefinitionNameFactory(); self::assertEquals( 'DummyClass.jsonapi', - $definitionNameFactory->create(\ApiPlatform\Tests\JsonSchema\Dummy\NamespaceA\Module\DummyClass::class, 'jsonapi') + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceA\Module\DummyClass::class, 'jsonapi') ); self::assertEquals( 'Module.DummyClass.jsonapi', - $definitionNameFactory->create(\ApiPlatform\Tests\JsonSchema\Dummy\NamespaceB\Module\DummyClass::class, 'jsonapi') + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceB\Module\DummyClass::class, 'jsonapi') ); self::assertEquals( 'NamespaceC.Module.DummyClass.jsonapi', - $definitionNameFactory->create(\ApiPlatform\Tests\JsonSchema\Dummy\NamespaceC\Module\DummyClass::class, 'jsonapi') + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceC\Module\DummyClass::class, 'jsonapi') ); self::assertEquals( 'DummyClass.jsonhal', - $definitionNameFactory->create(\ApiPlatform\Tests\JsonSchema\Dummy\NamespaceA\Module\DummyClass::class, 'jsonhal') + $definitionNameFactory->create(Fixtures\DefinitionNameFactory\NamespaceA\Module\DummyClass::class, 'jsonhal') ); } } diff --git a/tests/JsonSchema/Dummy/NamespaceA/Module/DummyClass.php b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php similarity index 76% rename from tests/JsonSchema/Dummy/NamespaceA/Module/DummyClass.php rename to src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php index 6ea133b25ef..6663f66b941 100644 --- a/tests/JsonSchema/Dummy/NamespaceA/Module/DummyClass.php +++ b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceA/Module/DummyClass.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\JsonSchema\Dummy\NamespaceA\Module; +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceA\Module; class DummyClass { diff --git a/tests/JsonSchema/Dummy/NamespaceB/Module/DummyClass.php b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php similarity index 76% rename from tests/JsonSchema/Dummy/NamespaceB/Module/DummyClass.php rename to src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php index 5fbe6f30d10..5b043d1ddc3 100644 --- a/tests/JsonSchema/Dummy/NamespaceB/Module/DummyClass.php +++ b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceB/Module/DummyClass.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\JsonSchema\Dummy\NamespaceB\Module; +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceB\Module; class DummyClass { diff --git a/tests/JsonSchema/Dummy/NamespaceC/Module/DummyClass.php b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php similarity index 76% rename from tests/JsonSchema/Dummy/NamespaceC/Module/DummyClass.php rename to src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php index f5b4449ac80..1790061a52c 100644 --- a/tests/JsonSchema/Dummy/NamespaceC/Module/DummyClass.php +++ b/src/JsonSchema/Tests/Fixtures/DefinitionNameFactory/NamespaceC/Module/DummyClass.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\JsonSchema\Dummy\NamespaceC\Module; +namespace ApiPlatform\JsonSchema\Tests\Fixtures\DefinitionNameFactory\NamespaceC\Module; class DummyClass { diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index d0be41e60f2..e551f0d53f4 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -79,7 +79,7 @@ public function testBuildSchemaForNonResourceClassLegacy(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -154,7 +154,7 @@ public function testBuildSchemaForNonResourceClass(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -231,7 +231,7 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypesLegacy( $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -301,7 +301,7 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -385,7 +385,7 @@ public function testBuildSchemaWithSerializerGroupsLegacy(): void $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -462,7 +462,7 @@ public function testBuildSchemaWithSerializerGroups(): void $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -521,7 +521,7 @@ public function testBuildSchemaForAssociativeArrayLegacy(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), @@ -573,7 +573,7 @@ public function testBuildSchemaForAssociativeArray(): void $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index c784e1570db..f85e3400d76 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -560,7 +560,6 @@ public function testInvoke(): void 'type' => 'object', 'description' => 'This is a dummy', 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], - 'deprecated' => false, 'properties' => [ 'id' => new \ArrayObject([ 'type' => 'integer', @@ -595,7 +594,6 @@ public function testInvoke(): void $dummyErrorSchema->setDefinitions(new \ArrayObject([ 'type' => 'object', 'description' => 'nice one!', - 'deprecated' => false, 'properties' => [ 'type' => new \ArrayObject([ 'type' => 'string', @@ -621,7 +619,7 @@ public function testInvoke(): void ], ])); $errorSchema = clone $dummyErrorSchema->getDefinitions(); - $errorSchema['description'] = ''; + unset($errorSchema['description']); $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -646,7 +644,9 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), - 'Parameter' => $parameterSchema, + 'Dummy.jsonld' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto.jsonld' => $dummySchema->getDefinitions(), + 'Parameter.jsonld' => $parameterSchema, 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Error' => $errorSchema, ])); @@ -681,7 +681,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ]))), ])), ], @@ -711,7 +711,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -734,7 +734,7 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), ]), true ) @@ -753,7 +753,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]) ), '404' => new Response( @@ -775,7 +775,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource updated', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) @@ -803,7 +803,7 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -883,7 +883,7 @@ public function testInvoke(): void 'Dummy resource updated', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'text/csv' => new \ArrayObject(), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) @@ -912,7 +912,7 @@ public function testInvoke(): void 'The updated Dummy resource', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new \ArrayObject(), ]), true ) @@ -926,7 +926,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -961,7 +961,6 @@ public function testInvoke(): void 'enum' => ['asc', 'desc'], ]), ], - deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); @@ -972,7 +971,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -1004,7 +1003,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1045,7 +1044,6 @@ public function testInvoke(): void ]), false ), - deprecated: false, ), $requestBodyPath->getPost()); $requestBodyPath = $paths->getPath('/dummiesRequestBodyWithoutContent'); @@ -1056,7 +1054,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1079,11 +1077,10 @@ public function testInvoke(): void new RequestBody( 'Extended description for the new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), ]), false ), - deprecated: false, ), $requestBodyPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}'); @@ -1124,11 +1121,10 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPut()); $dummyItemPath = $paths->getPath('/dummyitems'); @@ -1164,11 +1160,10 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}/images'); @@ -1208,7 +1203,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1246,7 +1241,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ]))), ])), '418' => new Response( @@ -1276,7 +1271,6 @@ public function testInvoke(): void 'type' => 'boolean', ]), ], - deprecated: false ), $paths->getPath('/erroredDummies')->getGet()); $diamondsGetPath = $paths->getPath('/diamonds'); diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index 71fe0686e24..1937401e62b 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -208,7 +208,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory, diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index 0bf23802096..0e671974471 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -79,7 +79,7 @@ public function testCreate(): void { self::assertEquals([ 'exclusiveMinimum' => 10, - 'minimum' => 10 + 'minimum' => 10, ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); } @@ -87,17 +87,17 @@ public function testCreateWithNativeType(): void { self::assertEquals([ 'exclusiveMinimum' => 10, - 'minimum' => 10 + 'minimum' => 10, ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10]), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMinimum' => 0, - 'minimum' => 0 + 'minimum' => 0, ], $this->propertySchemaGreaterThanRestriction->create(new Positive(), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMinimum' => 10.99, - 'minimum' => 10.99 + 'minimum' => 10.99, ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(['value' => 10.99]), (new ApiProperty())->withNativeType(Type::float()))); } } diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index 371c936b0d0..c057075058a 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -79,7 +79,7 @@ public function testCreate(): void { self::assertEquals([ 'exclusiveMaximum' => 10, - 'maximum' => 10 + 'maximum' => 10, ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); } @@ -87,17 +87,17 @@ public function testCreateWithNativeType(): void { self::assertEquals([ 'exclusiveMaximum' => 10, - 'maximum' => 10 + 'maximum' => 10, ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10]), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMaximum' => 0, - 'maximum' => 0 + 'maximum' => 0, ], $this->propertySchemaLessThanRestriction->create(new Negative(), (new ApiProperty())->withNativeType(Type::int()))); self::assertEquals([ 'exclusiveMaximum' => 10.99, - 'maximum' => 10.99 + 'maximum' => 10.99, ], $this->propertySchemaLessThanRestriction->create(new LessThan(['value' => 10.99]), (new ApiProperty())->withNativeType(Type::float()))); } } diff --git a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 8cdb7925046..de673e67a7d 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -712,7 +712,7 @@ public function testCreateWithPropertyCollectionRestriction(): void 'phone' => ['pattern' => '^([+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\s\./0-9]*)$'], 'age' => [ 'exclusiveMinimum' => 0, - 'minimum' => 0 + 'minimum' => 0, ], 'social' => [ 'type' => 'object', diff --git a/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php b/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php index f598471ec75..40c7e803574 100644 --- a/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonApiJsonSchemaTest.php @@ -82,6 +82,10 @@ public function testJsonApi(): void public function testJsonApiIncludesSchemaForQuestion(): void { + if ('mongodb' === self::getContainer()->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + $questionSchema = $this->schemaFactory->buildSchema(Question::class, 'jsonapi', Schema::TYPE_OUTPUT); $json = $questionSchema->getDefinitions(); $properties = $json['Question.jsonapi']['properties']['data']['properties']; @@ -123,8 +127,7 @@ public function testJsonApiIncludesSchemaForAnimal(): void public function testJsonApiIncludesSchemaForSpecies(): void { $speciesSchema = $this->schemaFactory->buildSchema(Species::class, 'jsonapi', Schema::TYPE_OUTPUT, forceCollection: true); - $this->assertEquals('#/definitions/Species.jsonapi', $speciesSchema['$ref']); - $this->assertEquals( + $this->assertArraySubset( [ 'description' => 'Species.jsonapi collection.', 'allOf' => [ @@ -157,7 +160,7 @@ public function testJsonApiIncludesSchemaForSpecies(): void ], ], ], - $speciesSchema->getDefinitions()['Species.jsonapi'] + $speciesSchema->getArrayCopy() ); } } diff --git a/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php index 9fe737e8e72..c1eaa500ae1 100644 --- a/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonLdJsonSchemaTest.php @@ -160,11 +160,9 @@ public function testArraySchemaWithMultipleUnionTypes(): void { $schema = $this->schemaFactory->buildSchema(Nest::class, 'jsonld', 'output'); - $this->assertEquals($schema['definitions']['Nest.jsonld']['allOf'][1]['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Robin.jsonld'], - ['$ref' => '#/definitions/Wren.jsonld'], - ['type' => 'null'], - ]); + $this->assertContains(['$ref' => '#/definitions/Robin.jsonld'], $schema['definitions']['Nest.jsonld']['allOf'][1]['properties']['owner']['anyOf']); + $this->assertContains(['$ref' => '#/definitions/Wren.jsonld'], $schema['definitions']['Nest.jsonld']['allOf'][1]['properties']['owner']['anyOf']); + $this->assertContains(['type' => 'null'], $schema['definitions']['Nest.jsonld']['allOf'][1]['properties']['owner']['anyOf']); $this->assertArrayHasKey('Nest.jsonld', $schema['definitions']); } diff --git a/tests/Functional/JsonSchema/JsonSchemaTest.php b/tests/Functional/JsonSchema/JsonSchemaTest.php index 5f303bd8600..e3b5b65d8f4 100644 --- a/tests/Functional/JsonSchema/JsonSchemaTest.php +++ b/tests/Functional/JsonSchema/JsonSchemaTest.php @@ -118,6 +118,10 @@ public function testExecuteWithNotExposedResourceAndReadableLink(): void public function testArraySchemaWithReference(): void { + if ('mongodb' === self::getContainer()->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + $schema = $this->schemaFactory->buildSchema(BagOfTests::class, 'jsonld', Schema::TYPE_INPUT); $this->assertEquals($schema['definitions']['BagOfTests.jsonld-write']['properties']['tests'], new \ArrayObject([