Skip to content

Commit bbeaf70

Browse files
soyukaalanpoulain
andauthored
fix(graphql): always allow to query nested resources (api-platform#5112)
* fix(graphql): always allow to query nested resources * review Co-authored-by: Alan Poulain <[email protected]>
1 parent c1cb3cd commit bbeaf70

21 files changed

+583
-250
lines changed

features/graphql/collection.feature

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,3 +910,49 @@ Feature: GraphQL collection support
910910
Then the response status code should be 200
911911
And the response should be in JSON
912912
And the JSON node "data.fooDummies.collection" should have 1 element
913+
914+
@createSchema
915+
Scenario: Retrieve paginated collections using mixed pagination
916+
Given there are 5 fooDummy objects with fake names
917+
When I send the following GraphQL request:
918+
"""
919+
{
920+
fooDummies(page: 1) {
921+
collection {
922+
id
923+
name
924+
soManies(first: 2) {
925+
edges {
926+
node {
927+
content
928+
}
929+
cursor
930+
}
931+
pageInfo {
932+
startCursor
933+
endCursor
934+
hasNextPage
935+
hasPreviousPage
936+
}
937+
}
938+
}
939+
paginationInfo {
940+
itemsPerPage
941+
lastPage
942+
totalCount
943+
}
944+
}
945+
}
946+
"""
947+
Then the response status code should be 200
948+
And the response should be in JSON
949+
And the JSON node "data.fooDummies.collection" should have 3 elements
950+
And the JSON node "data.fooDummies.collection[2].id" should exist
951+
And the JSON node "data.fooDummies.collection[2].name" should exist
952+
And the JSON node "data.fooDummies.collection[2].soManies" should exist
953+
And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements
954+
And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1"
955+
And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA=="
956+
And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3
957+
And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2
958+
And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5

features/main/default_order.feature

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,35 +79,61 @@ Feature: Default order
7979
"@type": "FooDummy",
8080
"id": 5,
8181
"name": "Balbo",
82-
"dummy": "/dummies/5"
82+
"dummy": "/dummies/5",
83+
"soManies": [
84+
"/so_manies/13",
85+
"/so_manies/14",
86+
"/so_manies/15"
87+
]
88+
8389
},
8490
{
8591
"@id": "/foo_dummies/3",
8692
"@type": "FooDummy",
8793
"id": 3,
8894
"name": "Sthenelus",
89-
"dummy": "/dummies/3"
95+
"dummy": "/dummies/3",
96+
"soManies": [
97+
"/so_manies/7",
98+
"/so_manies/8",
99+
"/so_manies/9"
100+
]
90101
},
91102
{
92103
"@id": "/foo_dummies/2",
93104
"@type": "FooDummy",
94105
"id": 2,
95106
"name": "Ephesian",
96-
"dummy": "/dummies/2"
107+
"dummy": "/dummies/2",
108+
"soManies": [
109+
"/so_manies/4",
110+
"/so_manies/5",
111+
"/so_manies/6"
112+
]
97113
},
98114
{
99115
"@id": "/foo_dummies/1",
100116
"@type": "FooDummy",
101117
"id": 1,
102118
"name": "Hawsepipe",
103-
"dummy": "/dummies/1"
119+
"dummy": "/dummies/1",
120+
"soManies": [
121+
"/so_manies/1",
122+
"/so_manies/2",
123+
"/so_manies/3"
124+
]
104125
},
105126
{
106127
"@id": "/foo_dummies/4",
107128
"@type": "FooDummy",
108129
"id": 4,
109130
"name": "Separativeness",
110-
"dummy": "/dummies/4"
131+
"dummy": "/dummies/4",
132+
"soManies": [
133+
"/so_manies/10",
134+
"/so_manies/11",
135+
"/so_manies/12"
136+
]
111137
}
112138
],
113139
"hydra:totalItems": 5,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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\GraphQl\Metadata\Factory;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait;
18+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
19+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
20+
use Psr\Log\LoggerInterface;
21+
use Psr\Log\NullLogger;
22+
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
23+
24+
final class GraphQlNestedOperationResourceMetadataFactory implements ResourceMetadataCollectionFactoryInterface
25+
{
26+
use OperationDefaultsTrait;
27+
28+
public function __construct(array $defaults, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, ?LoggerInterface $logger = null)
29+
{
30+
$this->defaults = $defaults;
31+
$this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter();
32+
$this->logger = $logger ?? new NullLogger();
33+
}
34+
35+
public function create(string $resourceClass): ResourceMetadataCollection
36+
{
37+
$resourceMetadataCollection = new ResourceMetadataCollection($resourceClass);
38+
39+
if ($this->decorated) {
40+
$resourceMetadataCollection = $this->decorated->create($resourceClass);
41+
}
42+
43+
if (0 < \count($resourceMetadataCollection)) {
44+
return $resourceMetadataCollection;
45+
}
46+
47+
$shortName = (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass;
48+
49+
$apiResource = new ApiResource(
50+
class: $resourceClass,
51+
shortName: $shortName
52+
);
53+
54+
if (class_exists($resourceClass)) {
55+
$refl = new \ReflectionClass($resourceClass);
56+
$attribute = $refl->getAttributes(ApiResource::class)[0] ?? null;
57+
$attributeInstance = $attribute?->newInstance();
58+
if ($filters = $attributeInstance?->getFilters()) {
59+
$apiResource = $apiResource->withFilters($filters);
60+
}
61+
}
62+
63+
$resourceMetadataCollection[0] = $this->addDefaultGraphQlOperations($apiResource);
64+
65+
return $resourceMetadataCollection;
66+
}
67+
}

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 32 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@
2020
use ApiPlatform\Metadata\GraphQl\Mutation;
2121
use ApiPlatform\Metadata\GraphQl\Operation;
2222
use ApiPlatform\Metadata\GraphQl\Query;
23-
use ApiPlatform\Metadata\GraphQl\QueryCollection;
2423
use ApiPlatform\Metadata\GraphQl\Subscription;
25-
use ApiPlatform\Metadata\Operation as AbstractOperation;
2624
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2725
use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
2826
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
@@ -47,7 +45,7 @@
4745
*/
4846
final class FieldsBuilder implements FieldsBuilderInterface
4947
{
50-
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator)
48+
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ResolverFactoryInterface $collectionResolverFactory, private readonly ResolverFactoryInterface $itemMutationResolverFactory, private readonly ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator, private readonly ?ResourceMetadataCollectionFactoryInterface $graphQlNestedOperationResourceMetadataFactory = null)
5149
{
5250
}
5351

