From 71558fc9a8a030bf996087d3ad8ce59a143fbf73 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 12 Mar 2018 13:49:01 +0100 Subject: [PATCH 1/4] Add InputUnionType --- .../Type/Definition/InputUnionType.php | 187 ++++++++++++++++ .../Type/Definition/InputUnionTypeTest.php | 208 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 src/GraphQl/Type/Definition/InputUnionType.php create mode 100644 tests/GraphQl/Type/Definition/InputUnionTypeTest.php diff --git a/src/GraphQl/Type/Definition/InputUnionType.php b/src/GraphQl/Type/Definition/InputUnionType.php new file mode 100644 index 00000000000..de5a70c96b7 --- /dev/null +++ b/src/GraphQl/Type/Definition/InputUnionType.php @@ -0,0 +1,187 @@ + + * + * 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 + */ +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; + } +} diff --git a/tests/GraphQl/Type/Definition/InputUnionTypeTest.php b/tests/GraphQl/Type/Definition/InputUnionTypeTest.php new file mode 100644 index 00000000000..c76560cac3e --- /dev/null +++ b/tests/GraphQl/Type/Definition/InputUnionTypeTest.php @@ -0,0 +1,208 @@ + + * + * 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\GraphQl\Type\Definition; + +use ApiPlatform\Core\GraphQl\Type\Definition\InputUnionType; +use GraphQL\Error\Error; +use GraphQL\Error\InvariantViolation; +use GraphQL\Language\AST\StringValueNode; +use GraphQL\Type\Definition\LeafType; +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\StringType; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class InputUnionTypeTest extends TestCase +{ + public function testGetTypesNotSet() + { + $inputUnionType = new InputUnionType([]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('InputUnion types must be an Array or a callable which returns an Array.'); + + $inputUnionType->getTypes(); + } + + public function testGetTypesInvalid() + { + $inputUnionType = new InputUnionType(['types' => 1]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('InputUnion types must be an Array or a callable which returns an Array.'); + + $inputUnionType->getTypes(); + } + + public function testGetTypesCallable() + { + $inputUnionType = new InputUnionType(['types' => function () { + return ['foo']; + }]); + + $this->assertEquals(['foo'], $inputUnionType->getTypes()); + } + + public function testGetTypes() + { + $inputUnionType = new InputUnionType(['types' => ['bar']]); + + $this->assertEquals(['bar'], $inputUnionType->getTypes()); + } + + public function testAssertValidEmptyTypes() + { + $inputUnionType = new InputUnionType(['types' => []]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('InputUnion types must not be empty'); + + $inputUnionType->assertValid(); + } + + public function testAssertValidNotInputObjectTypes() + { + $inputUnionType = new InputUnionType(['types' => ['foo']]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('InputUnion may only contain input types, it cannot contain: "foo".'); + + $inputUnionType->assertValid(); + } + + public function testAssertValidDuplicateTypes() + { + $type = $this->prophesize(StringType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type, $type]]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('InputUnion can include String type only once.'); + + $inputUnionType->assertValid(); + } + + public function testSerializeNotLeafType() + { + $type = $this->prophesize(ObjectType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type]]); + + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('Types in union cannot represent value: "foo"'); + + $inputUnionType->serialize('foo'); + } + + public function testSerialize() + { + $type = $this->prophesize(LeafType::class); + $type->serialize('foo')->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $inputUnionType->serialize('foo'); + } + + public function testParseValueNotLeafType() + { + $type = $this->prophesize(ObjectType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type]]); + + $this->expectException(Error::class); + $this->expectExceptionMessage('Types in union cannot represent value: "foo"'); + + $inputUnionType->parseValue('foo'); + } + + public function testParseValue() + { + $type = $this->prophesize(LeafType::class); + $type->parseValue('foo')->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $inputUnionType->parseValue('foo'); + } + + public function testParseLiteralNotLeafType() + { + $type = $this->prophesize(ObjectType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type]]); + + $this->assertNull($inputUnionType->parseLiteral(new StringValueNode(['value' => 'foo']))); + } + + public function testParseLiteral() + { + $type = $this->prophesize(LeafType::class); + $node = new StringValueNode(['value' => 'foo']); + $type->parseLiteral($node)->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $inputUnionType->parseLiteral($node); + } + + public function testIsValidValueNotLeafType() + { + $type = $this->prophesize(ObjectType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type]]); + + $this->assertFalse($inputUnionType->isValidValue('foo')); + } + + public function testIsValidValueInvalid() + { + $type = $this->prophesize(LeafType::class); + $type->isValidValue('foo')->willReturn(false)->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $this->assertFalse($inputUnionType->isValidValue('foo')); + } + + public function testIsValidValue() + { + $type = $this->prophesize(LeafType::class); + $type->isValidValue('foo')->willReturn(true)->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $this->assertTrue($inputUnionType->isValidValue('foo')); + } + + public function testIsValidLiteralNotLeafType() + { + $type = $this->prophesize(ObjectType::class)->reveal(); + $inputUnionType = new InputUnionType(['types' => [$type]]); + + $this->assertFalse($inputUnionType->isValidLiteral(new StringValueNode(['value' => 'foo']))); + } + + public function testIsValidLiteralInvalid() + { + $type = $this->prophesize(LeafType::class); + $node = new StringValueNode(['value' => 'foo']); + $type->isValidLiteral($node)->willReturn(false)->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $this->assertFalse($inputUnionType->isValidLiteral($node)); + } + + public function testIsValidLiteral() + { + $type = $this->prophesize(LeafType::class); + $node = new StringValueNode(['value' => 'foo']); + $type->isValidLiteral($node)->willReturn(true)->shouldBeCalled(); + $inputUnionType = new InputUnionType(['types' => [$type->reveal()]]); + + $this->assertTrue($inputUnionType->isValidLiteral($node)); + } +} From ebad252bf34bb298ac04cff8872312efb6ee3eea Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 12 Mar 2018 13:51:05 +0100 Subject: [PATCH 2/4] Use InputUnionType for embedded entities --- features/graphql/mutation.feature | 16 +++++++++++++++ src/GraphQl/Type/SchemaBuilder.php | 31 +++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index 4f185002081..2ec99f244a5 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -106,6 +106,22 @@ 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: Delete an item through a mutation When I send the following GraphQL request: """ diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index f886740aa36..7b2163c4566 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -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; @@ -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; } @@ -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(); From 09df54d861c97e16d521d0e3384fa42410437921 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 26 Mar 2018 14:37:06 +0200 Subject: [PATCH 3/4] Remove final from ItemNormalizer (add a PHPDoc final instead) --- src/Serializer/ItemNormalizer.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index fe4b2a5b257..36bb1799f1d 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -18,9 +18,11 @@ /** * Generic item normalizer. * + * @final + * * @author Kévin Dunglas */ -final class ItemNormalizer extends AbstractItemNormalizer +class ItemNormalizer extends AbstractItemNormalizer { /** * {@inheritdoc} From b4415e52424469e74ab9b4b1cb28e3bc77f6b457 Mon Sep 17 00:00:00 2001 From: Alan Poulain Date: Mon, 12 Mar 2018 13:52:00 +0100 Subject: [PATCH 4/4] Use InputUnionType for updating related existing resources --- features/graphql/mutation.feature | 22 ++++++++++++++++++++++ src/GraphQl/Serializer/ItemNormalizer.php | 5 +++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index 2ec99f244a5..baffd9cf1ff 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -122,6 +122,28 @@ Feature: GraphQL mutation support 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: """ diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 164ec209e7a..9013036b8ae 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -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 */ -final class ItemNormalizer extends AbstractItemNormalizer +final class ItemNormalizer extends GenericItemNormalizer { const FORMAT = 'graphql'; const ITEM_KEY = '#item'; @@ -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;