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/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php index 52cf08f21aa..5f0113b5fab 100644 --- a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php +++ b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php @@ -82,7 +82,7 @@ public function create(string $resourceClass): ResourceMetadataCollection private function hasIndices(Operation $operation): bool { - if (false === $operation->getElasticsearch() || null === $operation->getElasticsearch()) { + if (false !== $operation->getElasticsearch()) { return false; } 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/ApiResource.php b/src/Metadata/ApiResource.php index 2ebb726e9d7..e1cee2f8ec7 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; /** * Resource metadata attribute. @@ -52,6 +53,7 @@ class ApiResource * @param array|null $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups * @param string[]|null $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra * @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param bool|OpenApiOperation|null $openapi https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts * @param array|null $validationContext https://api-platform.com/docs/core/validation/#using-validation-groups * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters * @param bool|null $elasticsearch https://api-platform.com/docs/core/elasticsearch/ @@ -110,7 +112,8 @@ public function __construct( protected ?array $normalizationContext = null, protected ?array $denormalizationContext = null, protected ?array $hydraContext = null, - protected ?array $openapiContext = null, + protected ?array $openapiContext = null, // TODO Remove in 4.0 + protected bool|OpenApiOperation|null $openapi = null, protected ?array $validationContext = null, protected ?array $filters = null, protected ?bool $elasticsearch = null, @@ -548,11 +551,21 @@ public function withHydraContext(array $hydraContext): self return $self; } + /** + * TODO Remove in 4.0. + * + * @deprecated + */ public function getOpenapiContext(): ?array { return $this->openapiContext; } + /** + * TODO Remove in 4.0. + * + * @deprecated + */ public function withOpenapiContext(array $openapiContext): self { $self = clone $this; @@ -561,6 +574,19 @@ public function withOpenapiContext(array $openapiContext): self return $self; } + public function getOpenapi(): bool|OpenApiOperation|null + { + return $this->openapi; + } + + public function withOpenapi(bool|OpenApiOperation $openapi): self + { + $self = clone $this; + $self->openapi = $openapi; + + return $self; + } + public function getValidationContext(): ?array { return $this->validationContext; diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index ec5a91f48b4..940b70aa2fb 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Delete extends HttpOperation implements DeleteOperationInterface { @@ -43,7 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index d229115fa6c..8cb9b2e7c05 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -21,6 +21,10 @@ use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\OpenApi\Model\Parameter; +use ApiPlatform\OpenApi\Model\RequestBody; use Symfony\Component\Config\Util\XmlUtils; /** @@ -87,7 +91,8 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array 'schemes' => $this->buildArrayValue($resource, 'scheme'), 'cacheHeaders' => $this->buildCacheHeaders($resource), 'hydraContext' => isset($resource->hydraContext->values) ? $this->buildValues($resource->hydraContext->values) : null, - 'openapiContext' => isset($resource->openapiContext->values) ? $this->buildValues($resource->openapiContext->values) : null, + 'openapiContext' => isset($resource->openapiContext->values) ? $this->buildValues($resource->openapiContext->values) : null, // TODO Remove in 4.0 + 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildPaginationViaCursor($resource), 'exceptionToStatus' => $this->buildExceptionToStatus($resource), 'queryParameterValidationEnabled' => $this->phpize($resource, 'queryParameterValidationEnabled', 'bool'), @@ -156,6 +161,91 @@ private function buildFormats(\SimpleXMLElement $resource, string $key): ?array return $data; } + private function buildOpenapi(\SimpleXMLElement $resource): bool|OpenApiOperation|null + { + if (!isset($resource->openapi) && !isset($resource['openapi'])) { + return null; + } + + if (isset($resource['openapi']) && (\is_bool($resource['openapi']) || \in_array((string) $resource['openapi'], ['1', '0', 'true', 'false'], true))) { + return $this->phpize($resource, 'openapi', 'bool'); + } + + $openapi = $resource->openapi; + $data = []; + $attributes = $openapi->attributes(); + foreach ($attributes as $attribute) { + $data[$attribute->getName()] = $this->phpize($attributes, 'deprecated', 'deprecated' === $attribute->getName() ? 'bool' : 'string'); + } + + $data['tags'] = $this->buildArrayValue($resource, 'tag'); + + if (isset($openapi->responses->response)) { + foreach ($openapi->responses->response as $response) { + $data['responses'][(string) $response->attributes()->status] = [ + 'description' => $this->phpize($response, 'description', 'string'), + 'content' => isset($response->content->values) ? $this->buildValues($response->content->values) : null, + 'headers' => isset($response->headers->values) ? $this->buildValues($response->headers->values) : null, + 'links' => isset($response->links->values) ? $this->buildValues($response->links->values) : null, + ]; + } + } + + $data['externalDocs'] = isset($openapi->externalDocs) ? new ExternalDocumentation( + description: $this->phpize($resource, 'description', 'string'), + url: $this->phpize($resource, 'url', 'string'), + ) : null; + + if (isset($openapi->parameters->parameter)) { + foreach ($openapi->parameters->parameter as $parameter) { + $data['parameters'][(string) $parameter->attributes()->name] = new Parameter( + name: $this->phpize($parameter, 'name', 'string'), + in: $this->phpize($parameter, 'in', 'string'), + description: $this->phpize($parameter, 'description', 'string'), + required: $this->phpize($parameter, 'required', 'bool'), + deprecated: $this->phpize($parameter, 'deprecated', 'bool'), + allowEmptyValue: $this->phpize($parameter, 'allowEmptyValue', 'bool'), + schema: isset($parameter->schema->values) ? $this->buildValues($parameter->schema->values) : null, + style: $this->phpize($parameter, 'style', 'string'), + explode: $this->phpize($parameter, 'explode', 'bool'), + allowReserved: $this->phpize($parameter, 'allowReserved', 'bool'), + example: $this->phpize($parameter, 'example', 'string'), + examples: isset($parameter->examples->values) ? new \ArrayObject($this->buildValues($parameter->examples->values)) : null, + content: isset($parameter->content->values) ? new \ArrayObject($this->buildValues($parameter->content->values)) : null, + ); + } + } + $data['requestBody'] = isset($openapi->requestBody) ? new RequestBody( + description: $this->phpize($openapi->requestBody, 'description', 'string'), + content: isset($openapi->requestBody->content->values) ? new \ArrayObject($this->buildValues($openapi->requestBody->values)) : null, + required: $this->phpize($openapi->requestBody, 'required', 'bool'), + ) : null; + + $data['callbacks'] = isset($openapi->callbacks->values) ? new \ArrayObject($this->buildValues($openapi->callbacks->values)) : null; + + $data['security'] = isset($openapi->security->values) ? $this->buildValues($openapi->security->values) : null; + + if (isset($openapi->servers->server)) { + foreach ($openapi->servers->server as $server) { + $data['servers'][] = [ + 'description' => $this->phpize($server, 'description', 'string'), + 'url' => $this->phpize($server, 'url', 'string'), + 'variables' => isset($server->variables->values) ? $this->buildValues($server->variables->values) : null, + ]; + } + } + + $data['extensionProperties'] = isset($openapi->extensionProperties->values) ? $this->buildValues($openapi->extensionProperties->values) : null; + + foreach ($data as $key => $value) { + if (null === $value) { + unset($data[$key]); + } + } + + return new OpenApiOperation(...$data); + } + private function buildUriVariables(\SimpleXMLElement $resource): ?array { if (!isset($resource->uriVariables->uriVariable)) { @@ -300,7 +390,6 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr } $data[] = array_merge($datum, [ - 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'collection' => $this->phpize($operation, 'collection', 'bool'), 'class' => (string) $operation['class'], 'method' => $this->phpize($operation, 'method', 'string'), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 665eb504bb8..f3fc2279338 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -21,6 +21,9 @@ use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\OpenApi\Model\RequestBody; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -106,7 +109,8 @@ private function buildExtendedBase(array $resource): array 'types' => $this->buildArrayValue($resource, 'types'), 'cacheHeaders' => $this->buildArrayValue($resource, 'cacheHeaders'), 'hydraContext' => $this->buildArrayValue($resource, 'hydraContext'), - 'openapiContext' => $this->buildArrayValue($resource, 'openapiContext'), + 'openapiContext' => $this->buildArrayValue($resource, 'openapiContext'), // TODO Remove in 4.0 + 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildArrayValue($resource, 'paginationViaCursor'), 'exceptionToStatus' => $this->buildArrayValue($resource, 'exceptionToStatus'), 'defaults' => $this->buildArrayValue($resource, 'defaults'), @@ -205,6 +209,36 @@ private function buildUriVariables(array $resource): ?array return $uriVariables; } + private function buildOpenapi(array $resource): bool|OpenApiOperation|null + { + if (!\array_key_exists('openapi', $resource)) { + return null; + } + + if (!\is_array($resource['openapi'])) { + return $this->phpize($resource, 'openapi', 'bool'); + } + + $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(OpenApiOperation::class))->getProperties()); + foreach ($resource['openapi'] as $key => $value) { + $resource['openapi'][$key] = match ($key) { + 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), + 'requestBody' => new RequestBody(description: $value['description'] ?? '', content: isset($value['content']) ? new \ArrayObject($value['content'] ?? []) : null, required: $value['required'] ?? false), + 'callbacks' => new \ArrayObject($value ?? []), + default => $value, + }; + + if (\in_array($key, $allowedProperties, true)) { + continue; + } + + $resource['openapi']['extensionProperties'][$key] = $value; + unset($resource['openapi'][$key]); + } + + return new OpenApiOperation(...$resource['openapi']); + } + /** * @return bool|string|string[]|null */ @@ -271,7 +305,6 @@ private function buildOperations(array $resource, array $root): ?array $data[] = array_merge($datum, [ 'read' => $this->phpize($operation, 'read', 'bool'), 'deserialize' => $this->phpize($operation, 'deserialize', 'bool'), - 'openapi' => $this->phpize($operation, 'openapi', 'bool'), 'validate' => $this->phpize($operation, 'validate', 'bool'), 'write' => $this->phpize($operation, 'write', 'bool'), 'serialize' => $this->phpize($operation, 'serialize', 'bool'), diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 3aa198190a0..2ccbce91581 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -280,6 +280,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -313,7 +404,8 @@ - + + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index d515e8e57b3..d1407288df9 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Get extends HttpOperation { @@ -43,7 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index b35d1ed4004..433ebfdcaf7 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class GetCollection extends HttpOperation implements CollectionOperationInterface { @@ -45,7 +47,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 888e72404e9..c608bf3337f 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + class HttpOperation extends Operation { public const METHOD_GET = 'GET'; @@ -38,6 +40,7 @@ class HttpOperation extends Operation * @param array|null $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra * @param array|null $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts + * @param bool|OpenApiOperation|null $openapi https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts * @param string[]|null $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters * @param bool|null $elasticsearch https://api-platform.com/docs/core/elasticsearch/ * @param mixed|null $mercure https://api-platform.com/docs/core/mercure @@ -93,8 +96,8 @@ public function __construct( protected ?array $cacheHeaders = null, protected ?array $hydraContext = null, - protected ?array $openapiContext = null, - protected ?bool $openapi = null, + protected ?array $openapiContext = null, // TODO Remove in 4.0 + protected bool|OpenApiOperation|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?bool $queryParameterValidationEnabled = null, @@ -498,11 +501,21 @@ public function withHydraContext(array $hydraContext): self return $self; } + /** + * TODO Remove in 4.0. + * + * @deprecated + */ public function getOpenapiContext(): ?array { return $this->openapiContext; } + /** + * TODO Remove in 4.0. + * + * @deprecated + */ public function withOpenapiContext(array $openapiContext): self { $self = clone $this; @@ -511,12 +524,12 @@ public function withOpenapiContext(array $openapiContext): self return $self; } - public function getOpenapi(): ?bool + public function getOpenapi(): bool|OpenApiOperation|null { return $this->openapi; } - public function withOpenapi(bool $openapi): self + public function withOpenapi(bool|OpenApiOperation $openapi): self { $self = clone $this; $self->openapi = $openapi; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 980359990ab..0806a29946c 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + /** * A NotExposed operation is an operation declared for internal usage, * for example to generate an IRI on a resource without item operations. @@ -51,7 +53,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = false, + bool|OpenApiOperation|null $openapi = false, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index f78335b3727..bb95825f7b3 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Patch extends HttpOperation { @@ -43,7 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 3a920671f61..b481a39b346 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Post extends HttpOperation { @@ -45,7 +47,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, 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/Put.php b/src/Metadata/Put.php index 94063c42995..0ba3cd81223 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class Put extends HttpOperation { @@ -43,7 +45,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - ?bool $openapi = null, + bool|OpenApiOperation|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, 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/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 70bda3d3131..29711bd29e3 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -130,12 +130,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + $openapiOperation = $operation->getOpenapi(); + // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + if ($operation instanceof HttpOperation && false === $openapiOperation) { continue; } - $uriVariables = $operation->getUriVariables(); $resourceClass = $operation->getClass() ?? $resource->getClass(); $routeName = $operation->getRouteName() ?? $operation->getName(); @@ -156,9 +157,70 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + if (!\is_object($openapiOperation)) { + $openapiOperation = new Model\Operation(); + } + + // Complete with defaults + $openapiOperation = new Model\Operation( + operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName), + tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName], + responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [], + summary: null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), + description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), + externalDocs: $openapiOperation->getExternalDocs(), + parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], + requestBody: $openapiOperation->getRequestBody(), + callbacks: $openapiOperation->getCallbacks(), + deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(), + security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null, + servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null, + extensionProperties: $openapiOperation->getExtensionProperties(), + ); + [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); - $operationId = $operation->getOpenapiContext()['operationId'] ?? $this->normalizeOperationName($operationName); + // TODO Remove in 4.0 + foreach (['operationId', 'tags', 'summary', 'description', 'security', 'servers'] as $key) { + if (null !== ($operation->getOpenapiContext()[$key] ?? null)) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($operation->getOpenapiContext()[$key]); + } + } + + // TODO Remove in 4.0 + if (null !== ($operation->getOpenapiContext()['externalDocs'] ?? null)) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $openapiOperation = $openapiOperation->withExternalDocs(new ExternalDocumentation($operation->getOpenapiContext()['externalDocs']['description'] ?? null, $operation->getOpenapiContext()['externalDocs']['url'])); + } + + // TODO Remove in 4.0 + if (null !== ($operation->getOpenapiContext()['callbacks'] ?? null)) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $openapiOperation = $openapiOperation->withCallbacks(new \ArrayObject($operation->getOpenapiContext()['callbacks'])); + } + + // TODO Remove in 4.0 + if (null !== ($operation->getOpenapiContext()['deprecated'] ?? null)) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $openapiOperation = $openapiOperation->withDeprecated((bool) $operation->getOpenapiContext()['deprecated']); + } if ($path) { $pathItem = $paths->getPath($path) ?: new PathItem(); @@ -178,36 +240,41 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); } - $parameters = []; - $responses = []; - + // TODO Remove in 4.0 if ($operation->getOpenapiContext()['parameters'] ?? false) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $parameters = []; foreach ($operation->getOpenapiContext()['parameters'] as $parameter) { $parameters[] = new Parameter($parameter['name'], $parameter['in'], $parameter['description'] ?? '', $parameter['required'] ?? false, $parameter['deprecated'] ?? false, $parameter['allowEmptyValue'] ?? false, $parameter['schema'] ?? [], $parameter['style'] ?? null, $parameter['explode'] ?? false, $parameter['allowReserved '] ?? false, $parameter['example'] ?? null, isset($parameter['examples']) ? new \ArrayObject($parameter['examples']) : null, isset($parameter['content']) ? new \ArrayObject($parameter['content']) : null); } + $openapiOperation = $openapiOperation->withParameters($parameters); } // Set up parameters - foreach ($uriVariables ?? [] as $parameterName => $uriVariable) { + foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { if ($uriVariable->getExpandedValue() ?? false) { continue; } $parameter = new Parameter($parameterName, 'path', (new \ReflectionClass($uriVariable->getFromClass()))->getShortName().' identifier', true, false, false, ['type' => 'string']); - if ($this->hasParameter($parameter, $parameters)) { + if ($this->hasParameter($openapiOperation, $parameter)) { continue; } - $parameters[] = $parameter; + $openapiOperation = $openapiOperation->withParameter($parameter); } if ($operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $method) { foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { - if ($this->hasParameter($parameter, $parameters)) { + if ($this->hasParameter($openapiOperation, $parameter)) { continue; } - $parameters[] = $parameter; + $openapiOperation = $openapiOperation->withParameter($parameter); } } @@ -216,48 +283,59 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection case HttpOperation::METHOD_GET: $successStatus = (string) $operation->getStatus() ?: 200; $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); - $responses[$successStatus] = new Response(sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $responseContent); + $openapiOperation = $openapiOperation->withResponse($successStatus, new Response(sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $responseContent)); break; case HttpOperation::METHOD_POST: $responseLinks = $this->getLinks($resourceMetadataCollection, $operation); $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); $successStatus = (string) $operation->getStatus() ?: 201; - $responses[$successStatus] = new Response(sprintf('%s resource created', $resourceShortName), $responseContent, null, $responseLinks); - $responses['400'] = new Response('Invalid input'); - $responses['422'] = new Response('Unprocessable entity'); + $openapiOperation = $openapiOperation->withResponse($successStatus, new Response(sprintf('%s resource created', $resourceShortName), $responseContent, null, $responseLinks)); + $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); + $openapiOperation = $openapiOperation->withResponse(422, new Response('Unprocessable entity')); break; case HttpOperation::METHOD_PATCH: case HttpOperation::METHOD_PUT: $responseLinks = $this->getLinks($resourceMetadataCollection, $operation); $successStatus = (string) $operation->getStatus() ?: 200; $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); - $responses[$successStatus] = new Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks); - $responses['400'] = new Response('Invalid input'); - $responses['422'] = new Response('Unprocessable entity'); + $openapiOperation = $openapiOperation->withResponse($successStatus, new Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks)); + $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); + $openapiOperation = $openapiOperation->withResponse(422, new Response('Unprocessable entity')); break; case HttpOperation::METHOD_DELETE: $successStatus = (string) $operation->getStatus() ?: 204; - $responses[$successStatus] = new Response(sprintf('%s resource deleted', $resourceShortName)); + $openapiOperation = $openapiOperation->withResponse($successStatus, new Response(sprintf('%s resource deleted', $resourceShortName))); break; } if (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) { - $responses['404'] = new Response('Resource not found'); + $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); } - if (!$responses) { - $responses['default'] = new Response('Unexpected error'); + if (!$openapiOperation->getResponses()) { + $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error')); } if ($contextResponses = $operation->getOpenapiContext()['responses'] ?? false) { + // TODO Remove this "elseif" in 4.0 + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); foreach ($contextResponses as $statusCode => $contextResponse) { - $responses[$statusCode] = new Response($contextResponse['description'] ?? '', isset($contextResponse['content']) ? new \ArrayObject($contextResponse['content']) : null, isset($contextResponse['headers']) ? new \ArrayObject($contextResponse['headers']) : null, isset($contextResponse['links']) ? new \ArrayObject($contextResponse['links']) : null); + $openapiOperation = $openapiOperation->withResponse($statusCode, new Response($contextResponse['description'] ?? '', isset($contextResponse['content']) ? new \ArrayObject($contextResponse['content']) : null, isset($contextResponse['headers']) ? new \ArrayObject($contextResponse['headers']) : null, isset($contextResponse['links']) ? new \ArrayObject($contextResponse['links']) : null)); } } - $requestBody = null; if ($contextRequestBody = $operation->getOpenapiContext()['requestBody'] ?? false) { - $requestBody = new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false); + // TODO Remove this "elseif" in 4.0 + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); } elseif (\in_array($method, [HttpOperation::METHOD_PATCH, HttpOperation::METHOD_PUT, HttpOperation::METHOD_POST], true)) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { @@ -266,26 +344,35 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } - $requestBody = new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true); + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + } + + // TODO Remove in 4.0 + if (null !== $operation->getOpenapiContext() && \count($operation->getOpenapiContext())) { + trigger_deprecation( + 'api-platform/core', + '3.1', + 'The "openapiContext" option is deprecated, use "openapi" instead.' + ); + $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Model\Operation::class))->getProperties()); + foreach ($operation->getOpenapiContext() as $key => $value) { + $value = match ($key) { + 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), + 'requestBody' => new RequestBody(description: $value['description'] ?? '', content: isset($value['content']) ? new \ArrayObject($value['content'] ?? []) : null, required: $value['required'] ?? false), + 'callbacks' => new \ArrayObject($value ?? []), + default => $value, + }; + + if (\in_array($key, $allowedProperties, true)) { + $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($value); + continue; + } + + $openapiOperation = $openapiOperation->withExtensionProperty((string) $key, $value); + } } - $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( - $operationId, - $operation->getOpenapiContext()['tags'] ?? [$operation->getShortName() ?: $resourceShortName], - $responses, - $operation->getOpenapiContext()['summary'] ?? $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), - $operation->getOpenapiContext()['description'] ?? $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), - isset($operation->getOpenapiContext()['externalDocs']) ? new ExternalDocumentation($operation->getOpenapiContext()['externalDocs']['description'] ?? null, $operation->getOpenapiContext()['externalDocs']['url']) : null, - $parameters, - $requestBody, - isset($operation->getOpenapiContext()['callbacks']) ? new \ArrayObject($operation->getOpenapiContext()['callbacks']) : null, - $operation->getOpenapiContext()['deprecated'] ?? (bool) $operation->getDeprecationReason(), - $operation->getOpenapiContext()['security'] ?? null, - $operation->getOpenapiContext()['servers'] ?? null, - array_filter($operation->getOpenapiContext() ?? [], static fn ($item): int|false => preg_match('/^x-.*$/i', (string) $item), \ARRAY_FILTER_USE_KEY) - )); - - $paths->addPath($path, $pathItem); + $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); } } @@ -560,12 +647,9 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de } } - /** - * @param Model\Parameter[] $parameters - */ - private function hasParameter(Parameter $parameter, array $parameters): bool + private function hasParameter(Model\Operation $operation, Parameter $parameter): bool { - foreach ($parameters as $existingParameter) { + foreach ($operation->getParameters() as $existingParameter) { if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { return true; } diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index e25fb4037ef..db2aac878be 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -17,7 +17,7 @@ final class Operation { use ExtensionTrait; - public function __construct(private ?string $operationId = null, private array $tags = [], private array $responses = [], private string $summary = '', private string $description = '', private ?ExternalDocumentation $externalDocs = null, private array $parameters = [], private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private bool $deprecated = false, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) + public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) { $this->extensionProperties = $extensionProperties; } @@ -29,27 +29,27 @@ public function addResponse(Response $response, $status = 'default'): self return $this; } - public function getOperationId(): string + public function getOperationId(): ?string { return $this->operationId; } - public function getTags(): array + public function getTags(): ?array { return $this->tags; } - public function getResponses(): array + public function getResponses(): ?array { return $this->responses; } - public function getSummary(): string + public function getSummary(): ?string { return $this->summary; } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } @@ -59,7 +59,7 @@ public function getExternalDocs(): ?ExternalDocumentation return $this->externalDocs; } - public function getParameters(): array + public function getParameters(): ?array { return $this->parameters; } @@ -74,7 +74,7 @@ public function getCallbacks(): ?\ArrayObject return $this->callbacks; } - public function getDeprecated(): bool + public function getDeprecated(): ?bool { return $this->deprecated; } @@ -113,6 +113,17 @@ public function withResponses(array $responses): self return $clone; } + public function withResponse(int|string $status, Response $response): self + { + $clone = clone $this; + if (!\is_array($clone->responses)) { + $clone->responses = []; + } + $clone->responses[(string) $status] = $response; + + return $clone; + } + public function withSummary(string $summary): self { $clone = clone $this; @@ -145,6 +156,17 @@ public function withParameters(array $parameters): self return $clone; } + public function withParameter(Parameter $parameter): self + { + $clone = clone $this; + if (!\is_array($clone->parameters)) { + $clone->parameters = []; + } + $clone->parameters[] = $parameter; + + return $clone; + } + public function withRequestBody(?RequestBody $requestBody = null): self { $clone = clone $this; 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/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/public/graphql-playground-style.css b/src/Symfony/Bundle/Resources/public/graphql-playground-style.css new file mode 100644 index 00000000000..fd0d03bcac9 --- /dev/null +++ b/src/Symfony/Bundle/Resources/public/graphql-playground-style.css @@ -0,0 +1,468 @@ +html { + font-family: "Open Sans", sans-serif; + overflow: hidden; +} + +body { + margin: 0; + background: #172a3a; +} + +.playgroundIn { + -webkit-animation: playgroundIn 0.5s ease-out forwards; + animation: playgroundIn 0.5s ease-out forwards; +} + +@-webkit-keyframes playgroundIn { + from { + opacity: 0; + -webkit-transform: translateY(10px); + -ms-transform: translateY(10px); + transform: translateY(10px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@keyframes playgroundIn { + from { + opacity: 0; + -webkit-transform: translateY(10px); + -ms-transform: translateY(10px); + transform: translateY(10px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +.fadeOut { + -webkit-animation: fadeOut 0.5s ease-out forwards; + animation: fadeOut 0.5s ease-out forwards; +} + +@-webkit-keyframes fadeIn { + from { + opacity: 0; + -webkit-transform: translateY(-10px); + -ms-transform: translateY(-10px); + transform: translateY(-10px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + -webkit-transform: translateY(-10px); + -ms-transform: translateY(-10px); + transform: translateY(-10px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@-webkit-keyframes fadeOut { + from { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } + to { + opacity: 0; + -webkit-transform: translateY(-10px); + -ms-transform: translateY(-10px); + transform: translateY(-10px); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } + to { + opacity: 0; + -webkit-transform: translateY(-10px); + -ms-transform: translateY(-10px); + transform: translateY(-10px); + } +} + +@-webkit-keyframes appearIn { + from { + opacity: 0; + -webkit-transform: translateY(0px); + -ms-transform: translateY(0px); + transform: translateY(0px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@keyframes appearIn { + from { + opacity: 0; + -webkit-transform: translateY(0px); + -ms-transform: translateY(0px); + transform: translateY(0px); + } + to { + opacity: 1; + -webkit-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); + } +} + +@-webkit-keyframes scaleIn { + from { + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + } + to { + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + } +} + +@keyframes scaleIn { + from { + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + } + to { + -webkit-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + } +} + +@-webkit-keyframes innerDrawIn { + 0% { + stroke-dashoffset: 70; + } + 50% { + stroke-dashoffset: 140; + } + 100% { + stroke-dashoffset: 210; + } +} + +@keyframes innerDrawIn { + 0% { + stroke-dashoffset: 70; + } + 50% { + stroke-dashoffset: 140; + } + 100% { + stroke-dashoffset: 210; + } +} + +@-webkit-keyframes outerDrawIn { + 0% { + stroke-dashoffset: 76; + } + 100% { + stroke-dashoffset: 152; + } +} + +@keyframes outerDrawIn { + 0% { + stroke-dashoffset: 76; + } + 100% { + stroke-dashoffset: 152; + } +} + +.hHWjkv { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.2222222222222222s; + animation: scaleIn 0.25s linear forwards 0.2222222222222222s; +} + +.gCDOzd { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.4222222222222222s; + animation: scaleIn 0.25s linear forwards 0.4222222222222222s; +} + +.hmCcxi { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.6222222222222222s; + animation: scaleIn 0.25s linear forwards 0.6222222222222222s; +} + +.eHamQi { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.8222222222222223s; + animation: scaleIn 0.25s linear forwards 0.8222222222222223s; +} + +.byhgGu { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 1.0222222222222221s; + animation: scaleIn 0.25s linear forwards 1.0222222222222221s; +} + +.llAKP { + -webkit-transform-origin: 0px 0px; + -ms-transform-origin: 0px 0px; + transform-origin: 0px 0px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 1.2222222222222223s; + animation: scaleIn 0.25s linear forwards 1.2222222222222223s; +} + +.bglIGM { + -webkit-transform-origin: 64px 28px; + -ms-transform-origin: 64px 28px; + transform-origin: 64px 28px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.2222222222222222s; + animation: scaleIn 0.25s linear forwards 0.2222222222222222s; +} + +.ksxRII { + -webkit-transform-origin: 95.98500061035156px 46.510000228881836px; + -ms-transform-origin: 95.98500061035156px 46.510000228881836px; + transform-origin: 95.98500061035156px 46.510000228881836px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.4222222222222222s; + animation: scaleIn 0.25s linear forwards 0.4222222222222222s; +} + +.cWrBmb { + -webkit-transform-origin: 95.97162628173828px 83.4900016784668px; + -ms-transform-origin: 95.97162628173828px 83.4900016784668px; + transform-origin: 95.97162628173828px 83.4900016784668px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.6222222222222222s; + animation: scaleIn 0.25s linear forwards 0.6222222222222222s; +} + +.Wnusb { + -webkit-transform-origin: 64px 101.97999572753906px; + -ms-transform-origin: 64px 101.97999572753906px; + transform-origin: 64px 101.97999572753906px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 0.8222222222222223s; + animation: scaleIn 0.25s linear forwards 0.8222222222222223s; +} + +.bfPqf { + -webkit-transform-origin: 32.03982162475586px 83.4900016784668px; + -ms-transform-origin: 32.03982162475586px 83.4900016784668px; + transform-origin: 32.03982162475586px 83.4900016784668px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 1.0222222222222221s; + animation: scaleIn 0.25s linear forwards 1.0222222222222221s; +} + +.edRCTN { + -webkit-transform-origin: 32.033552169799805px 46.510000228881836px; + -ms-transform-origin: 32.033552169799805px 46.510000228881836px; + transform-origin: 32.033552169799805px 46.510000228881836px; + -webkit-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-animation: scaleIn 0.25s linear forwards 1.2222222222222223s; + animation: scaleIn 0.25s linear forwards 1.2222222222222223s; +} + +.iEGVWn { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 0.3333333333333333s, appearIn 0.1s ease-out forwards 0.3333333333333333s; + animation: outerDrawIn 0.5s ease-out forwards 0.3333333333333333s, appearIn 0.1s ease-out forwards 0.3333333333333333s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.bsocdx { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 0.5333333333333333s, appearIn 0.1s ease-out forwards 0.5333333333333333s; + animation: outerDrawIn 0.5s ease-out forwards 0.5333333333333333s, appearIn 0.1s ease-out forwards 0.5333333333333333s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.jAZXmP { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 0.7333333333333334s, appearIn 0.1s ease-out forwards 0.7333333333333334s; + animation: outerDrawIn 0.5s ease-out forwards 0.7333333333333334s, appearIn 0.1s ease-out forwards 0.7333333333333334s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.hSeArx { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 0.9333333333333333s, appearIn 0.1s ease-out forwards 0.9333333333333333s; + animation: outerDrawIn 0.5s ease-out forwards 0.9333333333333333s, appearIn 0.1s ease-out forwards 0.9333333333333333s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.bVgqGk { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 1.1333333333333333s, appearIn 0.1s ease-out forwards 1.1333333333333333s; + animation: outerDrawIn 0.5s ease-out forwards 1.1333333333333333s, appearIn 0.1s ease-out forwards 1.1333333333333333s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.hEFqBt { + opacity: 0; + stroke-dasharray: 76; + -webkit-animation: outerDrawIn 0.5s ease-out forwards 1.3333333333333333s, appearIn 0.1s ease-out forwards 1.3333333333333333s; + animation: outerDrawIn 0.5s ease-out forwards 1.3333333333333333s, appearIn 0.1s ease-out forwards 1.3333333333333333s; + -webkit-animation-iteration-count: 1, 1; + animation-iteration-count: 1, 1; +} + +.dzEKCM { + opacity: 0; + stroke-dasharray: 70; + -webkit-animation: innerDrawIn 1s ease-in-out forwards 1.3666666666666667s, appearIn 0.1s linear forwards 1.3666666666666667s; + animation: innerDrawIn 1s ease-in-out forwards 1.3666666666666667s, appearIn 0.1s linear forwards 1.3666666666666667s; + -webkit-animation-iteration-count: infinite, 1; + animation-iteration-count: infinite, 1; +} + +.DYnPx { + opacity: 0; + stroke-dasharray: 70; + -webkit-animation: innerDrawIn 1s ease-in-out forwards 1.5333333333333332s, appearIn 0.1s linear forwards 1.5333333333333332s; + animation: innerDrawIn 1s ease-in-out forwards 1.5333333333333332s, appearIn 0.1s linear forwards 1.5333333333333332s; + -webkit-animation-iteration-count: infinite, 1; + animation-iteration-count: infinite, 1; +} + +.hjPEAQ { + opacity: 0; + stroke-dasharray: 70; + -webkit-animation: innerDrawIn 1s ease-in-out forwards 1.7000000000000002s, appearIn 0.1s linear forwards 1.7000000000000002s; + animation: innerDrawIn 1s ease-in-out forwards 1.7000000000000002s, appearIn 0.1s linear forwards 1.7000000000000002s; + -webkit-animation-iteration-count: infinite, 1; + animation-iteration-count: infinite, 1; +} + +.transform-translate-100x100 { + transform: translate(100px, 100px); +} + +#loading-wrapper { + position: absolute; + width: 100vw; + height: 100vh; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.logo { + width: 75px; + height: 75px; + margin-bottom: 20px; + opacity: 0; + -webkit-animation: fadeIn 0.5s ease-out forwards; + animation: fadeIn 0.5s ease-out forwards; +} + +.text { + font-size: 32px; + font-weight: 200; + text-align: center; + color: rgba(255, 255, 255, 0.6); + opacity: 0; + -webkit-animation: fadeIn 0.5s ease-out forwards; + animation: fadeIn 0.5s ease-out forwards; +} + +.dGfHfc { + font-weight: 400; +} diff --git a/src/Symfony/Bundle/Resources/public/init-common-ui.js b/src/Symfony/Bundle/Resources/public/init-common-ui.js new file mode 100644 index 00000000000..c7a7373243e --- /dev/null +++ b/src/Symfony/Bundle/Resources/public/init-common-ui.js @@ -0,0 +1,10 @@ +'use strict'; + +const graphiQlLink = document.querySelector('.graphiql-link'); +if (graphiQlLink) { + graphiQlLink.addEventListener('click', e => { + if (!e.target.hasAttribute('href')) { + alert('GraphQL support is not enabled, see https://api-platform.com/docs/core/graphql/'); + } + }); +} diff --git a/src/Symfony/Bundle/Resources/public/style.css b/src/Symfony/Bundle/Resources/public/style.css index 0981e734f1a..70b996f8d4d 100644 --- a/src/Symfony/Bundle/Resources/public/style.css +++ b/src/Symfony/Bundle/Resources/public/style.css @@ -42,6 +42,10 @@ header #logo img { background-color: rgba(40, 134, 144, .4) } +.svg-icons { + position:absolute;width:0;height:0 +} + /** WEBBY AND WEB **/ .web, .webby { 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/Bundle/Resources/views/GraphQlPlayground/index.html.twig b/src/Symfony/Bundle/Resources/views/GraphQlPlayground/index.html.twig index 199178c7cea..37b8c7970e3 100644 --- a/src/Symfony/Bundle/Resources/views/GraphQlPlayground/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/GraphQlPlayground/index.html.twig @@ -1,488 +1,34 @@ - - - {% if title %}{{ title }} - {% endif %}API Platform - - - - - {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} - + {% block head_metas %} + + + {% endblock head_metas %} + + {% block title %} + {% if title %}{{ title }} - {% endif %}API Platform + {% endblock %} + + {% block head_stylesheets %} + + {% endblock %} + + {% block head_javascript %} + + {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} + + {% endblock head_javascript %} - - -
-
Loading API Platform GraphQL Playground
+ {% endblock %}
- - +{% block body_javascript %} + +{% endblock body_javascript %} diff --git a/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig b/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig index 895850040f6..53943cdf3d9 100644 --- a/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig @@ -1,23 +1,34 @@ - - {% if title %}{{ title }} - {% endif %}API Platform + {% block head_metas %} + + {% endblock %} - - + {% block title %} + {% if title %}{{ title }} - {% endif %}API Platform + {% endblock %} - {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} - + {% block head_stylesheets %} + + + {% endblock %} + + {% block head_javascript %} + {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} + + {% endblock %}
Loading...
- - - - +{% block body_javascript %} + + + + +{% endblock %} diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index a27ec7dba40..b8e06ba1bc6 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -1,8 +1,13 @@ - - {% if title %}{{ title }} - {% endif %}API Platform + {% block head_metas %} + + {% endblock %} + + {% block title %} + {% if title %}{{ title }} - {% endif %}API Platform + {% endblock %} {% block stylesheet %} @@ -12,12 +17,15 @@ {% endblock %} {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} - {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} - + + {% block head_javascript %} + {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} + + {% endblock %} - + @@ -50,9 +58,12 @@ -
-
API Platform - + +{% block header %} +
+ +
+{% endblock %} {% if showWebby %}
@@ -73,8 +84,7 @@ {% set active_ui = app.request.get('ui', 'swagger_ui') %} {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} - {% if not graphQlEnabled %}GraphiQL{% endif %} - {% if graphiQlEnabled %}GraphiQL{% endif %} + {% if not graphQlEnabled or graphiQlEnabled %}GraphiQL{% endif %} {% if graphQlPlaygroundEnabled %}GraphQL Playground{% endif %}
@@ -89,6 +99,7 @@ {% endif %} + {% 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/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php b/tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php index d741b7366a5..6382e11b185 100644 --- a/tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php +++ b/tests/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php @@ -81,7 +81,7 @@ public function elasticsearchProvider(): array { return [ 'elasticsearch: false' => [false, 0, false], - 'elasticsearch: null' => [null, 1, false], + 'elasticsearch: null' => [null, 0, false], 'elasticsearch: true' => [true, 1, true], ]; } diff --git a/tests/Fixtures/TestBundle/Document/DummyCar.php b/tests/Fixtures/TestBundle/Document/DummyCar.php index 26e2706b870..16d482326c2 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; use Doctrine\Common\Collections\ArrayCollection; @@ -35,7 +36,7 @@ #[ApiFilter(PropertyFilter::class, arguments: ['parameterName' => 'foobar'])] #[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups'])] #[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups_override'], id: 'override')] -#[ApiResource(operations: [new Get(openapiContext: ['tags' => []]), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] +#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] #[ODM\Document] class DummyCar { 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/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index 4cf321ac0fe..080abef640e 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -23,6 +23,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; use Doctrine\Common\Collections\ArrayCollection; @@ -35,7 +36,7 @@ #[ApiFilter(PropertyFilter::class, arguments: ['parameterName' => 'foobar'])] #[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups'])] #[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups_override'], id: 'override')] -#[ApiResource(operations: [new Get(openapiContext: ['tags' => []]), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] +#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] #[ORM\Entity] class DummyCar { 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/TestBundle/Resources/config/api_resources/resources.yaml b/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml index 1e84954e151..ccb3155e8f4 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources/resources.yaml @@ -11,6 +11,7 @@ resources: ApiPlatform\Tests\Fixtures\TestBundle\Model\DummyAddress: operations: ApiPlatform\Metadata\GetCollection: + # TODO Remove in 4.0 openapiContext: x-visibility: hide 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/Extractor/Adapter/XmlResourceAdapter.php b/tests/Metadata/Extractor/Adapter/XmlResourceAdapter.php index 7a5d3cf145d..fb596e3bdee 100644 --- a/tests/Metadata/Extractor/Adapter/XmlResourceAdapter.php +++ b/tests/Metadata/Extractor/Adapter/XmlResourceAdapter.php @@ -218,11 +218,143 @@ private function buildHydraContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('hydraContext'), $values); } + /** + * TODO Remove in 4.0. + * + * @deprecated + */ private function buildOpenapiContext(\SimpleXMLElement $resource, array $values): void { $this->buildValues($resource->addChild('openapiContext'), $values); } + private function buildOpenapi(\SimpleXMLElement $resource, array $values): void + { + $node = $resource->openapi ?? $resource->addChild('openapi'); + + if (isset($values['tags'])) { + $tagsNode = $node->tags ?? $node->addChild('tags'); + foreach ($values['tags'] as $tag) { + $tagsNode->addChild('tag', $tag); + } + } + + if (isset($values['responses'])) { + $responsesNode = $node->responses ?? $node->addChild('responses'); + foreach ($values['responses'] as $status => $response) { + $responseNode = $responsesNode->addChild('response'); + $responseNode->addAttribute('status', $status); + if (isset($response['description'])) { + $responseNode->addAttribute('description', $response['description']); + } + if (isset($response['content'])) { + $this->buildValues($responseNode->addChild('content'), $response['content']); + } + if (isset($response['headers'])) { + $this->buildValues($responseNode->addChild('headers'), $response['headers']); + } + if (isset($response['links'])) { + $this->buildValues($responseNode->addChild('links'), $response['links']); + } + } + } + + if (isset($values['externalDocs'])) { + $externalDocsNode = $node->externalDocs ?? $node->addChild('externalDocs'); + if (isset($values['externalDocs']['description'])) { + $externalDocsNode->addAttribute('description', $values['externalDocs']['description']); + } + if (isset($values['url']['description'])) { + $externalDocsNode->addAttribute('url', $values['externalDocs']['url']); + } + } + + if (isset($values['parameters'])) { + $parametersNode = $node->parameters ?? $node->addChild('parameters'); + foreach ($values['parameters'] as $name => $parameter) { + $parameterNode = $parametersNode->addChild('parameter'); + $parameterNode->addAttribute('name', $name); + if (isset($parameter['in'])) { + $parameterNode->addAttribute('in', $parameter['in']); + } + if (isset($parameter['description'])) { + $parameterNode->addAttribute('description', $parameter['description']); + } + if (isset($parameter['required'])) { + $parameterNode->addAttribute('required', $parameter['required']); + } + if (isset($parameter['deprecated'])) { + $parameterNode->addAttribute('deprecated', $parameter['deprecated']); + } + if (isset($parameter['allowEmptyValue'])) { + $parameterNode->addAttribute('allowEmptyValue', $parameter['allowEmptyValue']); + } + if (isset($parameter['style'])) { + $parameterNode->addAttribute('style', $parameter['style']); + } + if (isset($parameter['explode'])) { + $parameterNode->addAttribute('explode', $parameter['explode']); + } + if (isset($parameter['allowReserved'])) { + $parameterNode->addAttribute('allowReserved', $parameter['allowReserved']); + } + if (isset($parameter['example'])) { + $parameterNode->addAttribute('example', $parameter['example']); + } + if (isset($parameter['schema'])) { + $this->buildValues($parameterNode->addChild('schema'), $parameter['schema']); + } + if (isset($parameter['examples'])) { + $this->buildValues($parameterNode->addChild('examples'), $parameter['examples']); + } + if (isset($parameter['content'])) { + $this->buildValues($parameterNode->addChild('content'), $parameter['content']); + } + } + } + + if (isset($values['requestBody'])) { + $requestBodyNode = $node->requestBody ?? $node->addChild('requestBody'); + if (isset($values['requestBody']['content'])) { + $this->buildValues($requestBodyNode->addChild('content'), $values['requestBody']['content']); + } + if (isset($values['requestBody']['description'])) { + $requestBodyNode->addAttribute('description', $values['requestBody']['description']); + } + if (isset($values['requestBody']['required'])) { + $requestBodyNode->addAttribute('required', $values['requestBody']['required']); + } + } + + if (isset($values['callbacks'])) { + $this->buildValues($node->callbacks ?? $node->addChild('callbacks'), $values['callbacks']); + } + + if (isset($values['security'])) { + $this->buildValues($node->security ?? $node->addChild('security'), $values['security']); + } + + if (isset($values['servers'])) { + $serversNode = $node->servers ?? $node->addChild('servers'); + foreach ($values['servers'] as $server) { + $serverNode = $serversNode->addChild('serverNode'); + if (isset($server['url'])) { + $serverNode->addAttribute('url', $server['url']); + } + if (isset($server['description'])) { + $serverNode->addAttribute('description', $server['description']); + } + if (isset($server['variables'])) { + $this->buildValues($serverNode->addChild('variables'), $server['variables']); + } + } + } + + if (isset($values['extensionProperties'])) { + $this->buildValues($node->extensionProperties ?? $node->addChild('extensionProperties'), $values['extensionProperties']); + } + } + private function buildValidationContext(\SimpleXMLElement $resource, array $values): void { $this->buildValues($resource->addChild('validationContext'), $values); diff --git a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php index 1838a1db1c2..a08da785150 100644 --- a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php +++ b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php @@ -32,6 +32,9 @@ use ApiPlatform\Metadata\Resource\Factory\ExtractorResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Comment; use ApiPlatform\Tests\Metadata\Extractor\Adapter\ResourceAdapterInterface; use ApiPlatform\Tests\Metadata\Extractor\Adapter\XmlResourceAdapter; @@ -135,9 +138,15 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + // TODO Remove in 4.0 'openapiContext' => [ 'bar' => 'baz', ], + 'openapi' => [ + 'extensionProperties' => [ + 'bar' => 'baz', + ], + ], 'validationContext' => [ 'foo' => 'bar', ], @@ -299,9 +308,15 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + // TODO Remove in 4.0 'openapiContext' => [ 'bar' => 'baz', ], + 'openapi' => [ + 'extensionProperties' => [ + 'bar' => 'baz', + ], + ], 'validationContext' => [ 'foo' => 'bar', ], @@ -426,7 +441,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'schemes', 'cacheHeaders', 'hydraContext', + // TODO Remove in 4.0 'openapiContext', + 'openapi', 'paginationViaCursor', ]; @@ -473,7 +490,6 @@ private function buildApiResources(): array // Build default operations $operations = []; foreach ([new Get(), new GetCollection(), new Post(), new Put(), new Patch(), new Delete()] as $operation) { - $operationName = $this->getDefaultOperationName($operation, self::RESOURCE_CLASS); [$name, $operation] = $this->getOperationWithDefaults($resource, $operation); $operations[$name] = $operation; } @@ -512,6 +528,32 @@ private function buildApiResources(): array return $resources; } + private function withOpenapi(array|bool $values): bool|OpenApiOperation + { + if (\is_bool($values)) { + return $values; + } + + $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(OpenApiOperation::class))->getProperties()); + foreach ($values as $key => $value) { + $values[$key] = match ($key) { + 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), + 'requestBody' => new RequestBody(description: $value['description'] ?? '', content: isset($value['content']) ? new \ArrayObject($value['content']) : null, required: $value['required'] ?? false), + 'callbacks' => new \ArrayObject($value), + default => $value, + }; + + if (\in_array($key, $allowedProperties, true)) { + continue; + } + + $values['extensionProperties'][$key] = $value; + unset($values[$key]); + } + + return new OpenApiOperation(...$values); + } + private function withUriVariables(array $values): array { $uriVariables = []; diff --git a/tests/Metadata/Extractor/XmlExtractorTest.php b/tests/Metadata/Extractor/XmlExtractorTest.php index b229f52f1b6..2613f959c24 100644 --- a/tests/Metadata/Extractor/XmlExtractorTest.php +++ b/tests/Metadata/Extractor/XmlExtractorTest.php @@ -82,6 +82,7 @@ public function testValidXML(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -161,6 +162,7 @@ public function testValidXML(): void 'foo' => ['bar' => 'baz'], ], 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => ['comment.custom_filter'], 'mercure' => ['private' => true], @@ -239,6 +241,7 @@ public function testValidXML(): void 'foo' => ['bar' => 'baz'], ], 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => ['comment.custom_filter'], 'mercure' => ['private' => true], @@ -262,7 +265,6 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, - 'openapi' => null, 'itemUriTemplate' => null, ], [ @@ -334,6 +336,7 @@ public function testValidXML(): void 'foo' => ['bar' => 'baz'], ], 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => ['comment.custom_filter'], 'mercure' => ['private' => true], @@ -359,7 +362,6 @@ public function testValidXML(): void 'priority' => null, 'processor' => null, 'provider' => null, - 'openapi' => null, ], ], 'graphQlOperations' => null, diff --git a/tests/Metadata/Extractor/YamlExtractorTest.php b/tests/Metadata/Extractor/YamlExtractorTest.php index 36d60ae3d06..cf6d0ad2ee5 100644 --- a/tests/Metadata/Extractor/YamlExtractorTest.php +++ b/tests/Metadata/Extractor/YamlExtractorTest.php @@ -83,6 +83,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -150,6 +151,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -218,6 +220,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -282,6 +285,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -299,7 +303,6 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, - 'openapi' => null, 'itemUriTemplate' => null, ], [ @@ -360,6 +363,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, @@ -377,7 +381,6 @@ public function testValidYaml(): void 'priority' => null, 'processor' => null, 'provider' => null, - 'openapi' => null, ], ], 'graphQlOperations' => null, @@ -438,6 +441,7 @@ public function testValidYaml(): void 'denormalizationContext' => null, 'hydraContext' => null, 'openapiContext' => null, + 'openapi' => null, 'validationContext' => null, 'filters' => null, 'mercure' => null, 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/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 52b46434984..af903a7e146 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -43,9 +43,11 @@ use ApiPlatform\OpenApi\Model\OAuthFlow; use ApiPlatform\OpenApi\Model\OAuthFlows; use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\OpenApi\Model\Response; +use ApiPlatform\OpenApi\Model\Response as OpenApiResponse; use ApiPlatform\OpenApi\Model\SecurityScheme; use ApiPlatform\OpenApi\Model\Server; use ApiPlatform\OpenApi\OpenApi; @@ -83,35 +85,48 @@ public function testInvoke(): void 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'customDummyItem' => (new HttpOperation())->withMethod(HttpOperation::METHOD_HEAD)->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapiContext([ - 'x-visibility' => 'hide', - 'description' => 'Custom description', - 'parameters' => [ - ['description' => 'Test parameter', 'name' => 'param', 'in' => 'path', 'required' => true], - ['description' => 'Replace parameter', 'name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string', 'format' => 'uuid']], - ], - 'tags' => ['Dummy', 'Profile'], - 'responses' => [ - '202' => [ - 'description' => 'Success', - 'content' => [ + 'customDummyItem' => (new HttpOperation())->withMethod(HttpOperation::METHOD_HEAD)->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( + tags: ['Dummy', 'Profile'], + responses: [ + '202' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ 'application/json' => [ 'schema' => ['$ref' => '#/components/schemas/Dummy'], ], - ], - 'headers' => [ + ]), + headers: new \ArrayObject([ 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], - ], - 'links' => [ + ]), + links: new \ArrayObject([ 'Foo' => ['$ref' => '#/components/schemas/Dummy'], - ], - ], - '205' => [], + ]), + ), + '205' => new OpenApiResponse(), + ], + description: 'Custom description', + externalDocs: new ExternalDocumentation( + description: 'See also', + url: 'http://schema.example.com/Dummy', + ), + parameters: [ + new Parameter( + name: 'param', + in: 'path', + description: 'Test parameter', + required: true, + ), + new Parameter( + name: 'id', + in: 'path', + description: 'Replace parameter', + required: true, + schema: ['type' => 'string', 'format' => 'uuid'], + ), ], - 'requestBody' => [ - 'required' => true, - 'description' => 'Custom request body', - 'content' => [ + requestBody: new RequestBody( + description: 'Custom request body', + content: new \ArrayObject([ 'multipart/form-data' => [ 'schema' => [ 'type' => 'object', @@ -123,19 +138,26 @@ public function testInvoke(): void ], ], ], - ], - ], - 'externalDocs' => ['url' => 'http://schema.example.com/Dummy', 'description' => 'See also'], - ] - ), + ]), + required: true, + ), + extensionProperties: ['x-visibility' => 'hide'], + )), 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), - 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenApiContext([ - 'parameters' => [ - ['description' => 'Test modified collection page number', 'name' => 'page', 'in' => 'query', 'required' => false, 'schema' => ['type' => 'integer', 'default' => 1], 'allowEmptyValue' => true], + 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( + parameters: [ + new Parameter( + name: 'page', + in: 'query', + description: 'Test modified collection page number', + required: false, + allowEmptyValue: true, + schema: ['type' => 'integer', 'default' => 1], + ), ], - ])->withOperation($baseOperation), + ))->withOperation($baseOperation), 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), // Filtered 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php index 77b49a3df78..917830a4220 100644 --- a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -34,6 +34,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactory; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Info; +use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\Paths; use ApiPlatform\OpenApi\Model\Server; @@ -113,7 +114,10 @@ public function testNormalize(): void 'put' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation), 'delete' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation), 'get_collection' => (new GetCollection())->withUriTemplate('/dummies')->withOperation($baseOperation), - 'post' => (new Post())->withUriTemplate('/dummies')->withOpenapiContext(['security' => [], 'servers' => ['url' => '/test']])->withOperation($baseOperation), + 'post' => (new Post())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( + security: [], + servers: ['url' => '/test'], + ))->withOperation($baseOperation), ] )), ]); 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/Command/OpenApiCommandTest.php b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php index 2bf3b8a962b..7b3b1b648d0 100644 --- a/tests/Symfony/Bundle/Command/OpenApiCommandTest.php +++ b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Symfony\Bundle\Command; use ApiPlatform\OpenApi\OpenApi; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Console\Tester\ApplicationTester; @@ -22,9 +23,15 @@ /** * @author Amrouche Hamza + * + * TODO Remove group legacy in 4.0 + * + * @group legacy */ class OpenApiCommandTest extends KernelTestCase { + use ExpectDeprecationTrait; + private ApplicationTester $tester; protected function setUp(): void @@ -36,6 +43,8 @@ protected function setUp(): void $application->setAutoExit(false); $this->tester = new ApplicationTester($application); + + $this->handleDeprecations(); } public function testExecute(): void @@ -109,4 +118,12 @@ private function assertYaml(string $data): void } $this->addToAssertionCount(1); } + + /** + * TODO Remove in 4.0. + */ + private function handleDeprecations(): void + { + $this->expectDeprecation('Since api-platform/core 3.1: The "%s" option is deprecated, use "openapi" instead.'); + } } 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')); + } +}