Skip to content
54 changes: 54 additions & 0 deletions features/graphql/query.feature
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,57 @@ Feature: GraphQL query support
}
}
"""

Scenario: Custom item query
When I send the following GraphQL request:
"""
{
testItemDummyCustomQuery {
message
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON should be equal to:
"""
{
"data": {
"testItemDummyCustomQuery": {
"message": "Success!"
}
}
}
"""

Scenario: Custom collection query
When I send the following GraphQL request:
"""
{
testCollectionDummyCustomQueries {
edges {
node {
message
}
}
}
}
"""
Then the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON should be equal to:
"""
{
"data": {
"testCollectionDummyCustomQueries": {
"edges": [
{
"node": {"message": "Success!"}
}
]
}
}
}
"""
2 changes: 2 additions & 0 deletions src/Bridge/Symfony/Bundle/ApiPlatformBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand All @@ -41,6 +42,7 @@ public function build(ContainerBuilder $container)
$container->addCompilerPass(new FilterPass());
$container->addCompilerPass(new ElasticsearchClientPass());
$container->addCompilerPass(new GraphQlTypePass());
$container->addCompilerPass(new GraphQlQueryResolverPass());
$container->addCompilerPass(new MetadataAwareNameConverterPass());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface;
use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
use Doctrine\Common\Annotations\Annotation;
use Doctrine\ORM\Version;
Expand Down Expand Up @@ -109,6 +110,8 @@ public function load(array $configs, ContainerBuilder $container)
->addTag('api_platform.subresource_data_provider');
$container->registerForAutoconfiguration(FilterInterface::class)
->addTag('api_platform.filter');
$container->registerForAutoconfiguration(QueryResolverInterface::class)
->addTag('api_platform.graphql.query_resolver');
$container->registerForAutoconfiguration(GraphQlTypeInterface::class)
->addTag('api_platform.graphql.type');

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
* Injects GraphQL resolvers.
*
* @internal
*
* @author Lukas Lücke <lukas@luecke.me>
*/
final class GraphQlQueryResolverPass implements CompilerPassInterface
{
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
$resolvers = [];
foreach ($container->findTaggedServiceIds('api_platform.graphql.query_resolver', true) as $serviceId => $tags) {
foreach ($tags as $tag) {
$resolvers[$tag['id'] ?? $serviceId] = new Reference($serviceId);
}
}

$container->getDefinition('api_platform.graphql.query_resolver_locator')->addArgument($resolvers);
}
}
5 changes: 5 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/graphql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
<argument type="service" id="api_platform.iri_converter" />
</service>

<service id="api_platform.graphql.query_resolver_locator" class="Symfony\Component\DependencyInjection\ServiceLocator">
<tag name="container.service_locator" />
</service>

<!-- Type -->

<service id="api_platform.graphql.iterable_type" class="ApiPlatform\Core\GraphQl\Type\Definition\IterableType">
Expand All @@ -62,6 +66,7 @@
<argument type="service" id="api_platform.graphql.resolver.factory.item_mutation" />
<argument type="service" id="api_platform.graphql.resolver.item" />
<argument type="service" id="api_platform.graphql.resolver.resource_field" />
<argument type="service" id="api_platform.graphql.query_resolver_locator" />
<argument type="service" id="api_platform.graphql.types_factory" />
<argument type="service" id="api_platform.filter_locator" />
<argument>%api_platform.collection.pagination.enabled%</argument>
Expand Down
2 changes: 1 addition & 1 deletion src/GraphQl/Resolver/ItemResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
* @author Alan Poulain <contact@alanpoulain.eu>
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class ItemResolver
final class ItemResolver implements QueryResolverInterface
{
use ClassInfoTrait;
use FieldsToAttributesTrait;
Expand Down
32 changes: 32 additions & 0 deletions src/GraphQl/Resolver/QueryResolverInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Core\GraphQl\Resolver;

use GraphQL\Type\Definition\ResolveInfo;

/**
* A function retrieving an item to resolve a GraphQL query.
* Should return the normalized item or collection.
*
* @experimental
*
* @author Lukas Lücke <lukas@luecke.me>
*/
interface QueryResolverInterface
{
/**
* @return mixed|null The normalized query result (item or collection)
*/
public function __invoke($source, $args, $context, ResolveInfo $info);
}
44 changes: 33 additions & 11 deletions src/GraphQl/Type/SchemaBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ final class SchemaBuilder implements SchemaBuilderInterface
private $itemResolver;
private $itemMutationResolverFactory;
private $defaultFieldResolver;
private $queryResolverLocator;
private $filterLocator;
private $typesFactory;
private $paginationEnabled;
private $graphqlTypes = [];

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)
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)
{
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
$this->propertyMetadataFactory = $propertyMetadataFactory;
Expand All @@ -69,6 +70,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName
$this->itemResolver = $itemResolver;
$this->itemMutationResolverFactory = $itemMutationResolverFactory;
$this->defaultFieldResolver = $defaultFieldResolver;
$this->queryResolverLocator = $queryResolverLocator;
$this->typesFactory = $typesFactory;
$this->filterLocator = $filterLocator;
$this->paginationEnabled = $paginationEnabled;
Expand All @@ -86,12 +88,28 @@ public function getSchema(): Schema
$graphqlConfiguration = $resourceMetadata->getGraphql() ?? [];
foreach ($graphqlConfiguration as $operationName => $value) {
if ('query' === $operationName) {
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata);
$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, ['args' => ['id' => ['type' => GraphQLType::id()]]], []);

continue;
}

