Skip to content

Commit 8744857

Browse files
authored
feat: add GraphQL enum support (#5199)
* fix(openapi): resource classes are no longer considered as enum * feat: make property metadata factories work with enum cases * feat: add GraphQL enum support
1 parent a828af0 commit 8744857

31 files changed

+692
-58
lines changed

.php-cs-fixer.dist.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
->notPath('src/Annotation/ApiResource.php') // temporary
2525
->notPath('src/Annotation/ApiSubresource.php') // temporary
2626
->notPath('tests/Fixtures/TestBundle/Entity/DummyPhp8.php') // temporary
27+
->notPath('tests/Fixtures/TestBundle/Enum/EnumWithDescriptions.php') // PHPDoc on enum cases
28+
->notPath('tests/Fixtures/TestBundle/Enum/GamePlayMode.php') // PHPDoc on enum cases
29+
->notPath('tests/Fixtures/TestBundle/Enum/GenderTypeEnum.php') // PHPDoc on enum cases
2730
->append([
2831
'tests/Fixtures/app/console',
2932
]);

features/graphql/introspection.feature

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,3 +566,58 @@ Feature: GraphQL introspection support
566566
And the JSON node "errors[0].debugMessage" should be equal to 'Type with id "VoDummyInspectionCursorConnection" is not present in the types container'
567567
And the JSON node "data.typeNotAvailable" should be null
568568
And the JSON node "data.typeOwner.fields[1].type.name" should be equal to "VoDummyInspectionCursorConnection"
569+
570+
Scenario: Introspect an enum
571+
When I send the following GraphQL request:
572+
"""
573+
{
574+
person: __type(name: "Person") {
575+
name
576+
fields {
577+
name
578+
type {
579+
name
580+
description
581+
enumValues {
582+
name
583+
description
584+
}
585+
}
586+
}
587+
}
588+
}
589+
"""
590+
Then the response status code should be 200
591+
And the response should be in JSON
592+
And the header "Content-Type" should be equal to "application/json"
593+
And the JSON node "data.person.fields[1].type.name" should be equal to "GenderTypeEnum"
594+
#And the JSON node "data.person.fields[1].type.description" should be equal to "An enumeration of genders."
595+
And the JSON node "data.person.fields[1].type.enumValues[0].name" should be equal to "MALE"
596+
#And the JSON node "data.person.fields[1].type.enumValues[0].description" should be equal to "The male gender."
597+
And the JSON node "data.person.fields[1].type.enumValues[1].name" should be equal to "FEMALE"
598+
And the JSON node "data.person.fields[1].type.enumValues[1].description" should be equal to "The female gender."
599+
600+
Scenario: Introspect an enum resource
601+
When I send the following GraphQL request:
602+
"""
603+
{
604+
videoGame: __type(name: "VideoGame") {
605+
name
606+
fields {
607+
name
608+
type {
609+
name
610+
kind
611+
ofType {
612+
name
613+
kind
614+
}
615+
}
616+
}
617+
}
618+
}
619+
"""
620+
Then the response status code should be 200
621+
And the response should be in JSON
622+
And the header "Content-Type" should be equal to "application/json"
623+
And the JSON node "data.videoGame.fields[3].type.ofType.name" should be equal to "GamePlayMode"

features/graphql/mutation.feature

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,69 @@ Feature: GraphQL mutation support
485485
And the JSON node "data.createDummy.dummy.arrayData[1]" should be equal to baz
486486
And the JSON node "data.createDummy.clientMutationId" should be equal to "myId"
487487

488+
Scenario: Create an item with an enum
489+
When I send the following GraphQL request:
490+
"""
491+
mutation {
492+
createPerson(input: {name: "Mob", genderType: FEMALE}) {
493+
person {
494+
id
495+
name
496+
genderType
497+
}
498+
}
499+
}
500+
"""
501+
Then the response status code should be 200
502+
And the response should be in JSON
503+
And the header "Content-Type" should be equal to "application/json"
504+
And the JSON node "data.createPerson.person.id" should be equal to "/people/1"
505+
And the JSON node "data.createPerson.person.name" should be equal to "Mob"
506+
And the JSON node "data.createPerson.person.genderType" should be equal to "FEMALE"
507+
508+
Scenario: Create an item with an enum as a resource
509+
When I send the following GraphQL request:
510+
"""
511+
{
512+
gamePlayModes {
513+
id
514+
name
515+
}
516+
gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") {
517+
name
518+
}
519+
}
520+
"""
521+
Then the response status code should be 200
522+
And the response should be in JSON
523+
And the header "Content-Type" should be equal to "application/json"
524+
And the JSON node "data.gamePlayModes" should have 3 elements
525+
And the JSON node "data.gamePlayModes[2].id" should be equal to "/game_play_modes/SINGLE_PLAYER"
526+
And the JSON node "data.gamePlayModes[2].name" should be equal to "SINGLE_PLAYER"
527+
And the JSON node "data.gamePlayMode.name" should be equal to "SINGLE_PLAYER"
528+
When I send the following GraphQL request:
529+
"""
530+
mutation {
531+
createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) {
532+
videoGame {
533+
id
534+
name
535+
playMode {
536+
id
537+
name
538+
}
539+
}
540+
}
541+
}
542+
"""
543+
Then the response status code should be 200
544+
And the response should be in JSON
545+
And the header "Content-Type" should be equal to "application/json"
546+
And the JSON node "data.createVideoGame.videoGame.id" should be equal to "/video_games/1"
547+
And the JSON node "data.createVideoGame.videoGame.name" should be equal to "Baten Kaitos"
548+
And the JSON node "data.createVideoGame.videoGame.playMode.id" should be equal to "/game_play_modes/SINGLE_PLAYER"
549+
And the JSON node "data.createVideoGame.videoGame.playMode.name" should be equal to "SINGLE_PLAYER"
550+
488551
Scenario: Delete an item through a mutation
489552
When I send the following GraphQL request:
490553
"""

features/openapi/docs.feature

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ Feature: Documentation support
7373
"nullable": true
7474
}
7575
"""
76+
And the "playMode" property exists for the OpenAPI class "VideoGame"
77+
And the "playMode" property for the OpenAPI class "VideoGame" should be equal to:
78+
"""
79+
{
80+
"type": "string",
81+
"format": "iri-reference"
82+
}
83+
"""
7684
# Enable these tests when SF 4.4 / PHP 7.1 support is dropped
7785
#And the "isDummyBoolean" property exists for the OpenAPI class "DummyBoolean"
7886
#And the "isDummyBoolean" property is not read only for the OpenAPI class "DummyBoolean"

src/GraphQl/Type/FieldsBuilder.php

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,16 @@
4242
*
4343
* @author Alan Poulain <[email protected]>
4444
*/
45-
final class FieldsBuilder implements FieldsBuilderInterface
45+
final class FieldsBuilder implements FieldsBuilderInterface, FieldsBuilderEnumInterface
4646
{
47-
public function __construct(private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ResourceClassResolverInterface $resourceClassResolver, private readonly TypesContainerInterface $typesContainer, private readonly 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)
47+
private readonly TypeBuilderEnumInterface|TypeBuilderInterface $typeBuilder;
48+
49+
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)
4850
{
51+
if ($typeBuilder instanceof TypeBuilderInterface) {
52+
@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);
53+
}
54+
$this->typeBuilder = $typeBuilder;
4955
}
5056

