diff --git a/features/graphql/schema.feature b/features/graphql/schema.feature index 5ef4892aa84..86a48cebd13 100644 --- a/features/graphql/schema.feature +++ b/features/graphql/schema.feature @@ -98,3 +98,16 @@ Feature: GraphQL schema-related features clientMutationId: String } """ + And the command output should contain: + """ + "Updates a OptionalRequiredDummy." + input updateOptionalRequiredDummyInput { + id: ID! + thirdLevel: updateThirdLevelNestedInput + thirdLevelRequired: updateThirdLevelNestedInput! + + "Get relatedToDummyFriend." + relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] + clientMutationId: String + } + """ diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index af610ea05c4..ae11da1a13c 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -16,8 +16,8 @@ use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\GraphQl\Tests\Fixtures\Serializer\NameConverter\CustomConverter; +use ApiPlatform\GraphQl\Type\ContextAwareTypeBuilderInterface; use ApiPlatform\GraphQl\Type\FieldsBuilder; -use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverterInterface; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; @@ -79,7 +79,7 @@ protected function setUp(): void $this->propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $this->resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $this->typesContainerProphecy = $this->prophesize(TypesContainerInterface::class); - $this->typeBuilderProphecy = $this->prophesize(TypeBuilderEnumInterface::class); + $this->typeBuilderProphecy = $this->prophesize(ContextAwareTypeBuilderInterface::class); $this->typeConverterProphecy = $this->prophesize(TypeConverterInterface::class); $this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); diff --git a/src/GraphQl/Tests/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php index c9afecb7af4..5c65e13a39b 100644 --- a/src/GraphQl/Tests/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -19,6 +19,7 @@ use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeBuilder; use ApiPlatform\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; @@ -83,9 +84,9 @@ public function testGetResourceObjectType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Query())->withShortName('shortName')->withDescription('description'); + $operation = (new Query())->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadataCollection, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadataCollection, $operation, null, ['input' => false]); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -111,7 +112,7 @@ public function testGetResourceObjectTypeOutputClass(): void /** @var Operation $operation */ $operation = (new Query())->withShortName('shortName')->withDescription('description')->withOutput(['class' => 'outputClass']); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadataCollection, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadataCollection, $operation, null, ['input' => false]); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -139,7 +140,7 @@ public function testGetResourceObjectTypeQuerySerializationGroups(string $itemSe $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); $this->assertSame($shortName, $resourceObjectType->name); } @@ -171,14 +172,14 @@ public function testGetResourceObjectTypeInput(): void { $resourceMetadata = new ResourceMetadataCollection('resourceClass', []); $this->typesContainerProphecy->has('customShortNameInput')->shouldBeCalled()->willReturn(false); - $this->typesContainerProphecy->set('customShortNameInput', Argument::type(NonNull::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('customShortNameInput', Argument::type(InputObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withName('custom')->withShortName('shortName')->withDescription('description'); + $operation = (new Mutation())->withName('custom')->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, true); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => true]); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -196,14 +197,14 @@ public function testGetResourceObjectTypeNestedInput(): void { $resourceMetadata = new ResourceMetadataCollection('resourceClass', []); $this->typesContainerProphecy->has('customShortNameNestedInput')->shouldBeCalled()->willReturn(false); - $this->typesContainerProphecy->set('customShortNameNestedInput', Argument::type(NonNull::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('customShortNameNestedInput', Argument::type(InputObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withName('custom')->withShortName('shortName')->withDescription('description'); + $operation = (new Mutation())->withName('custom')->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, true, false, 1); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => true, 'wrapped' => false, 'depth' => 1]); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -217,18 +218,48 @@ public function testGetResourceObjectTypeNestedInput(): void $wrappedType->config['fields'](); } + public function testGetResourceObjectTypeNestedInputNullable(): void + { + $resourceMetadata = new ResourceMetadataCollection('resourceClass', []); + $this->typesContainerProphecy->has('customShortNameNullableNestedInput')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('customShortNameNullableNestedInput', Argument::type(InputObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var Operation $operation */ + $operation = (new Mutation())->withName('custom')->withShortName('shortNameNullable')->withDescription('description nullable')->withClass('resourceClass'); + /** @var ApiProperty $propertyMetadata */ + $propertyMetadata = (new ApiProperty())->withRequired(false); + /** @var InputObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, $propertyMetadata, [ + 'input' => true, + 'wrapped' => false, + 'depth' => 1, + ]); + + $this->assertInstanceOf(InputObjectType::class, $resourceObjectType); + $this->assertSame('customShortNameNullableNestedInput', $resourceObjectType->name); + $this->assertSame('description nullable', $resourceObjectType->description); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, true, 1, null)->shouldBeCalled(); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + $resourceObjectType->config['fields'](); + } + public function testGetResourceObjectTypeCustomMutationInputArgs(): void { $resourceMetadata = new ResourceMetadataCollection('resourceClass', []); $this->typesContainerProphecy->has('customShortNameInput')->shouldBeCalled()->willReturn(false); - $this->typesContainerProphecy->set('customShortNameInput', Argument::type(NonNull::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('customShortNameInput', Argument::type(InputObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withArgs([])->withName('custom')->withShortName('shortName')->withDescription('description'); + $operation = (new Mutation())->withArgs([])->withName('custom')->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, true); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => true]); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -257,9 +288,9 @@ public function testGetResourceObjectTypeMutation(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description'); + $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -280,8 +311,8 @@ public function testGetResourceObjectTypeMutation(): void public function testGetResourceObjectTypeMutationWrappedType(): void { $resourceMetadata = new ResourceMetadataCollection('resourceClass', [(new ApiResource())->withGraphQlOperations([ - 'item_query' => (new Query())->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['item_query']]), - 'create' => (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['create']]), + 'item_query' => (new Query())->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['item_query']])->withClass('resourceClass'), + 'create' => (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['create']])->withClass('resourceClass'), ])]); $this->typesContainerProphecy->has('createShortNamePayload')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('createShortNamePayload', Argument::type(ObjectType::class))->shouldBeCalled(); @@ -289,9 +320,9 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['create']]); + $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['create']])->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -331,9 +362,9 @@ public function testGetResourceObjectTypeMutationNested(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description'); + $operation = (new Mutation())->withName('create')->withShortName('shortName')->withDescription('description')->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false, false, 1); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false, 'wrapped' => false, 'depth' => 1]); $this->assertSame('createShortNameNestedPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -359,9 +390,9 @@ public function testGetResourceObjectTypeSubscription(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withMercure(true); + $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withMercure(true)->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -384,8 +415,8 @@ public function testGetResourceObjectTypeSubscription(): void public function testGetResourceObjectTypeSubscriptionWrappedType(): void { $resourceMetadata = new ResourceMetadataCollection('resourceClass', [(new ApiResource())->withGraphQlOperations([ - 'item_query' => (new Query())->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['item_query']]), - 'update' => (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['update']]), + 'item_query' => (new Query())->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['item_query']])->withClass('resourceClass'), + 'update' => (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['update']])->withClass('resourceClass'), ])]); $this->typesContainerProphecy->has('updateShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('updateShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); @@ -393,9 +424,9 @@ public function testGetResourceObjectTypeSubscriptionWrappedType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['update']]); + $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withNormalizationContext(['groups' => ['update']])->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false]); $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -436,9 +467,9 @@ public function testGetResourceObjectTypeSubscriptionNested(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var Operation $operation */ - $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withMercure(true); + $operation = (new Subscription())->withName('update')->withShortName('shortName')->withDescription('description')->withMercure(true)->withClass('resourceClass'); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, $operation, false, false, 1); + $resourceObjectType = $this->typeBuilder->getResourceObjectType($resourceMetadata, $operation, null, ['input' => false, 'wrapped' => false, 'depth' => 1]); $this->assertSame('updateShortNameSubscriptionNestedPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); diff --git a/src/GraphQl/Tests/Type/TypeConverterTest.php b/src/GraphQl/Tests/Type/TypeConverterTest.php index 0ad0bac50ef..47135f6a80b 100644 --- a/src/GraphQl/Tests/Type/TypeConverterTest.php +++ b/src/GraphQl/Tests/Type/TypeConverterTest.php @@ -15,7 +15,7 @@ use ApiPlatform\GraphQl\Tests\Fixtures\Enum\GenderTypeEnum; use ApiPlatform\GraphQl\Tests\Fixtures\Type\Definition\DateTimeType; -use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; +use ApiPlatform\GraphQl\Type\ContextAwareTypeBuilderInterface; use ApiPlatform\GraphQl\Type\TypeConverter; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; @@ -54,7 +54,7 @@ class TypeConverterTest extends TestCase */ protected function setUp(): void { - $this->typeBuilderProphecy = $this->prophesize(TypeBuilderEnumInterface::class); + $this->typeBuilderProphecy = $this->prophesize(ContextAwareTypeBuilderInterface::class); $this->typesContainerProphecy = $this->prophesize(TypesContainerInterface::class); $this->resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $this->propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); @@ -155,13 +155,15 @@ public function testConvertTypeInputResource(): void $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, 'dummy'); /** @var Operation $operation */ $operation = new Query(); + /** @var ApiProperty $propertyMetadata */ + $propertyMetadata = (new ApiProperty())->withWritableLink(true); $graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['item_query' => $operation])]); $expectedGraphqlType = new ObjectType(['name' => 'resourceObjectType', 'fields' => []]); $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata); $this->typeBuilderProphecy->isCollection($type)->willReturn(false); $this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(true)); - $this->typeBuilderProphecy->getResourceObjectType('dummy', $graphqlResourceMetadata, $operation, true, false, 1)->shouldBeCalled()->willReturn($expectedGraphqlType); + $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $operation, $propertyMetadata, ['input' => true, 'wrapped' => false, 'depth' => 1])->shouldBeCalled()->willReturn($expectedGraphqlType); $graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1); $this->assertSame($expectedGraphqlType, $graphqlType); @@ -179,7 +181,11 @@ public function testConvertTypeCollectionResource(Type $type, ObjectType $expect $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); $this->resourceMetadataCollectionFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, $collectionOperation, false, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType); + $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $collectionOperation, null, [ + 'input' => false, + 'wrapped' => false, + 'depth' => 0, + ])->shouldBeCalled()->willReturn($expectedGraphqlType); /** @var Operation $rootOperation */ $rootOperation = (new Query())->withName('test'); diff --git a/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php new file mode 100644 index 00000000000..ce1c3e819ac --- /dev/null +++ b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php @@ -0,0 +1,58 @@ + + * + * 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\GraphQl\Type; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\Type as GraphQLType; +use Symfony\Component\PropertyInfo\Type; + +/** + * Interface implemented to build a GraphQL type. + * + * @author Antoine Bluchet + */ +interface ContextAwareTypeBuilderInterface +{ + /** + * Gets the object type of the given resource. + * + * @param array&array{input?: bool, wrapped?: bool, depth?: int} $context + * + * @return GraphQLType the object type, possibly wrapped by NonNull + */ + public function getResourceObjectType(ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, ApiProperty $propertyMetadata = null, array $context = []): GraphQLType; + + /** + * Get the interface type of a node. + */ + public function getNodeInterface(): InterfaceType; + + /** + * Gets the type of a paginated collection of the given resource type. + */ + public function getPaginatedCollectionType(GraphQLType $resourceType, Operation $operation): GraphQLType; + + /** + * Gets the type corresponding to an enum. + */ + public function getEnumType(Operation $operation): GraphQLType; + + /** + * Returns true if a type is a collection. + */ + public function isCollection(Type $type): bool; +} diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index c70c32e29b9..e10a9326193 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -47,13 +47,16 @@ */ final class FieldsBuilder implements FieldsBuilderInterface, FieldsBuilderEnumInterface { - private readonly TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder; + private readonly ContextAwareTypeBuilderInterface|TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder; - public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ?ResolverFactoryInterface $collectionResolverFactory, private readonly ?ResolverFactoryInterface $itemMutationResolverFactory, private readonly ?ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator) + public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, ContextAwareTypeBuilderInterface|TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypeConverterInterface $typeConverter, private readonly ResolverFactoryInterface $itemResolverFactory, private readonly ?ResolverFactoryInterface $collectionResolverFactory, private readonly ?ResolverFactoryInterface $itemMutationResolverFactory, private readonly ?ResolverFactoryInterface $itemSubscriptionResolverFactory, private readonly ContainerInterface $filterLocator, private readonly Pagination $pagination, private readonly ?NameConverterInterface $nameConverter, private readonly string $nestingSeparator) { if ($typeBuilder instanceof TypeBuilderInterface) { @trigger_error(sprintf('$typeBuilder argument of FieldsBuilder implementing "%s" is deprecated since API Platform 3.1. It has to implement "%s" instead.', TypeBuilderInterface::class, TypeBuilderEnumInterface::class), \E_USER_DEPRECATED); } + if ($typeBuilder instanceof TypeBuilderEnumInterface) { + @trigger_error(sprintf('$typeBuilder argument of TypeConverter implementing "%s" is deprecated since API Platform 3.3. It has to implement "%s" instead.', TypeBuilderEnumInterface::class, ContextAwareTypeBuilderInterface::class), \E_USER_DEPRECATED); + } $this->typeBuilder = $typeBuilder; } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 812565d1998..258344a30c4 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -14,6 +14,7 @@ namespace ApiPlatform\GraphQl\Type; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\GraphQl\Mutation; @@ -36,7 +37,7 @@ * * @author Alan Poulain */ -final class TypeBuilder implements TypeBuilderInterface, TypeBuilderEnumInterface +final class TypeBuilder implements ContextAwareTypeBuilderInterface { private $defaultFieldResolver; @@ -48,10 +49,13 @@ public function __construct(private readonly TypesContainerInterface $typesConta /** * {@inheritdoc} */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType + public function getResourceObjectType(ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, ApiProperty $propertyMetadata = null, array $context = []): GraphQLType { $shortName = $operation->getShortName(); $operationName = $operation->getName(); + $input = $context['input']; + $depth = $context['depth'] ?? 0; + $wrapped = $context['wrapped'] ?? false; if ($operation instanceof Mutation) { $shortName = $operationName.ucfirst($shortName); @@ -84,80 +88,21 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadataCo $shortName .= 'Data'; } - if ($this->typesContainer->has($shortName)) { - $resourceObjectType = $this->typesContainer->get($shortName); - if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull)) { - throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class]))); - } - - return $resourceObjectType; + $resourceObjectType = null; + if (!$this->typesContainer->has($shortName)) { + $resourceObjectType = $this->getResourceObjectTypeConfiguration($shortName, $resourceMetadataCollection, $operation, $context); + $this->typesContainer->set($shortName, $resourceObjectType); } - $ioMetadata = $input ? $operation->getInput() : $operation->getOutput(); - if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) { - $resourceClass = $ioMetadata['class']; + $resourceObjectType = $resourceObjectType ?? $this->typesContainer->get($shortName); + if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull || $resourceObjectType instanceof InputObjectType)) { + throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class, InputObjectType::class]))); } - $wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1; - - $configuration = [ - 'name' => $shortName, - 'description' => $operation->getDescription(), - 'resolveField' => $this->defaultFieldResolver, - 'fields' => function () use ($resourceClass, $operation, $operationName, $resourceMetadataCollection, $input, $wrapData, $depth, $ioMetadata) { - if ($wrapData) { - $queryNormalizationContext = $this->getQueryOperation($resourceMetadataCollection)?->getNormalizationContext() ?? []; - - try { - $mutationNormalizationContext = $operation instanceof Mutation || $operation instanceof Subscription ? ($resourceMetadataCollection->getOperation($operationName)->getNormalizationContext() ?? []) : []; - } catch (OperationNotFoundException) { - $mutationNormalizationContext = []; - } - // Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription. - // If not, use the query type in order to ensure the client cache could be used. - $useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext; - - $wrappedOperationName = $operationName; - - if (!$useWrappedType) { - $wrappedOperationName = $operation instanceof Query ? $operationName : 'item_query'; - } - - $wrappedOperation = $resourceMetadataCollection->getOperation($wrappedOperationName); - - $fields = [ - lcfirst($wrappedOperation->getShortName()) => $this->getResourceObjectType($resourceClass, $resourceMetadataCollection, $wrappedOperation instanceof Operation ? $wrappedOperation : null, $input, true, $depth), - ]; - - if ($operation instanceof Subscription) { - $fields['clientSubscriptionId'] = GraphQLType::string(); - if ($operation->getMercure()) { - $fields['mercureUrl'] = GraphQLType::string(); - } - - return $fields; - } - - return $fields + ['clientMutationId' => GraphQLType::string()]; - } - - $fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder'); - $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $operation, $input, $depth, $ioMetadata); - - if ($input && $operation instanceof Mutation && null !== $mutationArgs = $operation->getArgs()) { - return $fieldsBuilder->resolveResourceArgs($mutationArgs, $operation) + ['clientMutationId' => $fields['clientMutationId']]; - } - if ($input && $operation instanceof Mutation && null !== $extraMutationArgs = $operation->getExtraArgs()) { - return $fields + $fieldsBuilder->resolveResourceArgs($extraMutationArgs, $operation); - } - - return $fields; - }, - 'interfaces' => $wrapData ? [] : [$this->getNodeInterface()], - ]; - - $resourceObjectType = $input ? GraphQLType::nonNull(new InputObjectType($configuration)) : new ObjectType($configuration); - $this->typesContainer->set($shortName, $resourceObjectType); + $required = $propertyMetadata?->isRequired() ?? true; + if ($required && $input) { + $resourceObjectType = GraphQLType::nonNull($resourceObjectType); + } return $resourceObjectType; } @@ -359,4 +304,82 @@ private function getQueryOperation(ResourceMetadataCollection $resourceMetadataC return null; } + + private function getResourceObjectTypeConfiguration(string $shortName, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, array $context = []): InputObjectType|ObjectType + { + $operationName = $operation->getName(); + $resourceClass = $operation->getClass(); + $input = $context['input']; + $depth = $context['depth'] ?? 0; + $wrapped = $context['wrapped'] ?? false; + + $ioMetadata = $input ? $operation->getInput() : $operation->getOutput(); + if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) { + $resourceClass = $ioMetadata['class']; + } + + $wrapData = !$wrapped && ($operation instanceof Mutation || $operation instanceof Subscription) && !$input && $depth < 1; + + $configuration = [ + 'name' => $shortName, + 'description' => $operation->getDescription(), + 'resolveField' => $this->defaultFieldResolver, + 'fields' => function () use ($resourceClass, $operation, $operationName, $resourceMetadataCollection, $input, $wrapData, $depth, $ioMetadata) { + if ($wrapData) { + $queryNormalizationContext = $this->getQueryOperation($resourceMetadataCollection)?->getNormalizationContext() ?? []; + + try { + $mutationNormalizationContext = $operation instanceof Mutation || $operation instanceof Subscription ? ($resourceMetadataCollection->getOperation($operationName)->getNormalizationContext() ?? []) : []; + } catch (OperationNotFoundException) { + $mutationNormalizationContext = []; + } + // Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription. + // If not, use the query type in order to ensure the client cache could be used. + $useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext; + + $wrappedOperationName = $operationName; + + if (!$useWrappedType) { + $wrappedOperationName = $operation instanceof Query ? $operationName : 'item_query'; + } + + $wrappedOperation = $resourceMetadataCollection->getOperation($wrappedOperationName); + + $fields = [ + lcfirst($wrappedOperation->getShortName()) => $this->getResourceObjectType($resourceMetadataCollection, $wrappedOperation instanceof Operation ? $wrappedOperation : null, null, [ + 'input' => $input, + 'wrapped' => true, + 'depth' => $depth, + ]), + ]; + + if ($operation instanceof Subscription) { + $fields['clientSubscriptionId'] = GraphQLType::string(); + if ($operation->getMercure()) { + $fields['mercureUrl'] = GraphQLType::string(); + } + + return $fields; + } + + return $fields + ['clientMutationId' => GraphQLType::string()]; + } + + $fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder'); + $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $operation, $input, $depth, $ioMetadata); + + if ($input && $operation instanceof Mutation && null !== $mutationArgs = $operation->getArgs()) { + return $fieldsBuilder->resolveResourceArgs($mutationArgs, $operation) + ['clientMutationId' => $fields['clientMutationId']]; + } + if ($input && $operation instanceof Mutation && null !== $extraMutationArgs = $operation->getExtraArgs()) { + return $fields + $fieldsBuilder->resolveResourceArgs($extraMutationArgs, $operation); + } + + return $fields; + }, + 'interfaces' => $wrapData ? [] : [$this->getNodeInterface()], + ]; + + return $input ? new InputObjectType($configuration) : new ObjectType($configuration); + } } diff --git a/src/GraphQl/Type/TypeBuilderEnumInterface.php b/src/GraphQl/Type/TypeBuilderEnumInterface.php index 9bbaa5215b0..ab596ff82d1 100644 --- a/src/GraphQl/Type/TypeBuilderEnumInterface.php +++ b/src/GraphQl/Type/TypeBuilderEnumInterface.php @@ -13,11 +13,10 @@ namespace ApiPlatform\GraphQl\Type; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use GraphQL\Type\Definition\InterfaceType; -use GraphQL\Type\Definition\NonNull; -use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use Symfony\Component\PropertyInfo\Type; @@ -25,15 +24,17 @@ * Interface implemented to build a GraphQL type. * * @author Alan Poulain + * + * @deprecated Since API Platform 3.3. Use @see ContextAwareTypeBuilderInterface instead. */ interface TypeBuilderEnumInterface { /** * Gets the object type of the given resource. * - * @return ObjectType|NonNull the object type, possibly wrapped by NonNull + * @return GraphQLType the object type, possibly wrapped by NonNull */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0): GraphQLType; + public function getResourceObjectType(?string $resourceClass, ResourceMetadataCollection $resourceMetadataCollection, Operation $operation, bool $input, bool $wrapped = false, int $depth = 0, ApiProperty $propertyMetadata = null): GraphQLType; /** * Get the interface type of a node. diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 390ee7c5511..f643a68f500 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -37,11 +37,15 @@ */ final class TypeConverter implements TypeConverterInterface { - public function __construct(private readonly TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypesContainerInterface $typesContainer, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory) + public function __construct(private readonly ContextAwareTypeBuilderInterface|TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder, private readonly TypesContainerInterface $typesContainer, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory) { if ($typeBuilder instanceof TypeBuilderInterface) { @trigger_error(sprintf('$typeBuilder argument of TypeConverter implementing "%s" is deprecated since API Platform 3.1. It has to implement "%s" instead.', TypeBuilderInterface::class, TypeBuilderEnumInterface::class), \E_USER_DEPRECATED); } + + if ($typeBuilder instanceof TypeBuilderEnumInterface) { + @trigger_error(sprintf('$typeBuilder argument of TypeConverter implementing "%s" is deprecated since API Platform 3.3. It has to implement "%s" instead.', TypeBuilderEnumInterface::class, ContextAwareTypeBuilderInterface::class), \E_USER_DEPRECATED); + } } /** @@ -74,7 +78,7 @@ public function convertType(Type $type, bool $input, Operation $rootOperation, s if (!$resourceType && is_a($type->getClassName(), \BackedEnum::class, true)) { // Remove the condition in API Platform 4. - if ($this->typeBuilder instanceof TypeBuilderEnumInterface) { + if ($this->typeBuilder instanceof TypeBuilderEnumInterface || $this->typeBuilder instanceof ContextAwareTypeBuilderInterface) { $operation = null; try { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($type->getClassName()); @@ -182,7 +186,13 @@ private function getResourceType(Type $type, bool $input, Operation $rootOperati throw new OperationNotFoundException(); } - return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadataCollection, $operation, $input, false, $depth); + return $this->typeBuilder instanceof ContextAwareTypeBuilderInterface ? + $this->typeBuilder->getResourceObjectType($resourceMetadataCollection, $operation, $propertyMetadata, [ + 'input' => $input, + 'wrapped' => false, + 'depth' => $depth, + ]) : + $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadataCollection, $operation, $input, false, $depth); } private function resolveAstTypeNode(TypeNode $astTypeNode, string $fromType): ?GraphQLType diff --git a/tests/Fixtures/TestBundle/Entity/OptionalRequiredDummy.php b/tests/Fixtures/TestBundle/Entity/OptionalRequiredDummy.php new file mode 100644 index 00000000000..6499e636734 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/OptionalRequiredDummy.php @@ -0,0 +1,115 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * OptionalRequiredDummy. Used to test GraphQL Schema generation for nullable embedded relations. + */ +#[ApiResource( + graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), + ], +)] +#[ORM\Entity] +class OptionalRequiredDummy +{ + #[ApiProperty(writable: false)] + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Groups(['chicago', 'friends'])] + private $id; + + #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'], inversedBy: 'relatedDummies')] + #[Groups(['barcelona', 'chicago', 'friends'])] + public ?ThirdLevel $thirdLevel = null; + + #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'])] + #[ORM\JoinColumn(nullable: false)] + #[ApiProperty(required: true)] + #[Groups(['barcelona', 'chicago', 'friends'])] + public ThirdLevel $thirdLevelRequired; + + #[ORM\OneToMany(targetEntity: RelatedToDummyFriend::class, cascade: ['persist'], mappedBy: 'relatedDummy')] + #[Groups(['fakemanytomany', 'friends'])] + public Collection|iterable $relatedToDummyFriend; + + public function __construct() + { + $this->relatedToDummyFriend = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): void + { + $this->id = $id; + } + + public function getThirdLevel(): ?ThirdLevel + { + return $this->thirdLevel; + } + + public function setThirdLevel(ThirdLevel $thirdLevel = null): void + { + $this->thirdLevel = $thirdLevel; + } + + public function getThirdLevelRequired(): ThirdLevel + { + return $this->thirdLevelRequired; + } + + public function setThirdLevelRequired(ThirdLevel $thirdLevelRequired): void + { + $this->thirdLevelRequired = $thirdLevelRequired; + } + + /** + * Get relatedToDummyFriend. + */ + public function getRelatedToDummyFriend(): Collection|iterable + { + return $this->relatedToDummyFriend; + } + + /** + * Set relatedToDummyFriend. + * + * @param RelatedToDummyFriend $relatedToDummyFriend the value to set + */ + public function addRelatedToDummyFriend(RelatedToDummyFriend $relatedToDummyFriend): void + { + $this->relatedToDummyFriend->add($relatedToDummyFriend); + } + + public function __toString(): string + { + return (string) $this->getId(); + } +}