diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml index 49cbb1c2b4..cd96ca6d32 100644 --- a/phpunit.baseline.xml +++ b/phpunit.baseline.xml @@ -30,4 +30,19 @@ + + + + + + + + + + + + + + + diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 5130557e36..11f6d20c64 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -26,7 +26,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; @@ -76,16 +75,17 @@ public function normalize(mixed $data, ?string $format = null, array $context = foreach ($data->getResourceNameCollection() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); - $resourceMetadata = $resourceMetadataCollection[0]; - if (true === $resourceMetadata->getHideHydraOperation()) { - continue; - } + foreach ($resourceMetadataCollection as $resourceMetadata) { + if (true === $resourceMetadata->getHideHydraOperation()) { + continue; + } - $shortName = $resourceMetadata->getShortName(); - $prefixedShortName = $resourceMetadata->getTypes()[0] ?? "#$shortName"; + $shortName = $resourceMetadata->getShortName(); + $prefixedShortName = $resourceMetadata->getTypes()[0] ?? "#$shortName"; - $this->populateEntrypointProperties($resourceMetadata, $shortName, $prefixedShortName, $entrypointProperties, $hydraPrefix, $resourceMetadataCollection); - $classes[] = $this->getClass($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $context, $hydraPrefix, $resourceMetadataCollection); + $this->populateEntrypointProperties($resourceMetadata, $shortName, $prefixedShortName, $entrypointProperties, $hydraPrefix); + $classes[] = $this->getClass($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $context, $hydraPrefix); + } } return $this->computeDoc($data, $this->getClasses($entrypointProperties, $classes, $hydraPrefix), $hydraPrefix); @@ -94,9 +94,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = /** * Populates entrypoint properties. */ - private function populateEntrypointProperties(ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array &$entrypointProperties, string $hydraPrefix, ?ResourceMetadataCollection $resourceMetadataCollection = null): void + private function populateEntrypointProperties(ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array &$entrypointProperties, string $hydraPrefix): void { - $hydraCollectionOperations = $this->getHydraOperations(true, $resourceMetadataCollection, $hydraPrefix); + $hydraCollectionOperations = $this->getHydraOperations(true, $resourceMetadata, $hydraPrefix); if (empty($hydraCollectionOperations)) { return; } @@ -135,7 +135,7 @@ private function populateEntrypointProperties(ApiResource $resourceMetadata, str /** * Gets a Hydra class. */ - private function getClass(string $resourceClass, ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array $context, string $hydraPrefix, ?ResourceMetadataCollection $resourceMetadataCollection = null): array + private function getClass(string $resourceClass, ApiResource $resourceMetadata, string $shortName, string $prefixedShortName, array $context, string $hydraPrefix): array { $description = $resourceMetadata->getDescription(); $isDeprecated = $resourceMetadata->getDeprecationReason(); @@ -145,7 +145,7 @@ private function getClass(string $resourceClass, ApiResource $resourceMetadata, '@type' => $hydraPrefix.'Class', $hydraPrefix.'title' => $shortName, $hydraPrefix.'supportedProperty' => $this->getHydraProperties($resourceClass, $resourceMetadata, $shortName, $prefixedShortName, $context, $hydraPrefix), - $hydraPrefix.'supportedOperation' => $this->getHydraOperations(false, $resourceMetadataCollection, $hydraPrefix), + $hydraPrefix.'supportedOperation' => $this->getHydraOperations(false, $resourceMetadata, $hydraPrefix), ]; if (null !== $description) { @@ -252,21 +252,19 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource /** * Gets Hydra operations. */ - private function getHydraOperations(bool $collection, ?ResourceMetadataCollection $resourceMetadataCollection = null, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $hydraOperations = []; - foreach ($resourceMetadataCollection as $resourceMetadata) { - foreach ($resourceMetadata->getOperations() as $operation) { - if (true === $operation->getHideHydraOperation()) { - continue; - } - - if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { - continue; - } + foreach ($resourceMetadata->getOperations() as $operation) { + if (true === $operation->getHideHydraOperation()) { + continue; + } - $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); + if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { + continue; } + + $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); } return $hydraOperations; diff --git a/src/Hydra/Serializer/EntrypointNormalizer.php b/src/Hydra/Serializer/EntrypointNormalizer.php index 8ff3327b6f..5b2cdee4ec 100644 --- a/src/Hydra/Serializer/EntrypointNormalizer.php +++ b/src/Hydra/Serializer/EntrypointNormalizer.php @@ -50,10 +50,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadata as $resource) { - if ($resource->getExtraProperties()['is_alternate_resource_metadata'] ?? false) { - continue; - } - foreach ($resource->getOperations() as $operation) { $key = lcfirst($resource->getShortName()); if (true === $operation->getHideHydraOperation() || !$operation instanceof CollectionOperationInterface || isset($entrypoint[$key])) { diff --git a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php index d04c92cb3b..71b9334125 100644 --- a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php @@ -235,6 +235,88 @@ private function doTestNormalize($resourceMetadataFactory = null): void 'hydra:description' => 'Replaces the dummy resource.', 'returns' => 'dummy', ], + ], + ], + [ + '@id' => '#relatedDummy', + '@type' => 'hydra:Class', + 'hydra:title' => 'relatedDummy', + 'hydra:supportedProperty' => [ + [ + '@type' => 'hydra:SupportedProperty', + 'hydra:property' => [ + '@id' => '#relatedDummy/name', + '@type' => 'rdf:Property', + 'label' => 'name', + 'domain' => '#relatedDummy', + 'range' => 'xsd:string', + ], + 'hydra:title' => 'name', + 'hydra:required' => false, + 'hydra:readable' => true, + 'hydra:writeable' => true, + 'hydra:description' => 'name', + ], + [ + '@type' => 'hydra:SupportedProperty', + 'hydra:property' => [ + '@id' => '#relatedDummy/description', + '@type' => 'rdf:Property', + 'label' => 'description', + 'domain' => '#relatedDummy', + 'range' => '@id', + ], + 'hydra:title' => 'description', + 'hydra:required' => false, + 'hydra:readable' => true, + 'hydra:writeable' => true, + 'hydra:description' => 'description', + ], + [ + '@type' => 'hydra:SupportedProperty', + 'hydra:property' => [ + '@id' => '#relatedDummy/name_converted', + '@type' => 'rdf:Property', + 'label' => 'name_converted', + 'domain' => '#relatedDummy', + 'range' => 'xsd:string', + ], + 'hydra:title' => 'name_converted', + 'hydra:required' => false, + 'hydra:readable' => true, + 'hydra:writeable' => true, + 'hydra:description' => 'name converted', + ], + [ + '@type' => 'hydra:SupportedProperty', + 'hydra:property' => [ + '@id' => '#relatedDummy/relatedDummy', + '@type' => 'rdf:Property', + 'label' => 'relatedDummy', + 'domain' => '#relatedDummy', + 'range' => '#relatedDummy', + ], + 'hydra:title' => 'relatedDummy', + 'hydra:required' => false, + 'hydra:readable' => true, + 'hydra:writeable' => true, + 'hydra:description' => 'This is a name.', + ], + [ + '@type' => 'hydra:SupportedProperty', + 'hydra:property' => [ + '@id' => 'https://schema.org/Dummy', + '@type' => 'rdf:Property', + 'label' => 'iri', + 'domain' => '#relatedDummy', + ], + 'hydra:title' => 'iri', + 'hydra:required' => null, + 'hydra:readable' => null, + 'hydra:writeable' => false, + ], + ], + 'hydra:supportedOperation' => [ [ '@type' => ['hydra:Operation', 'schema:FindAction'], 'hydra:method' => 'GET', @@ -722,6 +804,88 @@ public function testNormalizeWithoutPrefix(): void 'description' => 'Replaces the dummy resource.', 'returns' => 'dummy', ], + ], + ], + [ + '@id' => '#relatedDummy', + '@type' => 'Class', + 'title' => 'relatedDummy', + 'supportedProperty' => [ + [ + '@type' => 'SupportedProperty', + 'property' => [ + '@id' => '#relatedDummy/name', + '@type' => 'rdf:Property', + 'label' => 'name', + 'domain' => '#relatedDummy', + 'range' => 'xsd:string', + ], + 'title' => 'name', + 'required' => false, + 'readable' => true, + 'writeable' => true, + 'description' => 'name', + ], + [ + '@type' => 'SupportedProperty', + 'property' => [ + '@id' => '#relatedDummy/description', + '@type' => 'rdf:Property', + 'label' => 'description', + 'domain' => '#relatedDummy', + 'range' => '@id', + ], + 'title' => 'description', + 'required' => false, + 'readable' => true, + 'writeable' => true, + 'description' => 'description', + ], + [ + '@type' => 'SupportedProperty', + 'property' => [ + '@id' => '#relatedDummy/name_converted', + '@type' => 'rdf:Property', + 'label' => 'name_converted', + 'domain' => '#relatedDummy', + 'range' => 'xsd:string', + ], + 'title' => 'name_converted', + 'required' => false, + 'readable' => true, + 'writeable' => true, + 'description' => 'name converted', + ], + [ + '@type' => 'SupportedProperty', + 'property' => [ + '@id' => '#relatedDummy/relatedDummy', + '@type' => 'rdf:Property', + 'label' => 'relatedDummy', + 'domain' => '#relatedDummy', + 'range' => '#relatedDummy', + ], + 'title' => 'relatedDummy', + 'required' => false, + 'readable' => true, + 'writeable' => true, + 'description' => 'This is a name.', + ], + [ + '@type' => 'SupportedProperty', + 'property' => [ + '@id' => 'https://schema.org/Dummy', + '@type' => 'rdf:Property', + 'label' => 'iri', + 'domain' => '#relatedDummy', + ], + 'title' => 'iri', + 'required' => null, + 'readable' => null, + 'writeable' => false, + ], + ], + 'supportedOperation' => [ [ '@type' => ['Operation', 'schema:FindAction'], 'method' => 'GET', diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 594dc68f5e..af693498a5 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -62,13 +62,14 @@ public function getEntrypointContext(int $referenceType = UrlGeneratorInterface: $context = $this->getBaseContext($referenceType); foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { - $shortName = $this->resourceMetadataFactory->create($resourceClass)[0]->getShortName(); - $resourceName = lcfirst($shortName); + foreach ($this->resourceMetadataFactory->create($resourceClass) as $resource) { + $resourceName = lcfirst($resource->getShortName()); - $context[$resourceName] = [ - '@id' => 'Entrypoint/'.$resourceName, - '@type' => '@id', - ]; + $context[$resourceName] = [ + '@id' => 'Entrypoint/'.$resourceName, + '@type' => '@id', + ]; + } } return $context; diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index 914f22662b..4afe72a63f 100644 --- a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php +++ b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php @@ -202,7 +202,7 @@ private function buildResourceOperations(array $metadataCollection, string $reso $resources[$index] = $resources[$index]->withGraphQlOperations($graphQlOperationsWithDefaults); } - return $resources; + return $this->deduplicateShortNames($resources); } /** @@ -224,6 +224,52 @@ private function hasSameOperation(ApiResource $resource, string $operationClass, return false; } + /** + * When multiple ApiResource declarations on the same class share the same shortName, + * suffix duplicates with an incrementing number (e.g. Book, Book2, Book3). + * + * @param ApiResource[] $resources + * + * @return ApiResource[] + */ + private function deduplicateShortNames(array $resources): array + { + $enabled = $this->defaults['extra_properties']['deduplicate_resource_short_names'] ?? false; + $shortNameCounts = []; + + foreach ($resources as $index => $resource) { + $shortName = $resource->getShortName(); + if (!isset($shortNameCounts[$shortName])) { + $shortNameCounts[$shortName] = 1; + continue; + } + + if (!$enabled) { + if (1 === $shortNameCounts[$shortName]) { + trigger_deprecation('api-platform/core', '4.2', 'Having multiple "#[ApiResource]" attributes with the same "shortName" "%s" on class "%s" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.', $shortName, $resource->getClass()); + } + ++$shortNameCounts[$shortName]; + continue; + } + + $newShortName = $shortName.(++$shortNameCounts[$shortName]); + $resource = $resource->withShortName($newShortName); + + // Update operations to reflect the new shortName + if ($operations = $resource->getOperations()) { + $updatedOperations = []; + foreach ($operations as $key => $operation) { + $updatedOperations[$key] = $operation->withShortName($newShortName); + } + $resource = $resource->withOperations(new Operations($updatedOperations)); + } + + $resources[$index] = $resource; + } + + return $resources; + } + /** * @template T of Metadata * diff --git a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php index b732c16b25..3f744979a0 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -268,6 +268,40 @@ public function testNameDeclarationShouldNotBeRemoved(): void $this->assertTrue($operations->has('password_reset')); } + public function testDeduplicateShortNamesWhenEnabled(): void + { + $factory = new AttributesResourceMetadataCollectionFactory(defaults: [ + 'extra_properties' => ['deduplicate_resource_short_names' => true], + ], graphQlEnabled: true); + + $collection = $factory->create(AttributeResource::class); + + // First resource keeps original shortName + $this->assertSame('AttributeResource', $collection[0]->getShortName()); + + // Second resource gets deduplicated shortName + $this->assertSame('AttributeResource2', $collection[1]->getShortName()); + + // Operations on the second resource also get the deduplicated shortName + foreach ($collection[1]->getOperations() as $operation) { + $this->assertSame('AttributeResource2', $operation->getShortName()); + } + } + + /** @group legacy */ + public function testDeduplicateShortNamesTriggersDeprecationWhenDisabled(): void + { + $factory = new AttributesResourceMetadataCollectionFactory(graphQlEnabled: true); + + $this->expectUserDeprecationMessage('Since api-platform/core 4.2: Having multiple "#[ApiResource]" attributes with the same "shortName" "AttributeResource" on class "ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResource" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.'); + + $collection = $factory->create(AttributeResource::class); + + // Without the flag, shortNames are NOT deduplicated + $this->assertSame('AttributeResource', $collection[0]->getShortName()); + $this->assertSame('AttributeResource', $collection[1]->getShortName()); + } + public function testWithParameters(): void { $attributeResourceMetadataCollectionFactory = new AttributesResourceMetadataCollectionFactory(); diff --git a/src/Metadata/phpunit.baseline.xml b/src/Metadata/phpunit.baseline.xml index b0766d7d30..d4629cdfe6 100644 --- a/src/Metadata/phpunit.baseline.xml +++ b/src/Metadata/phpunit.baseline.xml @@ -5,4 +5,10 @@ + + + + + + diff --git a/tests/Fixtures/TestBundle/ApiResource/MultipleResourceBook.php b/tests/Fixtures/TestBundle/ApiResource/MultipleResourceBook.php new file mode 100644 index 0000000000..a1c70f0a7e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MultipleResourceBook.php @@ -0,0 +1,41 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource( + uriTemplate: '/admin/multi_route_books', +)] +#[ApiResource( + shortName: 'MultipleResourceBook2', + uriTemplate: '/multi_route_books', +)] +class MultipleResourceBook +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $title; + + public string $isbn; + + public function __construct(int $id = 0, string $title = '', string $isbn = '') + { + $this->id = $id; + $this->title = $title; + $this->isbn = $isbn; + } +} diff --git a/tests/Functional/MultipleResourceEntrypointTest.php b/tests/Functional/MultipleResourceEntrypointTest.php new file mode 100644 index 0000000000..0277946dee --- /dev/null +++ b/tests/Functional/MultipleResourceEntrypointTest.php @@ -0,0 +1,121 @@ + + * + * 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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MultipleResourceBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Functional test for entrypoint with multiple ApiResource declarations. + * + * Tests that when a resource has multiple #[ApiResource] attributes, + * both are properly exposed in the entrypoint, context, and documentation, + * and that duplicate shortNames are suffixed with a number. + */ +class MultipleResourceEntrypointTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultipleResourceBook::class]; + } + + /** + * Test that /contexts/Entrypoint exposes both resource shortNames. + * + * The first resource keeps the class shortName (MultipleResourceBook), + * the second is suffixed (MultipleResourceBook2). + */ + public function testEntrypointContextExposesMultipleResources(): void + { + $response = self::createClient()->request('GET', '/contexts/Entrypoint', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayHasKey('@context', $data); + $context = $data['@context']; + + $this->assertArrayHasKey('multipleResourceBook', $context); + $this->assertIsArray($context['multipleResourceBook']); + $this->assertEquals('Entrypoint/multipleResourceBook', $context['multipleResourceBook']['@id']); + $this->assertEquals('@id', $context['multipleResourceBook']['@type']); + + $this->assertArrayHasKey('multipleResourceBook2', $context); + $this->assertIsArray($context['multipleResourceBook2']); + $this->assertEquals('Entrypoint/multipleResourceBook2', $context['multipleResourceBook2']['@id']); + $this->assertEquals('@id', $context['multipleResourceBook2']['@type']); + } + + /** + * Test that /index.jsonld (the entrypoint) exposes both routes. + */ + public function testEntrypointExposesMultipleRoutes(): void + { + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayHasKey('multipleResourceBook', $data); + $this->assertEquals('/admin/multi_route_books', $data['multipleResourceBook']); + + $this->assertArrayHasKey('multipleResourceBook2', $data); + $this->assertEquals('/multi_route_books', $data['multipleResourceBook2']); + } + + /** + * Test that /docs.jsonld documents both resources as supported classes. + */ + public function testDocumentationExposesMultipleResourcesAsSupportedClasses(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + + $this->assertArrayHasKey('hydra:supportedClass', $data); + $supportedClasses = $data['hydra:supportedClass']; + + $firstResourceFound = false; + $secondResourceFound = false; + + foreach ($supportedClasses as $supportedClass) { + if (isset($supportedClass['hydra:title']) && 'MultipleResourceBook' === $supportedClass['hydra:title']) { + $firstResourceFound = true; + $this->assertArrayHasKey('hydra:supportedOperation', $supportedClass); + } + if (isset($supportedClass['hydra:title']) && 'MultipleResourceBook2' === $supportedClass['hydra:title']) { + $secondResourceFound = true; + $this->assertArrayHasKey('hydra:supportedOperation', $supportedClass); + } + } + + $this->assertTrue($firstResourceFound, 'MultipleResourceBook should be in hydra:supportedClass'); + $this->assertTrue($secondResourceFound, 'MultipleResourceBook2 should be in hydra:supportedClass'); + } +} diff --git a/tests/Symfony/Bundle/Command/DebugResourceCommandTest.php b/tests/Symfony/Bundle/Command/DebugResourceCommandTest.php index 92006a89c1..7f512bc735 100644 --- a/tests/Symfony/Bundle/Command/DebugResourceCommandTest.php +++ b/tests/Symfony/Bundle/Command/DebugResourceCommandTest.php @@ -48,6 +48,7 @@ private function getCommandTester(?DataDumperInterface $dumper = null): CommandT return new CommandTester($command); } + /** @group legacy */ public function testDebugResource(): void { $varDumper = $this->prophesize(DataDumperInterface::class); @@ -61,6 +62,7 @@ public function testDebugResource(): void $this->assertStringContainsString('Successfully dumped the selected resource', $commandTester->getDisplay()); } + /** @group legacy */ public function testDebugOperation(): void { $varDumper = $this->prophesize(DataDumperInterface::class);