diff --git a/src/Doctrine/Common/State/LinksHandlerTrait.php b/src/Doctrine/Common/State/LinksHandlerTrait.php index 5ff78a3eacf..4074f4be943 100644 --- a/src/Doctrine/Common/State/LinksHandlerTrait.php +++ b/src/Doctrine/Common/State/LinksHandlerTrait.php @@ -35,6 +35,13 @@ private function getLinks(string $resourceClass, Operation $operation, array $co $links = $this->getOperationLinks($operation); if (!($linkClass = $context['linkClass'] ?? false)) { + // Root item lookup: GraphQl Query.links carries relation links (for nested traversal) + // and the identifier-self link. Keep only the identifier-self / self-references so + // handleLinks applies WHERE id=X without consuming identifiers via relation joins. + if ($operation instanceof GraphQlOperation) { + return array_values(array_filter($links, static fn ($l) => null === $l->getToClass() || $l->getToClass() === $resourceClass)); + } + return $links; } diff --git a/src/GraphQl/State/Provider/ReadProvider.php b/src/GraphQl/State/Provider/ReadProvider.php index def06700581..08240ba0b2d 100644 --- a/src/GraphQl/State/Provider/ReadProvider.php +++ b/src/GraphQl/State/Provider/ReadProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\IriConverterInterface; @@ -63,7 +64,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } try { - $item = $this->iriConverter->getResourceFromIri($identifier, $context); + // For item Query carrying its own provider, dispatch through that provider. + // Mutation/Subscription keep the route-matched HTTP op so ReadProvider's class-mismatch + // diagnostics below stay accurate. + $dispatchOperation = ($operation instanceof Query && null !== $operation->getProvider()) ? $operation : null; + $item = $this->iriConverter->getResourceFromIri($identifier, $context, $dispatchOperation); } catch (ItemNotFoundException) { $item = null; } diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index 8eec2a304d3..62bdb13020f 100644 --- a/src/Laravel/Eloquent/State/LinksHandler.php +++ b/src/Laravel/Eloquent/State/LinksHandler.php @@ -56,6 +56,22 @@ public function handleLinks(Builder $builder, array $uriVariables, array $contex } if (!($linkClass = $context['linkClass'] ?? false)) { + // GraphQl root item: walk the identifier-self link to apply WHERE id=X. + // Mirror of Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter. + $resourceClass = $builder->getModel()::class; + foreach ($operation->getLinks() ?? [] as $link) { + if ($link->getFromProperty() || $link->getToProperty()) { + continue; + } + if (null !== $link->getToClass() && $resourceClass !== $link->getToClass()) { + continue; + } + $parameterName = $link->getParameterName() ?? ($link->getIdentifiers()[0] ?? null); + if (null !== $parameterName && isset($uriVariables[$parameterName])) { + $builder = $this->buildQuery($builder, $link, $uriVariables[$parameterName]); + } + } + return $builder; } diff --git a/src/Laravel/Routing/IriConverter.php b/src/Laravel/Routing/IriConverter.php index e0bafb88878..82afad6be8a 100644 --- a/src/Laravel/Routing/IriConverter.php +++ b/src/Laravel/Routing/IriConverter.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; @@ -72,17 +73,21 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new InvalidArgumentException(\sprintf('No resource associated to "%s".', $iri)); } - $operation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); + $routeOperation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); - if ($operation instanceof CollectionOperationInterface) { + if ($routeOperation instanceof CollectionOperationInterface) { throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri)); } - if (!$operation instanceof HttpOperation) { + if (!$routeOperation instanceof HttpOperation) { throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri)); } - if ($item = $this->provider->provide($operation, $parameters['uri_variables'], $context)) { + $dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) + ? $operation + : $routeOperation; + + if ($item = $this->provider->provide($dispatchOperation, $parameters['uri_variables'], $context)) { return $item; // @phpstan-ignore-line } diff --git a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php index dd5107f08d8..be0c9532023 100644 --- a/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -52,6 +53,13 @@ public function create(string $resourceClass): ResourceMetadataCollection } $links = $this->mergeLinks($relationLinks, $links); + // Item Query/Mutation/Subscription need the identifier-self link so Doctrine + // LinksHandler can apply `WHERE id = X` when dispatched through the GraphQl op + // (see Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter). + if (!$graphQlOperation instanceof CollectionOperationInterface) { + $links = $this->mergeLinks($this->linkFactory->createLinksFromIdentifiers($graphQlOperation), $links); + } + $graphQlOperations[$graphQlOperation->getName()] = $graphQlOperation->withLinks($links); } diff --git a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php index 6ccc6cba356..68239216d99 100644 --- a/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php @@ -84,6 +84,7 @@ class: AttributeResource::class, class: AttributeResource::class, graphQlOperations: [ 'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([ + (new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['id'])->withParameterName('id'), (new Link())->withFromProperty('foo')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('foo2')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']), (new Link())->withFromProperty('bar')->withFromClass(AttributeResource::class)->withToClass(RelatedDummy::class)->withIdentifiers(['id']), @@ -132,6 +133,7 @@ class: AttributeResource::class, class: AttributeResource::class, graphQlOperations: [ 'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([ + (new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['identifier'])->withParameterName('identifier'), (new Link())->withParameterName('dummyId')->withFromProperty('dummy')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['identifier']), ]), ] diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 5a381802b7b..eb5ce19576f 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; @@ -87,24 +88,30 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new InvalidArgumentException(\sprintf('The iri "%s" does not reference the correct resource.', $iri)); } - $operation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); + $routeOperation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); - if ($operation instanceof CollectionOperationInterface) { + if ($routeOperation instanceof CollectionOperationInterface) { throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri)); } - if (!$operation instanceof HttpOperation) { + if (!$routeOperation instanceof HttpOperation) { throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri)); } $attributes = AttributesExtractor::extractAttributes($parameters); try { - $uriVariables = $this->getOperationUriVariables($operation, $parameters, $attributes['resource_class']); + $uriVariables = $this->getOperationUriVariables($routeOperation, $parameters, $attributes['resource_class']); } catch (InvalidIdentifierException|InvalidUriVariableException $e) { throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } - if ($item = $this->provider->provide($operation, $uriVariables, $context)) { + // 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. + $dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider()) + ? $operation + : $routeOperation; + + if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) { return $item; } diff --git a/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/Account.php b/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/Account.php new file mode 100644 index 00000000000..dcf157042c4 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/Account.php @@ -0,0 +1,47 @@ + + * + * 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\GraphQlCustomQueryProvider; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + operations: [], + graphQlOperations: [ + new Query(provider: [Account::class, 'provide']), + ], + normalizationContext: ['groups' => ['account:read']], +)] +final class Account +{ + public function __construct( + #[ApiProperty(identifier: true)] + #[Groups(['account:read'])] + public string $id = '1', + /** + * @var list + */ + #[Groups(['account:read'])] + public array $credentials = [], + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self(id: (string) ($uriVariables['id'] ?? '1'), credentials: [['key' => 'static-value']]); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/AccountWithGet.php b/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/AccountWithGet.php new file mode 100644 index 00000000000..5f031009530 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/GraphQlCustomQueryProvider/AccountWithGet.php @@ -0,0 +1,52 @@ + + * + * 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\GraphQlCustomQueryProvider; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + operations: [ + new Get(provider: [AccountWithGet::class, 'provideForGet']), + ], + graphQlOperations: [ + new Query(provider: [AccountWithGet::class, 'provideForQuery']), + ], + normalizationContext: ['groups' => ['account_with_get:read']], +)] +final class AccountWithGet +{ + public function __construct( + #[ApiProperty(identifier: true)] + #[Groups(['account_with_get:read'])] + public string $id = '1', + #[Groups(['account_with_get:read'])] + public string $source = '', + ) { + } + + public static function provideForGet(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'http-get'); + } + + public static function provideForQuery(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'graphql-query'); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php b/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php index fecf4cdabfe..64de9433946 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php @@ -25,7 +25,7 @@ new Get(provider: Availability::class.'::getCase'), ], graphQlOperations: [ - new Query(provider: Availability::class.'getCase'), + new Query(provider: Availability::class.'::getCase'), ] )] enum Availability: int diff --git a/tests/Functional/GraphQl/GraphQlCustomQueryProviderTest.php b/tests/Functional/GraphQl/GraphQlCustomQueryProviderTest.php new file mode 100644 index 00000000000..4a9a35eab2f --- /dev/null +++ b/tests/Functional/GraphQl/GraphQlCustomQueryProviderTest.php @@ -0,0 +1,88 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider\Account; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider\AccountWithGet; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * A resource declaring a GraphQl Query with a custom `provider:` must invoke that + * provider on the root item query — independently of any HTTP item operation. + * + * @see https://github.com/api-platform/core/issues/5805 + */ +final class GraphQlCustomQueryProviderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Account::class, AccountWithGet::class]; + } + + public function testGraphQlQueryUsesCustomProviderWhenNoHttpGetIsDeclared(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + account(id: "/accounts/1") { + id + credentials + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('/accounts/1', $json['data']['account']['id']); + $this->assertSame([['key' => 'static-value']], $json['data']['account']['credentials']); + } + + public function testGraphQlQueryUsesItsOwnProviderWhenHttpGetHasDifferentOne(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + accountWithGet(id: "/account_with_gets/1") { + id + source + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('/account_with_gets/1', $json['data']['accountWithGet']['id']); + $this->assertSame('graphql-query', $json['data']['accountWithGet']['source']); + } + + public function testHttpGetStillUsesItsOwnProvider(): void + { + $response = self::createClient()->request('GET', '/account_with_gets/1'); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains(['source' => 'http-get']); + } +}