5157
/**
@@ -226,6 +232,26 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o
226232
return $fields;
227233
}
228234

235+
/**
236+
* {@inheritdoc}
237+
*/
238+
public function getEnumFields(string $enumClass): array
239+
{
240+
$rEnum = new \ReflectionEnum($enumClass);
241+
242+
$enumCases = [];
243+
foreach ($rEnum->getCases() as $rCase) {
244+
$enumCase = ['value' => $rCase->getBackingValue()];
245+
$propertyMetadata = $this->propertyMetadataFactory->create($enumClass, $rCase->getName());
246+
if ($enumCaseDescription = $propertyMetadata->getDescription()) {
247+
$enumCase['description'] = $enumCaseDescription;
248+
}
249+
$enumCases[$rCase->getName()] = $enumCase;
250+
}
251+
252+
return $enumCases;
253+
}
254+
229255
/**
230256
* {@inheritdoc}
231257
*/
@@ -481,7 +507,16 @@ private function convertType(Type $type, bool $input, Operation $resourceOperati
481507
}
482508

483509
if ($this->typeBuilder->isCollection($type)) {
484-
return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation) : GraphQLType::listOf($graphqlType);
510+
if (!$input && $this->pagination->isGraphQlEnabled($resourceOperation)) {
511+
// Deprecated path, to remove in API Platform 4.
512+
if ($this->typeBuilder instanceof TypeBuilderInterface) {
513+
return $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation);
514+
}
515+
516+
return $this->typeBuilder->getPaginatedCollectionType($graphqlType, $resourceOperation);
517+
}
518+
519+
return GraphQLType::listOf($graphqlType);
485520
}
486521