@@ -256,7 +254,23 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
256254
$resourceClass = $type->getClassName();
257255
}
258256

259-
$graphqlType = $this->convertType($type, $input, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
257+
$resourceOperation = $rootOperation;
258+
if ($resourceClass && $rootOperation->getClass() && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
259+
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
260+
try {
261+
$resourceOperation = $resourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query');
262+
} catch (OperationNotFoundException) {
263+
// If there is no query operation for a nested resource we force one to exist
264+
$nestedResourceMetadataCollection = $this->graphQlNestedOperationResourceMetadataFactory->create($resourceClass);
265+
$resourceOperation = $nestedResourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query');
266+
}
267+
}
268+
269+
if (!$resourceOperation instanceof Operation) {
270+
throw new \LogicException('The resource operation should be a GraphQL operation.');
271+
}
272+
273+
$graphqlType = $this->convertType($type, $input, $resourceOperation, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable);
260274

261275
$graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType(true) : $graphqlType;
262276
$isStandardGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getStandardTypes(), true);
@@ -271,43 +285,22 @@ private function getResourceFieldConfiguration(?string $property, ?string $field
271285

272286
$args = [];
273287

274-
$resolverOperation = $rootOperation;
275-
276-
if ($resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) {
277-
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
278-
$resolverOperation = $resourceMetadataCollection->getOperation(null, $isCollectionType);
279-
280-
if (!$resolverOperation instanceof Operation) {
281-
$resolverOperation = ($isCollectionType ? new QueryCollection() : new Query())->withOperation($resolverOperation);
282-
}
283-
}
284-
285288
if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType && $isCollectionType) {
286-
if ($this->pagination->isGraphQlEnabled($rootOperation)) {
287-
$args = $this->getGraphQlPaginationArgs($rootOperation);
288-
}
289-
290-
// Find the collection operation to get filters, there might be a smarter way to do this
291-
$operation = null;
292-
if (!empty($resourceClass)) {
293-
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass);
294-
try {
295-
$operation = $resourceMetadataCollection->getOperation(null, true);
296-
} catch (OperationNotFoundException) {
297-
}
289+
if ($this->pagination->isGraphQlEnabled($resourceOperation)) {
290+
$args = $this->getGraphQlPaginationArgs($resourceOperation);
298291
}
299292

300-
$args = $this->getFilterArgs($args, $resourceClass, $rootResource, $rootOperation, $property, $depth, $operation);
293+
$args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth);
301294
}
302295

