Skip to content

Commit

Permalink
Support DTO class on GraphQL (#2427)
Browse files Browse the repository at this point in the history
* Support DTO class on GraphQL

* Fix review

* Add Behat tests

* Fix CS

* Fix left over

* Handle false value as input_class/output_class

* Fix review

* Optimize imports

* Revert optimize imports

* Fix review
  • Loading branch information
vincentchalamon authored and dunglas committed Jan 7, 2019
1 parent 009e86a commit c3e631e
Show file tree
Hide file tree
Showing 10 changed files with 460 additions and 20 deletions.
34 changes: 34 additions & 0 deletions features/bootstrap/FeatureContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCarColor;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDate;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyGroup;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate;
Expand Down Expand Up @@ -346,6 +348,38 @@ public function thereAreDummyObjectsWithRelatedDummy(int $nb)
$this->manager->flush();
}

/**
* @Given there are :nb dummyDtoNoInput objects
*/
public function thereAreDummyDtoNoInputObjects(int $nb)
{
for ($i = 1; $i <= $nb; ++$i) {
$dummyDto = new DummyDtoNoInput();
$dummyDto->lorem = 'DummyDtoNoInput foo #'.$i;
$dummyDto->ipsum = round($i / 3, 2);

$this->manager->persist($dummyDto);
}

$this->manager->flush();
}

/**
* @Given there are :nb dummyDtoNoOutput objects
*/
public function thereAreDummyDtoNoOutputObjects(int $nb)
{
for ($i = 1; $i <= $nb; ++$i) {
$dummyDto = new DummyDtoNoOutput();
$dummyDto->lorem = 'DummyDtoNoOutput foo #'.$i;
$dummyDto->ipsum = $i / 3;

$this->manager->persist($dummyDto);
}

$this->manager->flush();
}

/**
* @Given there are :nb dummy objects with JSON and array data
*/
Expand Down
69 changes: 69 additions & 0 deletions features/graphql/mutation.feature
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,72 @@ Feature: GraphQL mutation support
And the response should be in JSON
And the header "Content-Type" should be equal to "application/json"
And the JSON node "errors[0].message" should be equal to "name: This value should not be blank."

