From e9333c52255a34dddacaad14fa8b5d1d3508e1b2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 7 Nov 2025 11:25:21 +0100 Subject: [PATCH 1/3] fix(jsonld): read identifier with itemUriTemplate --- src/Metadata/IdentifiersExtractor.php | 20 +++++++++++-- tests/Functional/JsonLdTest.php | 41 ++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index a9f5efe801e..300afd87d82 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -62,6 +62,9 @@ public function getIdentifiersFromItem(object $item, ?Operation $operation = nul return $this->getIdentifiersFromOperation($item, $operation, $context); } + /** + * @param array $context + */ private function getIdentifiersFromOperation(object $item, Operation $operation, array $context = []): array { if ($operation instanceof HttpOperation) { @@ -75,7 +78,7 @@ private function getIdentifiersFromOperation(object $item, Operation $operation, if (1 < (is_countable($link->getIdentifiers()) ? \count($link->getIdentifiers()) : 0)) { $compositeIdentifiers = []; foreach ($link->getIdentifiers() as $identifier) { - $compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $identifier, $link->getParameterName()); + $compositeIdentifiers[$identifier] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $identifier, $link->getParameterName(), null, $context, $operation); } $identifiers[$link->getParameterName()] = CompositeIdentifierParser::stringify($compositeIdentifiers); @@ -83,7 +86,7 @@ private function getIdentifiersFromOperation(object $item, Operation $operation, } $parameterName = $link->getParameterName(); - $identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $link->getIdentifiers()[0] ?? $k, $parameterName, $link->getToProperty()); + $identifiers[$parameterName] = $this->getIdentifierValue($item, $link->getFromClass() ?? $operation->getClass(), $link->getIdentifiers()[0] ?? $k, $parameterName, $link->getToProperty(), $context, $operation); } return $identifiers; @@ -91,8 +94,10 @@ private function getIdentifiersFromOperation(object $item, Operation $operation, /** * Gets the value of the given class property. + * + * @param array $context */ - private function getIdentifierValue(object $item, string $class, string $property, string $parameterName, ?string $toProperty = null): float|bool|int|string + private function getIdentifierValue(object $item, string $class, string $property, string $parameterName, ?string $toProperty, array $context, Operation $operation): float|bool|int|string { if ($item instanceof $class) { try { @@ -102,6 +107,15 @@ private function getIdentifierValue(object $item, string $class, string $propert } } + // ItemUriTemplate is defined on a collection and we read the identifier alghough the PHP class may be different + if (isset($context['item_uri_template']) && $operation->getClass() === $class) { + try { + return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, $property), $parameterName); + } catch (NoSuchPropertyException $e) { + throw new RuntimeException(\sprintf('Could not retrieve identifier "%s" for class "%s" using itemUriTemplate "%s". Check that the property exists and is accessible.', $property, $class, $context['item_uri_template']), $e->getCode(), $e); + } + } + if ($toProperty) { return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$toProperty.$property"), $parameterName); } diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php index 250193d0ed6..90db99eeab8 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -22,6 +22,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\ImageModuleResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\PageResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\TitleModuleResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\Recipe; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; use ApiPlatform\Tests\SetupClassResourcesTrait; @@ -39,7 +41,20 @@ class JsonLdTest extends ApiTestCase */ public static function getResources(): array { - return [Foo::class, Bar::class, JsonLdContextOutput::class, GenIdFalse::class, AggregateRating::class, LevelFirst::class, LevelThird::class, PageResource::class, TitleModuleResource::class, ImageModuleResource::class]; + return [ + Foo::class, + Bar::class, + JsonLdContextOutput::class, + GenIdFalse::class, + AggregateRating::class, + LevelFirst::class, + LevelThird::class, + PageResource::class, + TitleModuleResource::class, + ImageModuleResource::class, + Recipe::class, + RecipeCollection::class, + ]; } /** @@ -129,6 +144,30 @@ public function testIssue7298(): void ]); } + public function testItemUriTemplate(): void + { + self::createClient()->request( + 'GET', + '/item_uri_template_recipes', + ); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'RecipeCollection', + '@id' => '/item_uri_template_recipes/1', + 'name' => 'Dummy Recipe', + ], + [ + '@type' => 'RecipeCollection', + '@id' => '/item_uri_template_recipes/2', + 'name' => 'Dummy Recipe 2', + ], + ], + ]); + } + protected function setUp(): void { self::bootKernel(); From e5d1f8e3fab2332e520516dfc07f27452fb2d47e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 7 Nov 2025 12:17:42 +0100 Subject: [PATCH 2/3] read item uri template operation to get proper @type --- src/JsonLd/Serializer/ItemNormalizer.php | 12 ++- .../Bundle/Resources/config/jsonld.xml | 1 + .../ItemUriTemplateWithCollection/Recipe.php | 76 +++++++++++++++++++ .../RecipeCollection.php | 54 +++++++++++++ tests/Fixtures/TestBundle/Entity/Recipe.php | 63 +++++++++++++++ tests/Functional/JsonLdTest.php | 60 ++++++++++++++- 6 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/Recipe.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/RecipeCollection.php create mode 100644 tests/Fixtures/TestBundle/Entity/Recipe.php diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 15efdaf4124..8fceae7be37 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -70,7 +71,7 @@ final class ItemNormalizer extends AbstractItemNormalizer '@vocab', ]; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); } @@ -115,7 +116,9 @@ public function normalize(mixed $object, ?string $format = null, array $context $context['output']['iri'] = null; } - if ($this->resourceClassResolver->isResourceClass($resourceClass)) { + if (isset($context['item_uri_template']) && $this->operationMetadataFactory) { + $context['output']['operation'] = $this->operationMetadataFactory->create($context['item_uri_template']); + } elseif ($this->resourceClassResolver->isResourceClass($resourceClass)) { $context['output']['operation'] = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } @@ -141,6 +144,11 @@ public function normalize(mixed $object, ?string $format = null, array $context } $operation = $context['operation'] ?? null; + + if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) { + $operation = $this->operationMetadataFactory->create($context['item_uri_template']); + } + if ($isResourceClass && !$operation) { $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } diff --git a/src/Symfony/Bundle/Resources/config/jsonld.xml b/src/Symfony/Bundle/Resources/config/jsonld.xml index 6bb7ea31d1d..33f2105f6bb 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Symfony/Bundle/Resources/config/jsonld.xml @@ -31,6 +31,7 @@ %api_platform.serializer.default_context% + diff --git a/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/Recipe.php b/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/Recipe.php new file mode 100644 index 00000000000..9bc43d8a610 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/Recipe.php @@ -0,0 +1,76 @@ + + * + * 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\ItemUriTemplateWithCollection; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; + +#[Get( + uriTemplate: '/item_uri_template_recipes/{id}{._format}', + shortName: 'ItemRecipe', + uriVariables: ['id'], + provider: [self::class, 'provide'], + openapi: false +)] +#[Get( + uriTemplate: '/item_uri_template_recipes_state_option/{id}{._format}', + shortName: 'ItemRecipe', + uriVariables: ['id'], + openapi: false, + stateOptions: new Options(entityClass: EntityRecipe::class) +)] +class Recipe +{ + public ?string $id; + public ?string $name = null; + + public ?string $description = null; + + public ?string $author = null; + + public ?array $recipeIngredient = []; + + public ?string $recipeInstructions = null; + + public ?string $prepTime = null; + + public ?string $cookTime = null; + + public ?string $totalTime = null; + + public ?string $recipeCategory = null; + + public ?string $recipeCuisine = null; + + public ?string $suitableForDiet = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $recipe = new self(); + $recipe->id = '1'; + $recipe->name = 'Dummy Recipe'; + $recipe->description = 'A simple recipe for testing purposes.'; + $recipe->prepTime = 'PT15M'; + $recipe->cookTime = 'PT30M'; + $recipe->totalTime = 'PT45M'; + $recipe->recipeYield = '2 servings'; + $recipe->recipeCategory = ['Lunch', 'Dinner']; + $recipe->recipeIngredient = ['Ingredient 1', 'Ingredient 2']; + $recipe->recipeInstructions = 'Do these things.'; + + return $recipe; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/RecipeCollection.php b/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/RecipeCollection.php new file mode 100644 index 00000000000..411852ad70c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ItemUriTemplateWithCollection/RecipeCollection.php @@ -0,0 +1,54 @@ + + * + * 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\ItemUriTemplateWithCollection; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; + +#[GetCollection( + uriTemplate: '/item_uri_template_recipes{._format}', + provider: [self::class, 'provide'], + openapi: false, + shortName: 'CollectionRecipe', + itemUriTemplate: '/item_uri_template_recipes/{id}{._format}', + normalizationContext: ['hydra_prefix' => false], +)] +#[GetCollection( + uriTemplate: '/item_uri_template_recipes_state_option{._format}', + openapi: false, + shortName: 'CollectionRecipe', + itemUriTemplate: '/item_uri_template_recipes_state_option/{id}{._format}', + stateOptions: new Options(entityClass: EntityRecipe::class), + normalizationContext: ['hydra_prefix' => false], +)] +class RecipeCollection +{ + public ?string $id; + public ?string $name = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $recipe = new self(); + $recipe->id = '1'; + $recipe->name = 'Dummy Recipe'; + + $recipe2 = new self(); + $recipe2->id = '2'; + $recipe2->name = 'Dummy Recipe 2'; + + return [$recipe, $recipe2]; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Recipe.php b/tests/Fixtures/TestBundle/Entity/Recipe.php new file mode 100644 index 00000000000..b816abf70d3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Recipe.php @@ -0,0 +1,63 @@ + + * + * 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\Entity; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class Recipe +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $name = null; + + #[ORM\Column(type: 'text', nullable: true)] + public ?string $description = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $author = null; + + #[ORM\Column(type: 'json', nullable: true)] + public ?array $recipeIngredient = []; + + #[ORM\Column(type: 'text', nullable: true)] + public ?string $recipeInstructions = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $prepTime = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $cookTime = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $totalTime = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $recipeCategory = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $recipeCuisine = null; + + #[ORM\Column(type: 'string', nullable: true)] + public ?string $suitableForDiet = null; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php index 90db99eeab8..d8835867522 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -26,6 +26,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; use ApiPlatform\Tests\SetupClassResourcesTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Tools\SchemaTool; @@ -155,12 +156,12 @@ public function testItemUriTemplate(): void $this->assertJsonContains([ 'member' => [ [ - '@type' => 'RecipeCollection', + '@type' => 'ItemRecipe', '@id' => '/item_uri_template_recipes/1', 'name' => 'Dummy Recipe', ], [ - '@type' => 'RecipeCollection', + '@type' => 'ItemRecipe', '@id' => '/item_uri_template_recipes/2', 'name' => 'Dummy Recipe 2', ], @@ -168,6 +169,59 @@ public function testItemUriTemplate(): void ]); } + public function testItemUriTemplateWithStateOption(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + for ($i = 0; $i < 10; ++$i) { + $recipe = new EntityRecipe(); + $recipe->name = "Recipe $i"; + $recipe->description = "Description of recipe $i"; + $recipe->author = "Author $i"; + $recipe->recipeIngredient = [ + "Ingredient 1 for recipe $i", + "Ingredient 2 for recipe $i", + ]; + $recipe->recipeInstructions = "Instructions for recipe $i"; + $recipe->prepTime = '10 minutes'; + $recipe->cookTime = '20 minutes'; + $recipe->totalTime = '30 minutes'; + $recipe->recipeCategory = "Category $i"; + $recipe->recipeCuisine = "Cuisine $i"; + $recipe->suitableForDiet = "Diet $i"; + + $manager->persist($recipe); + } + $manager->flush(); + + self::createClient()->request( + 'GET', + '/item_uri_template_recipes_state_option', + ); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'ItemRecipe', + '@id' => '/item_uri_template_recipes_state_option/1', + 'name' => 'Recipe 0', + ], + [ + '@type' => 'ItemRecipe', + '@id' => '/item_uri_template_recipes_state_option/2', + 'name' => 'Recipe 1', + ], + [ + '@type' => 'ItemRecipe', + '@id' => '/item_uri_template_recipes_state_option/3', + 'name' => 'Recipe 2', + ], + ], + ]); + } + protected function setUp(): void { self::bootKernel(); @@ -180,7 +234,7 @@ protected function setUp(): void } $classes = []; - foreach ([Foo::class, Bar::class] as $entityClass) { + foreach ([Foo::class, Bar::class, EntityRecipe::class] as $entityClass) { $classes[] = $manager->getClassMetadata($entityClass); } From 63ab5112db3cf6185c28a0bee5bdf43c2448115b Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 7 Nov 2025 17:03:42 +0100 Subject: [PATCH 3/3] ask gregoire --- features/hydra/item_uri_template.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/hydra/item_uri_template.feature b/features/hydra/item_uri_template.feature index 5653bb13e3d..0c732832351 100644 --- a/features/hydra/item_uri_template.feature +++ b/features/hydra/item_uri_template.feature @@ -146,13 +146,13 @@ Feature: Exposing a collection of objects should use the specified operation to "hydra:member":[ { "@id":"/item_referenced_in_collection/a", - "@type":"CollectionReferencingItem", + "@type":"ItemReferencedInCollection", "id":"a", "name":"hello" }, { "@id":"/item_referenced_in_collection/b", - "@type":"CollectionReferencingItem", + "@type":"ItemReferencedInCollection", "id":"b", "name":"you" }