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/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..1ae797b64f 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -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)->withFromProperty('id')->withIdentifiers(['id']), + ]), + (new Get()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withUriTemplate('/dummies/uuid/{uuid}') + ->withUriVariables([ + '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)->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)->withFromProperty('id')->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/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..c1ff1a00ff --- /dev/null +++ b/tests/Functional/OpenApiLinkSchemaTest.php @@ -0,0 +1,118 @@ + + * + * 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\LinkSchemaArrayIdentifier; +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, + LinkSchemaArrayIdentifier::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 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', [ + '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']); + + $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']); + } +}