Skip to content

Commit

Permalink
[GraphQL] Embedded entities support for mutations (api-platform#1765)
Browse files Browse the repository at this point in the history
* Add InputUnionType

* Use InputUnionType for embedded entities

* Use InputUnionType for updating related existing resources

* Remove final from ItemNormalizer (add a PHPDoc final instead)
  • Loading branch information
alanpoulain authored and dunglas committed Mar 28, 2018
1 parent 90a61ab commit d7ada1b
Show file tree
Hide file tree
Showing 6 changed files with 465 additions and 8 deletions.
38 changes: 38 additions & 0 deletions features/graphql/mutation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,44 @@ Feature: GraphQL mutation support
And the JSON node "data.createDummy.arrayData[1]" should be equal to baz
And the JSON node "data.createDummy.clientMutationId" should be equal to "myId"

Scenario: Create an item with an embedded field
When I send the following GraphQL request:
"""
mutation {
createRelatedDummy(input: {_id: 2, symfony: "symfony", embeddedDummy: {dummyName: "Embedded"}, clientMutationId: "myId"}) {
id
clientMutationId
}
}
"""
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 node "data.createRelatedDummy.id" should be equal to "/related_dummies/2"
And the JSON node "data.createRelatedDummy.clientMutationId" should be equal to "myId"

Scenario: Create an item and update a nested resource through a mutation
When I send the following GraphQL request:
"""
mutation {
createRelationEmbedder(input: {paris: "paris", krondstadt: "Krondstadt", anotherRelated: {id: 2, symfony: "laravel"}, clientMutationId: "myId"}) {
id
anotherRelated {
id
symfony
}
clientMutationId
}
}
"""
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 node "data.createRelationEmbedder.id" should be equal to "/relation_embedders/1"
And the JSON node "data.createRelationEmbedder.anotherRelated.id" should be equal to "/related_dummies/2"
And the JSON node "data.createRelationEmbedder.anotherRelated.symfony" should be equal to "laravel"
And the JSON node "data.createRelationEmbedder.clientMutationId" should be equal to "myId"

Scenario: Delete an item through a mutation
When I send the following GraphQL request:
"""
Expand Down
5 changes: 3 additions & 2 deletions src/GraphQl/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@

use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
use ApiPlatform\Core\Serializer\ItemNormalizer as GenericItemNormalizer;

/**
* GraphQL normalizer.
*
* @author Kévin Dunglas <[email protected]>
*/
final class ItemNormalizer extends AbstractItemNormalizer
final class ItemNormalizer extends GenericItemNormalizer
{
const FORMAT = 'graphql';
const ITEM_KEY = '#item';
Expand All @@ -39,7 +40,7 @@ final class ItemNormalizer extends AbstractItemNormalizer
*/
public function normalize($object, $format = null, array $context = [])
{
$data = parent::normalize($object, $format, $context);
$data = AbstractItemNormalizer::normalize($object, $format, $context);
$data[self::ITEM_KEY] = serialize($object); // calling serialize prevent weird normalization process done by Webonyx's GraphQL PHP

return $data;
Expand Down
187 changes: 187 additions & 0 deletions src/GraphQl/Type/Definition/InputUnionType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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\Type\Definition;

use GraphQL\Error\Error;
use GraphQL\Error\InvariantViolation;
use GraphQL\Type\Definition\InputObjectType;
use GraphQL\Type\Definition\InputType;
use GraphQL\Type\Definition\LeafType;
use GraphQL\Type\Definition\Type;
use GraphQL\Utils\Utils;

/**
* Represents an union of other input types.
*
* @experimental
*
* @author Alan Poulain <[email protected]>
*/
final class InputUnionType extends Type implements InputType, LeafType
{
/**
* @var InputObjectType[]
*/
private $types;

/**
* @var array
*/
private $config;

/**
* @throws InvariantViolation
*/
public function __construct(array $config)
{
if (!isset($config['name'])) {
$config['name'] = $this->tryInferName();
}

Utils::assertValidName($config['name']);

$this->name = $config['name'];
$this->description = $config['description'] ?? null;
$this->config = $config;
}

/**
* @throws InvariantViolation
*
* @return InputObjectType[]
*/
public function getTypes(): array
{
if (null !== $this->types) {
return $this->types;
}

if (($types = $this->config['types'] ?? null) && \is_callable($types)) {
$types = \call_user_func($this->config['types']);
}

if (!\is_array($types)) {
throw new InvariantViolation(
"{$this->name} types must be an Array or a callable which returns an Array."
);
}

return $this->types = $types;
}

/**
* {@inheritdoc}
*/
public function assertValid()
{
parent::assertValid();

$types = $this->getTypes();
Utils::invariant(\count($types) > 0, "{$this->name} types must not be empty");

$includedTypeNames = [];
foreach ($types as $inputType) {
Utils::invariant(
$inputType instanceof InputType,
"{$this->name} may only contain input types, it cannot contain: %s.",
Utils::printSafe($inputType)
);
Utils::invariant(
!isset($includedTypeNames[$inputType->name]),
"{$this->name} can include {$inputType->name} type only once."
);
$includedTypeNames[$inputType->name] = true;
}
}

/**
* {@inheritdoc}
*
* @throws InvariantViolation
*/
public function serialize($value)
{
foreach ($this->getTypes() as $type) {
if ($type instanceof LeafType) {
try {
return $type->serialize($value);
} catch (\Exception $e) {
}
}
}

throw new InvariantViolation(sprintf('Types in union cannot represent value: %s', Utils::printSafe($value)));
}

/**
* {@inheritdoc}
*
* @throws Error
*/
public function parseValue($value)
{
foreach ($this->getTypes() as $type) {
if ($type instanceof LeafType) {
try {
return $type->parseValue($value);
} catch (\Exception $e) {
}
}
}

throw new Error(sprintf('Types in union cannot represent value: %s', Utils::printSafeJson($value)));
}

/**
* {@inheritdoc}
*/
public function parseLiteral($valueNode)
{
foreach ($this->getTypes() as $type) {
if ($type instanceof LeafType && null !== $parsed = $type->parseLiteral($valueNode)) {
return $parsed;
}
}

return null;
}

/**
* {@inheritdoc}
*/
public function isValidValue($value): bool
{
foreach ($this->getTypes() as $type) {
if ($type instanceof LeafType && $type->isValidValue($value)) {
return true;
}
}

return false;
}

/**
* {@inheritdoc}
*/
public function isValidLiteral($valueNode): bool
{
foreach ($this->getTypes() as $type) {
if ($type instanceof LeafType && $type->isValidLiteral($valueNode)) {
return true;
}
}

return false;
}
}
31 changes: 26 additions & 5 deletions src/GraphQl/Type/SchemaBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
use ApiPlatform\Core\GraphQl\Resolver\Factory\ResolverFactoryInterface;
use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer;
use ApiPlatform\Core\GraphQl\Type\Definition\InputUnionType;
use ApiPlatform\Core\GraphQl\Type\Definition\IterableType;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
Expand Down Expand Up @@ -335,13 +336,24 @@ private function convertType(Type $type, bool $input = false, string $mutationNa
break;
case Type::BUILTIN_TYPE_ARRAY:
case Type::BUILTIN_TYPE_ITERABLE:
if (!isset($this->graphqlTypes['#iterable'])) {
$this->graphqlTypes['#iterable'] = new IterableType();
}
$graphqlType = $this->graphqlTypes['#iterable'];
$graphqlType = $this->getIterableType();
break;
case Type::BUILTIN_TYPE_OBJECT:
if (($input && $depth > 0) || is_a($type->getClassName(), \DateTimeInterface::class, true)) {
if ($input && $depth > 0) {
if (!isset($this->graphqlTypes['#stringIterableUnionInput'])) {
$this->graphqlTypes['#stringIterableUnionInput'] = new InputUnionType([
'name' => 'StringIterableUnionInput',
'description' => 'Resource\'s IRI or data (embedded entities or when updating a related existing resource)',
'types' => [
GraphQLType::string(),
$this->getIterableType(),
],
]);
}
$graphqlType = $this->graphqlTypes['#stringIterableUnionInput'];
break;
}
if (is_a($type->getClassName(), \DateTimeInterface::class, true)) {
$graphqlType = GraphQLType::string();
break;
}
Expand Down Expand Up @@ -492,6 +504,15 @@ private function getResourcePaginatedCollectionType(string $resourceClass, Graph
return $this->graphqlTypes[$resourceClass]['connection'] = new ObjectType($configuration);
}

private function getIterableType(): IterableType
{
if (!isset($this->graphqlTypes['#iterable'])) {
$this->graphqlTypes['#iterable'] = new IterableType();
}

return $this->graphqlTypes['#iterable'];
}

private function isCollection(Type $type): bool
{
return $type->isCollection() && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType();
Expand Down
4 changes: 3 additions & 1 deletion src/Serializer/ItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
/**
* Generic item normalizer.
*
* @final
*
* @author Kévin Dunglas <[email protected]>
*/
final class ItemNormalizer extends AbstractItemNormalizer
class ItemNormalizer extends AbstractItemNormalizer
{
/**
* {@inheritdoc}
Expand Down
Loading

0 comments on commit d7ada1b

Please sign in to comment.