487522
return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName())
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\GraphQl\Type;
15+
16+
use ApiPlatform\Metadata\GraphQl\Operation;
17+
18+
/**
19+
* Interface implemented to build GraphQL fields.
20+
*
21+
* @author Alan Poulain <[email protected]>
22+
*/
23+
interface FieldsBuilderEnumInterface
24+
{
25+
/**
26+
* Gets the fields of a node for a query.
27+
*/
28+
public function getNodeQueryFields(): array;
29+
30+
/**
31+
* Gets the item query fields of the schema.
32+
*/
33+
public function getItemQueryFields(string $resourceClass, Operation $operation, array $configuration): array;
34+
35+
/**
36+
* Gets the collection query fields of the schema.
37+
*/
38+
public function getCollectionQueryFields(string $resourceClass, Operation $operation, array $configuration): array;
39+
40+
/**
41+
* Gets the mutation fields of the schema.
42+
*/
43+
public function getMutationFields(string $resourceClass, Operation $operation): array;
44+
45+
/**
46+
* Gets the subscription fields of the schema.
47+
*/
48+
public function getSubscriptionFields(string $resourceClass, Operation $operation): array;
49+
50+
/**
51+
* Gets the fields of the type of the given resource.
52+
*/
53+
public function getResourceObjectTypeFields(?string $resourceClass, Operation $operation, bool $input, int $depth = 0, ?array $ioMetadata = null): array;
54+
55+
/**
56+
* Gets the fields (cases) of the enum.
57+
*/
58+
public function getEnumFields(string $enumClass): array;
59+
60+
/**
61+
* Resolve the args of a resource by resolving its types.
62+
*/
63+
public function resolveResourceArgs(array $args, Operation $operation): array;
64+
}

src/GraphQl/Type/FieldsBuilderInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
* Interface implemented to build GraphQL fields.
2020
*
2121
* @author Alan Poulain <[email protected]>
22+
*
23+
* @deprecated Since API Platform 3.1. Use @see FieldsBuilderEnumInterface instead.
2224
*/
2325
interface FieldsBuilderInterface
2426
{

src/GraphQl/Type/SchemaBuilder.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,11 @@
3232
*/
3333
final class SchemaBuilder implements SchemaBuilderInterface
3434
{
35-
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly TypesFactoryInterface $typesFactory, private readonly TypesContainerInterface $typesContainer, private readonly FieldsBuilderInterface $fieldsBuilder)
35+
public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly TypesFactoryInterface $typesFactory, private readonly TypesContainerInterface $typesContainer, private readonly FieldsBuilderEnumInterface|FieldsBuilderInterface $fieldsBuilder)
3636
{
37+
if ($this->fieldsBuilder instanceof FieldsBuilderInterface) {
38+
@trigger_error(sprintf('$fieldsBuilder argument of SchemaBuilder implementing "%s" is deprecated since API Platform 3.1. It has to implement "%s" instead.', FieldsBuilderInterface::class, FieldsBuilderEnumInterface::class), \E_USER_DEPRECATED);
39+
}
3740
}
3841

