From 4330967d37dbdb72f3f586b9f16309abcd0089e0 Mon Sep 17 00:00:00 2001 From: Bram Leeda Date: Mon, 12 Oct 2026 20:06:29 +0200 Subject: [PATCH 1/3] 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']); + } +} From 96f218a02c6c15c631730a195b9179420b1bcd9b Mon Sep 17 00:00:00 2001 From: Bram Leeda Date: Fri, 16 Oct 2026 16:34:33 +0200 Subject: [PATCH 2/3] moved link identifier schema logic from LinkFactory to OpenApiFactory --- src/Metadata/Resource/Factory/LinkFactory.php | 41 ------- .../Resource/Factory/LinkFactoryTest.php | 39 ------ src/OpenApi/Factory/OpenApiFactory.php | 47 +++++++- .../Tests/Factory/OpenApiFactoryTest.php | 111 +++++++++++++++++- .../LinkSchema/LinkSchemaArrayIdentifier.php | 48 ++++++++ tests/Functional/OpenApiLinkSchemaTest.php | 29 +++++ 6 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaArrayIdentifier.php diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index ec5e81c6ac..e3799874bb 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -142,10 +142,6 @@ 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; } @@ -203,41 +199,4 @@ 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 026ca7d6d0..ba7503be6c 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkFactoryTest.php @@ -143,45 +143,6 @@ 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/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 93f6244651..99a64ffcd6 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\FilterInterface; use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link as MetadataLink; use ApiPlatform\Metadata\Parameter as MetadataParameter; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -331,7 +332,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $uriVariable->getRequired() ?? true, false, null, - $uriVariable->getSchema() ?? ['type' => 'string'], + $this->getIdentifierSchema($uriVariable), ); if ($linkParameter = $uriVariable->getOpenApi()) { @@ -1258,4 +1259,48 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d return $this->localErrorResourceCache[$error] = $errorResource; } + + private function getIdentifierSchema(MetadataLink $link): array + { + $defaultSchema = ['type' => 'string']; + if (null !== ($schema = $link->getSchema())) { + return $schema; + } + + $identifiers = $link->getIdentifiers(); + if (true === $link->getCompositeIdentifier() || (null !== $identifiers && 1 !== \count($identifiers))) { + return $defaultSchema; + } + + $class = $link->getFromClass() ?? $link->getToClass(); + $property = $link->getFromProperty() ?? $link->getToProperty() ?? $link->getParameterName(); + + try { + $schema = $this->propertyMetadataFactory->create($class, $property)->getSchema(); + } catch (\Throwable) { + return $defaultSchema; + } + + if (null === $schema || !isset($schema['type'])) { + return $defaultSchema; + } + + if (!\is_array($schema['type'])) { + $schema['type'] = [$schema['type']]; + } + + // Remove possible null type, as this is not possible in the uriVariable context + $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']) { + return $defaultSchema; + } + + $schema['type'] = 1 === \count($schema['type']) ? $schema['type'][0] : $schema['type']; + + // only allow the type/format fields + return array_intersect_key($schema, array_flip(['type', 'format'])) ?: $defaultSchema; + } } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index e134c1bfac..a02ce6bf8c 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -787,7 +787,7 @@ public function testInvoke(): void 'Retrieves a Dummy resource.', 'Retrieves a Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])] ), $dummyPath->getGet()); $this->assertEquals(new Operation( @@ -821,7 +821,7 @@ public function testInvoke(): void 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])], new RequestBody( 'The updated Dummy resource', new \ArrayObject([ @@ -845,7 +845,7 @@ public function testInvoke(): void 'Removes the Dummy resource.', 'Removes the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])] ), $dummyPath->getDelete()); $customPath = $paths->getPath('/foo/{id}'); @@ -929,7 +929,7 @@ public function testInvoke(): void 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])], new RequestBody( 'The updated Dummy resource', new \ArrayObject([ @@ -1979,4 +1979,107 @@ private function getGeneratedQueryParameters(array $parameters, ?ContainerInterf return $openApi->getPaths()->getPath('/dummies')->getGet()->getParameters() ?? []; } + + public function testInferUriVariableSchema(): void + { + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $definitionNameFactory = new DefinitionNameFactory(); + + $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource(operations: [ + (new Get()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withUriTemplate('/dummies/{id}') + ->withUriVariables([ + 'id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id']), + ]), + (new Get()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withUriTemplate('/dummies/uuid/{uuid}') + ->withUriVariables([ + 'uuid' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['uuid']), + ]), + (new Get()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withUriTemplate('/dummies/composite/{id}') + ->withUriVariables([ + 'id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id', 'other'])->withCompositeIdentifier(true), + ]), + (new Get()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withUriTemplate('/dummies/custom/{id}') + ->withUriVariables([ + 'id' => (new Link(schema: ['type' => 'number']))->withFromClass(Dummy::class)->withIdentifiers(['id']), + ]), + ]))->withClass(Dummy::class), + ]); + + $resourceCollectionMetadataFactory + ->method('create') + ->willReturnCallback(static fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { + default => new ResourceMetadataCollection($resourceClass, []), + Dummy::class => $resourceCollectionMetadata, + }); + + $resourceNameCollectionFactory + ->method('create') + ->willReturn(new ResourceNameCollection([Dummy::class])); + + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['id', 'uuid'])); + + $propertyMetadataFactory + ->method('create') + ->willReturnCallback(static fn (string $resourceClass, string $property): ApiProperty => match ($property) { + 'id' => (new ApiProperty())->withIdentifier(true)->withSchema(['type' => 'integer', 'description' => 'Dummy ID', 'readOnly' => true]), + 'uuid' => (new ApiProperty())->withIdentifier(true)->withSchema(['type' => 'string', 'format' => 'uuid', 'description' => 'UUID']), + default => new ApiProperty(), + }); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactory, + $resourceCollectionMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $openApi = $factory->__invoke(); + + // Single integer identifier -> schema type: integer (type/format only) + $idParam = $openApi->getPaths()->getPath('/dummies/{id}')->getGet()->getParameters()[0]; + $this->assertSame(['type' => 'integer'], $idParam->getSchema()); + + // Single uuid identifier -> schema type: string, format: uuid + $uuidParam = $openApi->getPaths()->getPath('/dummies/uuid/{uuid}')->getGet()->getParameters()[0]; + $this->assertSame(['type' => 'string', 'format' => 'uuid'], $uuidParam->getSchema()); + + // Composite identifier -> schema type: string + $compositeParam = $openApi->getPaths()->getPath('/dummies/composite/{id}')->getGet()->getParameters()[0]; + $this->assertSame(['type' => 'string'], $compositeParam->getSchema()); + + // Custom schema on Link -> schema preserved + $customParam = $openApi->getPaths()->getPath('/dummies/custom/{id}')->getGet()->getParameters()[0]; + $this->assertSame(['type' => 'number'], $customParam->getSchema()); + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaArrayIdentifier.php b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaArrayIdentifier.php new file mode 100644 index 0000000000..7c000e889a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/LinkSchema/LinkSchemaArrayIdentifier.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\LinkSchema; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/link_schema_array_identifiers/{id}', + uriVariables: [ + 'id' => new Link( + fromProperty: 'id', + fromClass: LinkSchemaArrayIdentifier::class, + identifiers: ['id'] + ), + ], + provider: [LinkSchemaArrayIdentifier::class, 'provide'] + ), + ] +)] +class LinkSchemaArrayIdentifier +{ + /** + * @var string[] + */ + #[ApiProperty(identifier: true, schema: ['type' => 'array', 'items' => ['type' => 'string']])] + public array $id = []; + + public static function provide(): self + { + return new self(); + } +} diff --git a/tests/Functional/OpenApiLinkSchemaTest.php b/tests/Functional/OpenApiLinkSchemaTest.php index c9f958d95f..c1ff1a00ff 100644 --- a/tests/Functional/OpenApiLinkSchemaTest.php +++ b/tests/Functional/OpenApiLinkSchemaTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Functional; use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaArrayIdentifier; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaChild; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkSchema\LinkSchemaParent; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -32,6 +33,7 @@ public static function getResources(): array return [ LinkSchemaParent::class, LinkSchemaChild::class, + LinkSchemaArrayIdentifier::class, ]; } @@ -66,6 +68,25 @@ public function testLinkSchemaIsInferredFromLinkedProperty(): void $this->assertSame(['type' => 'integer'], $idParam['schema']); } + public function testLinkSchemaFallsBackToStringForArrayType(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + $this->assertResponseIsSuccessful(); + + $json = $response->toArray(); + + $this->assertArrayHasKey('/link_schema_array_identifiers/{id}', $json['paths']); + $parameters = $json['paths']['/link_schema_array_identifiers/{id}']['get']['parameters']; + + $this->assertCount(1, $parameters); + $this->assertSame('id', $parameters[0]['name']); + $this->assertSame('path', $parameters[0]['in']); + $this->assertTrue($parameters[0]['required']); + $this->assertSame(['type' => 'string'], $parameters[0]['schema']); + } + public function testLinkSchemaIsInferredFromLinkedNonIdentifierProperty(): void { $response = self::createClient()->request('GET', '/docs', [ @@ -85,5 +106,13 @@ public function testLinkSchemaIsInferredFromLinkedNonIdentifierProperty(): void $this->assertSame('path', $parentCodeParam['in']); $this->assertTrue($parentCodeParam['required']); $this->assertSame(['type' => 'integer'], $parentCodeParam['schema']); + + $this->assertSame($byCodeParameters[1]['name'], 'id'); + $parentId = $byCodeParameters[1]; + + $this->assertNotNull($parentId); + $this->assertSame('path', $parentId['in']); + $this->assertTrue($parentId['required']); + $this->assertSame(['type' => 'integer'], $parentId['schema']); } } From 100ad0fa6cbcc377162d7ee09563c898af790944 Mon Sep 17 00:00:00 2001 From: Bram Leeda Date: Fri, 16 Oct 2026 16:54:07 +0200 Subject: [PATCH 3/3] remove unused import from old implementation corrected OpenApiFactoryTest, adding the fromProperty field to the used Links --- src/Metadata/Resource/Factory/LinkFactory.php | 1 - src/OpenApi/Tests/Factory/OpenApiFactoryTest.php | 16 ++++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index e3799874bb..9f80599db9 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Metadata\Resource\Factory; -use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Metadata; diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index a02ce6bf8c..1ae797b64f 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -787,7 +787,7 @@ public function testInvoke(): void 'Retrieves a Dummy resource.', 'Retrieves a Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])] + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] ), $dummyPath->getGet()); $this->assertEquals(new Operation( @@ -821,7 +821,7 @@ public function testInvoke(): void 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])], + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], new RequestBody( 'The updated Dummy resource', new \ArrayObject([ @@ -845,7 +845,7 @@ public function testInvoke(): void 'Removes the Dummy resource.', 'Removes the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])] + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])] ), $dummyPath->getDelete()); $customPath = $paths->getPath('/foo/{id}'); @@ -929,7 +929,7 @@ public function testInvoke(): void 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', null, - [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'integer'])], + [new Parameter('id', 'path', 'Dummy identifier', true, false, false, ['type' => 'string'])], new RequestBody( 'The updated Dummy resource', new \ArrayObject([ @@ -1995,28 +1995,28 @@ public function testInferUriVariableSchema(): void ->withShortName('Dummy') ->withUriTemplate('/dummies/{id}') ->withUriVariables([ - 'id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id']), + 'id' => (new Link())->withFromClass(Dummy::class)->withFromProperty('id')->withIdentifiers(['id']), ]), (new Get()) ->withClass(Dummy::class) ->withShortName('Dummy') ->withUriTemplate('/dummies/uuid/{uuid}') ->withUriVariables([ - 'uuid' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['uuid']), + 'uuid' => (new Link())->withFromClass(Dummy::class)->withFromProperty('uuid')->withIdentifiers(['uuid']), ]), (new Get()) ->withClass(Dummy::class) ->withShortName('Dummy') ->withUriTemplate('/dummies/composite/{id}') ->withUriVariables([ - 'id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id', 'other'])->withCompositeIdentifier(true), + 'id' => (new Link())->withFromClass(Dummy::class)->withFromProperty('id')->withIdentifiers(['id', 'other'])->withCompositeIdentifier(true), ]), (new Get()) ->withClass(Dummy::class) ->withShortName('Dummy') ->withUriTemplate('/dummies/custom/{id}') ->withUriVariables([ - 'id' => (new Link(schema: ['type' => 'number']))->withFromClass(Dummy::class)->withIdentifiers(['id']), + 'id' => (new Link(schema: ['type' => 'number']))->withFromClass(Dummy::class)->withFromProperty('id')->withIdentifiers(['id']), ]), ]))->withClass(Dummy::class), ]);