Skip to content

Commit ea73061

Browse files
raoulclaisalanpoulain
authored andcommitted
Add custom mutations to GraphQL (#2447)
1 parent ef76e6b commit ea73061

25 files changed

+638
-68
lines changed

features/bootstrap/DoctrineContext.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument;
2121
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument;
2222
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument;
23+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument;
2324
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument;
2425
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument;
2526
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument;
@@ -61,6 +62,7 @@
6162
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer;
6263
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar;
6364
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor;
65+
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation;
6466
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery;
6567
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDate;
6668
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom;
@@ -429,6 +431,21 @@ public function thereAreDummyCustomQueryObjects(int $nb)
429431
$this->manager->flush();
430432
}
431433

434+
/**
435+
* @Given there are :nb dummyCustomMutation objects
436+
*/
437+
public function thereAreDummyCustomMutationObjects(int $nb)
438+
{
439+
for ($i = 1; $i <= $nb; ++$i) {
440+
$customMutationDummy = $this->buildDummyCustomMutation();
441+
$customMutationDummy->setOperandA(3);
442+
443+
$this->manager->persist($customMutationDummy);
444+
}
445+
446+
$this->manager->flush();
447+
}
448+
432449
/**
433450
* @Given there are :nb dummy objects with JSON and array data
434451
*/
@@ -1355,6 +1372,14 @@ private function buildDummyCustomQuery()
13551372
return $this->isOrm() ? new DummyCustomQuery() : new DummyCustomQueryDocument();
13561373
}
13571374

1375+
/**
1376+
* @return DummyCustomMutation|DummyCustomMutationDocument
1377+
*/
1378+
private function buildDummyCustomMutation()
1379+
{
1380+
return $this->isOrm() ? new DummyCustomMutation() : new DummyCustomMutationDocument();
1381+
}
1382+
13581383
/**
13591384
* @return DummyFriend|DummyFriendDocument
13601385
*/

