diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 45af71d08c7..e557c899c83 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -151,6 +151,7 @@ use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolver; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\OpenApi\Command\OpenApiCommand; @@ -661,6 +662,7 @@ public function register(): void }); $this->app->bind(IriConverterInterface::class, IriConverter::class); + $this->app->bind(UriVariablesFromIriInterface::class, IriConverter::class); $this->app->singleton(IriConverter::class, static function (Application $app) { return new IriConverter($app->make(CallableProvider::class), $app->make(OperationMetadataFactoryInterface::class), $app->make(UrlGeneratorRouter::class), $app->make(IdentifiersExtractorInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(SkolemIriConverter::class), null, $app->make(OperationResourceClassResolverInterface::class)); }); diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index 029c31b703c..671ee6c929d 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; @@ -38,7 +39,7 @@ use Symfony\Component\Routing\Exception\ExceptionInterface as RoutingExceptionInterface; use Symfony\Component\Routing\RouterInterface; -class IriConverter implements IriConverterInterface +class IriConverter implements IriConverterInterface, UriVariablesFromIriInterface { use ClassInfoTrait; use ResourceClassInfoTrait; @@ -67,6 +68,37 @@ public function __construct(private readonly ProviderInterface $provider, privat * @param array $context */ public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object + { + [$routeOperation, $uriVariables] = $this->resolveIri($iri); + + $dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) + ? $operation + : $routeOperation; + + if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) { + return $item; // @phpstan-ignore-line + } + + throw new ItemNotFoundException(\sprintf('Item not found for "%s".', $iri)); + } + + /** + * @param array $context + * + * @return array + */ + public function getUriVariablesFromIri(string $iri, array $context = [], ?Operation $operation = null): array + { + return $this->resolveIri($iri, $operation)[1]; + } + + /** + * Matches the IRI against the router and resolves the item operation and its URI variables, + * without calling the state provider. When $operation is given, the IRI must reference its resource class. + * + * @return array{0: HttpOperation, 1: array} + */ + private function resolveIri(string $iri, ?Operation $operation = null): array { try { $parameters = $this->router->match($iri); @@ -78,6 +110,10 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new InvalidArgumentException(\sprintf('No resource associated to "%s".', $iri)); } + if ($operation && !is_a($parameters['_api_resource_class'], $operation->getClass(), true)) { + throw new InvalidArgumentException(\sprintf('The iri "%s" does not reference the correct resource.', $iri)); + } + $routeOperation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); if ($routeOperation instanceof CollectionOperationInterface) { @@ -88,15 +124,7 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri)); } - $dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) - ? $operation - : $routeOperation; - - if ($item = $this->provider->provide($dispatchOperation, $parameters['uri_variables'], $context)) { - return $item; // @phpstan-ignore-line - } - - throw new ItemNotFoundException(\sprintf('Item not found for "%s".', $iri)); + return [$routeOperation, $parameters['uri_variables']]; } /** diff --git a/src/Laravel/Tests/Unit/Routing/IriConverterTest.php b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php index 6904708474f..70ec5d24e65 100644 --- a/src/Laravel/Tests/Unit/Routing/IriConverterTest.php +++ b/src/Laravel/Tests/Unit/Routing/IriConverterTest.php @@ -263,4 +263,59 @@ public function testGetResourceFromIriWithoutGetRoute(): void $iriConverter->getResourceFromIri('/api/only_posts'); } + + public function testGetUriVariablesFromIriDoesNotCallTheProvider(): void + { + $itemOpName = 'item_op'; + $itemOp = (new Get())->withName($itemOpName)->withClass(Book::class); + + $router = $this->createMock(RouterInterface::class); + $router->expects($this->once())->method('match')->with('/api/books/1')->willReturn([ + '_api_resource_class' => Book::class, + '_api_operation_name' => $itemOpName, + 'uri_variables' => ['id' => '1'], + ]); + + $resourceMetadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->method('create')->willReturn(new ResourceMetadataCollection(Book::class, [ + (new ApiResource())->withOperations(new Operations([$itemOpName => $itemOp])), + ])); + + $provider = $this->createMock(ProviderInterface::class); + $provider->expects($this->never())->method('provide'); + + $iriConverter = new IriConverter( + $provider, + $this->createStub(OperationMetadataFactoryInterface::class), + $router, + $this->createStub(IdentifiersExtractorInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + $resourceMetadataFactory, + ); + + $this->assertSame(['id' => '1'], $iriConverter->getUriVariablesFromIri('/api/books/1')); + } + + public function testGetUriVariablesFromIriWithAnotherResourceOperation(): void + { + $router = $this->createStub(RouterInterface::class); + $router->method('match')->willReturn([ + '_api_resource_class' => Book::class, + '_api_operation_name' => 'item_op', + 'uri_variables' => ['id' => '1'], + ]); + + $iriConverter = new IriConverter( + $this->createStub(ProviderInterface::class), + $this->createStub(OperationMetadataFactoryInterface::class), + $router, + $this->createStub(IdentifiersExtractorInterface::class), + $this->createStub(ResourceClassResolverInterface::class), + $this->createStub(ResourceMetadataCollectionFactoryInterface::class), + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The iri "/api/books/1" does not reference the correct resource.'); + $iriConverter->getUriVariablesFromIri('/api/books/1', [], (new Get())->withClass(BookVariant::class)); + } } diff --git a/src/Mcp/Routing/IriConverter.php b/src/Mcp/Routing/IriConverter.php index b77af4994b2..11e434dc7d7 100644 --- a/src/Mcp/Routing/IriConverter.php +++ b/src/Mcp/Routing/IriConverter.php @@ -13,16 +13,18 @@ namespace ApiPlatform\Mcp\Routing; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; /** * @experimental */ -final class IriConverter implements IriConverterInterface +final class IriConverter implements IriConverterInterface, UriVariablesFromIriInterface { public function __construct(private readonly IriConverterInterface $inner) { @@ -33,6 +35,15 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation return $this->inner->getResourceFromIri($iri, $context, $operation); } + public function getUriVariablesFromIri(string $iri, array $context = [], ?Operation $operation = null): array + { + if (!$this->inner instanceof UriVariablesFromIriInterface) { + throw new InvalidArgumentException(\sprintf('The decorated IRI converter "%s" does not implement "%s".', $this->inner::class, UriVariablesFromIriInterface::class)); + } + + return $this->inner->getUriVariablesFromIri($iri, $context, $operation); + } + public function getIriFromResource(object|string $resource, int $referenceType = UrlGeneratorInterface::ABS_PATH, ?Operation $operation = null, array $context = []): ?string { if (($operation instanceof McpTool || $operation instanceof McpResource) && !isset($context['item_uri_template'])) { diff --git a/src/Mcp/Tests/Routing/IriConverterTest.php b/src/Mcp/Tests/Routing/IriConverterTest.php index c3706413d43..71330b72658 100644 --- a/src/Mcp/Tests/Routing/IriConverterTest.php +++ b/src/Mcp/Tests/Routing/IriConverterTest.php @@ -14,12 +14,14 @@ namespace ApiPlatform\Mcp\Tests\Routing; use ApiPlatform\Mcp\Routing\IriConverter; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -42,6 +44,30 @@ public function testGetResourceFromIriIsDelegated(): void $this->assertSame($expected, $iriConverter->getResourceFromIri('/dummies/1', ['foo' => 'bar'], $operation)); } + public function testGetUriVariablesFromIriIsDelegated(): void + { + $operation = new Get(); + + $inner = $this->createMockForIntersectionOfInterfaces([IriConverterInterface::class, UriVariablesFromIriInterface::class]); + $inner->expects($this->once()) + ->method('getUriVariablesFromIri') + ->with('/dummies/1', ['foo' => 'bar'], $operation) + ->willReturn(['id' => 1]); + + $iriConverter = new IriConverter($inner); + + $this->assertSame(['id' => 1], $iriConverter->getUriVariablesFromIri('/dummies/1', ['foo' => 'bar'], $operation)); + } + + public function testGetUriVariablesFromIriThrowsWhenTheDecoratedConverterDoesNotSupportIt(): void + { + $iriConverter = new IriConverter($this->createStub(IriConverterInterface::class)); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('does not implement "%s".', UriVariablesFromIriInterface::class)); + $iriConverter->getUriVariablesFromIri('/dummies/1'); + } + /** * MCP operations are not routed, so they have no IRI to expose: the decorated * converter must not be asked for one. diff --git a/src/Metadata/UriVariablesFromIriInterface.php b/src/Metadata/UriVariablesFromIriInterface.php new file mode 100644 index 00000000000..0ca86af5ac8 --- /dev/null +++ b/src/Metadata/UriVariablesFromIriInterface.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\Metadata; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; + +/** + * Extracts the URI variables of an item from its IRI, without loading the item. + */ +interface UriVariablesFromIriInterface +{ + /** + * Retrieves the URI variables (identifiers) of the item an IRI references, without loading the item. + * + * The returned array is keyed by the operation's URI variable names (for example `['id' => 1]`), + * which are the same keys the `uri_variables` context of {@see IriConverterInterface::getIriFromResource()} accepts. + * Values are converted to the identifier's type by the UriVariablesConverter when one is available. + * + * @param array $context + * + * @phpstan-param array $context + * + * @psalm-param array{resource_class?: string|class-string, uri_variables?: array, ...} $context + * + * @throws InvalidArgumentException when no route matches the IRI, the IRI references a collection, or it does not reference the expected resource + * + * @return array + */ + public function getUriVariablesFromIri(string $iri, array $context = [], ?Operation $operation = null): array; +} diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index 91f8ce1d406..aff0e8cba48 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\ResourceClassResolver; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UriVariablesConverter; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UriVariableTransformer\ApiResourceUriVariableTransformer; use ApiPlatform\Metadata\UriVariableTransformer\DateTimeUriVariableTransformer; use ApiPlatform\Metadata\UriVariableTransformer\IntegerUriVariableTransformer; @@ -303,6 +304,7 @@ ]); $services->alias(IriConverterInterface::class, 'api_platform.symfony.iri_converter'); + $services->alias(UriVariablesFromIriInterface::class, 'api_platform.symfony.iri_converter'); $services->set('api_platform.state.error_provider', ErrorProvider::class) ->arg('$debug', '%kernel.debug%') diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index a5e316c2e53..daa78706386 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -31,6 +31,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\Metadata\UriVariablesFromIriInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\AttributesExtractor; use ApiPlatform\Metadata\Util\ClassInfoTrait; @@ -45,7 +46,7 @@ * * @author Antoine Bluchet */ -final class IriConverter implements IriConverterInterface +final class IriConverter implements IriConverterInterface, UriVariablesFromIriInterface { use ClassInfoTrait; use ResourceClassInfoTrait; @@ -64,6 +65,45 @@ public function __construct(private readonly ProviderInterface $provider, privat * {@inheritdoc} */ public function getResourceFromIri(string $iri, array $context = [], ?Operation $operation = null): object + { + [$dispatchOperation, $uriVariables] = $this->resolveIri($iri, $context, $operation); + + // If a caller-provided GraphQl operation carries its own provider, dispatch through it + // so the user-defined Query(provider: X) wins over the route-matched HTTP operation. + if ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) { + $dispatchOperation = $operation; + } + + if (false === ($context['map_resource'] ?? null)) { + $dispatchOperation = $dispatchOperation->withMap(false); + } + + if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) { + return $item; + } + + throw new ItemNotFoundException(\sprintf('Item not found for "%s".', $iri)); + } + + /** + * {@inheritdoc} + */ + public function getUriVariablesFromIri(string $iri, array $context = [], ?Operation $operation = null): array + { + return $this->resolveIri($iri, $context, $operation)[1]; + } + + /** + * Matches the IRI against the router and resolves the item operation and its URI variables, + * without calling the state provider. + * + * @param array $context + * + * @throws InvalidArgumentException + * + * @return array{0: HttpOperation, 1: array} + */ + private function resolveIri(string $iri, array $context, ?Operation $operation): array { try { $parameters = $this->router->match($iri); @@ -118,21 +158,7 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } - // If a caller-provided GraphQl operation carries its own provider, dispatch through it - // so the user-defined Query(provider: X) wins over the route-matched HTTP operation. - if ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) { - $dispatchOperation = $operation; - } - - if (false === ($context['map_resource'] ?? null)) { - $dispatchOperation = $dispatchOperation->withMap(false); - } - - if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) { - return $item; - } - - throw new ItemNotFoundException(\sprintf('Item not found for "%s".', $iri)); + return [$dispatchOperation, $uriVariables]; } /** diff --git a/tests/Symfony/Routing/IriConverterTest.php b/tests/Symfony/Routing/IriConverterTest.php index f987a11d72d..9078dfb46df 100644 --- a/tests/Symfony/Routing/IriConverterTest.php +++ b/tests/Symfony/Routing/IriConverterTest.php @@ -27,6 +27,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Symfony\Routing\IriConverter; @@ -37,6 +38,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\RouterInterface; class IriConverterTest extends TestCase @@ -423,6 +425,134 @@ public function testGetNoItemFromIri(): void $iriConverter->getResourceFromIri('/dummies/1'); } + public function testGetUriVariablesFromIri(): void + { + $operationName = 'operation_name'; + $operation = (new Get())->withUriVariables(['id' => new Link()])->withName($operationName); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/dummies/1')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + 'id' => '1', + ]); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([$operationName => $operation])), + ])); + + $stateProviderProphecy = $this->prophesize(ProviderInterface::class); + $stateProviderProphecy->provide(Argument::cetera())->shouldNotBeCalled(); + $iriConverter = $this->getIriConverter($stateProviderProphecy, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy); + $this->assertSame(['id' => '1'], $iriConverter->getUriVariablesFromIri('/dummies/1')); + } + + public function testGetUriVariablesFromIriUsesTheUriVariablesConverter(): void + { + $operationName = 'operation_name'; + $operation = (new Get())->withUriVariables(['id' => new Link()])->withName($operationName)->withClass(Dummy::class); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/dummies/1')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + 'id' => '1', + ]); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([$operationName => $operation])), + ])); + $uriVariablesConverterProphecy = $this->prophesize(UriVariablesConverterInterface::class); + $uriVariablesConverterProphecy->convert(['id' => '1'], Dummy::class, Argument::withEntry('operation', $operation))->shouldBeCalled()->willReturn(['id' => 1]); + + $stateProviderProphecy = $this->prophesize(ProviderInterface::class); + $stateProviderProphecy->provide(Argument::cetera())->shouldNotBeCalled(); + $iriConverter = $this->getIriConverter($stateProviderProphecy, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy, $uriVariablesConverterProphecy->reveal()); + $this->assertSame(['id' => 1], $iriConverter->getUriVariablesFromIri('/dummies/1')); + } + + public function testGetUriVariablesFromIriWithCompositeIdentifiers(): void + { + $operationName = 'operation_name'; + $operation = (new Get())->withUriVariables(['id' => (new Link())->withIdentifiers(['first', 'second'])->withCompositeIdentifier(true)])->withName($operationName); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/composites/first=1;second=2')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + 'id' => 'first=1;second=2', + ]); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([$operationName => $operation])), + ])); + + $stateProviderProphecy = $this->prophesize(ProviderInterface::class); + $stateProviderProphecy->provide(Argument::cetera())->shouldNotBeCalled(); + $iriConverter = $this->getIriConverter($stateProviderProphecy, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy); + $this->assertSame(['first' => '1', 'second' => '2'], $iriConverter->getUriVariablesFromIri('/composites/first=1;second=2')); + } + + public function testGetUriVariablesFromCollectionIri(): void + { + $operationName = 'operation_name'; + $operation = (new GetCollection())->withName($operationName); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The iri "/dummies" references a collection not an item.'); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/dummies')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + ]); + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([$operationName => $operation])), + ])); + + $iriConverter = $this->getIriConverter(null, $routerProphecy, null, $resourceMetadataCollectionFactoryProphecy); + $iriConverter->getUriVariablesFromIri('/dummies'); + } + + public function testGetUriVariablesFromIriWithNoMatchingRoute(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No route matches "/unknown/1".'); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/unknown/1')->shouldBeCalled()->willThrow(new ResourceNotFoundException()); + + $iriConverter = $this->getIriConverter(null, $routerProphecy); + $iriConverter->getUriVariablesFromIri('/unknown/1'); + } + + public function testGetUriVariablesFromIriWithAnotherResourceOperation(): void + { + $operationName = 'operation_name'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The iri "/dummies/1" does not reference the correct resource.'); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/dummies/1')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + 'id' => '1', + ]); + + $iriConverter = $this->getIriConverter(null, $routerProphecy); + $iriConverter->getUriVariablesFromIri('/dummies/1', [], (new Get())->withClass(RelatedDummy::class)); + } + + public function testGetUriVariablesFromIriWithMismatchingContextUriVariables(): void + { + $operationName = 'operation_name'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The iri "/dummies/1" does not reference the correct resource.'); + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->match('/dummies/1')->shouldBeCalled()->willReturn([ + '_api_resource_class' => Dummy::class, + '_api_operation_name' => $operationName, + 'id' => '1', + ]); + + $iriConverter = $this->getIriConverter(null, $routerProphecy); + $iriConverter->getUriVariablesFromIri('/dummies/1', ['uri_variables' => ['id' => '2']]); + } + private function getMockedIriConverter(RouterInterface $router, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?IdentifiersExtractorInterface $identifiersExtractor = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null): IriConverter { if (!$resourceClassResolver) {