Skip to content

Commit 4609a9e

Browse files
authored
fix(graphql): dispatch item Query through its own provider (#8237)
Fixes #5805
1 parent f4d2b56 commit 4609a9e

11 files changed

Lines changed: 248 additions & 11 deletions

File tree

src/Doctrine/Common/State/LinksHandlerTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ private function getLinks(string $resourceClass, Operation $operation, array $co
3535
$links = $this->getOperationLinks($operation);
3636

3737
if (!($linkClass = $context['linkClass'] ?? false)) {
38+
// Root item lookup: GraphQl Query.links carries relation links (for nested traversal)
39+
// and the identifier-self link. Keep only the identifier-self / self-references so
40+
// handleLinks applies WHERE id=X without consuming identifiers via relation joins.
41+
if ($operation instanceof GraphQlOperation) {
42+
return array_values(array_filter($links, static fn ($l) => null === $l->getToClass() || $l->getToClass() === $resourceClass));
43+
}
44+
3845
return $links;
3946
}
4047

src/GraphQl/State/Provider/ReadProvider.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
2222
use ApiPlatform\Metadata\GraphQl\Mutation;
2323
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
24+
use ApiPlatform\Metadata\GraphQl\Query;
2425
use ApiPlatform\Metadata\GraphQl\QueryCollection;
2526
use ApiPlatform\Metadata\GraphQl\Subscription;
2627
use ApiPlatform\Metadata\IriConverterInterface;
@@ -63,7 +64,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6364
}
6465

6566
try {
66-
$item = $this->iriConverter->getResourceFromIri($identifier, $context);
67+
// For item Query carrying its own provider, dispatch through that provider.
68+
// Mutation/Subscription keep the route-matched HTTP op so ReadProvider's class-mismatch
69+
// diagnostics below stay accurate.
70+
$dispatchOperation = ($operation instanceof Query && null !== $operation->getProvider()) ? $operation : null;
71+
$item = $this->iriConverter->getResourceFromIri($identifier, $context, $dispatchOperation);
6772
} catch (ItemNotFoundException) {
6873
$item = null;
6974
}

src/Laravel/Eloquent/State/LinksHandler.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ public function handleLinks(Builder $builder, array $uriVariables, array $contex
5656
}
5757

5858
if (!($linkClass = $context['linkClass'] ?? false)) {
59+
// GraphQl root item: walk the identifier-self link to apply WHERE id=X.
60+
// Mirror of Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter.
61+
$resourceClass = $builder->getModel()::class;
62+
foreach ($operation->getLinks() ?? [] as $link) {
63+
if ($link->getFromProperty() || $link->getToProperty()) {
64+
continue;
65+
}
66+
if (null !== $link->getToClass() && $resourceClass !== $link->getToClass()) {
67+
continue;
68+
}
69+
$parameterName = $link->getParameterName() ?? ($link->getIdentifiers()[0] ?? null);
70+
if (null !== $parameterName && isset($uriVariables[$parameterName])) {
71+
$builder = $this->buildQuery($builder, $link, $uriVariables[$parameterName]);
72+
}
73+
}
74+
5975
return $builder;
6076
}
6177

src/Laravel/Routing/IriConverter.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Metadata\Exception\RuntimeException;
2121
use ApiPlatform\Metadata\Get;
2222
use ApiPlatform\Metadata\GetCollection;
23+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
2324
use ApiPlatform\Metadata\HttpOperation;
2425
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
2526
use ApiPlatform\Metadata\IriConverterInterface;
@@ -72,17 +73,21 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation
7273
throw new InvalidArgumentException(\sprintf('No resource associated to "%s".', $iri));
7374
}
7475

75-
$operation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
76+
$routeOperation = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
7677

77-
if ($operation instanceof CollectionOperationInterface) {
78+
if ($routeOperation instanceof CollectionOperationInterface) {
7879
throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri));
7980
}
8081

81-
if (!$operation instanceof HttpOperation) {
82+
if (!$routeOperation instanceof HttpOperation) {
8283
throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri));
8384
}
8485

85-
if ($item = $this->provider->provide($operation, $parameters['uri_variables'], $context)) {
86+
$dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider())
87+
? $operation
88+
: $routeOperation;
89+
90+
if ($item = $this->provider->provide($dispatchOperation, $parameters['uri_variables'], $context)) {
8691
return $item; // @phpstan-ignore-line
8792
}
8893

src/Metadata/Resource/Factory/LinkResourceMetadataCollectionFactory.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Metadata\Resource\Factory;
1515

16+
use ApiPlatform\Metadata\CollectionOperationInterface;
1617
use ApiPlatform\Metadata\Link;
1718
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
1819

@@ -52,6 +53,13 @@ public function create(string $resourceClass): ResourceMetadataCollection
5253
}
5354
$links = $this->mergeLinks($relationLinks, $links);
5455

56+
// Item Query/Mutation/Subscription need the identifier-self link so Doctrine
57+
// LinksHandler can apply `WHERE id = X` when dispatched through the GraphQl op
58+
// (see Doctrine\Common\State\LinksHandlerTrait::getLinks root-mode filter).
59+
if (!$graphQlOperation instanceof CollectionOperationInterface) {
60+
$links = $this->mergeLinks($this->linkFactory->createLinksFromIdentifiers($graphQlOperation), $links);
61+
}
62+
5563
$graphQlOperations[$graphQlOperation->getName()] = $graphQlOperation->withLinks($links);
5664
}
5765

