Skip to content

[WIP] GraphQL Mutation support #1460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Nov 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 2.2.0

* Add GraphQL support
* Add GraphQL support``
* Add JSONAPI support
* Add a new `@ApiFilter` annotation to directly configure filters from resource classes
* Add a partial paginator that prevents `COUNT()` SQL queries
Expand Down
141 changes: 141 additions & 0 deletions features/graphql/mutation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
Feature: GraphQL mutation support
@createSchema
Scenario: Introspect types
When I send the following GraphQL request:
"""
{
__type(name: "Mutation") {
fields {
name
description
type {
name
kind
}
args {
name
type {
name
kind
}
}
}
}
}
"""
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.__type.fields[0].name" should contain "delete"
And the JSON node "data.__type.fields[0].description" should contain "Deletes "
And the JSON node "data.__type.fields[0].type.name" should contain "DeleteMutation"
And the JSON node "data.__type.fields[0].type.kind" should be equal to "OBJECT"
And the JSON node "data.__type.fields[0].args[0].name" should be equal to "input"
And the JSON node "data.__type.fields[0].args[0].type.name" should contain "InputDeleteMutation"
And the JSON node "data.__type.fields[0].args[0].type.kind" should be equal to "INPUT_OBJECT"

Scenario: Create an item
When I send the following GraphQL request:
"""
mutation {
createDummy(input: {name: "A new one", alias: "new", description: "brand new!"}) {
id,
name,
alias,
description
}
}
"""
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.createDummy.id" should be equal to 1
And the JSON node "data.createDummy.name" should be equal to "A new one"
And the JSON node "data.createDummy.alias" should be equal to "new"
And the JSON node "data.createDummy.description" should be equal to "brand new!"

@dropSchema
Scenario: Delete an item through a mutation
When I send the following GraphQL request:
"""
mutation {
deleteDummy(input: {id: 1}) {
id
}
}
"""
Then print last response
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.deleteDummy.id" should be equal to 1

@createSchema
@dropSchema
Scenario: Delete an item with composite identifiers through a mutation
Given there are Composite identifier objects
When I send the following GraphQL request:
"""
mutation {
deleteCompositeRelation(input: {compositeItem: {id: 1}, compositeLabel: {id: 1}}) {
compositeItem {
id
},
compositeLabel {
id
}
}
}
"""
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.deleteCompositeRelation.compositeItem.id" should be equal to 1
And the JSON node "data.deleteCompositeRelation.compositeLabel.id" should be equal to 1

@createSchema
@dropSchema
Scenario: Modify an item through a mutation
Given there is 1 dummy objects
When I send the following GraphQL request:
"""
mutation {
updateDummy(input: {id: 1, description: "Modified description."}) {
id,
name,
description
}
}
"""
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.updateDummy.id" should be equal to 1
And the JSON node "data.updateDummy.name" should be equal to "Dummy #1"
And the JSON node "data.updateDummy.description" should be equal to "Modified description."

# Composite identifiers are not supported yet
@createSchema
@dropSchema
Scenario: Modify an item with composite identifiers through a mutation
Given there are Composite identifier objects
When I send the following GraphQL request:
"""
mutation {
updateCompositeRelation(input: {compositeItem: {id: 2}, compositeLabel: {id: 8}, value: "Modified value."}) {
compositeItem {
id
},
compositeLabel {
id
},
value
}
}
"""
#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.putCompositeRelation.compositeItem.id" should be equal to 1
#And the JSON node "data.putCompositeRelation.compositeLabel.id" should be equal to 1
#And the JSON node "data.putCompositeRelation.value" should be equal to "Modified value."
120 changes: 120 additions & 0 deletions src/Bridge/Graphql/Resolver/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?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\Bridge\Graphql\Resolver;

use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
use GraphQL\Error\Error;
use GraphQL\Type\Definition\ResolveInfo;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
* Creates a function resolving a GraphQL mutation of an item.
*
* @author Alan Poulain <[email protected]>
*
* @internal
*/
final class ItemMutationResolverFactory implements ItemMutationResolverFactoryInterface
{
private $identifiersExtractor;
private $itemDataProvider;
private $normalizer;
private $resourceMetadataFactory;
private $dataPersister;

public function __construct(IdentifiersExtractorInterface $identifiersExtractor, ItemDataProviderInterface $itemDataProvider, NormalizerInterface $normalizer, ResourceMetadataFactoryInterface $resourceMetadataFactory, DataPersisterInterface $dataPersister)
{
if (!$normalizer instanceof DenormalizerInterface) {
throw new InvalidArgumentException(sprintf('The normalizer must implements the "%s" interface', DenormalizerInterface::class));
}

$this->identifiersExtractor = $identifiersExtractor;
$this->itemDataProvider = $itemDataProvider;
$this->normalizer = $normalizer;
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->dataPersister = $dataPersister;
}

public function createItemMutationResolver(string $resourceClass, string $mutationName): callable
{
return function ($root, $args, $context, ResolveInfo $info) use ($resourceClass, $mutationName) {
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);

$item = null;
if ('update' === $mutationName || 'delete' === $mutationName) {
$id = $this->getIdentifier($this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass), $args, $info);
$item = $this->getItem($resourceClass, $id, $resourceMetadata, $info);
}

switch ($mutationName) {
case 'create':
case 'update':
$context = null === $item ? ['resource_class' => $resourceClass] : ['resource_class' => $resourceClass, 'object_to_populate' => $item];
$item = $this->normalizer->denormalize($args['input'], $resourceClass, null, $context);
$this->dataPersister->persist($item);

return $this->normalizer->normalize(
$item,
null,
['graphql' => true] + $resourceMetadata->getGraphqlAttribute($mutationName, 'normalization_context', [], true)
);

case 'delete':
$this->dataPersister->remove($item);

return $args['input'];
}
};
}

private function getIdentifier(array $identifiers, $args, $info)
{
if (1 === \count($identifiers)) {
return $args['input'][$identifiers[0]];
}

$identifierPairs = [];
foreach ($identifiers as $key => $identifier) {
if (!\is_array($args['input'][$identifier])) {
$identifierPairs[$key] = "{$identifier}={$args['input'][$identifier]}";

continue;
}

if (\count($args['input'][$identifier]) > 1) {
throw Error::createLocatedError('Composite identifiers are not allowed for a resource already used as a composite identifier', $info->fieldNodes, $info->path);
}

$identifierPairs[$key] = "$identifier=".reset($args['input'][$identifier]);
}

return implode(';', $identifierPairs);
}

private function getItem(string $resourceClass, $id, ResourceMetadata $resourceMetadata, $info)
{
$item = $this->itemDataProvider->getItem($resourceClass, $id);
if (null === $item) {
throw Error::createLocatedError(sprintf('Item "%s" identified by "%s" not found', $resourceMetadata->getShortName(), $id), $info->fieldNodes, $info->path);
}

return $item;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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\Bridge\Graphql\Resolver;

/**
* Creates a function resolving a GraphQL mutation of an item.
*
* @author Alan Poulain <[email protected]>
*
* @internal
*/
interface ItemMutationResolverFactoryInterface
{
public function createItemMutationResolver(string $resourceClass, string $mutationName): callable;
}
Loading