Skip to content

Commit 996a6bd

Browse files
alanpoulaindunglas
authored andcommitted
[WIP] GraphQL Mutation support (#1460)
* Delete mutation for an item * Put mutation for an item * Use object_to_populate * Basic create support * Fix and test create support * wip * Don't test composite identifiers support for now * Fix CS * Fix some tests * Fix CS * Fix tests
1 parent 158ce32 commit 996a6bd

File tree

10 files changed

+475
-22
lines changed

10 files changed

+475
-22
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## 2.2.0
44

5-
* Add GraphQL support
5+
* Add GraphQL support``
66
* Add JSONAPI support
77
* Add a new `@ApiFilter` annotation to directly configure filters from resource classes
88
* Add a partial paginator that prevents `COUNT()` SQL queries

features/graphql/mutation.feature

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
Feature: GraphQL mutation support
2+
@createSchema
3+
Scenario: Introspect types
4+
When I send the following GraphQL request:
5+
"""
6+
{
7+
__type(name: "Mutation") {
8+
fields {
9+
name
10+
description
11+
type {
12+
name
13+
kind
14+
}
15+
args {
16+
name
17+
type {
18+
name
19+
kind
20+
}
21+
}
22+
}
23+
}
24+
}
25+
"""
26+
Then the response status code should be 200
27+
And the response should be in JSON
28+
And the header "Content-Type" should be equal to "application/json"
29+
And the JSON node "data.__type.fields[0].name" should contain "delete"
30+
And the JSON node "data.__type.fields[0].description" should contain "Deletes "
31+
And the JSON node "data.__type.fields[0].type.name" should contain "DeleteMutation"
32+
And the JSON node "data.__type.fields[0].type.kind" should be equal to "OBJECT"
33+
And the JSON node "data.__type.fields[0].args[0].name" should be equal to "input"
34+
And the JSON node "data.__type.fields[0].args[0].type.name" should contain "InputDeleteMutation"
35+
And the JSON node "data.__type.fields[0].args[0].type.kind" should be equal to "INPUT_OBJECT"
36+
37+
Scenario: Create an item
38+
When I send the following GraphQL request:
39+
"""
40+
mutation {
41+
createDummy(input: {name: "A new one", alias: "new", description: "brand new!"}) {
42+
id,
43+
name,
44+
alias,
45+
description
46+
}
47+
}
48+
"""
49+
Then the response status code should be 200
50+
And the response should be in JSON
51+
And the header "Content-Type" should be equal to "application/json"
52+
And the JSON node "data.createDummy.id" should be equal to 1
53+
And the JSON node "data.createDummy.name" should be equal to "A new one"
54+
And the JSON node "data.createDummy.alias" should be equal to "new"
55+
And the JSON node "data.createDummy.description" should be equal to "brand new!"
56+
57+
@dropSchema
58+
Scenario: Delete an item through a mutation
59+
When I send the following GraphQL request:
60+
"""
61+
mutation {
62+
deleteDummy(input: {id: 1}) {
63+
id
64+
}
65+
}
66+
"""
67+
Then print last response
68+
Then the response status code should be 200
69+
And the response should be in JSON
70+
And the header "Content-Type" should be equal to "application/json"
71+
And the JSON node "data.deleteDummy.id" should be equal to 1
72+
73+
@createSchema
74+
@dropSchema
75+
Scenario: Delete an item with composite identifiers through a mutation
76+
Given there are Composite identifier objects
77+
When I send the following GraphQL request:
78+
"""
79+
mutation {
80+
deleteCompositeRelation(input: {compositeItem: {id: 1}, compositeLabel: {id: 1}}) {
81+
compositeItem {
82+
id
83+
},
84+
compositeLabel {
85+
id
86+
}
87+
}
88+
}
89+
"""
90+
Then the response status code should be 200
91+
And the response should be in JSON
92+
And the header "Content-Type" should be equal to "application/json"
93+
And the JSON node "data.deleteCompositeRelation.compositeItem.id" should be equal to 1
94+
And the JSON node "data.deleteCompositeRelation.compositeLabel.id" should be equal to 1
95+
96+
@createSchema
97+
@dropSchema
98+
Scenario: Modify an item through a mutation
99+
Given there is 1 dummy objects
100+
When I send the following GraphQL request:
101+
"""
102+
mutation {
103+
updateDummy(input: {id: 1, description: "Modified description."}) {
104+
id,
105+
name,
106+
description
107+
}
108+
}
109+
"""
110+
Then the response status code should be 200
111+
And the response should be in JSON
112+
And the header "Content-Type" should be equal to "application/json"
113+
And the JSON node "data.updateDummy.id" should be equal to 1
114+
And the JSON node "data.updateDummy.name" should be equal to "Dummy #1"
115+
And the JSON node "data.updateDummy.description" should be equal to "Modified description."
116+
117+
# Composite identifiers are not supported yet
118+
@createSchema
119+
@dropSchema
120+
Scenario: Modify an item with composite identifiers through a mutation
121+
Given there are Composite identifier objects
122+
When I send the following GraphQL request:
123+
"""
124+
mutation {
125+
updateCompositeRelation(input: {compositeItem: {id: 2}, compositeLabel: {id: 8}, value: "Modified value."}) {
126+
compositeItem {
127+
id
128+
},
129+
compositeLabel {
130+
id
131+
},
132+
value
133+
}
134+
}
135+
"""
136+
#Then the response status code should be 200
137+
#And the response should be in JSON
138+
#And the header "Content-Type" should be equal to "application/json"
139+
#And the JSON node "data.putCompositeRelation.compositeItem.id" should be equal to 1
140+
#And the JSON node "data.putCompositeRelation.compositeLabel.id" should be equal to 1
141+
#And the JSON node "data.putCompositeRelation.value" should be equal to "Modified value."
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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\Graphql\Resolver;
15+
16+
use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
17+
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
18+
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
19+
use ApiPlatform\Core\Exception\InvalidArgumentException;
20+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
21+
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
22+
use GraphQL\Error\Error;
23+
use GraphQL\Type\Definition\ResolveInfo;
24+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
25+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
26+
27+
/**
28+
* Creates a function resolving a GraphQL mutation of an item.
29+
*
30+
* @author Alan Poulain <[email protected]>
31+
*
32+
* @internal
33+
*/
34+
final class ItemMutationResolverFactory implements ItemMutationResolverFactoryInterface
35+
{
36+
private $identifiersExtractor;
37+
private $itemDataProvider;
38+
private $normalizer;
39+
private $resourceMetadataFactory;
40+
private $dataPersister;
41+
42+
public function __construct(IdentifiersExtractorInterface $identifiersExtractor, ItemDataProviderInterface $itemDataProvider, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, DataPersisterInterface $dataPersister)
43+
{
44+
if (!$normalizer instanceof DenormalizerInterface) {
45+
throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class));
46+
}
47+
48+
$this->identifiersExtractor = $identifiersExtractor;
49+
$this->itemDataProvider = $itemDataProvider;
50+
$this->normalizer = $normalizer;
51+
$this->resourceMetadataFactory = $resourceMetadataFactory;
52+
$this->dataPersister = $dataPersister;
53+
}
54+
55+
public function createItemMutationResolver(string $resourceClass, string $mutationName): callable
56+
{
57+
return function ($root, $args, $context, ResolveInfo $info) use ($resourceClass, $mutationName) {
58+
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
59+
60+
$item = null;
61+
if ('update' === $mutationName || 'delete' === $mutationName) {
62+
$id = $this->getIdentifier($this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass), $args, $info);
63+
$item = $this->getItem($resourceClass, $id, $resourceMetadata, $info);
64+
}
65+
66+
switch ($mutationName) {
67+
case 'create':
68+
case 'update':
69+
$context = null === $item ? ['resource_class' => $resourceClass] : ['resource_class' => $resourceClass, 'object_to_populate' => $item];
70+
$item = $this->normalizer->denormalize($args['input'], $resourceClass, null, $context);
71+
$this->dataPersister->persist($item);
72+
73+
return $this->normalizer->normalize(
74+
$item,
75+
null,
76+
['graphql' => true] + $resourceMetadata->getGraphqlAttribute($mutationName, 'normalization_context', [], true)
77+
);
78+
79+
case 'delete':
80+
$this->dataPersister->remove($item);
81+
82+
return $args['input'];
83+
}
84+
};
85+
}
86+
87+
private function getIdentifier(array $identifiers, $args, $info)
88+
{
89+
if (1 === \count($identifiers)) {
90+
return $args['input'][$identifiers[0]];
91+
}
92+
93+
$identifierPairs = [];
94+
foreach ($identifiers as $key => $identifier) {
95+
if (!\is_array($args['input'][$identifier])) {
96+
$identifierPairs[$key] = "{$identifier}={$args['input'][$identifier]}";
97+
98+
continue;
99+
}
100+
101+
if (\count($args['input'][$identifier]) > 1) {
102+
throw Error::createLocatedError('Composite identifiers are not allowed for a resource already used as a composite identifier', $info->fieldNodes, $info->path);
103+
}
104+
105+
$identifierPairs[$key] = "$identifier=".reset($args['input'][$identifier]);
106+
}
107+
108+
return implode(';', $identifierPairs);
109+
}
110+
111+
private function getItem(string $resourceClass, $id, ResourceMetadata $resourceMetadata, $info)
112+
{
113+
$item = $this->itemDataProvider->getItem($resourceClass, $id);
114+
if (null === $item) {
115+
throw Error::createLocatedError(sprintf('Item "%s" identified by "%s" not found', $resourceMetadata->getShortName(), $id), $info->fieldNodes, $info->path);
116+
}
117+
118+
return $item;
119+
}
120+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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\Graphql\Resolver;
15+
16+
/**
17+
* Creates a function resolving a GraphQL mutation of an item.
18+
*
19+
* @author Alan Poulain <[email protected]>
20+
*
21+
* @internal
22+
*/
23+
interface ItemMutationResolverFactoryInterface
24+
{
25+
public function createItemMutationResolver(string $resourceClass, string $mutationName): callable;
26+
}

0 commit comments

Comments
 (0)