From 235acb3b0ef4c879a198851226243b34e6b35c9b Mon Sep 17 00:00:00 2001 From: Amoifr Date: Mon, 24 Aug 2026 11:36:01 +0200 Subject: [PATCH 1/2] feat(metadata): resource and operation level itemUriTemplate --- src/Laravel/Routing/IriConverter.php | 7 ++ src/Metadata/ApiResource.php | 14 +++ .../Extractor/XmlResourceExtractor.php | 6 +- .../Extractor/YamlResourceExtractor.php | 6 +- src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 4 +- src/Metadata/GetCollection.php | 18 +--- src/Metadata/HttpOperation.php | 14 +++ src/Metadata/Patch.php | 4 +- src/Metadata/Post.php | 18 +--- src/Metadata/Put.php | 4 +- .../Extractor/Adapter/XmlResourceAdapter.php | 1 + .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 1 + .../Tests/Extractor/XmlExtractorTest.php | 4 + .../Tests/Extractor/YamlExtractorTest.php | 6 ++ src/Symfony/Routing/IriConverter.php | 7 ++ .../TestBundle/Entity/CanonicalIriEntity.php | 46 ++++++++++ tests/Functional/CanonicalIriTest.php | 87 +++++++++++++++++++ tests/Symfony/Routing/IriConverterTest.php | 22 +++++ 21 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Entity/CanonicalIriEntity.php create mode 100644 tests/Functional/CanonicalIriTest.php diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 82afad6be8a..4ee8cc128f9 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -135,6 +135,13 @@ public function getIriFromResource(object|string $resource, int $referenceType = } $identifiersExtractorOperation = $operation; + + // The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate" + if (!isset($context['item_uri_template']) && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) { + $operation = $this->operationMetadataFactory->create($itemUriTemplate); + $identifiersExtractorOperation = $operation; + } + // In symfony the operation name is the route name, try to find one if none provided if ( !$operation->getName() diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 5d136533d83..bf62bfd0486 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -982,6 +982,7 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, + protected ?string $itemUriTemplate = null, ) { parent::__construct( shortName: $shortName, @@ -1093,6 +1094,19 @@ public function withUriTemplate(string $uriTemplate): static return $self; } + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(?string $itemUriTemplate = null): static + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; + } + public function getTypes(): ?array { return $this->types; diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index d439c1e9815..128ce96b7c5 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -17,9 +17,12 @@ use ApiPlatform\Doctrine\Orm\State\Options as OrmOptions; use ApiPlatform\Elasticsearch\State\Options as ElasticsearchOptions; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\ExternalDocumentation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; @@ -64,6 +67,7 @@ protected function extractPath(string $path): void foreach ($xml->resource as $resource) { $base = $this->buildExtendedBase($resource); $this->resources[$this->resolve((string) $resource['class'])][] = array_merge($base, [ + 'itemUriTemplate' => $this->phpize($resource, 'itemUriTemplate', 'string'), 'operations' => $this->buildOperations($resource, $base), 'graphQlOperations' => $this->buildGraphQlOperations($resource, $base), ]); @@ -411,7 +415,7 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr } } - if (\in_array((string) $operation['class'], [GetCollection::class, Post::class], true)) { + if (\in_array((string) $operation['class'], [GetCollection::class, Post::class, Get::class, Patch::class, Put::class], true)) { $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); } elseif (isset($operation['itemUriTemplate'])) { throw new InvalidArgumentException(\sprintf('"itemUriTemplate" option is not allowed on a %s operation.', $operation['class'])); diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index fe21862d065..a7f2bf436b9 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -17,9 +17,12 @@ use ApiPlatform\Doctrine\Orm\State\Options as OrmOptions; use ApiPlatform\Elasticsearch\State\Options as ElasticsearchOptions; use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\ExternalDocumentation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; @@ -89,6 +92,7 @@ private function buildResources(array $resourcesYaml, string $path): void try { $base = $this->buildExtendedBase($resourceYamlDatum); $this->resources[$resourceName][$resourcesCount + $key] = array_merge($base, [ + 'itemUriTemplate' => $this->phpize($resourceYamlDatum, 'itemUriTemplate', 'string'), 'operations' => $this->buildOperations($resourceYamlDatum, $base), 'graphQlOperations' => $this->buildGraphQlOperations($resourceYamlDatum, $base), ]); @@ -349,7 +353,7 @@ private function buildOperations(array $resource, array $root): ?array } } - if (\in_array((string) $class, [GetCollection::class, Post::class], true)) { + if (\in_array((string) $class, [GetCollection::class, Post::class, Get::class, Patch::class, Put::class], true)) { $datum['itemUriTemplate'] = $this->phpize($operation, 'itemUriTemplate', 'string'); } elseif (isset($operation['itemUriTemplate'])) { throw new InvalidArgumentException(\sprintf('"itemUriTemplate" option is not allowed on a %s operation.', $class)); diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 6019722d6bb..37b44e58dbc 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -22,6 +22,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 82a01f83dc8..28aa13e33df 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -105,6 +105,7 @@ public function __construct( ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, + ?string $itemUriTemplate = null, ) { parent::__construct( uriTemplate: $uriTemplate, @@ -189,7 +190,8 @@ class: $class, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, - map: $map + map: $map, + itemUriTemplate: $itemUriTemplate ); } } diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index a94ae240999..afbea941717 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -104,7 +104,7 @@ public function __construct( ?bool $jsonStream = null, array $extraProperties = [], ?bool $throwOnNotFound = null, - private ?string $itemUriTemplate = null, + ?string $itemUriTemplate = null, ?bool $map = null, ) { parent::__construct( @@ -190,20 +190,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, stateOptions: $stateOptions, - map: $map + map: $map, + itemUriTemplate: $itemUriTemplate ); } - - public function getItemUriTemplate(): ?string - { - return $this->itemUriTemplate; - } - - public function withItemUriTemplate(string $itemUriTemplate): self - { - $self = clone $this; - $self->itemUriTemplate = $itemUriTemplate; - - return $self; - } } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index a8f28f22d83..402379526c1 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -225,6 +225,7 @@ public function __construct( ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, + protected ?string $itemUriTemplate = null, ) { $this->formats = (null === $formats || \is_array($formats)) ? $formats : [$formats]; $this->inputFormats = (null === $inputFormats || \is_array($inputFormats)) ? $inputFormats : [$inputFormats]; @@ -316,6 +317,19 @@ public function withUriTemplate(?string $uriTemplate = null): static return $self; } + public function getItemUriTemplate(): ?string + { + return $this->itemUriTemplate; + } + + public function withItemUriTemplate(?string $itemUriTemplate = null): static + { + $self = clone $this; + $self->itemUriTemplate = $itemUriTemplate; + + return $self; + } + public function getTypes(): ?array { return $this->types; diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 100ac370e7a..356ca090a7a 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -105,6 +105,7 @@ public function __construct( ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, + ?string $itemUriTemplate = null, ) { parent::__construct( method: 'PATCH', @@ -190,7 +191,8 @@ class: $class, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, - map: $map + map: $map, + itemUriTemplate: $itemUriTemplate ); } } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 61a4a059c7c..0dd3ccee3fd 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -102,7 +102,7 @@ public function __construct( ?bool $jsonStream = null, ?bool $throwOnNotFound = null, array $extraProperties = [], - private ?string $itemUriTemplate = null, + ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, ?bool $map = null, @@ -191,20 +191,8 @@ class: $class, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, - map: $map + map: $map, + itemUriTemplate: $itemUriTemplate ); } - - public function getItemUriTemplate(): ?string - { - return $this->itemUriTemplate; - } - - public function withItemUriTemplate(string $itemUriTemplate): self - { - $self = clone $this; - $self->itemUriTemplate = $itemUriTemplate; - - return $self; - } } diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 87529e95879..ff1dbab7f25 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -106,6 +106,7 @@ public function __construct( ?bool $hideHydraOperation = null, private ?bool $allowCreate = null, ?bool $map = null, + ?string $itemUriTemplate = null, ) { parent::__construct( method: 'PUT', @@ -191,7 +192,8 @@ class: $class, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, - map: $map + map: $map, + itemUriTemplate: $itemUriTemplate ); } diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 4bebfa435e7..2c131457231 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -24,6 +24,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface { private const ATTRIBUTES = [ 'uriTemplate', + 'itemUriTemplate', 'shortName', 'description', 'routePrefix', diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 15883953196..c4ca0d619c6 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index fe1595bf154..2a4a3eb16f4 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -350,3 +350,4 @@ resources: 'Lorem ipsum': 'Dolor sit amet' map: null mcp: null + itemUriTemplate: '/users/{userId}/comments/{commentId}{._format}' diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 0accd28d7ca..88449583993 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -62,6 +62,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase null, [ 'uriTemplate' => '/users/{userId}/comments', + 'itemUriTemplate' => '/users/{userId}/comments/{commentId}{._format}', 'shortName' => self::SHORT_NAME, 'description' => 'A list of Comments from User', 'routePrefix' => '/api', diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index 7b3f496d04e..2ca552404eb 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -111,6 +111,7 @@ public function testValidXML(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -291,6 +292,7 @@ public function testValidXML(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, @@ -408,6 +410,7 @@ public function testValidXML(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], 'graphQlOperations' => null, @@ -424,6 +427,7 @@ public function testValidXML(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 3ff86c07f4b..a88bf0c43a3 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -108,6 +108,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], Program::class => [ @@ -184,6 +185,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -331,6 +333,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], [ 'name' => null, @@ -421,6 +424,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], 'graphQlOperations' => null, @@ -437,6 +441,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], SingleFileConfigDummy::class => [ @@ -513,6 +518,7 @@ public function testValidYaml(): void 'jsonldContext' => null, 'throwOnNotFound' => null, + 'itemUriTemplate' => null, ], ], ], $extractor->getResources()); diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index eb5ce19576f..ad772184c2c 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -156,6 +156,13 @@ public function getIriFromResource(object|string $resource, int $referenceType = } $identifiersExtractorOperation = $operation; + + // The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate" + if (!isset($context['item_uri_template']) && $this->operationMetadataFactory && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) { + $operation = $this->operationMetadataFactory->create($itemUriTemplate); + $identifiersExtractorOperation = $operation; + } + // In symfony the operation name is the route name, try to find one if none provided if ( !$operation->getName() diff --git a/tests/Fixtures/TestBundle/Entity/CanonicalIriEntity.php b/tests/Fixtures/TestBundle/Entity/CanonicalIriEntity.php new file mode 100644 index 00000000000..1b1d09d6552 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/CanonicalIriEntity.php @@ -0,0 +1,46 @@ + + * + * 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 ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use Doctrine\ORM\Mapping\Column; +use Doctrine\ORM\Mapping\Entity; +use Doctrine\ORM\Mapping\Id; + +#[ApiResource( + itemUriTemplate: '/canonical_iri_entities/{id}{._format}', + operations: [ + new Get(uriTemplate: '/canonical_iri_entities/{id}{._format}'), + new Patch(uriTemplate: '/canonical_iri_entities/{id}/rename{._format}', read: true), + // the operation-level template has precedence over the resource-level one + new Patch( + uriTemplate: '/canonical_iri_entities/{id}/override{._format}', + itemUriTemplate: '/canonical_iri_entities/{id}/rename{._format}', + read: true, + name: 'patch_override', + ), + ], +)] +#[Entity] +class CanonicalIriEntity +{ + #[Id] + #[Column] + public ?int $id = null; + + #[Column] + public string $name = ''; +} diff --git a/tests/Functional/CanonicalIriTest.php b/tests/Functional/CanonicalIriTest.php new file mode 100644 index 00000000000..9a0d7e08bda --- /dev/null +++ b/tests/Functional/CanonicalIriTest.php @@ -0,0 +1,87 @@ + + * + * 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\Entity\CanonicalIriEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CanonicalIriTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CanonicalIriEntity::class]; + } + + public function testItemOperationWithCustomUriTemplateUsesTheResourceItemUriTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CanonicalIriEntity::class]); + $this->createEntity(); + + self::createClient()->request('PATCH', '/canonical_iri_entities/1/rename', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'renamed'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/canonical_iri_entities/1', + 'name' => 'renamed', + ]); + } + + public function testOperationItemUriTemplateHasPrecedenceOverTheResourceOne(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CanonicalIriEntity::class]); + $this->createEntity(); + + self::createClient()->request('PATCH', '/canonical_iri_entities/1/override', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'renamed again'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/canonical_iri_entities/1/rename', + 'name' => 'renamed again', + ]); + } + + private function createEntity(): void + { + $manager = $this->getManager(); + $entity = new CanonicalIriEntity(); + $entity->id = 1; + $entity->name = 'initial'; + $manager->persist($entity); + $manager->flush(); + } +} diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index 0e05743a303..a5eecf4280b 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -323,6 +323,28 @@ private function getResourceClassResolver() return $resourceClassResolver->reveal(); } + public function testGetIriFromItemOperationWithItemUriTemplate(): void + { + $item = new Dummy(); + $item->setId(1); + + // e.g. a PATCH operation with a custom URI template, whose IRI must point to the canonical operation + $operation = (new \ApiPlatform\Metadata\Patch())->withName('patch_custom')->withItemUriTemplate('/dummies/{id}{._format}'); + $canonicalOperation = (new Get())->withName('canonical_get')->withUriTemplate('/dummies/{id}{._format}'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('canonical_get', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->shouldBeCalled()->willReturn('/dummies/1'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromItem($item, $canonicalOperation, Argument::any())->shouldBeCalled()->willReturn(['id' => 1]); + + $operationMetadataFactoryProphecy = $this->prophesize(OperationMetadataFactoryInterface::class); + $operationMetadataFactoryProphecy->create('/dummies/{id}{._format}')->shouldBeCalled()->willReturn($canonicalOperation); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, $identifiersExtractorProphecy, null, null, null, $operationMetadataFactoryProphecy); + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item, UrlGeneratorInterface::ABS_PATH, $operation)); + } + private function getIriConverter(?ObjectProphecy $stateProviderProphecy = null, ?ObjectProphecy $routerProphecy = null, ?ObjectProphecy $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null, $decorated = null, ?ObjectProphecy $operationMetadataFactory = null): IriConverter { if (!$stateProviderProphecy) { From 243ae88fed8807ba274a26f2a8281baf9cd26f18 Mon Sep 17 00:00:00 2001 From: Pascal CESCON - Amoifr Date: Wed, 2 Sep 2026 12:03:17 +0200 Subject: [PATCH 2/2] Guard the canonical operation lookup and document the option Following review: OperationMetadataFactory::create() can return null, so an unresolvable itemUriTemplate now leaves the operation alone instead of fataling on every IRI of the resource, on Symfony and on Laravel. The branch also sets $context['item_uri_template'], so the two routes to the same canonical operation give IdentifiersExtractor the same context. The IriConverter test moves up with the other tests, uses PHPUnit doubles rather than the Prophecy helper, and gains a case for the unresolvable template. ApiResource::$itemUriTemplate gets a docblock in the style of its neighbours. --- src/Laravel/Routing/IriConverter.php | 8 +- src/Metadata/ApiResource.php | 54 +++++++++++++ src/Symfony/Routing/IriConverter.php | 8 +- tests/Symfony/Routing/IriConverterTest.php | 88 +++++++++++++++++----- 4 files changed, 137 insertions(+), 21 deletions(-) diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 4ee8cc128f9..ed67ebf2577 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -138,8 +138,12 @@ public function getIriFromResource(object|string $resource, int $referenceType = // The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate" if (!isset($context['item_uri_template']) && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) { - $operation = $this->operationMetadataFactory->create($itemUriTemplate); - $identifiersExtractorOperation = $operation; + // An unresolvable template leaves the operation alone rather than breaking every IRI of the resource + if ($canonicalOperation = $this->operationMetadataFactory->create($itemUriTemplate)) { + $operation = $canonicalOperation; + $identifiersExtractorOperation = $operation; + $context['item_uri_template'] = $itemUriTemplate; + } } // In symfony the operation name is the route name, try to find one if none provided diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index bf62bfd0486..7fc685f8f99 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -982,6 +982,60 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, + /** + * The `itemUriTemplate` option is the URI template of the operation that item IRIs of this + * resource point to. It is the default for every operation of the resource, and an operation + * declaring its own `itemUriTemplate` wins over it. + * + * Use it when items are reachable through several URIs and one of them is the canonical one: + * the `@id` of an item read or written through a custom URI then points to the canonical + * operation rather than to the URI the request came from. On `GetCollection` and `Post`, + * where the option already existed, it keeps its meaning: the items of that collection, and + * the item just created, are given that URI. + * + *
+ * + * ```php + * + * + * + * + * + * ``` + * + *
+ */ protected ?string $itemUriTemplate = null, ) { parent::__construct( diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index ad772184c2c..b5b419d4f39 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -159,8 +159,12 @@ public function getIriFromResource(object|string $resource, int $referenceType = // The IRI of an item operation with a custom URI template points to the canonical operation declared with "itemUriTemplate" if (!isset($context['item_uri_template']) && $this->operationMetadataFactory && $operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && null !== ($itemUriTemplate = $operation->getItemUriTemplate())) { - $operation = $this->operationMetadataFactory->create($itemUriTemplate); - $identifiersExtractorOperation = $operation; + // An unresolvable template leaves the operation alone rather than breaking every IRI of the resource + if ($canonicalOperation = $this->operationMetadataFactory->create($itemUriTemplate)) { + $operation = $canonicalOperation; + $identifiersExtractorOperation = $operation; + $context['item_uri_template'] = $itemUriTemplate; + } } // In symfony the operation name is the route name, try to find one if none provided diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index a5eecf4280b..fbc6072755d 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -313,36 +314,89 @@ public function testGetNoItemFromIri(): void $iriConverter->getResourceFromIri('/dummies/1'); } - private function getResourceClassResolver() + public function testGetIriFromItemOperationWithItemUriTemplate(): void { - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(Argument::type('string'))->will(static fn ($args) => true); + $item = new Dummy(); + $item->setId(1); - $resourceClassResolver->getResourceClass(Argument::cetera())->will(static fn ($args) => $args[0]::class); + // e.g. a PATCH operation with a custom URI template, whose IRI must point to the canonical operation + $operation = (new Patch())->withName('patch_custom')->withItemUriTemplate('/dummies/{id}{._format}'); + $canonicalOperation = (new Get())->withName('canonical_get')->withUriTemplate('/dummies/{id}{._format}'); - return $resourceClassResolver->reveal(); + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once())->method('generate') + ->with('canonical_get', ['id' => 1], UrlGeneratorInterface::ABS_PATH) + ->willReturn('/dummies/1'); + + $identifiersExtractor = $this->createMock(IdentifiersExtractorInterface::class); + $identifiersExtractor->expects($this->once())->method('getIdentifiersFromItem') + ->with($item, $canonicalOperation, $this->anything()) + ->willReturn(['id' => 1]); + + $operationMetadataFactory = $this->createMock(OperationMetadataFactoryInterface::class); + $operationMetadataFactory->expects($this->once())->method('create') + ->with('/dummies/{id}{._format}') + ->willReturn($canonicalOperation); + + $iriConverter = $this->createIriConverter($router, $identifiersExtractor, $operationMetadataFactory); + + $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item, UrlGeneratorInterface::ABS_PATH, $operation)); } - public function testGetIriFromItemOperationWithItemUriTemplate(): void + public function testGetIriFromItemOperationWithAnUnresolvableItemUriTemplate(): void { $item = new Dummy(); $item->setId(1); - // e.g. a PATCH operation with a custom URI template, whose IRI must point to the canonical operation - $operation = (new \ApiPlatform\Metadata\Patch())->withName('patch_custom')->withItemUriTemplate('/dummies/{id}{._format}'); - $canonicalOperation = (new Get())->withName('canonical_get')->withUriTemplate('/dummies/{id}{._format}'); + $operation = (new Patch())->withName('patch_custom')->withItemUriTemplate('/does-not-exist/{id}'); - $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->generate('canonical_get', ['id' => 1], UrlGeneratorInterface::ABS_PATH)->shouldBeCalled()->willReturn('/dummies/1'); + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once())->method('generate') + ->with('patch_custom', ['id' => 1], UrlGeneratorInterface::ABS_PATH) + ->willReturn('/dummies/1/custom'); - $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); - $identifiersExtractorProphecy->getIdentifiersFromItem($item, $canonicalOperation, Argument::any())->shouldBeCalled()->willReturn(['id' => 1]); + $identifiersExtractor = $this->createMock(IdentifiersExtractorInterface::class); + $identifiersExtractor->expects($this->once())->method('getIdentifiersFromItem') + ->with($item, $operation, $this->anything()) + ->willReturn(['id' => 1]); - $operationMetadataFactoryProphecy = $this->prophesize(OperationMetadataFactoryInterface::class); - $operationMetadataFactoryProphecy->create('/dummies/{id}{._format}')->shouldBeCalled()->willReturn($canonicalOperation); + $operationMetadataFactory = $this->createMock(OperationMetadataFactoryInterface::class); + $operationMetadataFactory->expects($this->once())->method('create') + ->with('/does-not-exist/{id}') + ->willReturn(null); - $iriConverter = $this->getIriConverter(null, $routerProphecy, $identifiersExtractorProphecy, null, null, null, $operationMetadataFactoryProphecy); - $this->assertSame('/dummies/1', $iriConverter->getIriFromResource($item, UrlGeneratorInterface::ABS_PATH, $operation)); + $iriConverter = $this->createIriConverter($router, $identifiersExtractor, $operationMetadataFactory); + + // the template resolves to nothing, so the operation is left alone instead of breaking the IRI + $this->assertSame('/dummies/1/custom', $iriConverter->getIriFromResource($item, UrlGeneratorInterface::ABS_PATH, $operation)); + } + + private function createIriConverter(RouterInterface $router, IdentifiersExtractorInterface $identifiersExtractor, OperationMetadataFactoryInterface $operationMetadataFactory): IriConverter + { + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + $resourceClassResolver->method('getResourceClass')->willReturnCallback(static fn (?object $item): string => $item::class); + + return new IriConverter( + $this->createStub(ProviderInterface::class), + $router, + $identifiersExtractor, + $resourceClassResolver, + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + null, + null, + $operationMetadataFactory, + ); + } + + private function getResourceClassResolver() + { + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Argument::type('string'))->will(static fn ($args) => true); + + $resourceClassResolver->getResourceClass(Argument::cetera())->will(static fn ($args) => $args[0]::class); + + return $resourceClassResolver->reveal(); } private function getIriConverter(?ObjectProphecy $stateProviderProphecy = null, ?ObjectProphecy $routerProphecy = null, ?ObjectProphecy $identifiersExtractorProphecy = null, $resourceMetadataCollectionFactoryProphecy = null, $uriVariablesConverter = null, $decorated = null, ?ObjectProphecy $operationMetadataFactory = null): IriConverter