Skip to content

Commit d0bde7e

Browse files
lukaslueckealanpoulain
authored andcommitted
[GraphQL] Add custom queries (#2445)
* Add configuration for custom GraphQL queries on ApiResource * Add query suffix to graphql resolver key * Fix code-style * Fix tests * Resolve review comments * Fix cs and tests * Add behat test for custom query * Fixes
1 parent ebc98da commit d0bde7e

File tree

16 files changed

+338
-12
lines changed

16 files changed

+338
-12
lines changed

features/graphql/query.feature

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,57 @@ Feature: GraphQL query support
283283
}
284284
}
285285
"""
286+
287+
Scenario: Custom item query
288+
When I send the following GraphQL request:
289+
"""
290+
{
291+
testItemDummyCustomQuery {
292+
message
293+
}
294+
}
295+
"""
296+
Then the response status code should be 200
297+
And the response should be in JSON
298+
And the header "Content-Type" should be equal to "application/json"
299+
And the JSON should be equal to:
300+
"""
301+
{
302+
"data": {
303+
"testItemDummyCustomQuery": {
304+
"message": "Success!"
305+
}
306+
}
307+
}
308+
"""
309+
310+
Scenario: Custom collection query
311+
When I send the following GraphQL request:
312+
"""
313+
{
314+
testCollectionDummyCustomQueries {
315+
edges {
316+
node {
317+
message
318+
}
319+
}
320+
}
321+
}
322+
"""
323+
Then the response status code should be 200
324+
And the response should be in JSON
325+
And the header "Content-Type" should be equal to "application/json"
326+
And the JSON should be equal to:
327+
"""
328+
{
329+
"data": {
330+
"testCollectionDummyCustomQueries": {
331+
"edges": [
332+
{
333+
"node": {"message": "Success!"}
334+
}
335+
]
336+
}
337+
}
338+
}
339+
"""

src/Bridge/Symfony/Bundle/ApiPlatformBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass;
1818
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass;
1919
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass;
20+
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass;
2021
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass;
2122
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass;
2223
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -41,6 +42,7 @@ public function build(ContainerBuilder $container)
4142
$container->addCompilerPass(new FilterPass());
4243
$container->addCompilerPass(new ElasticsearchClientPass());
4344
$container->addCompilerPass(new GraphQlTypePass());
45+
$container->addCompilerPass(new GraphQlQueryResolverPass());
4446
$container->addCompilerPass(new MetadataAwareNameConverterPass());
4547
}
4648
}

src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
2828
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
2929
use ApiPlatform\Core\Exception\RuntimeException;
30+
use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface;
3031
use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
3132
use Doctrine\Common\Annotations\Annotation;
3233
use Doctrine\ORM\Version;
@@ -109,6 +110,8 @@ public function load(array $configs, ContainerBuilder $container)
109110
->addTag('api_platform.subresource_data_provider');
110111
$container->registerForAutoconfiguration(FilterInterface::class)
111112
->addTag('api_platform.filter');
113+
$container->registerForAutoconfiguration(QueryResolverInterface::class)
114+
->addTag('api_platform.graphql.query_resolver');
112115
$container->registerForAutoconfiguration(GraphQlTypeInterface::class)
113116
->addTag('api_platform.graphql.type');
114117

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler;
15+
16+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
17+
use Symfony\Component\DependencyInjection\ContainerBuilder;
18+
use Symfony\Component\DependencyInjection\Reference;
19+
20+
/**
21+
* Injects GraphQL resolvers.
22+
*
23+
* @internal
24+
*
25+
* @author Lukas Lücke <[email protected]>
26+
*/
27+
final class GraphQlQueryResolverPass implements CompilerPassInterface
28+
{
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function process(ContainerBuilder $container)
33+
{
34+
$resolvers = [];
35+
foreach ($container->findTaggedServiceIds('api_platform.graphql.query_resolver', true) as $serviceId => $tags) {
36+
foreach ($tags as $tag) {
37+
$resolvers[$tag['id'] ?? $serviceId] = new Reference($serviceId);
38+
}
39+
}
40+
41+
$container->getDefinition('api_platform.graphql.query_resolver_locator')->addArgument($resolvers);
42+
}
43+
}

src/Bridge/Symfony/Bundle/Resources/config/graphql.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
<argument type="service" id="api_platform.iri_converter" />
4040
</service>
4141

42+
<service id="api_platform.graphql.query_resolver_locator" class="Symfony\Component\DependencyInjection\ServiceLocator">
43+
<tag name="container.service_locator" />
44+
</service>
45+
4246
<!-- Type -->
4347

4448
<service id="api_platform.graphql.iterable_type" class="ApiPlatform\Core\GraphQl\Type\Definition\IterableType">
@@ -62,6 +66,7 @@
6266
<argument type="service" id="api_platform.graphql.resolver.factory.item_mutation" />
6367
<argument type="service" id="api_platform.graphql.resolver.item" />
6468
<argument type="service" id="api_platform.graphql.resolver.resource_field" />
69+
<argument type="service" id="api_platform.graphql.query_resolver_locator" />
6570
<argument type="service" id="api_platform.graphql.types_factory" />
6671
<argument type="service" id="api_platform.filter_locator" />
6772
<argument>%api_platform.collection.pagination.enabled%</argument>

src/GraphQl/Resolver/ItemResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
* @author Alan Poulain <[email protected]>
3131
* @author Kévin Dunglas <[email protected]>
3232
*/
33-
final class ItemResolver
33+
final class ItemResolver implements QueryResolverInterface
3434
{
3535
use ClassInfoTrait;
3636
use FieldsToAttributesTrait;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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\Core\GraphQl\Resolver;
15+
16+
use GraphQL\Type\Definition\ResolveInfo;
17+
18+
/**
19+
* A function retrieving an item to resolve a GraphQL query.
20+
* Should return the normalized item or collection.
21+
*
22+
* @experimental
23+
*
24+
* @author Lukas Lücke <[email protected]>
25+
*/
26+
interface QueryResolverInterface
27+
{
28+
/**
29+
* @return mixed|null The normalized query result (item or collection)
30+
*/
31+
public function __invoke($source, $args, $context, ResolveInfo $info);
32+
}

src/GraphQl/Type/SchemaBuilder.php

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ final class SchemaBuilder implements SchemaBuilderInterface
5454
private $itemResolver;
5555
private $itemMutationResolverFactory;
5656
private $defaultFieldResolver;
57+
private $queryResolverLocator;
5758
private $filterLocator;
5859
private $typesFactory;
5960
private $paginationEnabled;
6061
private $graphqlTypes = [];
6162

62-
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, callable $itemResolver, callable $defaultFieldResolver, TypesFactoryInterface $typesFactory, ContainerInterface $filterLocator = null, bool $paginationEnabled = true)
63+
public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, callable $itemResolver, callable $defaultFieldResolver, ContainerInterface $queryResolverLocator, TypesFactoryInterface $typesFactory, ContainerInterface $filterLocator = null, bool $paginationEnabled = true)
6364
{
6465
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
6566
$this->propertyMetadataFactory = $propertyMetadataFactory;
@@ -69,6 +70,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
6970
$this->itemResolver = $itemResolver;
7071
$this->itemMutationResolverFactory = $itemMutationResolverFactory;
7172
$this->defaultFieldResolver = $defaultFieldResolver;
73+
$this->queryResolverLocator = $queryResolverLocator;
7274
$this->typesFactory = $typesFactory;
7375
$this->filterLocator = $filterLocator;
7476
$this->paginationEnabled = $paginationEnabled;
@@ -86,12 +88,28 @@ public function getSchema(): Schema
8688
$graphqlConfiguration = $resourceMetadata->getGraphql() ?? [];
8789
foreach ($graphqlConfiguration as $operationName => $value) {
8890
if ('query' === $operationName) {
89-
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata);
91+
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, ['args' => ['id' => ['type' => GraphQLType::id()]]], []);
9092

9193
continue;
9294
}
9395

94-
$mutationFields[$operationName.$resourceMetadata->getShortName()] = $this->getMutationFields($resourceClass, $resourceMetadata, $operationName);
96+
if ($itemQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query')) {
97+
$value['resolve'] = $this->queryResolverLocator->get($itemQuery);
98+
99+
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, $value, false);
100+
101+
continue;
102+
}
103+
104+
if ($collectionQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'collection_query')) {
105+
$value['resolve'] = $this->queryResolverLocator->get($collectionQuery);
106+
107+
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, false, $value);
108+
109+
continue;
110+
}
111+
112+
$mutationFields[$operationName.$resourceMetadata->getShortName()] = $this->getMutationField($resourceClass, $resourceMetadata, $operationName);
95113
}
96114
}
97115

@@ -155,20 +173,24 @@ private function getNodeQueryField(): array
155173

156174
/**
157175
* Gets the query fields of the schema.
176+
*
177+
* @param array|false $itemConfiguration false if not configured
178+
* @param array|false $collectionConfiguration false if not configured
158179
*/
159-
private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata): array
180+
private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, $itemConfiguration, $collectionConfiguration): array
160181
{
161182
$queryFields = [];
162183
$shortName = $resourceMetadata->getShortName();
163-
$deprecationReason = $resourceMetadata->getGraphqlAttribute('query', 'deprecation_reason', '', true);
184+
$fieldName = lcfirst('query' === $operationName ? $shortName : $operationName.$shortName);
185+
186+
$deprecationReason = $resourceMetadata->getGraphqlAttribute($operationName, 'deprecation_reason', '', true);
164187

165-
if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass)) {
166-
$fieldConfiguration['args'] += ['id' => ['type' => GraphQLType::id()]];
167-
$queryFields[lcfirst($shortName)] = $fieldConfiguration;
188+
if (false !== $itemConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass)) {
189+
$queryFields[$fieldName] = array_merge($fieldConfiguration, $itemConfiguration);
168190
}
169191

170-
if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass)) {
171-
$queryFields[lcfirst(Inflector::pluralize($shortName))] = $fieldConfiguration;
192+
if (false !== $collectionConfiguration && $fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass)) {
193+
$queryFields[Inflector::pluralize($fieldName)] = array_merge($fieldConfiguration, $collectionConfiguration);
172194
}
173195

174196
return $queryFields;
@@ -177,7 +199,7 @@ private function getQueryFields(string $resourceClass, ResourceMetadata $resourc
177199
/**
178200
* Gets the mutation field for the given operation name.
179201
*/
180-
private function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
202+
private function getMutationField(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
181203
{
182204
$shortName = $resourceMetadata->getShortName();
183205
$resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass);

tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass;
1919
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass;
2020
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass;
21+
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass;
2122
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass;
2223
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass;
2324
use PHPUnit\Framework\TestCase;
@@ -37,6 +38,7 @@ public function testBuild()
3738
$containerProphecy->addCompilerPass(Argument::type(FilterPass::class))->shouldBeCalled();
3839
$containerProphecy->addCompilerPass(Argument::type(ElasticsearchClientPass::class))->shouldBeCalled();
3940
$containerProphecy->addCompilerPass(Argument::type(GraphQlTypePass::class))->shouldBeCalled();
41+
$containerProphecy->addCompilerPass(Argument::type(GraphQlQueryResolverPass::class))->shouldBeCalled();
4042
$containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->shouldBeCalled();
4143

4244
$bundle = new ApiPlatformBundle();

tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
use ApiPlatform\Core\Exception\FilterValidationException;
6060
use ApiPlatform\Core\Exception\InvalidArgumentException;
6161
use ApiPlatform\Core\Exception\RuntimeException;
62+
use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface;
6263
use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
6364
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
6465
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
@@ -637,6 +638,10 @@ private function getPartialContainerBuilderProphecy()
637638
->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1);
638639
$this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldBeCalledTimes(1);
639640

641+
$containerBuilderProphecy->registerForAutoconfiguration(QueryResolverInterface::class)
642+
->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1);
643+
$this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldBeCalledTimes(1);
644+
640645
$containerBuilderProphecy->getParameter('kernel.bundles')->willReturn([
641646
'DoctrineBundle' => DoctrineBundle::class,
642647
])->shouldBeCalled();
@@ -960,6 +965,7 @@ private function getBaseContainerBuilderProphecy()
960965
'api_platform.graphql.iterable_type',
961966
'api_platform.graphql.type_locator',
962967
'api_platform.graphql.types_factory',
968+
'api_platform.graphql.query_resolver_locator',
963969
'api_platform.graphql.normalizer.item',
964970
'api_platform.graphql.normalizer.item.non_resource',
965971
'api_platform.graphql.command.export_command',
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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\Core\Tests\Fixtures\TestBundle\Document;
15+
16+
use ApiPlatform\Core\Annotation\ApiResource;
17+
18+
/**
19+
* Dummy with custom GraphQL query resolvers.
20+
*
21+
* @author Lukas Lücke <[email protected]>
22+
*
23+
* @ApiResource(graphql={
24+
* "testItem"={
25+
* "item_query"="app.graphql.query_resolver.dummy_custom_item"
26+
* },
27+
* "testCollection"={
28+
* "collection_query"="app.graphql.query_resolver.dummy_custom_collection"
29+
* }
30+
* })
31+
*/
32+
class DummyCustomQuery
33+
{
34+
/**
35+
* @var string
36+
*/
37+
public $message;
38+
}

0 commit comments

Comments
 (0)