src/Metadata/Tests/Resource/Factory/LinkResourceMetadataCollectionFactoryTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class: AttributeResource::class,
8484
class: AttributeResource::class,
8585
graphQlOperations: [
8686
'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([
87+
(new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['id'])->withParameterName('id'),
8788
(new Link())->withFromProperty('foo')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']),
8889
(new Link())->withFromProperty('foo2')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['id']),
8990
(new Link())->withFromProperty('bar')->withFromClass(AttributeResource::class)->withToClass(RelatedDummy::class)->withIdentifiers(['id']),
@@ -132,6 +133,7 @@ class: AttributeResource::class,
132133
class: AttributeResource::class,
133134
graphQlOperations: [
134135
'item_query' => (new Query(shortName: 'AttributeResource', class: AttributeResource::class))->withLinks([
136+
(new Link())->withFromClass(AttributeResource::class)->withIdentifiers(['identifier'])->withParameterName('identifier'),
135137
(new Link())->withParameterName('dummyId')->withFromProperty('dummy')->withFromClass(AttributeResource::class)->withToClass(Dummy::class)->withIdentifiers(['identifier']),
136138
]),
137139
]

src/Symfony/Routing/IriConverter.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\Metadata\Exception\RuntimeException;
2323
use ApiPlatform\Metadata\Get;
2424
use ApiPlatform\Metadata\GetCollection;
25+
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;
2526
use ApiPlatform\Metadata\HttpOperation;
2627
use ApiPlatform\Metadata\IdentifiersExtractorInterface;
2728
use ApiPlatform\Metadata\IriConverterInterface;
@@ -87,24 +88,30 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation
8788
throw new InvalidArgumentException(\sprintf('The iri "%s" does not reference the correct resource.', $iri));
8889
}
8990

90-
$operation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
91+
$routeOperation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']);
9192

92-
if ($operation instanceof CollectionOperationInterface) {
93+
if ($routeOperation instanceof CollectionOperationInterface) {
9394
throw new InvalidArgumentException(\sprintf('The iri "%s" references a collection not an item.', $iri));
9495
}
9596

96-
if (!$operation instanceof HttpOperation) {
97+
if (!$routeOperation instanceof HttpOperation) {
9798
throw new RuntimeException(\sprintf('The iri "%s" does not reference an HTTP operation.', $iri));
9899
}
99100
$attributes = AttributesExtractor::extractAttributes($parameters);
100101

101102
try {
102-
$uriVariables = $this->getOperationUriVariables($operation, $parameters, $attributes['resource_class']);
103+
$uriVariables = $this->getOperationUriVariables($routeOperation, $parameters, $attributes['resource_class']);
103104
} catch (InvalidIdentifierException|InvalidUriVariableException $e) {
104105
throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
105106
}
106107

107-
if ($item = $this->provider->provide($operation, $uriVariables, $context)) {
108+
// If a caller-provided GraphQl operation carries its own provider, dispatch through it
109+
// so the user-defined Query(provider: X) wins over the route-matched HTTP operation.
110+
$dispatchOperation = ($operation instanceof GraphQlOperation && null !== $operation->getProvider())
111+
? $operation
112+
: $routeOperation;
113+
114+
if ($item = $this->provider->provide($dispatchOperation, $uriVariables, $context)) {
108115
return $item;
109116
}
110117

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\GraphQl\Query;
19+
use ApiPlatform\Metadata\Operation;
20+
use Symfony\Component\Serializer\Attribute\Groups;
21+
22+
#[ApiResource(
23+
operations: [],
24+
graphQlOperations: [
25+
new Query(provider: [Account::class, 'provide']),
26+
],
27+
normalizationContext: ['groups' => ['account:read']],
28+
)]
29+
final class Account
30+
{
31+
public function __construct(
32+
#[ApiProperty(identifier: true)]
33+
#[Groups(['account:read'])]
34+
public string $id = '1',
35+
/**
36+
* @var list<array{key: string}>
37+
*/
38+
#[Groups(['account:read'])]
39+
public array $credentials = [],
40+
) {
41+
}
42+
43+
public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self
44+
{
45+
return new self(id: (string) ($uriVariables['id'] ?? '1'), credentials: [['key' => 'static-value']]);
46+
}
47+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GraphQlCustomQueryProvider;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GraphQl\Query;
20+
use ApiPlatform\Metadata\Operation;
21+
use Symfony\Component\Serializer\Attribute\Groups;
22+
23+
#[ApiResource(
24+
operations: [
25+
new Get(provider: [AccountWithGet::class, 'provideForGet']),
26+
],
27+
graphQlOperations: [
28+
new Query(provider: [AccountWithGet::class, 'provideForQuery']),
29+
],
30+
normalizationContext: ['groups' => ['account_with_get:read']],
31+
)]
32+
final class AccountWithGet
33+
{
34+
public function __construct(
35+
#[ApiProperty(identifier: true)]
36+
#[Groups(['account_with_get:read'])]
37+
public string $id = '1',
38+
#[Groups(['account_with_get:read'])]
39+
public string $source = '',
40+
) {
41+
}
42+
43+
public static function provideForGet(Operation $operation, array $uriVariables = [], array $context = []): self
44+
{
45+
return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'http-get');
46+
}
47+
48+
public static function provideForQuery(Operation $operation, array $uriVariables = [], array $context = []): self
49+
{
50+
return new self(id: (string) ($uriVariables['id'] ?? '1'), source: 'graphql-query');
51+
}
52+
}

tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
new Get(provider: Availability::class.'::getCase'),
2626
],
2727
graphQlOperations: [
28-
new Query(provider: Availability::class.'getCase'),
28+
new Query(provider: Availability::class.'::getCase'),
2929
]
3030
)]
3131
enum Availability: int

0 commit comments

Comments
 (0)