diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 00000000000..6ad93d1570f --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,20 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - Hacktoberfest + - bug + - enhancement + - RFC + - ⭐ EU-FOSSA Hackathon +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9e5241ceca6..846e5a32281 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,6 +24,9 @@ ->notPath('src/Annotation/ApiResource.php') // temporary ->notPath('src/Annotation/ApiSubresource.php') // temporary ->notPath('tests/Fixtures/TestBundle/Entity/DummyPhp8.php') // temporary + ->notPath('tests/Fixtures/TestBundle/Enum/EnumWithDescriptions.php') // PHPDoc on enum cases + ->notPath('tests/Fixtures/TestBundle/Enum/GamePlayMode.php') // PHPDoc on enum cases + ->notPath('tests/Fixtures/TestBundle/Enum/GenderTypeEnum.php') // PHPDoc on enum cases ->append([ 'tests/Fixtures/app/console', ]); diff --git a/composer.json b/composer.json index c674192ed26..b07e362e7de 100644 --- a/composer.json +++ b/composer.json @@ -45,10 +45,9 @@ "guzzlehttp/guzzle": "^6.0 || ^7.0", "jangregor/phpstan-prophecy": "^1.0", "justinrainbow/json-schema": "^5.2.1", - "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.1", - "phpdocumentor/type-resolver": "^0.3 || ^0.4 || ^1.4", "phpspec/prophecy-phpunit": "^2.0", "phpstan/extension-installer": "^1.1", + "phpstan/phpdoc-parser": "^1.13", "phpstan/phpstan": "^1.1", "phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-phpunit": "^1.0", @@ -101,7 +100,7 @@ "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", "ocramius/package-versions": "To display the API Platform's version in the debug bar.", - "phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.", + "phpstan/phpdoc-parser": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", "symfony/cache": "To have metadata caching when using Symfony integration.", @@ -138,7 +137,7 @@ }, "extra": { "branch-alias": { - "dev-main": "3.0.x-dev" + "dev-main": "3.1.x-dev" }, "symfony": { "require": "^6.1" diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature index 22934bbbb31..03be840718e 100644 --- a/features/graphql/introspection.feature +++ b/features/graphql/introspection.feature @@ -566,3 +566,58 @@ Feature: GraphQL introspection support And the JSON node "errors[0].debugMessage" should be equal to 'Type with id "VoDummyInspectionCursorConnection" is not present in the types container' And the JSON node "data.typeNotAvailable" should be null And the JSON node "data.typeOwner.fields[1].type.name" should be equal to "VoDummyInspectionCursorConnection" + + Scenario: Introspect an enum + When I send the following GraphQL request: + """ + { + person: __type(name: "Person") { + name + fields { + name + type { + name + description + enumValues { + name + description + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.person.fields[1].type.name" should be equal to "GenderTypeEnum" + #And the JSON node "data.person.fields[1].type.description" should be equal to "An enumeration of genders." + And the JSON node "data.person.fields[1].type.enumValues[0].name" should be equal to "MALE" + #And the JSON node "data.person.fields[1].type.enumValues[0].description" should be equal to "The male gender." + And the JSON node "data.person.fields[1].type.enumValues[1].name" should be equal to "FEMALE" + And the JSON node "data.person.fields[1].type.enumValues[1].description" should be equal to "The female gender." + + Scenario: Introspect an enum resource + When I send the following GraphQL request: + """ + { + videoGame: __type(name: "VideoGame") { + name + fields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.videoGame.fields[3].type.ofType.name" should be equal to "GamePlayMode" diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index e67e55554b4..b0532bf4d80 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -485,6 +485,69 @@ Feature: GraphQL mutation support And the JSON node "data.createDummy.dummy.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 enum + When I send the following GraphQL request: + """ + mutation { + createPerson(input: {name: "Mob", genderType: FEMALE}) { + person { + id + name + genderType + } + } + } + """ + 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.createPerson.person.id" should be equal to "/people/1" + And the JSON node "data.createPerson.person.name" should be equal to "Mob" + And the JSON node "data.createPerson.person.genderType" should be equal to "FEMALE" + + Scenario: Create an item with an enum as a resource + When I send the following GraphQL request: + """ + { + gamePlayModes { + id + name + } + gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { + name + } + } + """ + 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.gamePlayModes" should have 3 elements + And the JSON node "data.gamePlayModes[2].id" should be equal to "/game_play_modes/SINGLE_PLAYER" + And the JSON node "data.gamePlayModes[2].name" should be equal to "SINGLE_PLAYER" + And the JSON node "data.gamePlayMode.name" should be equal to "SINGLE_PLAYER" + When I send the following GraphQL request: + """ + mutation { + createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { + videoGame { + id + name + playMode { + id + name + } + } + } + } + """ + 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.createVideoGame.videoGame.id" should be equal to "/video_games/1" + And the JSON node "data.createVideoGame.videoGame.name" should be equal to "Baten Kaitos" + And the JSON node "data.createVideoGame.videoGame.playMode.id" should be equal to "/game_play_modes/SINGLE_PLAYER" + And the JSON node "data.createVideoGame.videoGame.playMode.name" should be equal to "SINGLE_PLAYER" + Scenario: Delete an item through a mutation When I send the following GraphQL request: """ diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 2878240e3b5..3a413fa916f 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -36,6 +36,7 @@ Feature: Documentation support And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_put" exists And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_read" exists And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_write" exists + And the OpenAPI class "Person" exists And the OpenAPI class "RelatedDummy" exists And the OpenAPI class "NoCollectionDummy" exists And the OpenAPI class "RelatedToDummyFriend" exists @@ -57,6 +58,29 @@ Feature: Documentation support # Properties And the "id" property exists for the OpenAPI class "Dummy" And the "name" property is required for the OpenAPI class "Dummy" + And the "genderType" property exists for the OpenAPI class "Person" + And the "genderType" property for the OpenAPI class "Person" should be equal to: + """ + { + "default": "male", + "example": "male", + "type": "string", + "enum": [ + "male", + "female", + null + ], + "nullable": true + } + """ + And the "playMode" property exists for the OpenAPI class "VideoGame" + And the "playMode" property for the OpenAPI class "VideoGame" should be equal to: + """ + { + "type": "string", + "format": "iri-reference" + } + """ # Enable these tests when SF 4.4 / PHP 7.1 support is dropped #And the "isDummyBoolean" property exists for the OpenAPI class "DummyBoolean" #And the "isDummyBoolean" property is not read only for the OpenAPI class "DummyBoolean" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 12a2bdc85c9..b671cb89893 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -83,3 +83,5 @@ parameters: - message: '#^Property .+ is unused.$#' path: tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineDummy.php + # Waiting for https://github.com/laminas/laminas-code/pull/150 + - '#Call to an undefined method ReflectionEnum::.+#' diff --git a/src/Api/FilterInterface.php b/src/Api/FilterInterface.php index d4467041fda..967147ce336 100644 --- a/src/Api/FilterInterface.php +++ b/src/Api/FilterInterface.php @@ -43,6 +43,11 @@ interface FilterInterface * 'type' => 'integer', * ] * ] + * - schema (optional): schema definition, + * e.g. 'schema' => [ + * 'type' => 'string', + * 'enum' => ['value_1', 'value_2'], + * ] * The description can contain additional data specific to a filter. * * @see \ApiPlatform\OpenApi\Factory\OpenApiFactory::getFiltersParameters diff --git a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php index 9969c8606fd..93ce4a4d5d0 100644 --- a/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php +++ b/src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php @@ -92,6 +92,10 @@ public function getTypes($class, $property, array $context = []): ?array if ($metadata->hasField($property)) { $typeOfField = $metadata->getTypeOfField($property); $nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property); + $enumType = null; + if (null !== $enumClass = $metadata instanceof MongoDbClassMetadata ? $metadata->getFieldMapping($property)['enumType'] ?? null : null) { + $enumType = new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, $enumClass); + } switch ($typeOfField) { case MongoDbType::DATE: @@ -102,11 +106,16 @@ public function getTypes($class, $property, array $context = []): ?array return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true)]; case MongoDbType::COLLECTION: return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, new Type(Type::BUILTIN_TYPE_INT))]; - default: - $builtinType = $this->getPhpType($typeOfField); - - return $builtinType ? [new Type($builtinType, $nullable)] : null; + case MongoDbType::INT: + case MongoDbType::STRING: + if ($enumType) { + return [$enumType]; + } } + + $builtinType = $this->getPhpType($typeOfField); + + return $builtinType ? [new Type($builtinType, $nullable)] : null; } return null; diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index ce34306b619..931814fe33c 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -22,7 +22,6 @@ use ApiPlatform\Util\CloneTrait; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\RequestStack; /** * Creates a function retrieving a collection to resolve a GraphQL query or a field returned by a mutation. @@ -35,7 +34,7 @@ final class CollectionResolverFactory implements ResolverFactoryInterface { use CloneTrait; - public function __construct(private readonly ReadStageInterface $readStage, private readonly SecurityStageInterface $securityStage, private readonly SecurityPostDenormalizeStageInterface $securityPostDenormalizeStage, private readonly SerializeStageInterface $serializeStage, private readonly ContainerInterface $queryResolverLocator, private readonly ?RequestStack $requestStack = null) + public function __construct(private readonly ReadStageInterface $readStage, private readonly SecurityStageInterface $securityStage, private readonly SecurityPostDenormalizeStageInterface $securityPostDenormalizeStage, private readonly SerializeStageInterface $serializeStage, private readonly ContainerInterface $queryResolverLocator) { } @@ -47,13 +46,6 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul return null; } - if ($this->requestStack && null !== $request = $this->requestStack->getCurrentRequest()) { - $request->attributes->set( - '_graphql_collections_args', - [$resourceClass => $args] + $request->attributes->get('_graphql_collections_args', []) - ); - } - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $collection = ($this->readStage)($resourceClass, $rootClass, $operation, $resolverContext); diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 26a9c7d42c3..77bb27c9dbc 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -42,10 +42,16 @@ * * @author Alan Poulain */ -final class FieldsBuilder implements FieldsBuilderInterface +final class FieldsBuilder implements FieldsBuilderInterface, FieldsBuilderEnumInterface { - 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) + private readonly 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) { + 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); + } + $this->typeBuilder = $typeBuilder; } /** @@ -226,6 +232,26 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o return $fields; } + /** + * {@inheritdoc} + */ + public function getEnumFields(string $enumClass): array + { + $rEnum = new \ReflectionEnum($enumClass); + + $enumCases = []; + foreach ($rEnum->getCases() as $rCase) { + $enumCase = ['value' => $rCase->getBackingValue()]; + $propertyMetadata = $this->propertyMetadataFactory->create($enumClass, $rCase->getName()); + if ($enumCaseDescription = $propertyMetadata->getDescription()) { + $enumCase['description'] = $enumCaseDescription; + } + $enumCases[$rCase->getName()] = $enumCase; + } + + return $enumCases; + } + /** * {@inheritdoc} */ @@ -481,7 +507,16 @@ private function convertType(Type $type, bool $input, Operation $resourceOperati } if ($this->typeBuilder->isCollection($type)) { - return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation) : GraphQLType::listOf($graphqlType); + if (!$input && $this->pagination->isGraphQlEnabled($resourceOperation)) { + // Deprecated path, to remove in API Platform 4. + if ($this->typeBuilder instanceof TypeBuilderInterface) { + return $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation); + } + + return $this->typeBuilder->getPaginatedCollectionType($graphqlType, $resourceOperation); + } + + return GraphQLType::listOf($graphqlType); } return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName()) diff --git a/src/GraphQl/Type/FieldsBuilderEnumInterface.php b/src/GraphQl/Type/FieldsBuilderEnumInterface.php new file mode 100644 index 00000000000..0517796e71c --- /dev/null +++ b/src/GraphQl/Type/FieldsBuilderEnumInterface.php @@ -0,0 +1,64 @@ + + * + * 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\GraphQl\Operation; + +/** + * Interface implemented to build GraphQL fields. + * + * @author Alan Poulain + */ +interface FieldsBuilderEnumInterface +{ + /** + * Gets the fields of a node for a query. + */ + public function getNodeQueryFields(): array; + + /** + * Gets the item query fields of the schema. + */ + public function getItemQueryFields(string $resourceClass, Operation $operation, array $configuration): array; + + /** + * Gets the collection query fields of the schema. + */ + public function getCollectionQueryFields(string $resourceClass, Operation $operation, array $configuration): array; + + /** + * Gets the mutation fields of the schema. + */ + public function getMutationFields(string $resourceClass, Operation $operation): array; + + /** + * Gets the subscription fields of the schema. + */ + public function getSubscriptionFields(string $resourceClass, Operation $operation): array; + + /** + * Gets the fields of the type of the given resource. + */ + public function getResourceObjectTypeFields(?string $resourceClass, Operation $operation, bool $input, int $depth = 0, ?array $ioMetadata = null): array; + + /** + * Gets the fields (cases) of the enum. + */ + public function getEnumFields(string $enumClass): array; + + /** + * Resolve the args of a resource by resolving its types. + */ + public function resolveResourceArgs(array $args, Operation $operation): array; +} diff --git a/src/GraphQl/Type/FieldsBuilderInterface.php b/src/GraphQl/Type/FieldsBuilderInterface.php index afab39d8aaa..dc4bd57f003 100644 --- a/src/GraphQl/Type/FieldsBuilderInterface.php +++ b/src/GraphQl/Type/FieldsBuilderInterface.php @@ -19,6 +19,8 @@ * Interface implemented to build GraphQL fields. * * @author Alan Poulain + * + * @deprecated Since API Platform 3.1. Use @see FieldsBuilderEnumInterface instead. */ interface FieldsBuilderInterface { diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index 8be0411010d..c1e2d1ce942 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -32,8 +32,11 @@ */ final class SchemaBuilder implements SchemaBuilderInterface { - public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly TypesFactoryInterface $typesFactory, private readonly TypesContainerInterface $typesContainer, private readonly FieldsBuilderInterface $fieldsBuilder) + public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly TypesFactoryInterface $typesFactory, private readonly TypesContainerInterface $typesContainer, private readonly FieldsBuilderEnumInterface|FieldsBuilderInterface $fieldsBuilder) { + if ($this->fieldsBuilder instanceof FieldsBuilderInterface) { + @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); + } } public function getSchema(): Schema diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 480b102bb2c..4c9fec5e649 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; +use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\NonNull; @@ -35,7 +36,7 @@ * * @author Alan Poulain */ -final class TypeBuilder implements TypeBuilderInterface +final class TypeBuilder implements TypeBuilderInterface, TypeBuilderEnumInterface { private $defaultFieldResolver; @@ -201,6 +202,16 @@ public function getNodeInterface(): InterfaceType * {@inheritdoc} */ public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, Operation $operation): GraphQLType + { + @trigger_error('Using getResourcePaginatedCollectionType method of TypeBuilder is deprecated since API Platform 3.1. Use getPaginatedCollectionType method instead.', \E_USER_DEPRECATED); + + return $this->getPaginatedCollectionType($resourceType, $operation); + } + + /** + * {@inheritdoc} + */ + public function getPaginatedCollectionType(GraphQLType $resourceType, Operation $operation): GraphQLType { $shortName = $resourceType->name; $paginationType = $this->pagination->getGraphQlPaginationType($operation); @@ -226,6 +237,42 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType, st return $resourcePaginatedCollectionType; } + public function getEnumType(Operation $operation): GraphQLType + { + $enumName = $operation->getShortName(); + $enumKey = $enumName; + if (!str_ends_with($enumName, 'Enum')) { + $enumKey = sprintf('%sEnum', $enumName); + } + + if ($this->typesContainer->has($enumKey)) { + return $this->typesContainer->get($enumKey); + } + + /** @var FieldsBuilderEnumInterface|FieldsBuilderInterface $fieldsBuilder */ + $fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder'); + $enumCases = []; + // Remove the condition in API Platform 4. + if ($fieldsBuilder instanceof FieldsBuilderEnumInterface) { + $enumCases = $fieldsBuilder->getEnumFields($operation->getClass()); + } else { + @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); + } + + $enumConfig = [ + 'name' => $enumName, + 'values' => $enumCases, + ]; + if ($enumDescription = $operation->getDescription()) { + $enumConfig['description'] = $enumDescription; + } + + $enumType = new EnumType($enumConfig); + $this->typesContainer->set($enumKey, $enumType); + + return $enumType; + } + /** * {@inheritdoc} */ diff --git a/src/GraphQl/Type/TypeBuilderEnumInterface.php b/src/GraphQl/Type/TypeBuilderEnumInterface.php new file mode 100644 index 00000000000..9bbaa5215b0 --- /dev/null +++ b/src/GraphQl/Type/TypeBuilderEnumInterface.php @@ -0,0 +1,57 @@ + + * + * 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\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; + +/** + * Interface implemented to build a GraphQL type. + * + * @author Alan Poulain + */ +interface TypeBuilderEnumInterface +{ + /** + * Gets the object type of the given resource. + * + * @return ObjectType|NonNull 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; + + /** + * 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/TypeBuilderInterface.php b/src/GraphQl/Type/TypeBuilderInterface.php index 50bb0077893..8b782e32461 100644 --- a/src/GraphQl/Type/TypeBuilderInterface.php +++ b/src/GraphQl/Type/TypeBuilderInterface.php @@ -25,6 +25,8 @@ * Interface implemented to build a GraphQL type. * * @author Alan Poulain + * + * @deprecated Since API Platform 3.1. Use @see TypeBuilderEnumInterface instead. */ interface TypeBuilderInterface { @@ -42,6 +44,8 @@ public function getNodeInterface(): InterfaceType; /** * Gets the type of a paginated collection of the given resource type. + * + * @deprecated Since API Platform 3.1. Use @see TypeBuilderEnumInterface::getPaginatedCollectionType() method instead. */ public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, Operation $operation): GraphQLType; diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 6ba6df9192e..f868962c6ae 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -37,8 +37,11 @@ */ final class TypeConverter implements TypeConverterInterface { - public function __construct(private readonly TypeBuilderInterface $typeBuilder, private readonly TypesContainerInterface $typesContainer, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory) + public function __construct(private readonly 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); + } } /** @@ -67,7 +70,28 @@ public function convertType(Type $type, bool $input, Operation $rootOperation, s return GraphQLType::string(); } - return $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth); + $resourceType = $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth); + + if (!$resourceType && is_a($type->getClassName(), \BackedEnum::class, true)) { + // Remove the condition in API Platform 4. + if ($this->typeBuilder instanceof TypeBuilderEnumInterface) { + $operation = null; + try { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($type->getClassName()); + $operation = $resourceMetadataCollection->getOperation(); + } catch (ResourceClassNotFoundException|OperationNotFoundException) { + } + /** @var Query $enumOperation */ + $enumOperation = (new Query()) + ->withClass($type->getClassName()) + ->withShortName($operation?->getShortName() ?? (new \ReflectionClass($type->getClassName()))->getShortName()) + ->withDescription($operation?->getDescription()); + + return $this->typeBuilder->getEnumType($enumOperation); + } + } + + return $resourceType; default: return null; } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1cdd167e739..41a39e5e5e5 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -174,7 +174,13 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchema['externalDocs'] = ['url' => $iri]; } - if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault())) { + // TODO: 3.0 support multiple types + $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; + + if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault()) && (null === $type?->getClassName() || !$this->isResourceClass($type->getClassName()))) { + if ($default instanceof \BackedEnum) { + $default = $default->value; + } $propertySchema['default'] = $default; } @@ -187,8 +193,6 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $valueSchema = []; - // TODO: 3.0 support multiple types - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; if (null !== $type) { if ($isCollection = $type->isCollection()) { $keyType = $type->getCollectionKeyTypes()[0] ?? null; diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index a96c5549d8e..62c06e4cc83 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -72,7 +72,7 @@ private function makeBasicType(Type $type, string $format = 'json', ?bool $reada Type::BUILTIN_TYPE_INT => ['type' => 'integer'], Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $format, $readableLink, $serializerContext, $schema), + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $format, $readableLink, $serializerContext, $schema), default => ['type' => 'string'], }; } @@ -80,7 +80,7 @@ private function makeBasicType(Type $type, string $format = 'json', ?bool $reada /** * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. */ - private function getClassType(?string $className, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array + private function getClassType(?string $className, bool $nullable, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array { if (null === $className) { return ['type' => 'string']; @@ -116,6 +116,18 @@ private function getClassType(?string $className, string $format, ?bool $readabl 'format' => 'binary', ]; } + if (!$this->isResourceClass($className) && is_a($className, \BackedEnum::class, true)) { + $rEnum = new \ReflectionEnum($className); + $enumCases = array_map(static fn (\ReflectionEnumBackedCase $rCase) => $rCase->getBackingValue(), $rEnum->getCases()); + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => (string) $rEnum->getBackingType(), + 'enum' => $enumCases, + ]; + } // Skip if $schema is null (filters only support basic types) if (null === $schema) { diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index b01bad439f2..8541dc210af 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -20,7 +20,7 @@ * * @author Kévin Dunglas */ -#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER | \Attribute::TARGET_CLASS_CONSTANT)] final class ApiProperty { /** diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 94fa423dad4..6100f784393 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -42,9 +42,30 @@ public function create(string $resourceClass, string $property, array $options = } } + $reflectionClass = null; + $reflectionEnum = null; + try { $reflectionClass = new \ReflectionClass($resourceClass); } catch (\ReflectionException) { + } + try { + $reflectionEnum = new \ReflectionEnum($resourceClass); + } catch (\ReflectionException) { + } + + if (!$reflectionClass && !$reflectionEnum) { + return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property); + } + + if ($reflectionEnum) { + if ($reflectionEnum->hasCase($property)) { + $reflectionCase = $reflectionEnum->getCase($property); + if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) { + return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata); + } + } + return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property); } diff --git a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php index b94e6bffdf1..2a5163f424c 100644 --- a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php @@ -18,6 +18,13 @@ use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactoryInterface; use phpDocumentor\Reflection\Types\ContextFactory; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; +use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\ConstExprParser; +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPStan\PhpDocParser\Parser\TokenIterator; +use PHPStan\PhpDocParser\Parser\TypeParser; /** * Extracts descriptions from PHPDoc. @@ -26,13 +33,37 @@ */ final class PhpDocResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - private readonly DocBlockFactoryInterface $docBlockFactory; - private readonly ContextFactory $contextFactory; + private readonly ?DocBlockFactoryInterface $docBlockFactory; + private readonly ?ContextFactory $contextFactory; + private readonly ?PhpDocParser $phpDocParser; + private readonly ?Lexer $lexer; - public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated, DocBlockFactoryInterface $docBlockFactory = null) + /** @var array */ + private array $docBlocks = []; + + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated, ?DocBlockFactoryInterface $docBlockFactory = null) { - $this->docBlockFactory = $docBlockFactory ?: DocBlockFactory::createInstance(); - $this->contextFactory = new ContextFactory(); + $contextFactory = null; + if ($docBlockFactory instanceof DocBlockFactoryInterface) { + trigger_deprecation('api-platform/core', '3.1', 'Using a 2nd argument to PhpDocResourceMetadataCollectionFactory is deprecated.'); + } + if (class_exists(DocBlockFactory::class) && class_exists(ContextFactory::class)) { + $docBlockFactory = $docBlockFactory ?? DocBlockFactory::createInstance(); + $contextFactory = new ContextFactory(); + } + $this->docBlockFactory = $docBlockFactory; + $this->contextFactory = $contextFactory; + if (class_exists(DocBlockFactory::class) && !class_exists(PhpDocParser::class)) { + trigger_deprecation('api-platform/core', '3.1', 'Using phpdocumentor/reflection-docblock is deprecated. Require phpstan/phpdoc-parser instead.'); + } + $phpDocParser = null; + $lexer = null; + if (class_exists(PhpDocParser::class)) { + $phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); + $lexer = new Lexer(); + } + $this->phpDocParser = $phpDocParser; + $this->lexer = $lexer; } /** @@ -47,41 +78,97 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $reflectionClass = new \ReflectionClass($resourceClass); + $description = null; - try { - $docBlock = $this->docBlockFactory->create($reflectionClass, $this->contextFactory->createFromReflector($reflectionClass)); - $resourceMetadataCollection[$key] = $resourceMetadata->withDescription($docBlock->getSummary()); + // Deprecated path. To remove in API Platform 4. + if (!$this->phpDocParser instanceof PhpDocParser && $this->docBlockFactory instanceof DocBlockFactoryInterface && $this->contextFactory) { + $reflectionClass = new \ReflectionClass($resourceClass); - $operations = $resourceMetadata->getOperations() ?? new Operations(); - foreach ($operations as $operationName => $operation) { - if (null !== $operation->getDescription()) { - continue; - } - - $operations->add($operationName, $operation->withDescription($docBlock->getSummary())); + try { + $docBlock = $this->docBlockFactory->create($reflectionClass, $this->contextFactory->createFromReflector($reflectionClass)); + $description = $docBlock->getSummary(); + } catch (\InvalidArgumentException) { + // Ignore empty DocBlocks } + } else { + $description = $this->getShortDescription($resourceClass); + } - $resourceMetadataCollection[$key] = $resourceMetadataCollection[$key]->withOperations($operations); + if (!$description) { + return $resourceMetadataCollection; + } - if (!$resourceMetadata->getGraphQlOperations()) { + $resourceMetadataCollection[$key] = $resourceMetadata->withDescription($description); + + $operations = $resourceMetadata->getOperations() ?? new Operations(); + foreach ($operations as $operationName => $operation) { + if (null !== $operation->getDescription()) { continue; } - foreach ($graphQlOperations = $resourceMetadata->getGraphQlOperations() as $operationName => $operation) { - if (null !== $operation->getDescription()) { - continue; - } + $operations->add($operationName, $operation->withDescription($description)); + } - $graphQlOperations[$operationName] = $operation->withDescription($docBlock->getSummary()); + $resourceMetadataCollection[$key] = $resourceMetadataCollection[$key]->withOperations($operations); + + if (!$resourceMetadata->getGraphQlOperations()) { + continue; + } + + foreach ($graphQlOperations = $resourceMetadata->getGraphQlOperations() as $operationName => $operation) { + if (null !== $operation->getDescription()) { + continue; } - $resourceMetadataCollection[$key] = $resourceMetadataCollection[$key]->withGraphQlOperations($graphQlOperations); - } catch (\InvalidArgumentException) { - // Ignore empty DocBlocks + $graphQlOperations[$operationName] = $operation->withDescription($description); } + + $resourceMetadataCollection[$key] = $resourceMetadataCollection[$key]->withGraphQlOperations($graphQlOperations); } return $resourceMetadataCollection; } + + /** + * Gets the short description of the class. + */ + private function getShortDescription(string $class): ?string + { + if (!$docBlock = $this->getDocBlock($class)) { + return null; + } + + foreach ($docBlock->children as $docChild) { + if ($docChild instanceof PhpDocTextNode && !empty($docChild->text)) { + return $docChild->text; + } + } + + return null; + } + + private function getDocBlock(string $class): ?PhpDocNode + { + if (isset($this->docBlocks[$class])) { + return $this->docBlocks[$class]; + } + + try { + $reflectionClass = new \ReflectionClass($class); + } catch (\ReflectionException) { + return null; + } + + $rawDocNode = $reflectionClass->getDocComment(); + + if (!$rawDocNode) { + return null; + } + + $tokens = new TokenIterator($this->lexer->tokenize($rawDocNode)); + $phpDocNode = $this->phpDocParser->parse($tokens); + $tokens->consumeTokenType(Lexer::TOKEN_END); + + return $this->docBlocks[$class] = $phpDocNode; + } } diff --git a/src/Serializer/Filter/GroupFilter.php b/src/Serializer/Filter/GroupFilter.php index c8d09c9aee3..0e96fe49bcb 100644 --- a/src/Serializer/Filter/GroupFilter.php +++ b/src/Serializer/Filter/GroupFilter.php @@ -58,13 +58,23 @@ public function apply(Request $request, bool $normalization, array $attributes, */ public function getDescription(string $resourceClass): array { - return [ - "$this->parameterName[]" => [ - 'property' => null, - 'type' => 'string', - 'is_collection' => true, - 'required' => false, - ], + $description = [ + 'property' => null, + 'type' => 'string', + 'is_collection' => true, + 'required' => false, ]; + + if ($this->whitelist) { + $description['schema'] = [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => $this->whitelist, + ], + ]; + } + + return ["$this->parameterName[]" => $description]; } } diff --git a/src/State/ProviderInterface.php b/src/State/ProviderInterface.php index ea72049c7f7..8323433b074 100644 --- a/src/State/ProviderInterface.php +++ b/src/State/ProviderInterface.php @@ -26,6 +26,8 @@ interface ProviderInterface { /** * Provides data. + * + * @return T|Pagination\PartialPaginatorInterface|iterable|null */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null; } diff --git a/src/Symfony/Bundle/DataCollector/DataCollected.php b/src/Symfony/Bundle/DataCollector/DataCollected.php new file mode 100644 index 00000000000..33732226bc9 --- /dev/null +++ b/src/Symfony/Bundle/DataCollector/DataCollected.php @@ -0,0 +1,43 @@ + + * + * 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\Symfony\Bundle\DataCollector; + +use Symfony\Component\VarDumper\Cloner\Data; + +final class DataCollected +{ + public function __construct(private readonly string $resourceClass, private readonly Data $resourceMetadataCollection, private readonly array $filters, private readonly array $counters) + { + } + + public function getResourceClass(): string + { + return $this->resourceClass; + } + + public function getResourceMetadataCollection(): Data + { + return $this->resourceMetadataCollection; + } + + public function getFilters(): array + { + return $this->filters; + } + + public function getCounters(): array + { + return $this->counters; + } +} diff --git a/src/Symfony/Bundle/DataCollector/RequestDataCollector.php b/src/Symfony/Bundle/DataCollector/RequestDataCollector.php index ba48baec1d2..38386ddb2a1 100644 --- a/src/Symfony/Bundle/DataCollector/RequestDataCollector.php +++ b/src/Symfony/Bundle/DataCollector/RequestDataCollector.php @@ -21,6 +21,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\VarDumper\Cloner\Data; /** * @author Julien DENIAU @@ -37,20 +38,10 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn */ public function collect(Request $request, Response $response, \Throwable $exception = null): void { - $resourceClass = $request->attributes->get('_api_resource_class'); - $resourceMetadataCollection = $resourceClass ? $this->metadataFactory->create($resourceClass) : []; - - $filters = []; - $counters = ['ignored_filters' => 0]; - $resourceMetadataCollectionData = []; - - /** @var ApiResource $resourceMetadata */ - foreach ($resourceMetadataCollection as $index => $resourceMetadata) { - $this->setFilters($resourceMetadata, $index, $filters, $counters); - $resourceMetadataCollectionData[] = [ - 'resource' => $resourceMetadata, - 'operations' => null !== $resourceMetadata->getOperations() ? iterator_to_array($resourceMetadata->getOperations()) : [], - ]; + if ($request->attributes->get('_graphql', false)) { + $resourceClasses = array_keys($request->attributes->get('_graphql_args', [])); + } else { + $resourceClasses = array_filter([$request->attributes->get('_api_resource_class')]); } $requestAttributes = RequestAttributesExtractor::extractAttributes($request); @@ -58,14 +49,9 @@ public function collect(Request $request, Response $response, \Throwable $except $requestAttributes['previous_data'] = $this->cloneVar($requestAttributes['previous_data']); } - $this->data = [ - 'resource_class' => $resourceClass, - 'resource_metadata_collection' => $this->cloneVar($resourceMetadataCollectionData), - 'acceptable_content_types' => $request->getAcceptableContentTypes(), - 'filters' => $filters, - 'counters' => $counters, - 'request_attributes' => $requestAttributes, - ]; + $this->data['request_attributes'] = $requestAttributes; + $this->data['acceptable_content_types'] = $request->getAcceptableContentTypes(); + $this->data['resources'] = array_map(fn (string $resourceClass): DataCollected => $this->collectDataByResource($resourceClass, $request), $resourceClasses); } private function setFilters(ApiResource $resourceMetadata, int $index, array &$filters, array &$counters): void @@ -81,58 +67,75 @@ private function setFilters(ApiResource $resourceMetadata, int $index, array &$f } } - public function getAcceptableContentTypes(): array + public function getVersion(): ?string { - return $this->data['acceptable_content_types'] ?? []; - } + if (!class_exists(Versions::class)) { + return null; + } - public function getResourceClass() - { - return $this->data['resource_class'] ?? null; + $version = Versions::getVersion('api-platform/core'); + preg_match('/^v(.*?)@/', (string) $version, $output); + + return $output[1] ?? strtok($version, '@'); } - public function getResourceMetadataCollection() + /** + * {@inheritdoc} + */ + public function getName(): string { - return $this->data['resource_metadata_collection'] ?? null; + return 'api_platform.data_collector.request'; } - public function getRequestAttributes(): array + public function getData(): array|Data { - return $this->data['request_attributes'] ?? []; + return $this->data; } - public function getFilters(): array + public function getAcceptableContentTypes(): array { - return $this->data['filters'] ?? []; + return $this->data['acceptable_content_types'] ?? []; } - public function getCounters(): array + public function getRequestAttributes(): array { - return $this->data['counters'] ?? []; + return $this->data['request_attributes'] ?? []; } - public function getVersion(): ?string + public function getResources(): array { - if (!class_exists(Versions::class)) { - return null; - } - - $version = Versions::getVersion('api-platform/core'); - preg_match('/^v(.*?)@/', (string) $version, $output); - - return $output[1] ?? strtok($version, '@'); + return $this->data['resources'] ?? []; } /** * {@inheritdoc} */ - public function getName(): string + public function reset(): void { - return 'api_platform.data_collector.request'; + $this->data = []; } - public function reset(): void + private function collectDataByResource(string $resourceClass, Request $request): DataCollected { - $this->data = []; + $resourceMetadataCollection = $resourceClass ? $this->metadataFactory->create($resourceClass) : []; + $filters = []; + $counters = ['ignored_filters' => 0]; + $resourceMetadataCollectionData = []; + + /** @var ApiResource $resourceMetadata */ + foreach ($resourceMetadataCollection as $index => $resourceMetadata) { + $this->setFilters($resourceMetadata, $index, $filters, $counters); + $resourceMetadataCollectionData[] = [ + 'resource' => $resourceMetadata, + 'operations' => null !== $resourceMetadata->getOperations() ? iterator_to_array($resourceMetadata->getOperations()) : [], + ]; + } + + return new DataCollected( + $resourceClass, + $this->cloneVar($resourceMetadataCollectionData), + $filters, + $counters + ); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 230a609d120..c846e0ef9d9 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -32,14 +32,17 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\GraphQl\Resolver\Factory\DataCollectorResolverFactory; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface; use Doctrine\Persistence\ManagerRegistry; use phpDocumentor\Reflection\DocBlockFactoryInterface; +use PHPStan\PhpDocParser\Parser\PhpDocParser; use Ramsey\Uuid\Uuid; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\DirectoryResource; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Exception\RuntimeException; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; @@ -263,7 +266,7 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra $container->getDefinition('api_platform.metadata.resource_extractor.xml')->replaceArgument(0, $xmlResources); $container->getDefinition('api_platform.metadata.property_extractor.xml')->replaceArgument(0, $xmlResources); - if (interface_exists(DocBlockFactoryInterface::class)) { + if (class_exists(PhpDocParser::class) || interface_exists(DocBlockFactoryInterface::class)) { $loader->load('metadata/php_doc.xml'); } @@ -499,6 +502,34 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array ->addTag('api_platform.graphql.type'); $container->registerForAutoconfiguration(ErrorHandlerInterface::class) ->addTag('api_platform.graphql.error_handler'); + + if (!$container->getParameter('kernel.debug')) { + return; + } + + $requestStack = new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE); + $collectionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.collection') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.collection.inner'), $requestStack]); + + $itemDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item.inner'), $requestStack]); + + $itemMutationDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item_mutation') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_mutation.inner'), $requestStack]); + + $itemSubscriptionDataCollectorResolverFactory = (new Definition(DataCollectorResolverFactory::class)) + ->setDecoratedService('api_platform.graphql.resolver.factory.item_subscription') + ->setArguments([new Reference('api_platform.graphql.data_collector.resolver.factory.item_subscription.inner'), $requestStack]); + + $container->addDefinitions([ + 'api_platform.graphql.data_collector.resolver.factory.collection' => $collectionDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item' => $itemDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item_mutation' => $itemMutationDataCollectorResolverFactory, + 'api_platform.graphql.data_collector.resolver.factory.item_subscription' => $itemSubscriptionDataCollectorResolverFactory, + ]); } private function registerCacheConfiguration(ContainerBuilder $container): void diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index b5a8e4e80e5..fb412236b11 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -162,7 +162,6 @@ - diff --git a/src/Symfony/Bundle/Resources/views/DataCollector/request.html.twig b/src/Symfony/Bundle/Resources/views/DataCollector/request.html.twig index 2aecfd097d1..dedfca3e5fc 100644 --- a/src/Symfony/Bundle/Resources/views/DataCollector/request.html.twig +++ b/src/Symfony/Bundle/Resources/views/DataCollector/request.html.twig @@ -89,16 +89,24 @@ {{ collector.version }} {% endif %} -
- Resource Class - {{ collector.resourceClass|default('Not an API Platform resource') }} -
- {% if collector.counters.ignored_filters|default(false) %} + {% if collector.resources|length == 0 %}
- Ignored Filters - {{ collector.counters.ignored_filters }} + Resource Class + Not an API Platform resource
{% endif %} + {% for resource in collector.resources %} +
+ Resource Class + {{ resource.resourceClass }} +
+ {% if resource.counters.ignored_filters|default(false) %} +
+ Ignored Filters + {{ collector.counters.ignored_filters }} +
+ {% endif %} + {% endfor %} {% endset %} {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { 'link': true, status: status_color }) }} @@ -106,7 +114,7 @@ {% block menu %} {# This left-hand menu appears when using the full-screen profiler. #} - + {{ include('@ApiPlatform/DataCollector/api-platform.svg') }} @@ -115,77 +123,86 @@ {% endblock %} {% block panel %} -
-
- {{ collector.resourceClass|default('Not an API Platform resource') }} - Resource class + {% if collector.resources|length == 0 %} +
+
+ Not an API Platform resource + Resource Class +
-
- - {% if collector.resourceMetadataCollection is not empty %} -
-
- -

Resources

-
- - {% endif %} + {% endif %} + {% endfor %} {% endblock %} diff --git a/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php b/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php new file mode 100644 index 00000000000..9091ab3dc9e --- /dev/null +++ b/src/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactory.php @@ -0,0 +1,40 @@ + + * + * 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\Symfony\GraphQl\Resolver\Factory; + +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; +use ApiPlatform\Metadata\GraphQl\Operation; +use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpFoundation\RequestStack; + +final class DataCollectorResolverFactory implements ResolverFactoryInterface +{ + public function __construct(private readonly ResolverFactoryInterface $resolverFactory, private readonly ?RequestStack $requestStack) + { + } + + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?Operation $operation = null): callable + { + return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operation) { + if ($this->requestStack && null !== $request = $this->requestStack->getCurrentRequest()) { + $request->attributes->set( + '_graphql_args', + [$resourceClass => $args] + $request->attributes->get('_graphql_args', []) + ); + } + + return ($this->resolverFactory)($resourceClass, $rootClass, $operation)($source, $args, $context, $info); + }; + } +} diff --git a/tests/Behat/OpenApiContext.php b/tests/Behat/OpenApiContext.php index 6865763e9be..c651149e3ff 100644 --- a/tests/Behat/OpenApiContext.php +++ b/tests/Behat/OpenApiContext.php @@ -16,7 +16,9 @@ use Behat\Behat\Context\Context; use Behat\Behat\Context\Environment\InitializedContextEnvironment; use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\PyStringNode; use Behatch\Context\RestContext; +use Behatch\Json\Json; use PHPUnit\Framework\Assert; use PHPUnit\Framework\ExpectationFailedException; @@ -42,51 +44,25 @@ public function gatherContexts(BeforeScenarioScope $scope): void $this->restContext = $restContext; } - /** - * @Then the Swagger class :class exists - */ - public function assertTheSwaggerClassExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e); - } - } - /** * @Then the OpenAPI class :class exists */ public function assertTheOpenApiClassExist(string $className): void { try { - $this->getClassInfo($className, 3); + $this->getClassInfo($className); } catch (\InvalidArgumentException $e) { throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e); } } - /** - * @Then the Swagger class :class doesn't exist - */ - public function assertTheSwaggerClassNotExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(sprintf('The class "%s" exists.', $className)); - } - /** * @Then the OpenAPI class :class doesn't exist */ public function assertTheOpenAPIClassNotExist(string $className): void { try { - $this->getClassInfo($className, 3); + $this->getClassInfo($className); } catch (\InvalidArgumentException) { return; } @@ -95,7 +71,6 @@ public function assertTheOpenAPIClassNotExist(string $className): void } /** - * @Then the Swagger path :arg1 exists * @Then the OpenAPI path :arg1 exists */ public function assertThePathExist(string $path): void @@ -105,54 +80,32 @@ public function assertThePathExist(string $path): void Assert::assertTrue(isset($json->paths) && isset($json->paths->{$path})); } - /** - * @Then the :prop property exists for the Swagger class :class - */ - public function assertThePropertyExistForTheSwaggerClass(string $propertyName, string $className): void - { - try { - $this->getPropertyInfo($propertyName, $className); - } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e); - } - } - /** * @Then the :prop property exists for the OpenAPI class :class */ public function assertThePropertyExistForTheOpenApiClass(string $propertyName, string $className): void { try { - $this->getPropertyInfo($propertyName, $className, 3); + $this->getPropertyInfo($propertyName, $className); } catch (\InvalidArgumentException $e) { throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e); } } - /** - * @Then the :prop property is required for the Swagger class :class - */ - public function assertThePropertyIsRequiredForTheSwaggerClass(string $propertyName, string $className): void - { - if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) { - throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className)); - } - } - /** * @Then the :prop property is required for the OpenAPI class :class */ public function assertThePropertyIsRequiredForTheOpenAPIClass(string $propertyName, string $className): void { - if (!\in_array($propertyName, $this->getClassInfo($className, 3)->required, true)) { + if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) { throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className)); } } /** - * @Then the :prop property is not read only for the Swagger class :class + * @Then the :prop property is not read only for the OpenAPI class :class */ - public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propertyName, string $className): void + public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void { $propertyInfo = $this->getPropertyInfo($propertyName, $className); if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) { @@ -161,13 +114,15 @@ public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propert } /** - * @Then the :prop property is not read only for the OpenAPI class :class + * @Then the :prop property for the OpenAPI class :class should be equal to: */ - public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void + public function assertThePropertyForTheOpenAPIClassShouldBeEqualTo(string $propertyName, string $className, PyStringNode $propertyContent): void { - $propertyInfo = $this->getPropertyInfo($propertyName, $className, 3); - if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) { - throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should not be read only', $propertyName, $className)); + $propertyInfo = $this->getPropertyInfo($propertyName, $className); + $propertyInfoJson = new Json(json_encode($propertyInfo)); + + if (new Json($propertyContent) != $propertyInfoJson) { + throw new ExpectationFailedException(sprintf("Property \"%s\" of class \"%s\" is '%s'", $propertyName, $className, $propertyInfoJson)); } } @@ -176,12 +131,10 @@ public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propert * * @throws \InvalidArgumentException */ - private function getPropertyInfo(string $propertyName, string $className, int $specVersion = 2): \stdClass + private function getPropertyInfo(string $propertyName, string $className): \stdClass { - /** - * @var iterable $properties - */ - $properties = $this->getProperties($className, $specVersion); + /** @var iterable $properties */ + $properties = $this->getProperties($className); foreach ($properties as $classPropertyName => $property) { if ($classPropertyName === $propertyName) { return $property; @@ -194,9 +147,9 @@ private function getPropertyInfo(string $propertyName, string $className, int $s /** * Gets all operations of a given class. */ - private function getProperties(string $className, int $specVersion = 2): \stdClass + private function getProperties(string $className): \stdClass { - return $this->getClassInfo($className, $specVersion)->{'properties'} ?? new \stdClass(); + return $this->getClassInfo($className)->{'properties'} ?? new \stdClass(); } /** @@ -204,9 +157,9 @@ private function getProperties(string $className, int $specVersion = 2): \stdCla * * @throws \InvalidArgumentException */ - private function getClassInfo(string $className, int $specVersion = 2): \stdClass + private function getClassInfo(string $className): \stdClass { - $nodes = 2 === $specVersion ? $this->getLastJsonResponse()->{'definitions'} : $this->getLastJsonResponse()->{'components'}->{'schemas'}; + $nodes = $this->getLastJsonResponse()->{'components'}->{'schemas'}; foreach ($nodes as $classTitle => $classData) { if ($classTitle === $className) { return $classData; diff --git a/tests/Doctrine/Odm/PropertyInfo/DoctrineExtractorTest.php b/tests/Doctrine/Odm/PropertyInfo/DoctrineExtractorTest.php index 50c697632e2..84b10c33b8e 100644 --- a/tests/Doctrine/Odm/PropertyInfo/DoctrineExtractorTest.php +++ b/tests/Doctrine/Odm/PropertyInfo/DoctrineExtractorTest.php @@ -17,10 +17,13 @@ use ApiPlatform\Test\DoctrineMongoDbOdmSetup; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineDummy; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineEmbeddable; +use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineEnum; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineFooType; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineGeneratedValue; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineRelation; use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineWithEmbedded; +use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\EnumInt; +use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\EnumString; use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Types\Type as MongoDbType; @@ -128,6 +131,13 @@ public function testExtractWithEmbedMany(): void $this->assertEquals($expectedTypes, $actualTypes); } + public function testExtractEnum(): void + { + $this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumString::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumString')); + $this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumInt::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumInt')); + $this->assertNull($this->createExtractor()->getTypes(DoctrineEnum::class, 'enumCustom')); + } + public function typesProvider(): array { return [ diff --git a/tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineEnum.php b/tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineEnum.php new file mode 100644 index 00000000000..57efa0ec47e --- /dev/null +++ b/tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineEnum.php @@ -0,0 +1,37 @@ + + * + * 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\Doctrine\Odm\PropertyInfo\Fixtures; + +use Doctrine\ODM\MongoDB\Mapping\Annotations\Document; +use Doctrine\ODM\MongoDB\Mapping\Annotations\Field; +use Doctrine\ODM\MongoDB\Mapping\Annotations\Id; + +/** + * @author Alan Poulain + */ +#[Document] +class DoctrineEnum +{ + #[Id] + public int $id; + + #[Field(enumType: EnumString::class)] + protected EnumString $enumString; + + #[Field(type: 'int', enumType: EnumInt::class)] + protected EnumInt $enumInt; + + #[Field(type: 'custom_foo', enumType: EnumInt::class)] + protected EnumInt $enumCustom; +} diff --git a/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumInt.php b/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumInt.php new file mode 100644 index 00000000000..0fc31cff2a5 --- /dev/null +++ b/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumInt.php @@ -0,0 +1,20 @@ + + * + * 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\Doctrine\Odm\PropertyInfo\Fixtures; + +enum EnumInt: int +{ + case Foo = 0; + case Bar = 1; +} diff --git a/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumString.php b/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumString.php new file mode 100644 index 00000000000..f96c6e29bd3 --- /dev/null +++ b/tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumString.php @@ -0,0 +1,20 @@ + + * + * 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\Doctrine\Odm\PropertyInfo\Fixtures; + +enum EnumString: string +{ + case Foo = 'f'; + case Bar = 'b'; +} diff --git a/tests/Fixtures/TestBundle/Document/Person.php b/tests/Fixtures/TestBundle/Document/Person.php index 0e4f098366a..21d287dc6ad 100644 --- a/tests/Fixtures/TestBundle/Document/Person.php +++ b/tests/Fixtures/TestBundle/Document/Person.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; @@ -29,13 +30,20 @@ class Person { #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private $id; + private ?int $id = null; + + #[ODM\Field(type: 'string', enumType: GenderTypeEnum::class, nullable: true)] + #[Groups(['people.pets'])] + public ?GenderTypeEnum $genderType = GenderTypeEnum::MALE; + #[Groups(['people.pets'])] #[ODM\Field(type: 'string')] - public $name; + public string $name; + #[Groups(['people.pets'])] #[ODM\ReferenceMany(targetDocument: PersonToPet::class, mappedBy: 'person')] public Collection|iterable $pets; + #[ODM\ReferenceMany(targetDocument: Greeting::class, mappedBy: 'sender')] public Collection|iterable|null $sentGreetings = null; @@ -44,7 +52,7 @@ public function __construct() $this->pets = new ArrayCollection(); } - public function getId() + public function getId(): ?int { return $this->id; } diff --git a/tests/Fixtures/TestBundle/Document/VideoGame.php b/tests/Fixtures/TestBundle/Document/VideoGame.php new file mode 100644 index 00000000000..60dc2a5abf4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/VideoGame.php @@ -0,0 +1,37 @@ + + * + * 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\Document; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ApiResource] +#[ODM\Document] +class VideoGame +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + public string $name; + + #[ODM\Field(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Person.php b/tests/Fixtures/TestBundle/Entity/Person.php index 59b452b8103..809da04a5b6 100644 --- a/tests/Fixtures/TestBundle/Entity/Person.php +++ b/tests/Fixtures/TestBundle/Entity/Person.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -31,13 +32,20 @@ class Person #[ORM\Id] #[ORM\Column(type: 'integer')] #[ORM\GeneratedValue(strategy: 'AUTO')] - private $id; + private ?int $id = null; + + #[ORM\Column(type: 'string', enumType: GenderTypeEnum::class, nullable: true)] + #[Groups(['people.pets'])] + public ?GenderTypeEnum $genderType = GenderTypeEnum::MALE; + #[ORM\Column(type: 'string')] #[Groups(['people.pets'])] - public $name; + public string $name; + #[ORM\OneToMany(targetEntity: PersonToPet::class, mappedBy: 'person')] #[Groups(['people.pets'])] public Collection|iterable $pets; + #[ORM\OneToMany(targetEntity: Greeting::class, mappedBy: 'sender')] public Collection|iterable|null $sentGreetings = null; @@ -46,7 +54,7 @@ public function __construct() $this->pets = new ArrayCollection(); } - public function getId() + public function getId(): ?int { return $this->id; } diff --git a/tests/Fixtures/TestBundle/Entity/VideoGame.php b/tests/Fixtures/TestBundle/Entity/VideoGame.php new file mode 100644 index 00000000000..34dd41b2f29 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/VideoGame.php @@ -0,0 +1,39 @@ + + * + * 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\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class VideoGame +{ + #[ORM\Id] + #[ORM\Column(type: 'integer')] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + public string $name; + + #[ORM\Column(type: 'string', enumType: GamePlayMode::class)] + public GamePlayMode $playMode = GamePlayMode::SINGLE_PLAYER; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Enum/EnumWithDescriptions.php b/tests/Fixtures/TestBundle/Enum/EnumWithDescriptions.php new file mode 100644 index 00000000000..301d159815a --- /dev/null +++ b/tests/Fixtures/TestBundle/Enum/EnumWithDescriptions.php @@ -0,0 +1,29 @@ + + * + * 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\Enum; + +enum EnumWithDescriptions +{ + /** + * A short description for case one. + */ + case ONE; + + /** + * A short description for case two. + * + * A long description for case two. + */ + case TWO; +} diff --git a/tests/Fixtures/TestBundle/Enum/GamePlayMode.php b/tests/Fixtures/TestBundle/Enum/GamePlayMode.php new file mode 100644 index 00000000000..ec6c38a2fe7 --- /dev/null +++ b/tests/Fixtures/TestBundle/Enum/GamePlayMode.php @@ -0,0 +1,53 @@ + + * + * 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\Enum; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Metadata\Get; + +#[Get(description: 'Indicates whether this game is multi-player, co-op or single-player.', provider: self::class.'::getCase')] +#[GetCollection(provider: self::class.'::getCases')] +#[Query(provider: self::class.'::getCase')] +#[QueryCollection(provider: self::class.'::getCases', paginationEnabled: false)] +enum GamePlayMode: string +{ + /** Co-operative games, where you play on the same team with friends. */ + case CO_OP = 'CoOp'; + + /** Requiring or allowing multiple human players to play simultaneously. */ + case MULTI_PLAYER = 'MultiPlayer'; + + /** Which is played by a lone player. */ + case SINGLE_PLAYER = 'SinglePlayer'; + + public function getId(): string + { + return $this->name; + } + + public static function getCase(Operation $operation, array $uriVariables): GamePlayMode + { + $name = $uriVariables['id'] ?? null; + + return \constant(self::class."::$name"); + } + + public static function getCases(): array + { + return self::cases(); + } +} diff --git a/tests/Fixtures/TestBundle/Enum/GenderTypeEnum.php b/tests/Fixtures/TestBundle/Enum/GenderTypeEnum.php new file mode 100644 index 00000000000..7eb7acffc53 --- /dev/null +++ b/tests/Fixtures/TestBundle/Enum/GenderTypeEnum.php @@ -0,0 +1,28 @@ + + * + * 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\Enum; + +use ApiPlatform\Metadata\ApiProperty; + +/** + * An enumeration of genders. + */ +enum GenderTypeEnum: string +{ + /** The male gender. */ + case MALE = 'male'; + + #[ApiProperty(description: 'The female gender.')] + case FEMALE = 'female'; +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 98ff470df5e..f8055e315fd 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -78,6 +78,7 @@ api_platform: doctrine_mongodb_odm: false mapping: paths: + - '%kernel.project_dir%/../TestBundle/Enum' - '%kernel.project_dir%/../TestBundle/Model' parameters: diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index f6854c74167..33f5cbb1c4e 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -24,9 +24,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\ParameterBag; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; /** * @author Alan Poulain @@ -42,7 +39,6 @@ class CollectionResolverFactoryTest extends TestCase private ObjectProphecy $securityPostDenormalizeStageProphecy; private ObjectProphecy $serializeStageProphecy; private ObjectProphecy $queryResolverLocatorProphecy; - private ObjectProphecy $requestStackProphecy; /** * {@inheritdoc} @@ -54,7 +50,6 @@ protected function setUp(): void $this->securityPostDenormalizeStageProphecy = $this->prophesize(SecurityPostDenormalizeStageInterface::class); $this->serializeStageProphecy = $this->prophesize(SerializeStageInterface::class); $this->queryResolverLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->requestStackProphecy = $this->prophesize(RequestStack::class); $this->collectionResolverFactory = new CollectionResolverFactory( $this->readStageProphecy->reveal(), @@ -62,7 +57,6 @@ protected function setUp(): void $this->securityPostDenormalizeStageProphecy->reveal(), $this->serializeStageProphecy->reveal(), $this->queryResolverLocatorProphecy->reveal(), - $this->requestStackProphecy->reveal() ); } @@ -78,13 +72,6 @@ public function testResolve(): void $info->fieldName = 'testField'; $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; - $request = new Request(); - $attributesParameterBagProphecy = $this->prophesize(ParameterBag::class); - $attributesParameterBagProphecy->get('_graphql_collections_args', [])->willReturn(['collection_args']); - $attributesParameterBagProphecy->set('_graphql_collections_args', [$resourceClass => $args, 'collection_args'])->shouldBeCalled(); - $request->attributes = $attributesParameterBagProphecy->reveal(); - $this->requestStackProphecy->getCurrentRequest()->willReturn($request); - $readStageCollection = [new \stdClass()]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operation, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); @@ -148,13 +135,6 @@ public function testResolveNullSource(): void $info = $this->prophesize(ResolveInfo::class)->reveal(); $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; - $request = new Request(); - $attributesParameterBagProphecy = $this->prophesize(ParameterBag::class); - $attributesParameterBagProphecy->get('_graphql_collections_args', [])->willReturn(['collection_args']); - $attributesParameterBagProphecy->set('_graphql_collections_args', [$resourceClass => $args, 'collection_args'])->shouldBeCalled(); - $request->attributes = $attributesParameterBagProphecy->reveal(); - $this->requestStackProphecy->getCurrentRequest()->willReturn($request); - $readStageCollection = [new \stdClass()]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operation, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 5313db3537d..853b962223f 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -17,7 +17,7 @@ use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; use ApiPlatform\GraphQl\Type\FieldsBuilder; -use ApiPlatform\GraphQl\Type\TypeBuilderInterface; +use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverterInterface; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; @@ -33,6 +33,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use ApiPlatform\Tests\Fixtures\TestBundle\Serializer\NameConverter\CustomConverter; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; @@ -78,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(TypeBuilderInterface::class); + $this->typeBuilderProphecy = $this->prophesize(TypeBuilderEnumInterface::class); $this->typeConverterProphecy = $this->prophesize(TypeConverterInterface::class); $this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); @@ -207,7 +208,7 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation)->willReturn($graphqlType); + $this->typeBuilderProphecy->getPaginatedCollectionType($graphqlType, $operation)->willReturn($graphqlType); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); $filterProphecy = $this->prophesize(FilterInterface::class); @@ -827,6 +828,25 @@ public function resourceObjectTypeFieldsProvider(): array ]; } + public function testGetEnumFields(): void + { + $enumClass = GenderTypeEnum::class; + + $this->propertyMetadataFactoryProphecy->create($enumClass, GenderTypeEnum::MALE->name)->willReturn(new ApiProperty( + description: 'Description of MALE case', + )); + $this->propertyMetadataFactoryProphecy->create($enumClass, GenderTypeEnum::FEMALE->name)->willReturn(new ApiProperty( + description: 'Description of FEMALE case', + )); + + $enumFields = $this->fieldsBuilder->getEnumFields($enumClass); + + $this->assertSame([ + GenderTypeEnum::MALE->name => ['value' => GenderTypeEnum::MALE->value, 'description' => 'Description of MALE case'], + GenderTypeEnum::FEMALE->name => ['value' => GenderTypeEnum::FEMALE->value, 'description' => 'Description of FEMALE case'], + ], $enumFields); + } + /** * @dataProvider resolveResourceArgsProvider */ diff --git a/tests/GraphQl/Type/SchemaBuilderTest.php b/tests/GraphQl/Type/SchemaBuilderTest.php index 294d5312c3d..1850951d402 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/tests/GraphQl/Type/SchemaBuilderTest.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\GraphQl\Type; -use ApiPlatform\GraphQl\Type\FieldsBuilderInterface; +use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\SchemaBuilder; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\GraphQl\Type\TypesFactoryInterface; @@ -42,15 +42,10 @@ class SchemaBuilderTest extends TestCase use ProphecyTrait; private ObjectProphecy $resourceNameCollectionFactoryProphecy; - private ObjectProphecy $resourceMetadataCollectionFactoryProphecy; - private ObjectProphecy $typesFactoryProphecy; - private ObjectProphecy $typesContainerProphecy; - private ObjectProphecy $fieldsBuilderProphecy; - private SchemaBuilder $schemaBuilder; /** @@ -62,7 +57,7 @@ protected function setUp(): void $this->resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $this->typesFactoryProphecy = $this->prophesize(TypesFactoryInterface::class); $this->typesContainerProphecy = $this->prophesize(TypesContainerInterface::class); - $this->fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $this->fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $this->schemaBuilder = new SchemaBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->typesFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->fieldsBuilderProphecy->reveal()); } diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index ee6dc591d4e..0eed16b666f 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Tests\GraphQl\Type; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; -use ApiPlatform\GraphQl\Type\FieldsBuilderInterface; +use ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeBuilder; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiResource; @@ -26,6 +26,8 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; @@ -48,12 +50,9 @@ class TypeBuilderTest extends TestCase use ProphecyTrait; private ObjectProphecy $typesContainerProphecy; - /** @var callable */ private $defaultFieldResolver; - private ObjectProphecy $fieldsBuilderLocatorProphecy; - private TypeBuilder $typeBuilder; /** @@ -93,7 +92,7 @@ public function testGetResourceObjectType(): void $this->assertArrayHasKey('interfaces', $resourceObjectType->config); $this->assertArrayHasKey('fields', $resourceObjectType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, false, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); @@ -119,7 +118,7 @@ public function testGetResourceObjectTypeOutputClass(): void $this->assertArrayHasKey('interfaces', $resourceObjectType->config); $this->assertArrayHasKey('fields', $resourceObjectType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('outputClass', $operation, false, 0, ['class' => 'outputClass'])->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); @@ -188,7 +187,7 @@ public function testGetResourceObjectTypeInput(): void $this->assertArrayHasKey('interfaces', $wrappedType->config); $this->assertArrayHasKey('fields', $wrappedType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, true, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); @@ -214,7 +213,7 @@ public function testGetResourceObjectTypeNestedInput(): void $this->assertArrayHasKey('interfaces', $wrappedType->config); $this->assertArrayHasKey('fields', $wrappedType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $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()); $wrappedType->config['fields'](); @@ -240,7 +239,7 @@ public function testGetResourceObjectTypeCustomMutationInputArgs(): void $this->assertArrayHasKey('interfaces', $wrappedType->config); $this->assertArrayHasKey('fields', $wrappedType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, true, 0, null) ->shouldBeCalled()->willReturn(['clientMutationId' => GraphQLType::string()]); $fieldsBuilderProphecy->resolveResourceArgs([], $operation)->shouldBeCalled(); @@ -320,7 +319,7 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->assertArrayHasKey('interfaces', $wrappedType->config); $this->assertArrayHasKey('fields', $wrappedType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, false, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); @@ -344,7 +343,7 @@ public function testGetResourceObjectTypeMutationNested(): void $this->assertArrayHasKey('interfaces', $resourceObjectType->config); $this->assertArrayHasKey('fields', $resourceObjectType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, false, 1, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); @@ -425,7 +424,7 @@ public function testGetResourceObjectTypeSubscriptionWrappedType(): void $this->assertArrayHasKey('interfaces', $wrappedType->config); $this->assertArrayHasKey('fields', $wrappedType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, false, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); @@ -449,7 +448,7 @@ public function testGetResourceObjectTypeSubscriptionNested(): void $this->assertArrayHasKey('interfaces', $resourceObjectType->config); $this->assertArrayHasKey('fields', $resourceObjectType->config); - $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $operation, false, 1, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); @@ -477,7 +476,7 @@ public function testGetNodeInterface(): void $this->assertSame(GraphQLType::string(), $resolvedType); } - public function testCursorBasedGetResourcePaginatedCollectionType(): void + public function testCursorBasedGetPaginatedCollectionType(): void { /** @var Operation $operation */ $operation = (new Query())->withPaginationType('cursor'); @@ -487,7 +486,7 @@ public function testCursorBasedGetResourcePaginatedCollectionType(): void $this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled(); /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation); + $resourcePaginatedCollectionType = $this->typeBuilder->getPaginatedCollectionType(GraphQLType::string(), $operation); $this->assertSame('StringCursorConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Cursor connection for String.', $resourcePaginatedCollectionType->description); @@ -533,7 +532,7 @@ public function testCursorBasedGetResourcePaginatedCollectionType(): void $this->assertSame(GraphQLType::int(), $totalCountType->getWrappedType()); } - public function testPageBasedGetResourcePaginatedCollectionType(): void + public function testPageBasedGetPaginatedCollectionType(): void { /** @var Operation $operation */ $operation = (new Query())->withPaginationType('page'); @@ -542,7 +541,7 @@ public function testPageBasedGetResourcePaginatedCollectionType(): void $this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled(); /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation); + $resourcePaginatedCollectionType = $this->typeBuilder->getPaginatedCollectionType(GraphQLType::string(), $operation); $this->assertSame('StringPageConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Page connection for String.', $resourcePaginatedCollectionType->description); @@ -568,6 +567,35 @@ public function testPageBasedGetResourcePaginatedCollectionType(): void $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['totalCount']->getType()->getWrappedType()); } + public function testGetEnumType(): void + { + $enumClass = GamePlayMode::class; + $enumName = 'GamePlayMode'; + $enumDescription = 'GamePlayModeEnum description'; + /** @var Operation $operation */ + $operation = (new Operation()) + ->withClass($enumClass) + ->withShortName($enumName) + ->withDescription('GamePlayModeEnum description'); + + $this->typesContainerProphecy->has('GamePlayModeEnum')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('GamePlayModeEnum', Argument::type(EnumType::class))->shouldBeCalled(); + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderEnumInterface::class); + $enumValues = [ + GamePlayMode::CO_OP->name => ['value' => GamePlayMode::CO_OP->value], + GamePlayMode::MULTI_PLAYER->name => ['value' => GamePlayMode::MULTI_PLAYER->value], + GamePlayMode::SINGLE_PLAYER->name => ['value' => GamePlayMode::SINGLE_PLAYER->value, 'description' => 'Which is played by a lone player.'], + ]; + $fieldsBuilderProphecy->getEnumFields($enumClass)->willReturn($enumValues); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->willReturn($fieldsBuilderProphecy->reveal()); + + self::assertEquals(new EnumType([ + 'name' => $enumName, + 'description' => $enumDescription, + 'values' => $enumValues, + ]), $this->typeBuilder->getEnumType($operation)); + } + /** * @dataProvider typesProvider */ diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/tests/GraphQl/Type/TypeConverterTest.php index 6e7634b7900..500fdd03921 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -14,7 +14,7 @@ namespace ApiPlatform\Tests\GraphQl\Type; use ApiPlatform\Exception\ResourceClassNotFoundException; -use ApiPlatform\GraphQl\Type\TypeBuilderInterface; +use ApiPlatform\GraphQl\Type\TypeBuilderEnumInterface; use ApiPlatform\GraphQl\Type\TypeConverter; use ApiPlatform\GraphQl\Type\TypesContainerInterface; use ApiPlatform\Metadata\ApiProperty; @@ -25,7 +25,9 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use ApiPlatform\Tests\Fixtures\TestBundle\GraphQl\Type\Definition\DateTimeType; +use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\TestCase; @@ -42,13 +44,9 @@ class TypeConverterTest extends TestCase use ProphecyTrait; private ObjectProphecy $typeBuilderProphecy; - private ObjectProphecy $typesContainerProphecy; - private ObjectProphecy $resourceMetadataCollectionFactoryProphecy; - private ObjectProphecy $propertyMetadataFactoryProphecy; - private TypeConverter $typeConverter; /** @@ -56,7 +54,7 @@ class TypeConverterTest extends TestCase */ protected function setUp(): void { - $this->typeBuilderProphecy = $this->prophesize(TypeBuilderInterface::class); + $this->typeBuilderProphecy = $this->prophesize(TypeBuilderEnumInterface::class); $this->typesContainerProphecy = $this->prophesize(TypesContainerInterface::class); $this->resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $this->propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); @@ -69,6 +67,8 @@ protected function setUp(): void public function testConvertType(Type $type, bool $input, int $depth, GraphQLType|string|null $expectedGraphqlType): void { $this->typeBuilderProphecy->isCollection($type)->willReturn(false); + $this->resourceMetadataCollectionFactoryProphecy->create(Argument::type('string'))->willThrow(new ResourceClassNotFoundException()); + $this->typeBuilderProphecy->getEnumType(Argument::type(Operation::class))->willReturn($expectedGraphqlType); /** @var Operation $operation */ $operation = (new Query())->withName('test'); @@ -86,6 +86,7 @@ public function convertTypeProvider(): array [new Type(Type::BUILTIN_TYPE_ARRAY), false, 0, 'Iterable'], [new Type(Type::BUILTIN_TYPE_ITERABLE), false, 0, 'Iterable'], [new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeInterface::class), false, 0, GraphQLType::string()], + [new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class), false, 0, new EnumType(['name' => 'GenderTypeEnum'])], [new Type(Type::BUILTIN_TYPE_OBJECT), false, 0, null], [new Type(Type::BUILTIN_TYPE_CALLABLE), false, 0, null], [new Type(Type::BUILTIN_TYPE_NULL), false, 0, null], diff --git a/tests/JsonSchema/SchemaFactoryTest.php b/tests/JsonSchema/SchemaFactoryTest.php index a7f75ff6ece..415cdbb6d6e 100644 --- a/tests/JsonSchema/SchemaFactoryTest.php +++ b/tests/JsonSchema/SchemaFactoryTest.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Tests\Fixtures\NotAResource; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OverriddenOperationDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -53,15 +54,22 @@ public function testBuildSchemaForNonResourceClass(): void ), Argument::cetera())->willReturn([ 'type' => 'integer', ]); + $typeFactoryProphecy->getType(Argument::allOf( + Argument::type(Type::class), + Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) + ), Argument::cetera())->willReturn([ + 'type' => 'object', + ]); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); + $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withReadable(true)->withDefault('default_bar')->withExample('example_bar')); + $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); @@ -91,6 +99,14 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('integer', $definitions[$rootDefinitionKey]['properties']['bar']['type']); $this->assertSame('default_bar', $definitions[$rootDefinitionKey]['properties']['bar']['default']); $this->assertSame('example_bar', $definitions[$rootDefinitionKey]['properties']['bar']['example']); + + $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); + $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['default']); + $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); } public function testBuildSchemaWithSerializerGroups(): void @@ -102,6 +118,12 @@ public function testBuildSchemaWithSerializerGroups(): void ), Argument::cetera())->willReturn([ 'type' => 'string', ]); + $typeFactoryProphecy->getType(Argument::allOf( + Argument::type(Type::class), + Argument::which('getBuiltinType', Type::BUILTIN_TYPE_OBJECT) + ), Argument::cetera())->willReturn([ + 'type' => 'object', + ]); $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -119,14 +141,16 @@ public function testBuildSchemaWithSerializerGroups(): void $serializerGroup = 'custom_operation_dummy'; $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description'])); + $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)); + $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)])->withReadable(true)->withDefault(GenderTypeEnum::MALE)); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); $schemaFactory = new SchemaFactory($typeFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), null, $resourceClassResolverProphecy->reveal()); $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); @@ -147,6 +171,11 @@ public function testBuildSchemaWithSerializerGroups(): void $this->assertArrayHasKey('description', $definitions[$rootDefinitionKey]['properties']); $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['description']); $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['description']['type']); + $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); + $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); + $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); } public function testBuildSchemaForAssociativeArray(): void diff --git a/tests/JsonSchema/TypeFactoryTest.php b/tests/JsonSchema/TypeFactoryTest.php index 0b29bce5e10..996027a62f1 100644 --- a/tests/JsonSchema/TypeFactoryTest.php +++ b/tests/JsonSchema/TypeFactoryTest.php @@ -13,10 +13,13 @@ namespace ApiPlatform\Tests\JsonSchema; +use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\JsonSchema\TypeFactory; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -31,7 +34,10 @@ class TypeFactoryTest extends TestCase */ public function testGetType(array $schema, Type $type): void { - $typeFactory = new TypeFactory(); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); + $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); + $typeFactory = new TypeFactory($resourceClassResolver->reveal()); $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_OPENAPI))); } @@ -53,6 +59,10 @@ public function typeProvider(): iterable yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; yield [['nullable' => true, 'type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; + yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; + yield ['nullable enum' => ['type' => 'string', 'enum' => ['male', 'female', null], 'nullable' => true], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; + yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; + yield ['nullable enum resource' => ['type' => 'string', 'format' => 'iri-reference', 'nullable' => true], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; yield 'array can be itself nullable' => [ ['nullable' => true, 'type' => 'array', 'items' => ['type' => 'string']], @@ -152,7 +162,10 @@ public function typeProvider(): iterable */ public function testGetTypeWithJsonSchemaSyntax(array $schema, Type $type): void { - $typeFactory = new TypeFactory(); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); + $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); + $typeFactory = new TypeFactory($resourceClassResolver->reveal()); $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_JSON_SCHEMA))); } @@ -174,6 +187,10 @@ public function jsonSchemaTypeProvider(): iterable yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; yield [['type' => ['string', 'null'], 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; + yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; + yield ['nullable enum' => ['type' => ['string', 'null'], 'enum' => ['male', 'female', null]], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; + yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; + yield ['nullable enum resource' => ['type' => ['string', 'null'], 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; yield 'array can be itself nullable' => [ ['type' => ['array', 'null'], 'items' => ['type' => 'string']], @@ -266,7 +283,10 @@ public function jsonSchemaTypeProvider(): iterable /** @dataProvider openAPIV2TypeProvider */ public function testGetTypeWithOpenAPIV2Syntax(array $schema, Type $type): void { - $typeFactory = new TypeFactory(); + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(GenderTypeEnum::class)->willReturn(false); + $resourceClassResolver->isResourceClass(Argument::type('string'))->willReturn(true); + $typeFactory = new TypeFactory($resourceClassResolver->reveal()); $this->assertEquals($schema, $typeFactory->getType($type, 'json', null, null, new Schema(Schema::VERSION_SWAGGER))); } @@ -288,6 +308,10 @@ public function openAPIV2TypeProvider(): iterable yield [['type' => 'string', 'format' => 'binary'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \SplFileInfo::class)]; yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)]; yield [['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)]; + yield ['enum' => ['type' => 'string', 'enum' => ['male', 'female']], new Type(Type::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]; + yield ['nullable enum' => ['type' => 'string', 'enum' => ['male', 'female', null]], new Type(Type::BUILTIN_TYPE_OBJECT, true, GenderTypeEnum::class)]; + yield ['enum resource' => ['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, false, GamePlayMode::class)]; + yield ['nullable enum resource' => ['type' => 'string', 'format' => 'iri-reference'], new Type(Type::BUILTIN_TYPE_OBJECT, true, GamePlayMode::class)]; yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)]; yield 'array can be itself nullable, but ignored in OpenAPI V2' => [ ['type' => 'array', 'items' => ['type' => 'string']], diff --git a/tests/Metadata/Property/Factory/AttributePropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/AttributePropertyMetadataFactoryTest.php index 35ad053b952..21c38321e36 100644 --- a/tests/Metadata/Property/Factory/AttributePropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/AttributePropertyMetadataFactoryTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Property\Factory\AttributePropertyMetadataFactory; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPhp8ApiPropertyAttribute; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -38,6 +39,9 @@ public function testCreateAttribute(): void $metadata = $factory->create(DummyPhp8ApiPropertyAttribute::class, 'foo'); $this->assertSame('a foo', $metadata->getDescription()); + + $metadata = $factory->create(GenderTypeEnum::class, 'FEMALE'); + $this->assertSame('The female gender.', $metadata->getDescription()); } public function testClassNotFound(): void diff --git a/tests/Serializer/Filter/GroupFilterTest.php b/tests/Serializer/Filter/GroupFilterTest.php index 12f7238a987..168ffec3319 100644 --- a/tests/Serializer/Filter/GroupFilterTest.php +++ b/tests/Serializer/Filter/GroupFilterTest.php @@ -125,4 +125,26 @@ public function testGetDescription(): void $this->assertSame($expectedDescription, $groupFilter->getDescription(DummyGroup::class)); } + + public function testGetDescriptionWithWhitelist(): void + { + $groupFilter = new GroupFilter('custom_groups', false, ['default_group', 'another_default_group']); + $expectedDescription = [ + 'custom_groups[]' => [ + 'property' => null, + 'type' => 'string', + 'is_collection' => true, + 'required' => false, + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['default_group', 'another_default_group'], + ], + ], + ], + ]; + + $this->assertSame($expectedDescription, $groupFilter->getDescription(DummyGroup::class)); + } } diff --git a/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 9a0fb828f8f..c9af7f056eb 100644 --- a/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -73,11 +73,8 @@ public function testNoResourceClass(): void ); $this->assertEquals([], $dataCollector->getRequestAttributes()); - $this->assertEquals([], $dataCollector->getFilters()); - $this->assertEquals(['ignored_filters' => 0], $dataCollector->getCounters()); $this->assertEquals(['foo', 'bar'], $dataCollector->getAcceptableContentTypes()); - $this->assertNull($dataCollector->getResourceClass()); - $this->assertEmpty($dataCollector->getResourceMetadataCollection()->getValue()); + $this->assertEquals([], $dataCollector->getResources()); } public function testNotCallingCollect(): void @@ -92,10 +89,7 @@ public function testNotCallingCollect(): void $this->assertEquals([], $dataCollector->getRequestAttributes()); $this->assertEquals([], $dataCollector->getAcceptableContentTypes()); - $this->assertEquals([], $dataCollector->getFilters()); - $this->assertEquals([], $dataCollector->getCounters()); - $this->assertNull($dataCollector->getResourceClass()); - $this->assertNull($dataCollector->getResourceMetadataCollection()); + $this->assertEquals([], $dataCollector->getResources()); } public function testWithResource(): void @@ -126,10 +120,12 @@ public function testWithResource(): void 'persist' => true, ], $dataCollector->getRequestAttributes()); $this->assertEquals(['foo', 'bar'], $dataCollector->getAcceptableContentTypes()); - $this->assertSame(DummyEntity::class, $dataCollector->getResourceClass()); - $this->assertEquals([['foo' => null, 'a_filter' => \stdClass::class]], $dataCollector->getFilters()); - $this->assertEquals(['ignored_filters' => 1], $dataCollector->getCounters()); - $this->assertInstanceOf(Data::class, $dataCollector->getResourceMetadataCollection()); + + $resource = $dataCollector->getResources()[0]; + $this->assertSame(DummyEntity::class, $resource->getResourceClass()); + $this->assertEquals([['foo' => null, 'a_filter' => \stdClass::class]], $resource->getFilters()); + $this->assertEquals(['ignored_filters' => 1], $resource->getCounters()); + $this->assertInstanceOf(Data::class, $resource->getResourceMetadataCollection()); } public function testWithResourceWithTraceables(): void @@ -199,7 +195,8 @@ public function testWithPreviousData(): void private function apiResourceClassWillReturn(?string $data, array $context = []): void { $this->attributes->get('_api_resource_class')->shouldBeCalled()->willReturn($data); - $this->attributes->all()->shouldBeCalled()->willReturn([ + $this->attributes->get('_graphql', false)->shouldBeCalled()->willReturn(false); + $this->attributes->all()->willReturn([ '_api_resource_class' => $data, ] + $context); $this->request->attributes = $this->attributes->reveal(); diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 452b80d2f8f..05e02befe07 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -66,6 +66,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\ORM\OptimisticLockException; use phpDocumentor\Reflection\DocBlockFactoryInterface; +use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; @@ -427,8 +428,8 @@ public function testMetadataConfiguration(): void public function testMetadataConfigurationDocBlockFactoryInterface(): void { - if (!interface_exists(DocBlockFactoryInterface::class)) { - $this->markTestSkipped('class phpDocumentor\Reflection\DocBlockFactoryInterface does not exist'); + if (!class_exists(PhpDocParser::class) || !interface_exists(DocBlockFactoryInterface::class)) { + $this->markTestSkipped('class PHPStan\PhpDocParser\Parser\PhpDocParser or phpDocumentor\Reflection\DocBlockFactoryInterface does not exist'); } $config = self::DEFAULT_CONFIG; @@ -628,6 +629,7 @@ public function testGraphQlConfiguration(): void { $config = self::DEFAULT_CONFIG; $config['api_platform']['graphql']['enabled'] = true; + $this->container->setParameter('kernel.debug', true); (new ApiPlatformExtension())->load($config, $this->container); $services = [ @@ -673,6 +675,10 @@ public function testGraphQlConfiguration(): void 'api_platform.graphql.normalizer.validation_exception', 'api_platform.graphql.normalizer.http_exception', 'api_platform.graphql.normalizer.runtime_exception', + 'api_platform.graphql.data_collector.resolver.factory.collection', + 'api_platform.graphql.data_collector.resolver.factory.item', + 'api_platform.graphql.data_collector.resolver.factory.item_mutation', + 'api_platform.graphql.data_collector.resolver.factory.item_subscription', ]; $aliases = [ diff --git a/tests/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactoryTest.php b/tests/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactoryTest.php new file mode 100644 index 00000000000..433ab3fd9a1 --- /dev/null +++ b/tests/Symfony/GraphQl/Resolver/Factory/DataCollectorResolverFactoryTest.php @@ -0,0 +1,52 @@ + + * + * 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\Symfony\GraphQl\Resolver\Factory; + +use ApiPlatform\GraphQl\Resolver\Factory\ResolverFactoryInterface; +use ApiPlatform\Symfony\GraphQl\Resolver\Factory\DataCollectorResolverFactory; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; + +class DataCollectorResolverFactoryTest extends TestCase +{ + use ProphecyTrait; + + private ObjectProphecy $requestStack; + private ObjectProphecy $resolverFactory; + private DataCollectorResolverFactory $dataCollectorResolverFactory; + + protected function setUp(): void + { + $this->requestStack = $this->prophesize(RequestStack::class); + $this->resolverFactory = $this->prophesize(ResolverFactoryInterface::class); + $this->dataCollectorResolverFactory = new DataCollectorResolverFactory($this->resolverFactory->reveal(), $this->requestStack->reveal()); + } + + public function testDataCollectorAddDataInsideRequestAttribute(): void + { + $request = new Request(); + $this->requestStack->getCurrentRequest()->willReturn($request); + $this->resolverFactory->__invoke(Dummy::class, null, null)->willReturn(static fn (?array $source, array $args, $context, ResolveInfo $info): array => $args); + + $result = $this->dataCollectorResolverFactory->__invoke(Dummy::class)(null, ['bar'], [], $this->prophesize(ResolveInfo::class)->reveal()); + + $this->assertEquals(['bar'], $result); + $this->assertEquals([Dummy::class => ['bar']], $request->attributes->get('_graphql_args')); + } +}