Scenario: Create an item using custom inputClass & disabled outputClass
Given there are 2 dummyDtoNoOutput objects
When I send the following GraphQL request:
"""
mutation {
createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) {
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 should be equal to:
"""
{
"data": {
"createDummyDtoNoOutput": {
"clientMutationId": "myId"
}
}
}
"""

Scenario: Cannot create an item using disabled inputClass
Given there are 2 dummyDtoNoInput objects
When I send the following GraphQL request:
"""
mutation {
createDummyDtoNoInput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) {
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 should be equal to:
"""
{
"errors": [
{
"message": "Field \"foo\" is not defined by type createDummyDtoNoInputInput.",
"extensions": {
"category": "graphql"
},
"locations": [
{
"line": 2,
"column": 33
}
]
},
{
"message": "Field \"bar\" is not defined by type createDummyDtoNoInputInput.",
"extensions": {
"category": "graphql"
},
"locations": [
{
"line": 2,
"column": 51
}
]
}
]
}
"""
72 changes: 72 additions & 0 deletions features/graphql/query.feature
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,75 @@ Feature: GraphQL query support
}
}
"""

Scenario: Use outputClass instead of resource class through a GraphQL query
Given there are 2 dummyDtoNoInput objects
When I send the following GraphQL request:
"""
{
dummyDtoNoInputs {
edges {
node {
baz
bat
}
}
}
}
"""
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 should be equal to:
"""
{
"data": {
"dummyDtoNoInputs": {
"edges": [
{
"node": {
"baz": 0.33,
"bat": "DummyDtoNoInput foo #1"
}
},
{
"node": {
"baz": 0.67,
"bat": "DummyDtoNoInput foo #2"
}
}
]
}
}
}
"""

@createSchema
Scenario: Disable outputClass leads to an empty response through a GraphQL query
Given there are 2 dummyDtoNoOutput objects
When I send the following GraphQL request:
"""
{
dummyDtoNoInputs {
edges {
node {
baz
bat
}
}
}
}
"""
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 should be equal to:
"""
{
"data": {
"dummyDtoNoInputs": {
"edges": []
}
}
}
"""
3 changes: 3 additions & 0 deletions src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ public function __invoke(string $resourceClass = null, string $rootClass = null,

$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
$this->canAccess($this->resourceAccessChecker, $resourceMetadata, $resourceClass, $info, $item, $operationName);
if (false === $resourceClass = $resourceMetadata->getAttribute('input_class', $resourceClass)) {
return null;
}

switch ($operationName) {
case 'create':
Expand Down
45 changes: 25 additions & 20 deletions src/GraphQl/Type/SchemaBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,10 @@ private function convertType(Type $type, bool $input = false, string $mutationNa
return null;
}

$graphqlType = $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $mutationName, $depth);
if (false !== $dtoClass = $resourceMetadata->getAttribute($input ? 'input_class' : 'output_class', $resourceClass)) {
$resourceClass = $dtoClass;
}
$graphqlType = $this->getResourceObjectType(false === $dtoClass ? null : $resourceClass, $resourceMetadata, $input, $mutationName, $depth);
break;
default:
throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $builtinType));
Expand All @@ -399,7 +402,7 @@ private function convertType(Type $type, bool $input = false, string $mutationNa
*
* @return ObjectType|InputObjectType
*/
private function getResourceObjectType(string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0): GraphQLType
private function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0): GraphQLType
{
$shortName = $resourceMetadata->getShortName();
if (null !== $mutationName) {
Expand Down Expand Up @@ -431,7 +434,7 @@ private function getResourceObjectType(string $resourceClass, ResourceMetadata $
/**
* Gets the fields of the type of the given resource.
*/
private function getResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0): array
private function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input = false, string $mutationName = null, int $depth = 0): array
{
$fields = [];
$idField = ['type' => GraphQLType::nonNull(GraphQLType::id())];
Expand All @@ -448,25 +451,27 @@ private function getResourceObjectTypeFields(string $resourceClass, ResourceMeta
$fields['id'] = $idField;
}

foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? 'query']);
if (
null === ($propertyType = $propertyMetadata->getType())
|| (!$input && null === $mutationName && false === $propertyMetadata->isReadable())
|| (null !== $mutationName && false === $propertyMetadata->isWritable())
) {
continue;
}
if (null !== $resourceClass) {
foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) {
$propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? 'query']);
if (
null === ($propertyType = $propertyMetadata->getType())
|| (!$input && null === $mutationName && false === $propertyMetadata->isReadable())
|| (null !== $mutationName && false === $propertyMetadata->isWritable())
) {
continue;
}

$rootResource = $resourceClass;
if (null !== $propertyMetadata->getSubresource()) {
$resourceClass = $propertyMetadata->getSubresource()->getResourceClass();
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
}
if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $rootResource, $input, $mutationName, ++$depth)) {
$fields['id' === $property ? '_id' : $property] = $fieldConfiguration;
$rootResource = $resourceClass;
if (null !== $propertyMetadata->getSubresource()) {
$resourceClass = $propertyMetadata->getSubresource()->getResourceClass();
$resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
}
if ($fieldConfiguration = $this->getResourceFieldConfiguration($resourceClass, $resourceMetadata, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', ''), $propertyType, $rootResource, $input, $mutationName, ++$depth)) {
$fields['id' === $property ? '_id' : $property] = $fieldConfiguration;
}
$resourceClass = $rootResource;
}
$resourceClass = $rootResource;
}

if (null !== $mutationName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Tests\Fixtures\TestBundle\DataPersister;

use ApiPlatform\Core\DataPersister\DataPersisterInterface;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput;
use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InputDto;
use Doctrine\Common\Persistence\ManagerRegistry;

class DummyDtoNoOutputDataPersister implements DataPersisterInterface
{
private $registry;

public function __construct(ManagerRegistry $registry)
{
$this->registry = $registry;
}

/**
* {@inheritdoc}
*/
public function supports($data): bool
{
return $data instanceof InputDto;
}

/**
* {@inheritdoc}
*/
public function persist($data)
{
$output = new DummyDtoNoOutput();
$output->lorem = $data->foo;
$output->ipsum = (string) $data->bar;

$em = $this->registry->getManagerForClass(DummyDtoNoOutput::class);
$em->persist($output);
$em->flush();

return $output;
}

/**
* {@inheritdoc}
*/
public function remove($data)
{
return null;
}
}
Loading

0 comments on commit c3e631e

Please sign in to comment.