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) {