From 4330967d37dbdb72f3f586b9f16309abcd0089e0 Mon Sep 17 00:00:00 2001 From: Bram Leeda Date: Mon, 12 Oct 2026 20:06:29 +0200 Subject: [PATCH] fix(metadata): infer schema for Link URI variables from linked property --- src/Metadata/Resource/Factory/LinkFactory.php | 42 +++++++++ .../Resource/Factory/LinkFactoryTest.php | 39 ++++++++ ...eResourceMetadataCollectionFactoryTest.php | 1 + .../LinkSchema/LinkSchemaChild.php | 64 +++++++++++++ .../LinkSchema/LinkSchemaParent.php | 42 +++++++++ tests/Functional/OpenApiLinkSchemaTest.php | 89 +++++++++++++++++++ 6 files changed, 277 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php create mode 100644 tests/Functional/OpenApiLinkSchemaTest.php diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index 9f80599db9..ec5e81c6ac 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Metadata; @@ -141,6 +142,10 @@ public function completeLink(Link $link): Link $link = $link->withCompositeIdentifier(true); } + if (null === $link->getSchema() && null !== ($schema = $this->getSchemaFromLinkedProperty($link))) { + $link = $link->withSchema($schema); + } + return $link; } @@ -198,4 +203,41 @@ private function getPropertyClassType(?Type $type): ?string return TypeHelper::getClassName($type); } + + private function getSchemaFromLinkedProperty(Link $link): ?array + { + $resourceClass = $link->getFromClass() ?? $link->getToClass(); + $property = $link->getFromProperty() ?? $link->getToProperty() ?? $link->getParameterName(); + if (!$resourceClass || !$property) { + return null; + } + + try { + $schema = $this->propertyMetadataFactory->create($resourceClass, $property)->getSchema(); + } catch (PropertyNotFoundException) { + return null; + } + + if (null === $schema || !isset($schema['type'])) { + return null; + } + + if (!\is_array($schema['type'])) { + $schema['type'] = [$schema['type']]; + } + + // Properties can be nullable, but the uriVariable won't be + $schema['type'] = array_diff($schema['type'], ['null']); + + // Link types are limited to primitive types + $schema['type'] = array_values(array_filter($schema['type'], static fn ($type) => \in_array($type, ['string', 'number', 'integer', 'boolean'], true))); + if (!$schema['type']) { + unset($schema['type']); + } elseif (1 === \count($schema['type'])) { + $schema['type'] = $schema['type'][0]; + } + + // only allow the type/format fields + return array_intersect_key($schema, array_flip(['type', 'format'])) ?: null; + } } diff --git a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php index ba7503be6c..026ca7d6d0 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php @@ -143,6 +143,45 @@ public function testCompleteLink(): void ); } + #[DataProvider('provideCompleteLinkSchemaCases')] + public function testCompleteLinkSchema(Link $link, ?array $propertySchema, ?array $expectedSchema): void + { + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->expects($this->never())->method('create'); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->expects($this->any())->method('create')->with(AttributeResource::class, 'uuid')->willReturn(new ApiProperty(schema: $propertySchema)); + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $linkFactory = new LinkFactory($propertyNameCollectionFactory, $propertyMetadataFactory, $resourceClassResolver); + + self::assertEquals( + null === $expectedSchema ? $link : $link->withSchema($expectedSchema), + $linkFactory->completeLink($link) + ); + } + + public static function provideCompleteLinkSchemaCases(): \Generator + { + $schema = ['type' => 'string', 'format' => 'uuid']; + + yield 'fromClass and toProperty' => [new Link(toProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'fromClass and parameterName' => [new Link(parameterName: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'fromClass and fromProperty' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, $schema]; + yield 'no class' => [new Link(parameterName: 'uuid', identifiers: ['uuid']), $schema, null]; + yield 'no property' => [new Link(fromClass: AttributeResource::class, identifiers: ['uuid']), $schema, null]; + + yield 'existing schema - string type is preserved' => [ + new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid'], schema: ['type' => 'string', 'format' => 'custom']), + $schema, + ['type' => 'string', 'format' => 'custom'], + ]; + yield 'existing schema - array type is ignored' => [new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), ['type' => 'array'], null]; + yield 'nullable type is filtered' => [ + new Link(fromProperty: 'uuid', fromClass: AttributeResource::class, identifiers: ['uuid']), + ['type' => ['string', 'null'], 'format' => 'uuid'], + ['type' => 'string', 'format' => 'uuid'], + ]; + } + public function testCreateLinksFromAttributesWithExtendedLink(): void { $propertyNameCollectionFactory = new PropertyInfoPropertyNameCollectionFactory(new PropertyInfoExtractor([new ReflectionExtractor()])); diff --git a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php index 31d47c2ec8..b26605249a 100644 --- a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php @@ -48,6 +48,7 @@ public function testCreate(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Argument::cetera())->willReturn(new PropertyNameCollection()); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Argument::cetera())->willReturn(new ApiProperty()); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $linkFactory = new LinkFactory($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $resourceClassResolverProphecy->reveal()); $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.php new file mode 100644 index 0000000000..18f4aa82d4 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaChild.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\LinkSchema; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/link_schema_parents/{parentId}/children/{id}', + uriVariables: [ + 'parentId' => new Link( + fromProperty: 'id', + fromClass: LinkSchemaParent::class + ), + 'id' => new Link( + fromClass: LinkSchemaChild::class, + identifiers: ['id'] + ), + ], + provider: [LinkSchemaChild::class, 'provide'] + ), + new Get( + uriTemplate: '/link_schema_parents_by_code/{parentCode}/children/{id}', + uriVariables: [ + 'parentCode' => new Link( + fromProperty: 'code', + fromClass: LinkSchemaParent::class + ), + 'id' => new Link( + fromClass: LinkSchemaChild::class, + identifiers: ['id'] + ), + ], + provider: [LinkSchemaChild::class, 'provide'] + ), + ] +)] +class LinkSchemaChild +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $title = null; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php new file mode 100644 index 0000000000..8852588c42 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaParent.php @@ -0,0 +1,42 @@ + + * + * 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\LinkSchema; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Uid\Uuid; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/link_schema_parents/{id}', + provider: [LinkSchemaParent::class, 'provide'] + ), + ] +)] +class LinkSchemaParent +{ + #[ApiProperty(identifier: true)] + public ?Uuid $id = null; + + public ?int $code = null; + + public ?string $name = null; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Functional/OpenApiLinkSchemaTest.php b/tests/Functional/OpenApiLinkSchemaTest.php new file mode 100644 index 0000000000..c9f958d95f --- /dev/null +++ b/tests/Functional/OpenApiLinkSchemaTest.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OpenApiLinkSchemaTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + LinkSchemaParent::class, + LinkSchemaChild::class, + ]; + } + + public function testLinkSchemaIsInferredFromLinkedProperty(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $this->assertArrayHasKey('/link_schema_parents/{parentId}/children/{id}', $json['paths']); + $parameters = $json['paths']['/link_schema_parents/{parentId}/children/{id}']['get']['parameters']; + + $this->assertCount(2, $parameters); + $this->assertSame(['parentId', 'id'], array_column($parameters, 'name')); + $this->assertSame($parameters[0]['name'], 'parentId'); + $this->assertSame($parameters[1]['name'], 'id'); + + $parentParam = $parameters[0]; + $idParam = $parameters[1]; + + $this->assertNotNull($parentParam); + $this->assertSame('path', $parentParam['in']); + $this->assertTrue($parentParam['required']); + $this->assertSame(['type' => 'string', 'format' => 'uuid'], $parentParam['schema']); + + $this->assertNotNull($idParam); + $this->assertSame('path', $idParam['in']); + $this->assertTrue($idParam['required']); + $this->assertSame(['type' => 'integer'], $idParam['schema']); + } + + public function testLinkSchemaIsInferredFromLinkedNonIdentifierProperty(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $this->assertArrayHasKey('/link_schema_parents_by_code/{parentCode}/children/{id}', $json['paths']); + $byCodeParameters = $json['paths']['/link_schema_parents_by_code/{parentCode}/children/{id}']['get']['parameters']; + + $this->assertSame($byCodeParameters[0]['name'], 'parentCode'); + $parentCodeParam = $byCodeParameters[0]; + + $this->assertNotNull($parentCodeParam); + $this->assertSame('path', $parentCodeParam['in']); + $this->assertTrue($parentCodeParam['required']); + $this->assertSame(['type' => 'integer'], $parentCodeParam['schema']); + } +}