Skip to content

Commit d7ada1b

Browse files
alanpoulaindunglas
authored andcommitted
[GraphQL] Embedded entities support for mutations (#1765)
* Add InputUnionType * Use InputUnionType for embedded entities * Use InputUnionType for updating related existing resources * Remove final from ItemNormalizer (add a PHPDoc final instead)
1 parent 90a61ab commit d7ada1b

File tree

6 files changed

+465
-8
lines changed

6 files changed

+465
-8
lines changed

features/graphql/mutation.feature

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,44 @@ Feature: GraphQL mutation support
106106
And the JSON node "data.createDummy.arrayData[1]" should be equal to baz
107107
And the JSON node "data.createDummy.clientMutationId" should be equal to "myId"
108108

109+
Scenario: Create an item with an embedded field
110+
When I send the following GraphQL request:
111+
"""
112+
mutation {
113+
createRelatedDummy(input: {_id: 2, symfony: "symfony", embeddedDummy: {dummyName: "Embedded"}, clientMutationId: "myId"}) {
114+
id
115+
clientMutationId
116+
}
117+
}
118+
"""
119+
Then the response status code should be 200
120+
And the response should be in JSON
121+
And the header "Content-Type" should be equal to "application/json"
122+
And the JSON node "data.createRelatedDummy.id" should be equal to "/related_dummies/2"
123+
And the JSON node "data.createRelatedDummy.clientMutationId" should be equal to "myId"
124+
125+
Scenario: Create an item and update a nested resource through a mutation
126+
When I send the following GraphQL request:
127+
"""
128+
mutation {
129+
createRelationEmbedder(input: {paris: "paris", krondstadt: "Krondstadt", anotherRelated: {id: 2, symfony: "laravel"}, clientMutationId: "myId"}) {
130+
id
131+
anotherRelated {
132+
id
133+
symfony
134+
}
135+
clientMutationId
136+
}
137+
}
138+
"""
139+
Then the response status code should be 200
140+
And the response should be in JSON
141+
And the header "Content-Type" should be equal to "application/json"
142+
And the JSON node "data.createRelationEmbedder.id" should be equal to "/relation_embedders/1"
143+
And the JSON node "data.createRelationEmbedder.anotherRelated.id" should be equal to "/related_dummies/2"
144+
And the JSON node "data.createRelationEmbedder.anotherRelated.symfony" should be equal to "laravel"
145+
And the JSON node "data.createRelationEmbedder.clientMutationId" should be equal to "myId"
146+
109147
Scenario: Delete an item through a mutation
110148
When I send the following GraphQL request:
111149
"""

src/GraphQl/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@
2323

2424
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
2525
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
26+
use ApiPlatform\Core\Serializer\ItemNormalizer as GenericItemNormalizer;
2627

2728
/**
2829
* GraphQL normalizer.
2930
*
3031
* @author Kévin Dunglas <[email protected]>
3132
*/
32-
final class ItemNormalizer extends AbstractItemNormalizer
33+
final class ItemNormalizer extends GenericItemNormalizer
3334
{
3435
const FORMAT = 'graphql';
3536
const ITEM_KEY = '#item';
@@ -39,7 +40,7 @@ final class ItemNormalizer extends AbstractItemNormalizer
3940
*/
4041
public function normalize($object, $format = null, array $context = [])
4142
{
42-
$data = parent::normalize($object, $format, $context);
43+
$data = AbstractItemNormalizer::normalize($object, $format, $context);
4344
$data[self::ITEM_KEY] = serialize($object); // calling serialize prevent weird normalization process done by Webonyx's GraphQL PHP
4445

4546
return $data;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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\Type\Definition;
15+
16+
use GraphQL\Error\Error;
17+
use GraphQL\Error\InvariantViolation;
18+
use GraphQL\Type\Definition\InputObjectType;
19+
use GraphQL\Type\Definition\InputType;
20+
use GraphQL\Type\Definition\LeafType;
21+
use GraphQL\Type\Definition\Type;
22+
use GraphQL\Utils\Utils;
23+
24+
/**
25+
* Represents an union of other input types.
26+
*
27+
* @experimental
28+
*
29+
* @author Alan Poulain <[email protected]>
30+
*/
31+
final class InputUnionType extends Type implements InputType, LeafType
32+
{
33+
/**
34+
* @var InputObjectType[]
35+
*/
36+
private $types;
37+
38+
/**
39+
* @var array
40+
*/
41+
private $config;
42+
43+
/**
44+
* @throws InvariantViolation
45+
*/
46+
public function __construct(array $config)
47+
{
48+
if (!isset($config['name'])) {
49+
$config['name'] = $this->tryInferName();
50+
}
51+
52+
Utils::assertValidName($config['name']);
53+
54+
$this->name = $config['name'];
55+
$this->description = $config['description'] ?? null;
56+
$this->config = $config;
57+
}
58+
59+
/**
60+
* @throws InvariantViolation
61+
*
62+
* @return InputObjectType[]
63+
*/
64+
public function getTypes(): array
65+
{
66+
if (null !== $this->types) {
67+
return $this->types;
68+
}
69+
70+
if (($types = $this->config['types'] ?? null) && \is_callable($types)) {
71+
$types = \call_user_func($this->config['types']);
72+
}
73+
74+
if (!\is_array($types)) {
75+
throw new InvariantViolation(
76+
"{$this->name} types must be an Array or a callable which returns an Array."
77+
);
78+
}
79+
80+
return $this->types = $types;
81+
}
82+
83+
/**
84+
* {@inheritdoc}
85+
*/
86+
public function assertValid()
87+
{
88+
parent::assertValid();
89+
90+
$types = $this->getTypes();
91+
Utils::invariant(\count($types) > 0, "{$this->name} types must not be empty");
92+
93+
$includedTypeNames = [];
94+
foreach ($types as $inputType) {
95+
Utils::invariant(
96+
$inputType instanceof InputType,
97+
"{$this->name} may only contain input types, it cannot contain: %s.",
98+
Utils::printSafe($inputType)
99+
);
100+
Utils::invariant(
101+
!isset($includedTypeNames[$inputType->name]),
102+
"{$this->name} can include {$inputType->name} type only once."
103+
);
104+
$includedTypeNames[$inputType->name] = true;
105+
}
106+
}
107+
108+
/**
109+
* {@inheritdoc}
110+
*
111+
* @throws InvariantViolation
112+
*/
113+
public function serialize($value)
114+
{
115+
foreach ($this->getTypes() as $type) {
116+
if ($type instanceof LeafType) {
117+
try {
118+
return $type->serialize($value);
119+
} catch (\Exception $e) {
120+
}
121+
}
122+
}
123+
124+
throw new InvariantViolation(sprintf('Types in union cannot represent value: %s', Utils::printSafe($value)));
125+
}
126+
127+
/**
128+
* {@inheritdoc}
129+
*
130+
* @throws Error
131+
*/
132+
public function parseValue($value)
133+
{
134+
foreach ($this->getTypes() as $type) {
135+
if ($type instanceof LeafType) {
136+
try {
137+
return $type->parseValue($value);
138+
} catch (\Exception $e) {
139+
}
140+
}
141+
}
142+
143+
throw new Error(sprintf('Types in union cannot represent value: %s', Utils::printSafeJson($value)));
144+
}
145+
146+
/**
147+
* {@inheritdoc}
148+
*/
149+
public function parseLiteral($valueNode)
150+
{
151+
foreach ($this->getTypes() as $type) {
152+
if ($type instanceof LeafType && null !== $parsed = $type->parseLiteral($valueNode)) {
153+
return $parsed;
154+
}
155+
}
156+
157+
return null;
158+
}
159+
160+
/**
161+
* {@inheritdoc}
162+
*/
163+
public function isValidValue($value): bool
164+
{
165+
foreach ($this->getTypes() as $type) {
166+
if ($type instanceof LeafType && $type->isValidValue($value)) {
167+
return true;
168+
}
169+
}
170+
171+
return false;
172+
}
173+
174+
/**
175+
* {@inheritdoc}
176+
*/
177+
public function isValidLiteral($valueNode): bool
178+
{
179+
foreach ($this->getTypes() as $type) {
180+
if ($type instanceof LeafType && $type->isValidLiteral($valueNode)) {
181+
return true;
182+
}
183+
}
184+
185+
return false;
186+
}
187+
}

src/GraphQl/Type/SchemaBuilder.php

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
1717
use ApiPlatform\Core\GraphQl\Resolver\Factory\ResolverFactoryInterface;
1818
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
19+
use ApiPlatform\Core\GraphQl\Type\Definition\InputUnionType;
1920
use ApiPlatform\Core\GraphQl\Type\Definition\IterableType;
2021
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
2122
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
@@ -335,13 +336,24 @@ private function convertType(Type $type, bool $input = false, string $mutationNa
335336
break;
336337
case Type::BUILTIN_TYPE_ARRAY:
337338
case Type::BUILTIN_TYPE_ITERABLE:
338-
if (!isset($this->graphqlTypes['#iterable'])) {
339-
$this->graphqlTypes['#iterable'] = new IterableType();
340-
}
341-
$graphqlType = $this->graphqlTypes['#iterable'];
339+
$graphqlType = $this->getIterableType();
342340
break;
343341
case Type::BUILTIN_TYPE_OBJECT:
344-
if (($input && $depth > 0) || is_a($type->getClassName(), \DateTimeInterface::class, true)) {
342+
if ($input && $depth > 0) {
343+
if (!isset($this->graphqlTypes['#stringIterableUnionInput'])) {
344+
$this->graphqlTypes['#stringIterableUnionInput'] = new InputUnionType([
345+
'name' => 'StringIterableUnionInput',
346+
'description' => 'Resource\'s IRI or data (embedded entities or when updating a related existing resource)',
347+
'types' => [
348+
GraphQLType::string(),
349+
$this->getIterableType(),
350+
],
351+
]);
352+
}
353+
$graphqlType = $this->graphqlTypes['#stringIterableUnionInput'];
354+
break;
355+
}
356+
if (is_a($type->getClassName(), \DateTimeInterface::class, true)) {
345357
$graphqlType = GraphQLType::string();
346358
break;
347359
}
@@ -492,6 +504,15 @@ private function getResourcePaginatedCollectionType(string $resourceClass, Graph
492504
return $this->graphqlTypes[$resourceClass]['connection'] = new ObjectType($configuration);
493505
}
494506

507+
private function getIterableType(): IterableType
508+
{
509+
if (!isset($this->graphqlTypes['#iterable'])) {
510+
$this->graphqlTypes['#iterable'] = new IterableType();
511+
}
512+
513+
return $this->graphqlTypes['#iterable'];
514+
}
515+
495516
private function isCollection(Type $type): bool
496517
{
497518
return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType();

src/Serializer/ItemNormalizer.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
/**
1919
* Generic item normalizer.
2020
*
21+
* @final
22+
*
2123
* @author Kévin Dunglas <[email protected]>
2224
*/
23-
final class ItemNormalizer extends AbstractItemNormalizer
25+
class ItemNormalizer extends AbstractItemNormalizer
2426
{
2527
/**
2628
* {@inheritdoc}

0 commit comments

Comments
 (0)