diff --git a/src/JsonApi/JsonSchema/SchemaFactory.php b/src/JsonApi/JsonSchema/SchemaFactory.php index eccfb67d3db..e86aedfe6d7 100644 --- a/src/JsonApi/JsonSchema/SchemaFactory.php +++ b/src/JsonApi/JsonSchema/SchemaFactory.php @@ -63,6 +63,7 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI 'format' => 'iri-reference', ], ], + 'required' => ['type', 'id'], ]; private const PROPERTY_PROPS = [ 'id' => [ @@ -321,7 +322,10 @@ private function buildDefinitionPropertiesSchema(string $key, string $className, $refs[$this->getSchemaUriPrefix($schema->getVersion()).$definitionName] = '$ref'; } - $relatedDefinitions[$propertyName] = array_flip($refs); + // keep one entry per related definition: a polymorphic relation targets several resource classes, all of which may appear in "included" + foreach (array_keys($refs) as $ref) { + $relatedDefinitions[$ref] = ['$ref' => $ref]; + } if ($isOne) { $relationships[$propertyName]['properties']['data'] = [ 'oneOf' => [ diff --git a/src/JsonApi/Tests/Fixtures/OtherRelatedDummy.php b/src/JsonApi/Tests/Fixtures/OtherRelatedDummy.php new file mode 100644 index 00000000000..aad2f3e4d52 --- /dev/null +++ b/src/JsonApi/Tests/Fixtures/OtherRelatedDummy.php @@ -0,0 +1,37 @@ + + * + * 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\JsonApi\Tests\Fixtures; + +use ApiPlatform\Metadata\ApiResource; + +/** + * A second related resource used to exercise polymorphic (union-typed) relationships. + */ +#[ApiResource] +class OtherRelatedDummy +{ + private ?int $id = null; + + public ?string $label = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): void + { + $this->id = $id; + } +} diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 5c647242691..648cfd87701 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonApi\JsonSchema\SchemaFactory; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\OtherRelatedDummy; use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; @@ -230,6 +231,110 @@ public function testRelationIsExcludedFromAttributes(): void $this->assertArrayHasKey('relatedDummy', $dataProperties['relationships']['properties']); } + public function testRelationshipLinkageRequiresTypeAndId(): void + { + $schemaFactory = $this->buildSchemaFactoryWithRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $dataProperties = $definitions[$rootDefinitionKey]['properties']['data']['properties']; + + // a resource identifier object MUST contain type and id, @see https://jsonapi.org/format/#document-resource-identifier-objects + $linkage = $dataProperties['relationships']['properties']['relatedDummy']['properties']['data']['oneOf'][1]; + $this->assertSame('object', $linkage['type']); + $this->assertSame(['type', 'id'], $linkage['required']); + } + + public function testIncludedListsAllPolymorphicRelationTargets(): void + { + $schemaFactory = $this->buildSchemaFactoryWithPolymorphicRelation(); + $resultSchema = $schemaFactory->buildSchema(Dummy::class, 'jsonapi'); + + $definitions = $resultSchema->getDefinitions(); + $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); + $included = $definitions[$rootDefinitionKey]['properties']['included']; + + $refs = array_column($included['items']['anyOf'], '$ref'); + $this->assertContains('#/definitions/RelatedDummy.jsonapi', $refs); + $this->assertContains('#/definitions/OtherRelatedDummy.jsonapi', $refs, 'every target of a polymorphic relation must be listed in included'); + } + + private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory + { + $dummyOperation = (new Get())->withName('get')->withShortName('Dummy'); + $relatedOperation = (new Get())->withName('get')->withShortName('RelatedDummy'); + $otherRelatedOperation = (new Get())->withName('get')->withShortName('OtherRelatedDummy'); + + $resourceMetadataFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->create(Dummy::class)->willReturn( + new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $dummyOperation])), + ]) + ); + $resourceMetadataFactory->create(RelatedDummy::class)->willReturn( + new ResourceMetadataCollection(RelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $relatedOperation])), + ]) + ); + $resourceMetadataFactory->create(OtherRelatedDummy::class)->willReturn( + new ResourceMetadataCollection(OtherRelatedDummy::class, [ + (new ApiResource())->withOperations(new Operations(['get' => $otherRelatedOperation])), + ]) + ); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name', 'relatedDummy'])); + $propertyNameCollectionFactory->create(RelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'name'])); + $propertyNameCollectionFactory->create(OtherRelatedDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['id', 'label'])); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Dummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(Dummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::union(Type::object(RelatedDummy::class), Type::object(OtherRelatedDummy::class)))->withReadable(true)->withSchema(['type' => Schema::UNKNOWN_TYPE]) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'id', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withSchema(['type' => 'integer']) + ); + $propertyMetadataFactory->create(OtherRelatedDummy::class, 'label', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withSchema(['type' => 'string']) + ); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); + + $definitionNameFactory = new DefinitionNameFactory(null); + + $baseSchemaFactory = new BaseSchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactory->reveal(), + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + + return new SchemaFactory( + schemaFactory: $baseSchemaFactory, + propertyMetadataFactory: $propertyMetadataFactory->reveal(), + resourceClassResolver: $resourceClassResolver->reveal(), + resourceMetadataFactory: $resourceMetadataFactory->reveal(), + definitionNameFactory: $definitionNameFactory, + ); + } + private function buildSchemaFactoryWithRelation(): SchemaFactory { $dummyOperation = (new Get())->withName('get')->withShortName('Dummy');