$mutationFields[$operationName.$resourceMetadata->getShortName()] = $this->getMutationFields($resourceClass, $resourceMetadata, $operationName);
if ($itemQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query')) {
$value['resolve'] = $this->queryResolverLocator->get($itemQuery);

$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, $value, false);

continue;
}

if ($collectionQuery = $resourceMetadata->getGraphqlAttribute($operationName, 'collection_query')) {
$value['resolve'] = $this->queryResolverLocator->get($collectionQuery);

$queryFields += $this->getQueryFields($resourceClass, $resourceMetadata, $operationName, false, $value);

continue;
}

$mutationFields[$operationName.$resourceMetadata->getShortName()] = $this->getMutationField($resourceClass, $resourceMetadata, $operationName);
}
}

Expand Down Expand Up @@ -155,20 +173,24 @@ private function getNodeQueryField(): array

/**
* Gets the query fields of the schema.
*
* @param array|false $itemConfiguration false if not configured
* @param array|false $collectionConfiguration false if not configured
*/
private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata): array
private function getQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $operationName, $itemConfiguration, $collectionConfiguration): array
{
$queryFields = [];
$shortName = $resourceMetadata->getShortName();
$deprecationReason = $resourceMetadata->getGraphqlAttribute('query', 'deprecation_reason', '', true);
$fieldName = lcfirst('query' === $operationName ? $shortName : $operationName.$shortName);

$deprecationReason = $resourceMetadata->getGraphqlAttribute($operationName, 'deprecation_reason', '', true);

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

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)) {
$queryFields[lcfirst(Inflector::pluralize($shortName))] = $fieldConfiguration;
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)) {
$queryFields[Inflector::pluralize($fieldName)] = array_merge($fieldConfiguration, $collectionConfiguration);
}

return $queryFields;
Expand All @@ -177,7 +199,7 @@ private function getQueryFields(string $resourceClass, ResourceMetadata $resourc
/**
* Gets the mutation field for the given operation name.
*/
private function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
private function getMutationField(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array
{
$shortName = $resourceMetadata->getShortName();
$resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass);
Expand Down
2 changes: 2 additions & 0 deletions tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlQueryResolverPass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlTypePass;
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\MetadataAwareNameConverterPass;
use PHPUnit\Framework\TestCase;
Expand All @@ -37,6 +38,7 @@ public function testBuild()
$containerProphecy->addCompilerPass(Argument::type(FilterPass::class))->shouldBeCalled();
$containerProphecy->addCompilerPass(Argument::type(ElasticsearchClientPass::class))->shouldBeCalled();
$containerProphecy->addCompilerPass(Argument::type(GraphQlTypePass::class))->shouldBeCalled();
$containerProphecy->addCompilerPass(Argument::type(GraphQlQueryResolverPass::class))->shouldBeCalled();
$containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->shouldBeCalled();

$bundle = new ApiPlatformBundle();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
use ApiPlatform\Core\Exception\FilterValidationException;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\GraphQl\Resolver\QueryResolverInterface;
use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
Expand Down Expand Up @@ -637,6 +638,10 @@ private function getPartialContainerBuilderProphecy()
->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1);
$this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldBeCalledTimes(1);

$containerBuilderProphecy->registerForAutoconfiguration(QueryResolverInterface::class)
->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1);
$this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldBeCalledTimes(1);

$containerBuilderProphecy->getParameter('kernel.bundles')->willReturn([
'DoctrineBundle' => DoctrineBundle::class,
])->shouldBeCalled();
Expand Down Expand Up @@ -960,6 +965,7 @@ private function getBaseContainerBuilderProphecy()
'api_platform.graphql.iterable_type',
'api_platform.graphql.type_locator',
'api_platform.graphql.types_factory',
'api_platform.graphql.query_resolver_locator',
'api_platform.graphql.normalizer.item',
'api_platform.graphql.normalizer.item.non_resource',
'api_platform.graphql.command.export_command',
Expand Down
38 changes: 38 additions & 0 deletions tests/Fixtures/TestBundle/Document/DummyCustomQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Core\Tests\Fixtures\TestBundle\Document;

use ApiPlatform\Core\Annotation\ApiResource;

/**
* Dummy with custom GraphQL query resolvers.
*
* @author Lukas Lücke <lukas@luecke.me>
*
* @ApiResource(graphql={
* "testItem"={
* "item_query"="app.graphql.query_resolver.dummy_custom_item"
* },
* "testCollection"={
* "collection_query"="app.graphql.query_resolver.dummy_custom_collection"
* }
* })
*/
class DummyCustomQuery
{
/**
* @var string
*/
public $message;
}
Loading