3942
public function getSchema(): Schema

src/GraphQl/Type/TypeBuilder.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use ApiPlatform\Metadata\GraphQl\Subscription;
2323
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2424
use ApiPlatform\State\Pagination\Pagination;
25+
use GraphQL\Type\Definition\EnumType;
2526
use GraphQL\Type\Definition\InputObjectType;
2627
use GraphQL\Type\Definition\InterfaceType;
2728
use GraphQL\Type\Definition\NonNull;
@@ -35,7 +36,7 @@
3536
*
3637
* @author Alan Poulain <[email protected]>
3738
*/
38-
final class TypeBuilder implements TypeBuilderInterface
39+
final class TypeBuilder implements TypeBuilderInterface, TypeBuilderEnumInterface
3940
{
4041
private $defaultFieldResolver;
4142

@@ -201,6 +202,16 @@ public function getNodeInterface(): InterfaceType
201202
* {@inheritdoc}
202203
*/
203204
public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, Operation $operation): GraphQLType
205+
{
206+
@trigger_error('Using getResourcePaginatedCollectionType method of TypeBuilder is deprecated since API Platform 3.1. Use getPaginatedCollectionType method instead.', \E_USER_DEPRECATED);
207+
208+
return $this->getPaginatedCollectionType($resourceType, $operation);
209+
}
210+
211+
/**
212+
* {@inheritdoc}
213+
*/
214+
public function getPaginatedCollectionType(GraphQLType $resourceType, Operation $operation): GraphQLType
204215
{
205216
$shortName = $resourceType->name;
206217
$paginationType = $this->pagination->getGraphQlPaginationType($operation);
@@ -226,6 +237,42 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType, st
226237
return $resourcePaginatedCollectionType;
227238
}
228239

240+
public function getEnumType(Operation $operation): GraphQLType
241+
{
242+
$enumName = $operation->getShortName();
243+
$enumKey = $enumName;
244+
if (!str_ends_with($enumName, 'Enum')) {
245+
$enumKey = sprintf('%sEnum', $enumName);
246+
}
247+
248+
if ($this->typesContainer->has($enumKey)) {
249+
return $this->typesContainer->get($enumKey);
250+
}
251+
252+
/** @var FieldsBuilderEnumInterface|FieldsBuilderInterface $fieldsBuilder */
253+
$fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder');
254+
$enumCases = [];
255+
// Remove the condition in API Platform 4.
256+
if ($fieldsBuilder instanceof FieldsBuilderEnumInterface) {
257+
$enumCases = $fieldsBuilder->getEnumFields($operation->getClass());
258+
} else {
259+
@trigger_error(sprintf('api_platform.graphql.fields_builder service implementing "%s" is deprecated since API Platform 3.1. It has to implement "%s" instead.', FieldsBuilderInterface::class, FieldsBuilderEnumInterface::class), \E_USER_DEPRECATED);
260+
}
261+
262+
$enumConfig = [
263+
'name' => $enumName,
264+
'values' => $enumCases,
265+
];
266+
if ($enumDescription = $operation->getDescription()) {
267+
$enumConfig['description'] = $enumDescription;
268+
}
269+
270+
$enumType = new EnumType($enumConfig);
271+
$this->typesContainer->set($enumKey, $enumType);
272+
273+
return $enumType;
274+
}
275+
229276
/**
230277
* {@inheritdoc}
231278
*/

0 commit comments

Comments
 (0)