303296
if ($isStandardGraphqlType || $input) {
304297
$resolve = null;
305298
} elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) {
306-
$resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resolverOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
299+
$resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
307300
} elseif ($this->typeBuilder->isCollection($type)) {
308-
$resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resolverOperation);
301+
$resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation);
309302
} else {
310-
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resolverOperation);
303+
$resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation);
311304
}
312305

313306
return [
@@ -368,21 +361,21 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array
368361
return $args;
369362
}
370363

371-
private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $rootOperation, ?string $property, int $depth, ?AbstractOperation $operation = null): array
364+
private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array
372365
{
373-
if (null === $operation || null === $resourceClass) {
366+
if (null === $resourceClass) {
374367
return $args;
375368
}
376369

377-
foreach ($operation->getFilters() ?? [] as $filterId) {
370+
foreach ($resourceOperation->getFilters() ?? [] as $filterId) {
378371
if (!$this->filterLocator->has($filterId)) {
379372
continue;
380373
}
381374

382375
foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) {
383376
$nullable = isset($value['required']) ? !$value['required'] : true;
384377
$filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']);
385-
$graphqlFilterType = $this->convertType($filterType, false, $rootOperation, $resourceClass, $rootResource, $property, $depth);
378+
$graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth);
386379

387380
if (str_ends_with($key, '[]')) {
388381
$graphqlFilterType = GraphQLType::listOf($graphqlFilterType);
@@ -399,14 +392,14 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root
399392
array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void {
400393
$value = $graphqlFilterType;
401394
});
402-
$args = $this->mergeFilterArgs($args, $parsed, $operation, $key);
395+
$args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key);
403396
}
404397
}
405398

406399
return $this->convertFilterArgsToTypes($args);
407400
}
408401

409-
private function mergeFilterArgs(array $args, array $parsed, ?AbstractOperation $operation = null, string $original = ''): array
402+
private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array
410403
{
411404
foreach ($parsed as $key => $value) {
412405
// Never override keys that cannot be merged
@@ -470,7 +463,7 @@ private function convertFilterArgsToTypes(array $args): array
470463
*
471464
* @throws InvalidTypeException
472465
*/
473-
private function convertType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
466+
private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull
474467
{
475468
$graphqlType = $this->typeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth);
476469

@@ -487,7 +480,7 @@ private function convertType(Type $type, bool $input, Operation $rootOperation,
487480
}
488481

489482
if ($this->typeBuilder->isCollection($type)) {
490-
return $this->pagination->isGraphQlEnabled($rootOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $rootOperation) : GraphQLType::listOf($graphqlType);
483+
return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation) : GraphQLType::listOf($graphqlType);
491484
}
492485

493486
return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName())

0 commit comments

Comments
 (0)