From e6d743b6970fdb41e189f51709a06e30d52f3beb Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Mon, 2 Mar 2026 15:10:41 +0100 Subject: [PATCH] feat: support polymorphism --- features/hal/table_inheritance.feature | 1 + features/main/table_inheritance.feature | 3 + src/JsonSchema/SchemaFactory.php | 145 ++++++++++++--- src/Serializer/AbstractItemNormalizer.php | 8 +- .../Tests/AbstractItemNormalizerTest.php | 169 ++++++++++++++++++ .../Polymorphism/ApiResource/Author.php | 52 ++++++ .../Polymorphism/ApiResource/Book.php | 100 +++++++++++ .../Polymorphism/Entity/FictionBook.php | 71 ++++++++ .../Polymorphism/Entity/TechnicalBook.php | 88 +++++++++ .../ApiResource/Polymorphism/Author.php | 48 +++++ .../ApiResource/Polymorphism/Book.php | 94 ++++++++++ .../ApiResource/Polymorphism/FictionBook.php | 64 +++++++ .../Polymorphism/TechnicalBook.php | 80 +++++++++ tests/Functional/OpenApiTest.php | 99 ++++++++++ 14 files changed, 998 insertions(+), 24 deletions(-) create mode 100644 src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Author.php create mode 100644 src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Book.php create mode 100644 src/Serializer/Tests/Fixtures/Polymorphism/Entity/FictionBook.php create mode 100644 src/Serializer/Tests/Fixtures/Polymorphism/Entity/TechnicalBook.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Polymorphism/Author.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Polymorphism/Book.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Polymorphism/FictionBook.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Polymorphism/TechnicalBook.php diff --git a/features/hal/table_inheritance.feature b/features/hal/table_inheritance.feature index 55ef27b7cad..24b8929c81b 100644 --- a/features/hal/table_inheritance.feature +++ b/features/hal/table_inheritance.feature @@ -75,6 +75,7 @@ Feature: Table inheritance "href": "/dummy_table_inheritances/2" } }, + "swaggerThanParent": true, "id": 2, "name": "Foobarbaz inheritance" } diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature index 1c3617f5f92..77c2bf48a2e 100644 --- a/features/main/table_inheritance.feature +++ b/features/main/table_inheritance.feature @@ -548,6 +548,9 @@ Feature: Table inheritance "type": "string", "pattern": "^single item$" }, + "bar": { + "type": ["string", "null"] + }, "fooz": { "type": "string", "pattern": "fooz" diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 7014020afc4..d0bafab09a0 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -160,6 +161,8 @@ public function buildSchema(string $className, string $format = 'json', string $ } } + $this->buildDiscriminatorSchema($schema, $definitions, $definitionName, $definition, $inputOrOutputClass, $format, $type, $version, $options, $serializerContext, $isJsonMergePatch); + return $schema; } @@ -169,16 +172,7 @@ public function buildSchema(string $className, string $format = 'json', string $ private function buildLegacyPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); - if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { - $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); - } else { - $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); - } - - $propertySchema = array_merge( - $propertyMetadata->getSchema() ?? [], - $additionalPropertySchema ?? [] - ); + $propertySchema = $this->getBasePropertySchema($propertyMetadata, $version); // @see https://github.com/api-platform/core/issues/6299 if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { @@ -300,16 +294,7 @@ private function buildLegacyPropertySchema(Schema $schema, string $definitionNam private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); - if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { - $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); - } else { - $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); - } - - $propertySchema = array_merge( - $propertyMetadata->getSchema() ?? [], - $additionalPropertySchema ?? [] - ); + $propertySchema = $this->getBasePropertySchema($propertyMetadata, $version); $extraProperties = $propertyMetadata->getExtraProperties(); // see AttributePropertyMetadataFactory @@ -566,4 +551,124 @@ private function getSchemaValue(array $schema, string $key): array|string|null return $schema[$key] ?? $schema['allOf'][0][$key] ?? $schema['anyOf'][0][$key] ?? $schema['oneOf'][0][$key] ?? null; } + + private function getBasePropertySchema(ApiProperty $propertyMetadata, string $version): array + { + if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { + $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); + } else { + $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); + } + + return array_merge( + $propertyMetadata->getSchema() ?? [], + $additionalPropertySchema ?? [] + ); + } + + /** + * @return array|null + */ + private function buildSubclassPropertySchema(Schema $schema, ApiProperty $propertyMetadata): ?array + { + $propertySchema = $this->getBasePropertySchema($propertyMetadata, $schema->getVersion()); + + return $propertySchema ?: null; + } + + /** + * Builds polymorphic schema (oneOf + discriminator) when the class has a Symfony DiscriminatorMap attribute. + */ + private function buildDiscriminatorSchema(Schema $schema, \ArrayObject $definitions, string $definitionName, \ArrayObject $definition, string $inputOrOutputClass, string $format, string $type, string $version, array $options, array $serializerContext, bool $isJsonMergePatch): void + { + $reflectionClass = new \ReflectionClass($inputOrOutputClass); + + $discriminatorMapAttributes = $reflectionClass->getAttributes(DiscriminatorMap::class); + if (!$discriminatorMapAttributes) { + return; + } + + $discriminatorMapInstance = $discriminatorMapAttributes[0]->newInstance(); + + $discriminatorReflection = new \ReflectionObject($discriminatorMapInstance); + $typeProperty = $discriminatorReflection->getProperty('typeProperty'); + $mappingProperty = $discriminatorReflection->getProperty('mapping'); + + $typeProperty = $typeProperty->getValue($discriminatorMapInstance); + $mapping = $mappingProperty->getValue($discriminatorMapInstance); + + if (!$mapping) { + return; + } + + $uriPrefix = $this->getSchemaUriPrefix($version); + $oneOf = []; + $discriminatorMapping = []; + + $parentPropertyNames = []; + foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { + $parentPropertyNames[$propertyName] = true; + } + + foreach ($mapping as $typeValue => $subClassName) { + $subDefinitionName = $this->definitionNameFactory->create($subClassName, $format, $subClassName, null, $serializerContext + ['schema_type' => $type]); + + if (isset($definitions[$subDefinitionName])) { + $oneOf[] = ['$ref' => $uriPrefix.$subDefinitionName]; + $discriminatorMapping[$typeValue] = $uriPrefix.$subDefinitionName; + continue; + } + + /** @var \ArrayObject> $subclassProperties */ + $subclassProperties = new \ArrayObject(); + + foreach ($this->propertyNameCollectionFactory->create($subClassName, $options) as $propertyName) { + if (isset($parentPropertyNames[$propertyName])) { + continue; + } + + $propertyMetadata = $this->propertyMetadataFactory->create($subClassName, $propertyName, $options); + + if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { + continue; + } + + $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $subClassName, $format, $serializerContext) : $propertyName; + if ($propertyMetadata->isRequired() && !$isJsonMergePatch) { + $definition['required'][] = $normalizedPropertyName; + } + + if ($propertySchema = $this->buildSubclassPropertySchema($schema, $propertyMetadata)) { + $subclassProperties[$normalizedPropertyName] = $propertySchema; + } + } + + $allOf = [ + ['$ref' => $uriPrefix.$definitionName], + ]; + + if (\count($subclassProperties) > 0) { + $extra = ['type' => 'object', 'properties' => $subclassProperties->getArrayCopy()]; + if (isset($definition['required']) && \count($definition['required']) > 0) { + $extra['required'] = $definition['required']; + } + $allOf[] = $extra; + } + + $subDefinition = new \ArrayObject(['allOf' => new \ArrayObject($allOf)]); + $definitions[$subDefinitionName] = $subDefinition; + + $oneOf[] = ['$ref' => $uriPrefix.$subDefinitionName]; + $discriminatorMapping[$typeValue] = $uriPrefix.$subDefinitionName; + } + + if (\count($oneOf) > 0) { + $definition['oneOf'] = $oneOf; + $normalizedTypeProperty = $this->nameConverter ? $this->nameConverter->normalize($typeProperty, $inputOrOutputClass, $format, $serializerContext) : $typeProperty; + $definition['discriminator'] = [ + 'propertyName' => $normalizedTypeProperty, + 'mapping' => $discriminatorMapping, + ]; + } + } } diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 7b141132aaa..ff00bbf8b94 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -472,17 +472,17 @@ protected function extractAttributes(object $object, ?string $format = null, arr */ protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool { - if (!$this->resourceClassResolver->isResourceClass($context['resource_class'])) { + $contextResourceClass = $context['resource_class']; + if (!$this->resourceClassResolver->isResourceClass($contextResourceClass)) { return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); } - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); // fix for abstract classes and interfaces $options = $this->getFactoryOptions($context); - $propertyNames = $this->propertyNameCollectionFactory->create($resourceClass, $options); + $propertyNames = $this->propertyNameCollectionFactory->create($contextResourceClass, $options); $allowedAttributes = []; foreach ($propertyNames as $propertyName) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName, $options); + $propertyMetadata = $this->propertyMetadataFactory->create($contextResourceClass, $propertyName, $options); if ( $this->isAllowedAttribute($classOrObject, $propertyName, null, $context) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 49266814339..9fc8f3de6bd 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -43,6 +43,10 @@ use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\RelatedDummy; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\SecuredDummy; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Author; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Book; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\Entity\FictionBook; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\Entity\TechnicalBook; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -1979,6 +1983,171 @@ public function testDenormalizeReportsAllMissingConstructorArguments(): void $this->assertSame(['title', 'rating', 'comment'], $e->getMissingConstructorArguments()); } } + + public function testNormalizePolymorphicFictionBook(): void + { + $author = new Author(); + $author->setName('author name'); + + $fictionBook = new FictionBook(); + $fictionBook->setTitle('The Hobbit'); + $fictionBook->setAuthor($author); + $fictionBook->setIsbn('978-0-345-33312-1'); + $fictionBook->setGenre('Fantasy'); + $fictionBook->setPageCount(310); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(FictionBook::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'author', 'isbn', 'bookType', 'genre', 'pageCount'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $stringType = new LegacyType(LegacyType::BUILTIN_TYPE_STRING); + $intType = new LegacyType(LegacyType::BUILTIN_TYPE_INT); + $authorType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, Author::class); + + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'author', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$authorType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'isbn', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'bookType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(false)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'genre', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'pageCount', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$intType])->withReadable(true)->withWritable(true)); + } else { + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'author', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(Author::class))->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'isbn', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'bookType', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(false)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'genre', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(FictionBook::class, 'pageCount', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); + } + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($fictionBook, Argument::cetera())->willReturn('/books/1'); + $iriConverterProphecy->getIriFromResource($author, Argument::cetera())->willReturn('/authors/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($fictionBook, 'title')->willReturn('The Hobbit'); + $propertyAccessorProphecy->getValue($fictionBook, 'author')->willReturn($author); + $propertyAccessorProphecy->getValue($fictionBook, 'isbn')->willReturn('978-0-345-33312-1'); + $propertyAccessorProphecy->getValue($fictionBook, 'bookType')->willReturn('fiction'); + $propertyAccessorProphecy->getValue($fictionBook, 'genre')->willReturn('Fantasy'); + $propertyAccessorProphecy->getValue($fictionBook, 'pageCount')->willReturn(310); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, FictionBook::class)->willReturn(Book::class); + $resourceClassResolverProphecy->getResourceClass($fictionBook, null)->willReturn(FictionBook::class); + $resourceClassResolverProphecy->getResourceClass($author, Author::class)->willReturn(Author::class); + $resourceClassResolverProphecy->isResourceClass(FictionBook::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Author::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('The Hobbit', null, Argument::type('array'))->willReturn('The Hobbit'); + $serializerProphecy->normalize('/authors/1', null, Argument::type('array'))->willReturn('/authors/1'); + $serializerProphecy->normalize('978-0-345-33312-1', null, Argument::type('array'))->willReturn('978-0-345-33312-1'); + $serializerProphecy->normalize('fiction', null, Argument::type('array'))->willReturn('fiction'); + $serializerProphecy->normalize('Fantasy', null, Argument::type('array'))->willReturn('Fantasy'); + $serializerProphecy->normalize(310, null, Argument::type('array'))->willReturn(310); + + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'title' => 'The Hobbit', + 'author' => '/authors/1', + 'isbn' => '978-0-345-33312-1', + 'bookType' => 'fiction', + 'genre' => 'Fantasy', + 'pageCount' => 310, + ]; + $result = $normalizer->normalize($fictionBook, null, ['resources' => []]); + $this->assertSame($expected, $result); + } + + public function testNormalizePolymorphicTechnicalBook(): void + { + $author = new Author(); + $author->setName('author name'); + + $technicalBook = new TechnicalBook(); + $technicalBook->setTitle('Design Patterns'); + $technicalBook->setAuthor($author); + $technicalBook->setIsbn('978-0-201-63361-0'); + $technicalBook->setProgrammingLanguage('C++'); + $technicalBook->setDifficultyLevel('advanced'); + $technicalBook->setTopic('Software Design'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(TechnicalBook::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'author', 'isbn', 'bookType', 'programmingLanguage', 'difficultyLevel', 'topic'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $stringType = new LegacyType(LegacyType::BUILTIN_TYPE_STRING); + $authorType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, Author::class); + + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'author', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$authorType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'isbn', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'bookType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(false)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'programmingLanguage', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'difficultyLevel', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'topic', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$stringType])->withReadable(true)->withWritable(true)); + } else { + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'author', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(Author::class))->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'isbn', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'bookType', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(false)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'programmingLanguage', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'difficultyLevel', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(TechnicalBook::class, 'topic', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + } + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($technicalBook, Argument::cetera())->willReturn('/books/2'); + $iriConverterProphecy->getIriFromResource($author, Argument::cetera())->willReturn('/authors/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($technicalBook, 'title')->willReturn('Design Patterns'); + $propertyAccessorProphecy->getValue($technicalBook, 'author')->willReturn($author); + $propertyAccessorProphecy->getValue($technicalBook, 'isbn')->willReturn('978-0-201-63361-0'); + $propertyAccessorProphecy->getValue($technicalBook, 'bookType')->willReturn('technical'); + $propertyAccessorProphecy->getValue($technicalBook, 'programmingLanguage')->willReturn('C++'); + $propertyAccessorProphecy->getValue($technicalBook, 'difficultyLevel')->willReturn('advanced'); + $propertyAccessorProphecy->getValue($technicalBook, 'topic')->willReturn('Software Design'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, TechnicalBook::class)->willReturn(Book::class); + $resourceClassResolverProphecy->getResourceClass($technicalBook, null)->willReturn(TechnicalBook::class); + $resourceClassResolverProphecy->getResourceClass($author, Author::class)->willReturn(Author::class); + $resourceClassResolverProphecy->isResourceClass(TechnicalBook::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Author::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('Design Patterns', null, Argument::type('array'))->willReturn('Design Patterns'); + $serializerProphecy->normalize('/authors/1', null, Argument::type('array'))->willReturn('/authors/1'); + $serializerProphecy->normalize('978-0-201-63361-0', null, Argument::type('array'))->willReturn('978-0-201-63361-0'); + $serializerProphecy->normalize('technical', null, Argument::type('array'))->willReturn('technical'); + $serializerProphecy->normalize('C++', null, Argument::type('array'))->willReturn('C++'); + $serializerProphecy->normalize('advanced', null, Argument::type('array'))->willReturn('advanced'); + $serializerProphecy->normalize('Software Design', null, Argument::type('array'))->willReturn('Software Design'); + + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'title' => 'Design Patterns', + 'author' => '/authors/1', + 'isbn' => '978-0-201-63361-0', + 'bookType' => 'technical', + 'programmingLanguage' => 'C++', + 'difficultyLevel' => 'advanced', + 'topic' => 'Software Design', + ]; + $result = $normalizer->normalize($technicalBook, null, ['resources' => []]); + $this->assertSame($expected, $result); + } } class ObjectWithBasicProperties diff --git a/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Author.php b/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Author.php new file mode 100644 index 00000000000..5bc79629a3c --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Author.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class Author +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $name; + + public function __construct(string $name = '') + { + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Book.php b/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Book.php new file mode 100644 index 00000000000..96792ae1ab3 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Polymorphism/ApiResource/Book.php @@ -0,0 +1,100 @@ + + * + * 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\Serializer\Tests\Fixtures\Polymorphism\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\Entity\FictionBook; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\Entity\TechnicalBook; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource( + operations: [ + new GetCollection(), + ], +)] +#[ORM\Entity] +#[ORM\InheritanceType('SINGLE_TABLE')] +#[ORM\DiscriminatorColumn(name: 'book_type', type: 'string')] +#[ORM\DiscriminatorMap([ + 'fiction' => FictionBook::class, + 'technical' => TechnicalBook::class, +])] +abstract class Book +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 255)] + private string $title; + + #[ORM\ManyToOne(targetEntity: Author::class)] + #[ORM\JoinColumn(nullable: false)] + private Author $author; + + #[ORM\Column(type: 'string', length: 20)] + private string $isbn; + + public function __construct(string $title = '', ?Author $author = null, string $isbn = '') + { + $this->title = $title; + $this->author = $author ?? new Author('Unknown'); + $this->isbn = $isbn; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAuthor(): Author + { + return $this->author; + } + + public function setAuthor(Author $author): self + { + $this->author = $author; + + return $this; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): self + { + $this->isbn = $isbn; + + return $this; + } + + abstract public function getBookType(): string; +} diff --git a/src/Serializer/Tests/Fixtures/Polymorphism/Entity/FictionBook.php b/src/Serializer/Tests/Fixtures/Polymorphism/Entity/FictionBook.php new file mode 100644 index 00000000000..2bc710a84c0 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Polymorphism/Entity/FictionBook.php @@ -0,0 +1,71 @@ + + * + * 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\Serializer\Tests\Fixtures\Polymorphism\Entity; + +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Author; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Book; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class FictionBook extends Book +{ + public const BOOK_TYPE = 'fiction'; + + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $genre = null; + + #[ORM\Column(type: 'integer', nullable: true)] + private ?int $pageCount = null; + + public function __construct( + string $title = '', + ?Author $author = null, + string $isbn = '', + ?string $genre = null, + ?int $pageCount = null, + ) { + parent::__construct($title, $author, $isbn); + $this->genre = $genre; + $this->pageCount = $pageCount; + } + + public function getBookType(): string + { + return self::BOOK_TYPE; + } + + public function getGenre(): ?string + { + return $this->genre; + } + + public function setGenre(?string $genre): self + { + $this->genre = $genre; + + return $this; + } + + public function getPageCount(): ?int + { + return $this->pageCount; + } + + public function setPageCount(?int $pageCount): self + { + $this->pageCount = $pageCount; + + return $this; + } +} diff --git a/src/Serializer/Tests/Fixtures/Polymorphism/Entity/TechnicalBook.php b/src/Serializer/Tests/Fixtures/Polymorphism/Entity/TechnicalBook.php new file mode 100644 index 00000000000..b259a32d222 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/Polymorphism/Entity/TechnicalBook.php @@ -0,0 +1,88 @@ + + * + * 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\Serializer\Tests\Fixtures\Polymorphism\Entity; + +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Author; +use ApiPlatform\Serializer\Tests\Fixtures\Polymorphism\ApiResource\Book; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class TechnicalBook extends Book +{ + public const BOOK_TYPE = 'technical'; + + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $programmingLanguage = null; + + #[ORM\Column(type: 'string', length: 50, nullable: true)] + private ?string $difficultyLevel = null; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $topic = null; + + public function __construct( + string $title = '', + ?Author $author = null, + string $isbn = '', + ?string $programmingLanguage = null, + ?string $difficultyLevel = null, + ?string $topic = null, + ) { + parent::__construct($title, $author, $isbn); + $this->programmingLanguage = $programmingLanguage; + $this->difficultyLevel = $difficultyLevel; + $this->topic = $topic; + } + + public function getBookType(): string + { + return self::BOOK_TYPE; + } + + public function getProgrammingLanguage(): ?string + { + return $this->programmingLanguage; + } + + public function setProgrammingLanguage(?string $programmingLanguage): self + { + $this->programmingLanguage = $programmingLanguage; + + return $this; + } + + public function getDifficultyLevel(): ?string + { + return $this->difficultyLevel; + } + + public function setDifficultyLevel(?string $difficultyLevel): self + { + $this->difficultyLevel = $difficultyLevel; + + return $this; + } + + public function getTopic(): ?string + { + return $this->topic; + } + + public function setTopic(?string $topic): self + { + $this->topic = $topic; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Author.php b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Author.php new file mode 100644 index 00000000000..20b1ce0b9c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Author.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Polymorphism; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource] +class Author +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name; + + public function __construct(string $name = '') + { + $this->name = $name; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Book.php b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Book.php new file mode 100644 index 00000000000..dda1124c7e7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/Book.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Polymorphism; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; + +#[ApiResource( + uriTemplate: '/polymorphism_books', + operations: [ + new GetCollection(), + new Get(uriTemplate: '/polymorphism_books/{id}'), + ], +)] +#[DiscriminatorMap(typeProperty: 'bookType', mapping: [ + 'fiction' => FictionBook::class, + 'technical' => TechnicalBook::class, +])] +abstract class Book +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $title = ''; + + public ?Author $author = null; + + public string $isbn = ''; + + public function __construct(string $title = '', ?Author $author = null, string $isbn = '') + { + $this->id = null; + $this->title = $title; + $this->author = $author ?? new Author('Unknown'); + $this->isbn = $isbn; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAuthor(): Author + { + return $this->author; + } + + public function setAuthor(Author $author): self + { + $this->author = $author; + + return $this; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): self + { + $this->isbn = $isbn; + + return $this; + } + + abstract public function getBookType(): string; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Polymorphism/FictionBook.php b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/FictionBook.php new file mode 100644 index 00000000000..c215dd8d9d2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/FictionBook.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Polymorphism; + +class FictionBook extends Book +{ + public const BOOK_TYPE = 'fiction'; + + public ?string $genre = null; + + public ?int $pageCount = null; + + public function __construct( + string $title = '', + ?Author $author = null, + string $isbn = '', + ?string $genre = null, + ?int $pageCount = null, + ) { + parent::__construct($title, $author, $isbn); + $this->genre = $genre; + $this->pageCount = $pageCount; + } + + public function getBookType(): string + { + return self::BOOK_TYPE; + } + + public function getGenre(): ?string + { + return $this->genre; + } + + public function setGenre(?string $genre): self + { + $this->genre = $genre; + + return $this; + } + + public function getPageCount(): ?int + { + return $this->pageCount; + } + + public function setPageCount(?int $pageCount): self + { + $this->pageCount = $pageCount; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Polymorphism/TechnicalBook.php b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/TechnicalBook.php new file mode 100644 index 00000000000..68fd84931b5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Polymorphism/TechnicalBook.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Polymorphism; + +class TechnicalBook extends Book +{ + public const BOOK_TYPE = 'technical'; + + public ?string $programmingLanguage = null; + + public ?string $difficultyLevel = null; + + public ?string $topic = null; + + public function __construct( + string $title = '', + ?Author $author = null, + string $isbn = '', + ?string $programmingLanguage = null, + ?string $difficultyLevel = null, + ?string $topic = null, + ) { + parent::__construct($title, $author, $isbn); + $this->programmingLanguage = $programmingLanguage; + $this->difficultyLevel = $difficultyLevel; + $this->topic = $topic; + } + + public function getBookType(): string + { + return self::BOOK_TYPE; + } + + public function getProgrammingLanguage(): ?string + { + return $this->programmingLanguage; + } + + public function setProgrammingLanguage(?string $programmingLanguage): self + { + $this->programmingLanguage = $programmingLanguage; + + return $this; + } + + public function getDifficultyLevel(): ?string + { + return $this->difficultyLevel; + } + + public function setDifficultyLevel(?string $difficultyLevel): self + { + $this->difficultyLevel = $difficultyLevel; + + return $this; + } + + public function getTopic(): ?string + { + return $this->topic; + } + + public function setTopic(?string $topic): self + { + $this->topic = $topic; + + return $this; + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 7aef5c6d172..82ea6c66200 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyWebhook; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6151\OverrideOpenApiResponses; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParentAttribute; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Polymorphism\Book; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; @@ -104,6 +105,7 @@ public static function getResources(): array WrappedResponseEntity::class, ParentAttribute::class, ChildAttribute::class, + Book::class, ]; } @@ -649,4 +651,101 @@ public function testOpenApiSchemaWithNormalizationAttributes(): void $this->assertArrayNotHasKey('hiddenData', $childProperties); $this->assertArrayNotHasKey('id', $childProperties); } + + public function testOpenApiPolymorphicBookSchema(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $this->assertArrayHasKey('components', $json); + $this->assertArrayHasKey('schemas', $json['components']); + + $schemas = $json['components']['schemas']; + + $this->assertArrayHasKey('Book', $schemas, 'Book parent schema should exist'); + $bookSchema = $schemas['Book']; + + $this->assertArrayHasKey('type', $bookSchema); + $this->assertArrayHasKey('properties', $bookSchema); + + $bookSchemaProperties = $bookSchema['properties']; + $this->assertIsArray($bookSchemaProperties); + $this->assertCount(5, $bookSchemaProperties); + + $this->assertArrayHasKey('id', $bookSchemaProperties); + $this->assertArrayHasKey('title', $bookSchemaProperties); + $this->assertArrayHasKey('author', $bookSchemaProperties); + $this->assertArrayHasKey('isbn', $bookSchemaProperties); + $this->assertArrayHasKey('bookType', $bookSchemaProperties); + + $this->assertArrayHasKey('discriminator', $bookSchema); + $discriminator = $bookSchema['discriminator']; + + $this->assertIsArray($discriminator); + + $this->assertArrayHasKey('propertyName', $discriminator); + $this->assertSame('bookType', $discriminator['propertyName']); + + $this->assertArrayHasKey('mapping', $discriminator); + $discriminatorMapping = $discriminator['mapping']; + $this->assertIsArray($discriminatorMapping); + $this->assertArrayHasKey('fiction', $discriminatorMapping); + $this->assertArrayHasKey('technical', $discriminatorMapping); + $this->assertSame('#/components/schemas/FictionBook', $discriminatorMapping['fiction']); + $this->assertSame('#/components/schemas/TechnicalBook', $discriminatorMapping['technical']); + + $this->assertArrayHasKey('oneOf', $bookSchema); + $bookSchemaOneOf = $bookSchema['oneOf']; + $this->assertIsArray($bookSchemaOneOf); + $this->assertCount(2, $bookSchemaOneOf); + + $this->assertArrayHasKey('$ref', $bookSchemaOneOf[0]); + $this->assertSame('#/components/schemas/FictionBook', $bookSchemaOneOf[0]['$ref']); + $this->assertArrayHasKey('$ref', $bookSchemaOneOf[1]); + $this->assertSame('#/components/schemas/TechnicalBook', $bookSchemaOneOf[1]['$ref']); + + $this->assertArrayHasKey('FictionBook', $schemas, 'FictionBook schema should exist'); + $fictionBookSchema = $schemas['FictionBook']; + + $this->assertArrayHasKey('allOf', $fictionBookSchema); + $fictionBookSchemaAllOf = $fictionBookSchema['allOf']; + $this->assertIsArray($fictionBookSchemaAllOf); + $this->assertCount(2, $fictionBookSchemaAllOf); + + $this->assertArrayHasKey('$ref', $fictionBookSchemaAllOf[0]); + $this->assertSame('#/components/schemas/Book', $fictionBookSchemaAllOf[0]['$ref']); + $this->assertArrayHasKey('type', $fictionBookSchemaAllOf[1]); + $this->assertArrayHasKey('properties', $fictionBookSchemaAllOf[1]); + + $fictionBookSchemaAllOfProperties = $fictionBookSchemaAllOf[1]['properties']; + $this->assertIsArray($fictionBookSchemaAllOfProperties); + $this->assertCount(2, $fictionBookSchemaAllOfProperties); + + $this->assertArrayHasKey('genre', $fictionBookSchemaAllOfProperties); + $this->assertArrayHasKey('pageCount', $fictionBookSchemaAllOfProperties); + + $this->assertArrayHasKey('TechnicalBook', $schemas, 'TechnicalBook schema should exist'); + $technicalBookSchema = $schemas['TechnicalBook']; + + $this->assertArrayHasKey('allOf', $technicalBookSchema); + $technicalBookSchemaAllOf = $technicalBookSchema['allOf']; + $this->assertIsArray($technicalBookSchemaAllOf); + $this->assertCount(2, $technicalBookSchemaAllOf); + + $this->assertArrayHasKey('$ref', $technicalBookSchemaAllOf[0]); + $this->assertSame('#/components/schemas/Book', $technicalBookSchemaAllOf[0]['$ref']); + $this->assertArrayHasKey('type', $technicalBookSchemaAllOf[1]); + $this->assertArrayHasKey('properties', $technicalBookSchemaAllOf[1]); + + $technicalBookSchemaAllOfProperties = $technicalBookSchemaAllOf[1]['properties']; + $this->assertIsArray($technicalBookSchemaAllOfProperties); + $this->assertCount(3, $technicalBookSchemaAllOfProperties); + + $this->assertArrayHasKey('programmingLanguage', $technicalBookSchemaAllOfProperties); + $this->assertArrayHasKey('difficultyLevel', $technicalBookSchemaAllOfProperties); + $this->assertArrayHasKey('topic', $technicalBookSchemaAllOfProperties); + } }