diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 980b896959a..4f98c666751 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -153,7 +153,7 @@ 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.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany.output" # Deprecations And the JSON node "paths./dummies.get.deprecated" should be false @@ -165,8 +165,8 @@ Feature: Documentation support And the JSON node "paths./deprecated_resources/{id}.patch.deprecated" should be true # Formats - And the OpenAPI class "Dummy.jsonld" exists - And the "@id" property exists for the OpenAPI class "Dummy.jsonld" + And the OpenAPI class "Dummy.jsonld.output" exists + And the "@id" property exists for the OpenAPI class "Dummy.jsonld.output" And the JSON node "paths./dummies.get.responses.200.content.application/ld+json" should be equal to: """ { @@ -176,7 +176,7 @@ Feature: Documentation support "hydra:member": { "type": "array", "items": { - "$ref": "#/components/schemas/Dummy.jsonld" + "$ref": "#/components/schemas/Dummy.jsonld.output" } }, "hydra:totalItems": { diff --git a/src/Hydra/JsonSchema/SchemaFactory.php b/src/Hydra/JsonSchema/SchemaFactory.php index f51af53e3d7..75144baee28 100644 --- a/src/Hydra/JsonSchema/SchemaFactory.php +++ b/src/Hydra/JsonSchema/SchemaFactory.php @@ -27,7 +27,6 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { private const BASE_PROP = [ - 'readOnly' => true, 'type' => 'string', ]; private const BASE_PROPS = [ @@ -36,7 +35,6 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI ]; private const BASE_ROOT_PROPS = [ '@context' => [ - 'readOnly' => true, 'oneOf' => [ ['type' => 'string'], [ @@ -74,18 +72,43 @@ public function buildSchema(string $className, string $format = 'jsonld', string return $schema; } - if ('input' === $type) { - return $schema; + if (($key = $schema->getRootDefinitionKey() ?? $schema->getItemsDefinitionKey()) !== null) { + $postfix = '.'.$type; + $definitions = $schema->getDefinitions(); + $definitions[$key.$postfix] = $definitions[$key]; + unset($definitions[$key]); + + if (($schema['type'] ?? '') === 'array') { + $schema['items']['$ref'] .= $postfix; + } else { + $schema['$ref'] .= $postfix; + } } $definitions = $schema->getDefinitions(); if ($key = $schema->getRootDefinitionKey()) { $definitions[$key]['properties'] = self::BASE_ROOT_PROPS + ($definitions[$key]['properties'] ?? []); + if (Schema::TYPE_OUTPUT === $type) { + foreach (array_keys(self::BASE_ROOT_PROPS) as $property) { + $definitions[$key]['required'] ??= []; + if (!\in_array($property, $definitions[$key]['required'], true)) { + $definitions[$key]['required'][] = $property; + } + } + } return $schema; } if ($key = $schema->getItemsDefinitionKey()) { $definitions[$key]['properties'] = self::BASE_PROPS + ($definitions[$key]['properties'] ?? []); + if (Schema::TYPE_OUTPUT === $type) { + foreach (array_keys(self::BASE_PROPS) as $property) { + $definitions[$key]['required'] ??= []; + if (!\in_array($property, $definitions[$key]['required'], true)) { + $definitions[$key]['required'][] = $property; + } + } + } } if (($schema['type'] ?? '') === 'array') { diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index f9c75720f8f..097eaddd4b8 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -34,9 +33,6 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface { use ResourceMetadataTrait; - - private const PATCH_SCHEMA_POSTFIX = '.patch'; - private ?TypeFactoryInterface $typeFactory = null; 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 @@ -92,12 +88,6 @@ public function buildSchema(string $className, string $format = 'json', string $ return $schema; } - $isJsonMergePatch = 'json' === $format && $operation instanceof Patch && Schema::TYPE_INPUT === $type; - - if ($isJsonMergePatch) { - $definitionName .= self::PATCH_SCHEMA_POSTFIX; - } - if (!isset($schema['$ref']) && !isset($schema['type'])) { $ref = Schema::VERSION_OPENAPI === $version ? '#/components/schemas/'.$definitionName : '#/definitions/'.$definitionName; if ($forceCollection || ('POST' !== $method && $operation instanceof CollectionOperationInterface)) { @@ -146,7 +136,7 @@ public function buildSchema(string $className, string $format = 'json', string $ } $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $inputOrOutputClass, $format, $serializerContext) : $propertyName; - if ($propertyMetadata->isRequired() && !$isJsonMergePatch) { + if ($propertyMetadata->isRequired()) { $definition['required'][] = $normalizedPropertyName; } diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index b1a6029503d..109c0cc398b 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -49,6 +50,7 @@ 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_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); @@ -69,7 +71,12 @@ public function testBuildSchema(): void $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); $this->assertTrue($resultSchema->isDefined()); - $this->assertSame('Dummy.jsonld', $resultSchema->getRootDefinitionKey()); + $this->assertSame('Dummy.jsonld.output', $resultSchema->getRootDefinitionKey()); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_INPUT, new Post()); + + $this->assertTrue($resultSchema->isDefined()); + $this->assertSame('Dummy.jsonld.input', $resultSchema->getRootDefinitionKey()); } public function testCustomFormatBuildSchema(): void @@ -94,7 +101,6 @@ public function testHasRootDefinitionKeyBuildSchema(): void $this->assertArrayHasKey('@context', $properties); $this->assertEquals( [ - 'readOnly' => true, 'oneOf' => [ ['type' => 'string'], [ @@ -122,7 +128,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection()); - $definitionName = 'Dummy.jsonld'; + $definitionName = 'Dummy.jsonld.output'; $this->assertNull($resultSchema->getRootDefinitionKey()); // @noRector @@ -151,6 +157,12 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayNotHasKey('@context', $properties); $this->assertArrayHasKey('@type', $properties); $this->assertArrayHasKey('@id', $properties); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_INPUT, new Post()); + $definitionName = 'Dummy.jsonld.input'; + + $this->assertSame($definitionName, $resultSchema->getRootDefinitionKey()); + $this->assertFalse(isset($resultSchema['properties'])); } public function testHasHydraViewNavigationBuildSchema(): void @@ -168,4 +180,36 @@ public function testHasHydraViewNavigationBuildSchema(): void $this->assertArrayHasKey('hydra:previous', $resultSchema['properties']['hydra:view']['properties']); $this->assertArrayHasKey('hydra:next', $resultSchema['properties']['hydra:view']['properties']); } + + public function testRequiredBasePropertiesBuildSchema(): void + { + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + + $this->assertTrue(isset($definitions[$rootDefinitionKey])); + $this->assertTrue(isset($definitions[$rootDefinitionKey]['required'])); + $requiredProperties = $resultSchema['definitions'][$rootDefinitionKey]['required']; + $this->assertContains('@context', $requiredProperties); + $this->assertContains('@id', $requiredProperties); + $this->assertContains('@type', $requiredProperties); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, new GetCollection()); + $definitions = $resultSchema->getDefinitions(); + $itemsDefinitionKey = array_key_first($definitions->getArrayCopy()); + + $this->assertTrue(isset($definitions[$itemsDefinitionKey])); + $this->assertTrue(isset($definitions[$itemsDefinitionKey]['required'])); + $requiredProperties = $resultSchema['definitions'][$itemsDefinitionKey]['required']; + $this->assertNotContains('@context', $requiredProperties); + $this->assertContains('@id', $requiredProperties); + $this->assertContains('@type', $requiredProperties); + + $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_INPUT, new Post()); + $definitions = $resultSchema->getDefinitions(); + $itemsDefinitionKey = array_key_first($definitions->getArrayCopy()); + + $this->assertTrue(isset($definitions[$itemsDefinitionKey])); + $this->assertFalse(isset($definitions[$itemsDefinitionKey]['required'])); + } } diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 67248042f5c..ed68a69e50e 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -76,9 +76,9 @@ public function testExecuteWithJsonldTypeInput(): void $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => $this->entityClass, '--operation' => '_api_/dummies{._format}_post', '--format' => 'jsonld', '--type' => 'input']); $result = $this->tester->getDisplay(); - $this->assertStringNotContainsString('@id', $result); - $this->assertStringNotContainsString('@context', $result); - $this->assertStringNotContainsString('@type', $result); + $this->assertStringContainsString('@id', $result); + $this->assertStringContainsString('@context', $result); + $this->assertStringContainsString('@type', $result); } /** @@ -103,24 +103,24 @@ public function testArraySchemaWithReference(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['tests'], [ + $this->assertEquals($json['definitions']['BagOfTests.jsonld-write.input']['properties']['tests'], [ 'type' => 'string', 'foo' => 'bar', ]); - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['nonResourceTests'], [ + $this->assertEquals($json['definitions']['BagOfTests.jsonld-write.input']['properties']['nonResourceTests'], [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/NonResourceTestEntity.jsonld-write', + '$ref' => '#/definitions/NonResourceTestEntity.jsonld-write.input', ], ]); - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['description'], [ + $this->assertEquals($json['definitions']['BagOfTests.jsonld-write.input']['properties']['description'], [ 'maxLength' => 255, ]); - $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['type'], [ - '$ref' => '#/definitions/TestEntity.jsonld-write', + $this->assertEquals($json['definitions']['BagOfTests.jsonld-write.input']['properties']['type'], [ + '$ref' => '#/definitions/TestEntity.jsonld-write.input', ]); } @@ -130,14 +130,14 @@ public function testArraySchemaWithMultipleUnionTypesJsonLd(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['Nest.jsonld']['properties']['owner']['anyOf'], [ - ['$ref' => '#/definitions/Wren.jsonld'], - ['$ref' => '#/definitions/Robin.jsonld'], + $this->assertEquals($json['definitions']['Nest.jsonld.output']['properties']['owner']['anyOf'], [ + ['$ref' => '#/definitions/Wren.jsonld.output'], + ['$ref' => '#/definitions/Robin.jsonld.output'], ['type' => 'null'], ]); - $this->assertArrayHasKey('Wren.jsonld', $json['definitions']); - $this->assertArrayHasKey('Robin.jsonld', $json['definitions']); + $this->assertArrayHasKey('Wren.jsonld.output', $json['definitions']); + $this->assertArrayHasKey('Robin.jsonld.output', $json['definitions']); } public function testArraySchemaWithMultipleUnionTypesJsonApi(): void @@ -183,7 +183,7 @@ public function testArraySchemaWithTypeFactory(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['Foo.jsonld']['properties']['expiration'], ['type' => 'string', 'format' => 'date']); + $this->assertEquals($json['definitions']['Foo.jsonld.output']['properties']['expiration'], ['type' => 'string', 'format' => 'date']); } /** @@ -195,7 +195,7 @@ public function testWritableNonResourceRef(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals($json['definitions']['SaveProduct.jsonld']['properties']['codes']['items']['$ref'], '#/definitions/ProductCode.jsonld'); + $this->assertEquals($json['definitions']['SaveProduct.jsonld.input']['properties']['codes']['items']['$ref'], '#/definitions/ProductCode.jsonld.input'); } /** @@ -207,8 +207,8 @@ public function testOpenApiResourceRefIsNotOverwritten(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertEquals('#/definitions/DummyFriend', $json['definitions']['Issue6299.Issue6299OutputDto.jsonld']['properties']['itemDto']['$ref']); - $this->assertEquals('#/definitions/DummyDate', $json['definitions']['Issue6299.Issue6299OutputDto.jsonld']['properties']['collectionDto']['items']['$ref']); + $this->assertEquals('#/definitions/DummyFriend', $json['definitions']['Issue6299.Issue6299OutputDto.jsonld.output']['properties']['itemDto']['$ref']); + $this->assertEquals('#/definitions/DummyDate', $json['definitions']['Issue6299.Issue6299OutputDto.jsonld.output']['properties']['collectionDto']['items']['$ref']); } /** @@ -220,7 +220,7 @@ public function testSubSchemaJsonLd(): void $result = $this->tester->getDisplay(); $json = json_decode($result, associative: true); - $this->assertArrayHasKey('@id', $json['definitions']['ThirdLevel.jsonld-friends']['properties']); + $this->assertArrayHasKey('@id', $json['definitions']['ThirdLevel.jsonld-friends.output']['properties']); } public function testJsonApiIncludesSchema(): void