features/graphql/mutation.feature

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,38 @@ Feature: GraphQL mutation support
435435
}
436436
}
437437
"""
438+
439+
Scenario: Execute a custom mutation
440+
Given there are 1 dummyCustomMutation objects
441+
When I send the following GraphQL request:
442+
"""
443+
mutation {
444+
sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) {
445+
dummyCustomMutation {
446+
id
447+
result
448+
}
449+
}
450+
}
451+
"""
452+
Then the response status code should be 200
453+
And the response should be in JSON
454+
And the header "Content-Type" should be equal to "application/json"
455+
And the JSON node "data.sumDummyCustomMutation.dummyCustomMutation.result" should be equal to "8"
456+
457+
Scenario: Execute a not persisted custom mutation
458+
When I send the following GraphQL request:
459+
"""
460+
mutation {
461+
sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) {
462+
dummyCustomMutation {
463+
id
464+
result
465+
}
466+
}
467+
}
468+
"""
469+
Then the response status code should be 200
470+
And the response should be in JSON
471+
And the header "Content-Type" should be equal to "application/json"
472+
And the JSON node "data.sumNotPersistedDummyCustomMutation.dummyCustomMutation" should be null

phpstan.neon.dist

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@ parameters:
4444
-
4545
message: '#Parameter \#9 \$nameConverter of class ApiPlatform\\Core\\Swagger\\Serializer\\DocumentationNormalizer constructor expects Symfony\\Component\\Serializer\\NameConverter\\NameConverterInterface\|null, object given\.#'
4646
path: %currentWorkingDirectory%/tests/Swagger/Serializer/DocumentationNormalizer*Test.php
47-
-
48-
message: '#Parameter \#3 \$normalizer of class ApiPlatform\\Core\\GraphQl\\Resolver\\Factory\\ItemMutationResolverFactory constructor expects Symfony\\Component\\Serializer\\Normalizer\\NormalizerInterface, object given\.#'
49-
path: %currentWorkingDirectory%/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php
5047
-
5148
message: '#Parameter \#1 \$resource of method ApiPlatform\\Core\\Metadata\\Extractor\\XmlExtractor::getAttributes\(\) expects SimpleXMLElement, object given\.#'
5249
path: %currentWorkingDirectory%/src/Metadata/Extractor/XmlExtractor.php

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\GraphQlMutationResolverPass;
2021
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;
@@ -43,6 +44,7 @@ public function build(ContainerBuilder $container)
4344
$container->addCompilerPass(new ElasticsearchClientPass());
4445
$container->addCompilerPass(new GraphQlTypePass());
4546
$container->addCompilerPass(new GraphQlQueryResolverPass());
47+
$container->addCompilerPass(new GraphQlMutationResolverPass());
4648
$container->addCompilerPass(new MetadataAwareNameConverterPass());
4749
}
4850
}

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\MutationResolverInterface;
3031
use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface;
3132
use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface;
3233
use ApiPlatform\Core\GraphQl\Type\Definition\TypeInterface as GraphQlTypeInterface;
@@ -115,6 +116,8 @@ public function load(array $configs, ContainerBuilder $container)
115116
->addTag('api_platform.graphql.query_resolver');
116117
$container->registerForAutoconfiguration(QueryCollectionResolverInterface::class)
117118
->addTag('api_platform.graphql.query_resolver');
119+
$container->registerForAutoconfiguration(MutationResolverInterface::class)
120+
->addTag('api_platform.graphql.mutation_resolver');
118121
$container->registerForAutoconfiguration(GraphQlTypeInterface::class)
119122
->addTag('api_platform.graphql.type');
120123

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 Mutation resolvers.
22+
*
23+
* @internal
24+
*
25+
* @author Raoul Clais <[email protected]>
26+
*/
27+
final class GraphQlMutationResolverPass implements CompilerPassInterface
28+
{
29+
/**
30+
* {@inheritdoc}
31+
*/
32+
public function process(ContainerBuilder $container)
33+
{
34+
$mutations = [];
35+
foreach ($container->findTaggedServiceIds('api_platform.graphql.mutation_resolver', true) as $serviceId => $tags) {
36+
foreach ($tags as $tag) {
37+
$mutations[$tag['id'] ?? $serviceId] = new Reference($serviceId);
38+
}
39+
}
40+
41+
$container->getDefinition('api_platform.graphql.mutation_resolver_locator')->addArgument($mutations);
42+
}
43+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
<service id="api_platform.graphql.resolver.factory.item_mutation" class="ApiPlatform\Core\GraphQl\Resolver\Factory\ItemMutationResolverFactory" public="false">
3232
<argument type="service" id="api_platform.iri_converter" />
3333
<argument type="service" id="api_platform.data_persister" />
34+
<argument type="service" id="api_platform.graphql.mutation_resolver_locator" />
3435
<argument type="service" id="serializer" />
3536
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
3637
<argument type="service" id="api_platform.security.resource_access_checker" on-invalid="null" />
@@ -45,6 +46,10 @@
4546
<tag name="container.service_locator" />
4647
</service>
4748

49+
<service id="api_platform.graphql.mutation_resolver_locator" class="Symfony\Component\DependencyInjection\ServiceLocator">
50+
<tag name="container.service_locator" />
51+
</service>
52+
4853
<!-- Type -->
4954

5055
<service id="api_platform.graphql.iterable_type" class="ApiPlatform\Core\GraphQl\Type\Definition\IterableType">
@@ -132,6 +137,7 @@
132137
<argument type="service" id="api_platform.graphql.schema_builder" />
133138
<tag name="console.command" />
134139
</service>
140+
135141
</services>
136142

137143
</container>

src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Core\Exception\InvalidArgumentException;
1919
use ApiPlatform\Core\Exception\ItemNotFoundException;
2020
use ApiPlatform\Core\GraphQl\Resolver\FieldsToAttributesTrait;
21+
use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface;
2122
use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait;
2223
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
2324
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
@@ -28,6 +29,7 @@
2829
use ApiPlatform\Core\Validator\ValidatorInterface;
2930
use GraphQL\Error\Error;
3031
use GraphQL\Type\Definition\ResolveInfo;
32+
use Psr\Container\ContainerInterface;
3133
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
3234
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
3335

@@ -46,19 +48,21 @@ final class ItemMutationResolverFactory implements ResolverFactoryInterface
4648

4749
private $iriConverter;
4850
private $dataPersister;
51+
private $mutationResolverLocator;
4952
private $normalizer;
5053
private $resourceMetadataFactory;
5154
private $resourceAccessChecker;
5255
private $validator;
5356

54-
public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, ValidatorInterface $validator = null)
57+
public function __construct(IriConverterInterface $iriConverter, DataPersisterInterface $dataPersister, ContainerInterface $mutationResolverLocator, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, ResourceAccessCheckerInterface $resourceAccessChecker = null, ValidatorInterface $validator = null)
5558
{
5659
if (!$normalizer instanceof DenormalizerInterface) {
5760
throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class));
5861
}
5962

6063
$this->iriConverter = $iriConverter;
6164
$this->dataPersister = $dataPersister;
65+
$this->mutationResolverLocator = $mutationResolverLocator;
6266
$this->normalizer = $normalizer;
6367
$this->resourceMetadataFactory = $resourceMetadataFactory;
6468
$this->resourceAccessChecker = $resourceAccessChecker;
@@ -67,7 +71,7 @@ public function __construct(IriConverterInterface $iriConverter, DataPersisterIn
6771

6872
public function __invoke(string $resourceClass = null, string $rootClass = null, string $operationName = null): callable
6973
{
70-
return function ($root, $args, $context, ResolveInfo $info) use ($resourceClass, $operationName) {
74+
return function ($source, $args, $context, ResolveInfo $info) use ($resourceClass, $operationName) {
7175
if (null === $resourceClass) {
7276
return null;
7377
}
@@ -99,30 +103,41 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,
99103
return $data;
100104
}
101105

102-
switch ($operationName) {
103-
case 'create':
104-
case 'update':
105-
$context = null === $item ? ['resource_class' => $resourceClass] : ['resource_class' => $resourceClass, 'object_to_populate' => $item];
106-
$context += $resourceMetadata->getGraphqlAttribute($operationName, 'denormalization_context', [], true);
107-
$item = $this->normalizer->denormalize($args['input'], $resourceClass, ItemNormalizer::FORMAT, $context);
108-
$this->validate($item, $info, $resourceMetadata, $operationName);
109-
$persistResult = $this->dataPersister->persist($item);
110-
111-
if (null === $persistResult) {
112-
@trigger_error(sprintf('Returning void from %s::persist() is deprecated since API Platform 2.3 and will not be supported in API Platform 3, an object should always be returned.', DataPersisterInterface::class), E_USER_DEPRECATED);
113-
}
114-
115-
return [$wrapFieldName => $this->normalizer->normalize($persistResult ?? $item, ItemNormalizer::FORMAT, $normalizationContext)] + $data;
116-
case 'delete':
117-
if ($item) {
118-
$this->dataPersister->remove($item);
119-
$data[$wrapFieldName]['id'] = $args['input']['id'];
120-
} else {
121-
$data[$wrapFieldName]['id'] = null;
122-
}
106+
if ('delete' === $operationName) {
107+
if ($item) {
108+
$this->dataPersister->remove($item);
109+
$data[$wrapFieldName]['id'] = $args['input']['id'];
110+
} else {
111+
$data[$wrapFieldName]['id'] = null;
112+
}
113+
114+
return $data;
115+
}
116+
117+
$denormalizationContext = null === $item ? ['resource_class' => $resourceClass] : ['resource_class' => $resourceClass, 'object_to_populate' => $item];
118+
$denormalizationContext += $resourceMetadata->getGraphqlAttribute($operationName, 'denormalization_context', [], true);
119+
$item = $this->normalizer->denormalize($args['input'], $resourceClass, ItemNormalizer::FORMAT, $denormalizationContext);
120+
121+
$mutationResolverId = $resourceMetadata->getGraphqlAttribute($operationName, 'mutation');
122+
if (null !== $mutationResolverId) {
123+
/** @var MutationResolverInterface $mutationResolver */
124+
$mutationResolver = $this->mutationResolverLocator->get($mutationResolverId);
125+
$item = $mutationResolver($item, ['source' => $source, 'args' => $args, 'info' => $info]);
126+
if (null !== $item && $resourceClass !== $itemClass = $this->getObjectClass($item)) {
127+
throw Error::createLocatedError(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path);
128+
}
129+
}
130+
131+
if (null !== $item) {
132+
$this->validate($item, $info, $resourceMetadata, $operationName);
133+
$persistResult = $this->dataPersister->persist($item);
134+
135+
if (null === $persistResult) {
136+
@trigger_error(sprintf('Returning void from %s::persist() is deprecated since API Platform 2.3 and will not be supported in API Platform 3, an object should always be returned.', DataPersisterInterface::class), E_USER_DEPRECATED);
137+
}
123138
}
124139

125-
return $data;
140+
return [$wrapFieldName => $this->normalizer->normalize($persistResult ?? $item, ItemNormalizer::FORMAT, $normalizationContext)] + $data;
126141
};
127142
}
128143

src/GraphQl/Resolver/Factory/ItemResolverFactory.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@
1515

1616
use ApiPlatform\Core\Api\IriConverterInterface;
1717
use ApiPlatform\Core\Exception\ItemNotFoundException;
18-
use ApiPlatform\Core\Exception\RuntimeException;
1918
use ApiPlatform\Core\GraphQl\Resolver\FieldsToAttributesTrait;
2019
use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface;
2120
use ApiPlatform\Core\GraphQl\Resolver\ResourceAccessCheckerTrait;
2221
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
2322
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2423
use ApiPlatform\Core\Security\ResourceAccessCheckerInterface;
2524
use ApiPlatform\Core\Util\ClassInfoTrait;
25+
use GraphQL\Error\Error;
2626
use GraphQL\Type\Definition\ResolveInfo;
2727
use Psr\Container\ContainerInterface;
2828
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -66,7 +66,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
6666

6767
$baseNormalizationContext = ['attributes' => $this->fieldsToAttributes($info)];
6868
$item = $this->getItem($args, $baseNormalizationContext);
69-
$resourceClass = $this->getResourceClass($item, $resourceClass);
69+
$resourceClass = $this->getResourceClass($item, $resourceClass, $info);
7070

7171
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
7272

@@ -75,7 +75,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul
7575
/** @var QueryItemResolverInterface $queryResolver */
7676
$queryResolver = $this->queryResolverLocator->get($queryResolverId);
7777
$item = $queryResolver($item, ['source' => $source, 'args' => $args, 'info' => $info]);
78-
$resourceClass = $this->getResourceClass($item, $resourceClass, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s');
78+
$resourceClass = $this->getResourceClass($item, $resourceClass, $info, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.');
7979
}
8080

8181
$this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, $operationName ?? 'query');
@@ -107,9 +107,9 @@ private function getItem($args, array $baseNormalizationContext)
107107
/**
108108
* @param object|null $item
109109
*
110-
* @throws RuntimeException
110+
* @throws Error
111111
*/
112-
private function getResourceClass($item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s'): ?string
112+
private function getResourceClass($item, ?string $resourceClass, ResolveInfo $info, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): ?string
113113
{
114114
if (null === $item) {
115115
return $resourceClass;
@@ -122,7 +122,7 @@ private function getResourceClass($item, ?string $resourceClass, string $errorMe
122122
}
123123

124124
if ($resourceClass !== $itemClass) {
125-
throw new RuntimeException(sprintf($errorMessage, $resourceClass, $itemClass));
125+
throw Error::createLocatedError(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path);
126126
}
127127

128128
return $resourceClass;

0 commit comments

Comments
 (0)