diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index 1540549f7d8..9767505171a 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -910,3 +910,49 @@ Feature: GraphQL collection support Then the response status code should be 200 And the response should be in JSON And the JSON node "data.fooDummies.collection" should have 1 element + + @createSchema + Scenario: Retrieve paginated collections using mixed pagination + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1) { + collection { + id + name + soManies(first: 2) { + edges { + node { + content + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + paginationInfo { + itemsPerPage + lastPage + totalCount + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 3 elements + And the JSON node "data.fooDummies.collection[2].id" should exist + And the JSON node "data.fooDummies.collection[2].name" should exist + And the JSON node "data.fooDummies.collection[2].soManies" should exist + And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements + And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1" + And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA==" + And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 + And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 + And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 diff --git a/features/main/default_order.feature b/features/main/default_order.feature index 91211014315..7458c2456e9 100644 --- a/features/main/default_order.feature +++ b/features/main/default_order.feature @@ -79,35 +79,61 @@ Feature: Default order "@type": "FooDummy", "id": 5, "name": "Balbo", - "dummy": "/dummies/5" + "dummy": "/dummies/5", + "soManies": [ + "/so_manies/13", + "/so_manies/14", + "/so_manies/15" + ] + }, { "@id": "/foo_dummies/3", "@type": "FooDummy", "id": 3, "name": "Sthenelus", - "dummy": "/dummies/3" + "dummy": "/dummies/3", + "soManies": [ + "/so_manies/7", + "/so_manies/8", + "/so_manies/9" + ] }, { "@id": "/foo_dummies/2", "@type": "FooDummy", "id": 2, "name": "Ephesian", - "dummy": "/dummies/2" + "dummy": "/dummies/2", + "soManies": [ + "/so_manies/4", + "/so_manies/5", + "/so_manies/6" + ] }, { "@id": "/foo_dummies/1", "@type": "FooDummy", "id": 1, "name": "Hawsepipe", - "dummy": "/dummies/1" + "dummy": "/dummies/1", + "soManies": [ + "/so_manies/1", + "/so_manies/2", + "/so_manies/3" + ] }, { "@id": "/foo_dummies/4", "@type": "FooDummy", "id": 4, "name": "Separativeness", - "dummy": "/dummies/4" + "dummy": "/dummies/4", + "soManies": [ + "/so_manies/10", + "/so_manies/11", + "/so_manies/12" + ] } ], "hydra:totalItems": 5, diff --git a/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php b/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php new file mode 100644 index 00000000000..05163b0c2b7 --- /dev/null +++ b/src/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactory.php @@ -0,0 +1,67 @@ + + * + * 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\Metadata\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +final class GraphQlNestedOperationResourceMetadataFactory implements ResourceMetadataCollectionFactoryInterface +{ + use OperationDefaultsTrait; + + public function __construct(array $defaults, private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, ?LoggerInterface $logger = null) + { + $this->defaults = $defaults; + $this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter(); + $this->logger = $logger ?? new NullLogger(); + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + if (0 < \count($resourceMetadataCollection)) { + return $resourceMetadataCollection; + } + + $shortName = (false !== $pos = strrpos($resourceClass, '\\')) ? substr($resourceClass, $pos + 1) : $resourceClass; + + $apiResource = new ApiResource( + class: $resourceClass, + shortName: $shortName + ); + + if (class_exists($resourceClass)) { + $refl = new \ReflectionClass($resourceClass); + $attribute = $refl->getAttributes(ApiResource::class)[0] ?? null; + $attributeInstance = $attribute?->newInstance(); + if ($filters = $attributeInstance?->getFilters()) { + $apiResource = $apiResource->withFilters($filters); + } + } + + $resourceMetadataCollection[0] = $this->addDefaultGraphQlOperations($apiResource); + + return $resourceMetadataCollection; + } +} diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 8817899c8ef..d64f1cb800b 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -20,9 +20,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation; use ApiPlatform\Metadata\GraphQl\Query; -use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; -use ApiPlatform\Metadata\Operation as AbstractOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -47,7 +45,7 @@ */ final class FieldsBuilder implements FieldsBuilderInterface { - 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) + 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 ?ResourceMetadataCollectionFactoryInterface $graphQlNestedOperationResourceMetadataFactory = null) { } @@ -256,7 +254,23 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $resourceClass = $type->getClassName(); } - $graphqlType = $this->convertType($type, $input, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable); + $resourceOperation = $rootOperation; + if ($resourceClass && $rootOperation->getClass() && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); + try { + $resourceOperation = $resourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query'); + } catch (OperationNotFoundException) { + // If there is no query operation for a nested resource we force one to exist + $nestedResourceMetadataCollection = $this->graphQlNestedOperationResourceMetadataFactory->create($resourceClass); + $resourceOperation = $nestedResourceMetadataCollection->getOperation($isCollectionType ? 'collection_query' : 'item_query'); + } + } + + if (!$resourceOperation instanceof Operation) { + throw new \LogicException('The resource operation should be a GraphQL operation.'); + } + + $graphqlType = $this->convertType($type, $input, $resourceOperation, $rootOperation, $resourceClass ?? '', $rootResource, $property, $depth, $forceNullable); $graphqlWrappedType = $graphqlType instanceof WrappingType ? $graphqlType->getWrappedType(true) : $graphqlType; $isStandardGraphqlType = \in_array($graphqlWrappedType, GraphQLType::getStandardTypes(), true); @@ -271,43 +285,22 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = []; - $resolverOperation = $rootOperation; - - if ($resourceClass && $this->resourceClassResolver->isResourceClass($resourceClass) && $rootOperation->getClass() !== $resourceClass) { - $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); - $resolverOperation = $resourceMetadataCollection->getOperation(null, $isCollectionType); - - if (!$resolverOperation instanceof Operation) { - $resolverOperation = ($isCollectionType ? new QueryCollection() : new Query())->withOperation($resolverOperation); - } - } - if (!$input && !$rootOperation instanceof Mutation && !$rootOperation instanceof Subscription && !$isStandardGraphqlType && $isCollectionType) { - if ($this->pagination->isGraphQlEnabled($rootOperation)) { - $args = $this->getGraphQlPaginationArgs($rootOperation); - } - - // Find the collection operation to get filters, there might be a smarter way to do this - $operation = null; - if (!empty($resourceClass)) { - $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); - try { - $operation = $resourceMetadataCollection->getOperation(null, true); - } catch (OperationNotFoundException) { - } + if ($this->pagination->isGraphQlEnabled($resourceOperation)) { + $args = $this->getGraphQlPaginationArgs($resourceOperation); } - $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $rootOperation, $property, $depth, $operation); + $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); } if ($isStandardGraphqlType || $input) { $resolve = null; } elseif (($rootOperation instanceof Mutation || $rootOperation instanceof Subscription) && $depth <= 0) { - $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resolverOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resolverOperation); + $resolve = $rootOperation instanceof Mutation ? ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $resourceOperation) : ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $resourceOperation); } elseif ($this->typeBuilder->isCollection($type)) { - $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resolverOperation); + $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $resourceOperation); } else { - $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resolverOperation); + $resolve = ($this->itemResolverFactory)($resourceClass, $rootResource, $resourceOperation); } return [ @@ -368,13 +361,13 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array return $args; } - private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $rootOperation, ?string $property, int $depth, ?AbstractOperation $operation = null): array + private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array { - if (null === $operation || null === $resourceClass) { + if (null === $resourceClass) { return $args; } - foreach ($operation->getFilters() ?? [] as $filterId) { + foreach ($resourceOperation->getFilters() ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { continue; } @@ -382,7 +375,7 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) { $nullable = isset($value['required']) ? !$value['required'] : true; $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); - $graphqlFilterType = $this->convertType($filterType, false, $rootOperation, $resourceClass, $rootResource, $property, $depth); + $graphqlFilterType = $this->convertType($filterType, false, $resourceOperation, $rootOperation, $resourceClass, $rootResource, $property, $depth); if (str_ends_with($key, '[]')) { $graphqlFilterType = GraphQLType::listOf($graphqlFilterType); @@ -399,14 +392,14 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root array_walk_recursive($parsed, static function (&$value) use ($graphqlFilterType): void { $value = $graphqlFilterType; }); - $args = $this->mergeFilterArgs($args, $parsed, $operation, $key); + $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key); } } return $this->convertFilterArgsToTypes($args); } - private function mergeFilterArgs(array $args, array $parsed, ?AbstractOperation $operation = null, string $original = ''): array + private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array { foreach ($parsed as $key => $value) { // Never override keys that cannot be merged @@ -470,7 +463,7 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull + private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull { $graphqlType = $this->typeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth); @@ -487,7 +480,7 @@ private function convertType(Type $type, bool $input, Operation $rootOperation, } if ($this->typeBuilder->isCollection($type)) { - return $this->pagination->isGraphQlEnabled($rootOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $rootOperation) : GraphQLType::listOf($graphqlType); + return $this->pagination->isGraphQlEnabled($resourceOperation) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $resourceOperation) : GraphQLType::listOf($graphqlType); } return $forceNullable || !$graphqlType instanceof NullableType || $type->isNullable() || ($rootOperation instanceof Mutation && 'update' === $rootOperation->getName()) diff --git a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php index 9255c236fa8..9b125aed5c3 100644 --- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php @@ -14,20 +14,12 @@ namespace ApiPlatform\Metadata\Resource\Factory; use ApiPlatform\Exception\ResourceClassNotFoundException; -use ApiPlatform\Exception\RuntimeException; use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\GraphQl\DeleteMutation; -use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; -use ApiPlatform\Metadata\GraphQl\Query; -use ApiPlatform\Metadata\GraphQl\QueryCollection; -use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; @@ -45,12 +37,12 @@ */ final class AttributesResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - private readonly LoggerInterface $logger; - private readonly CamelCaseToSnakeCaseNameConverter $camelCaseToSnakeCaseNameConverter; + use OperationDefaultsTrait; - public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, LoggerInterface $logger = null, private readonly array $defaults = [], private readonly bool $graphQlEnabled = false) + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, LoggerInterface $logger = null, array $defaults = [], private readonly bool $graphQlEnabled = false) { $this->logger = $logger ?? new NullLogger(); + $this->defaults = $defaults; $this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter(); } @@ -164,115 +156,6 @@ private function buildResourceOperations(array $attributes, string $resourceClas return $resources; } - private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false): array - { - // Inherit from resource defaults - foreach (get_class_methods($resource) as $methodName) { - if (!str_starts_with($methodName, 'get')) { - continue; - } - - if (!method_exists($operation, $methodName) || null !== $operation->{$methodName}()) { - continue; - } - - if (null === ($value = $resource->{$methodName}())) { - continue; - } - - $operation = $operation->{'with'.substr($methodName, 3)}($value); - } - - $operation = $operation->withExtraProperties(array_merge( - $resource->getExtraProperties(), - $operation->getExtraProperties(), - $generated ? ['generated_operation' => true] : [] - )); - - // Add global defaults attributes to the operation - $operation = $this->addGlobalDefaults($operation); - - if ($operation instanceof GraphQlOperation) { - if (!$operation->getName()) { - throw new RuntimeException('No GraphQL operation name.'); - } - - if ($operation instanceof Mutation) { - $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}.")); - } - - return [$operation->getName(), $operation]; - } - - if (!$operation instanceof HttpOperation) { - throw new RuntimeException(sprintf('Operation should be an instance of "%s"', HttpOperation::class)); - } - - if ($operation->getRouteName()) { - /** @var HttpOperation $operation */ - $operation = $operation->withName($operation->getRouteName()); - } - - // Check for name conflict - if ($operation->getName()) { - if (null !== $resource->getOperations() && !$resource->getOperations()->has($operation->getName())) { - return [$operation->getName(), $operation]; - } - - $this->logger->warning(sprintf('The operation "%s" already exists on the resource "%s", pick a different name or leave it empty. In the meantime we will generate a unique name.', $operation->getName(), $resource->getClass())); - /** @var HttpOperation $operation */ - $operation = $operation->withName(''); - } - - return [ - sprintf( - '_api_%s_%s%s', - $operation->getUriTemplate() ?: $operation->getShortName(), - strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), - $operation instanceof CollectionOperationInterface ? '_collection' : '', - ), - $operation, - ]; - } - - private function addGlobalDefaults(ApiResource|HttpOperation|GraphQlOperation $operation): ApiResource|HttpOperation|GraphQlOperation - { - $extraProperties = []; - foreach ($this->defaults as $key => $value) { - $upperKey = ucfirst($this->camelCaseToSnakeCaseNameConverter->denormalize($key)); - $getter = 'get'.$upperKey; - - if (!method_exists($operation, $getter)) { - if (!isset($extraProperties[$key])) { - $extraProperties[$key] = $value; - } - } else { - $currentValue = $operation->{$getter}(); - - if (\is_array($currentValue) && $currentValue) { - $operation = $operation->{'with'.$upperKey}(array_merge($value, $currentValue)); - } - - if (null !== $currentValue) { - continue; - } - - $operation = $operation->{'with'.$upperKey}($value); - } - } - - return $operation->withExtraProperties(array_merge($extraProperties, $operation->getExtraProperties())); - } - - private function getResourceWithDefaults(string $resourceClass, string $shortName, ApiResource $resource): ApiResource - { - $resource = $resource - ->withShortName($resource->getShortName() ?? $shortName) - ->withClass($resourceClass); - - return $this->addGlobalDefaults($resource); - } - private function hasResourceAttributes(\ReflectionClass $reflectionClass): bool { foreach ($reflectionClass->getAttributes() as $attribute) { @@ -303,22 +186,6 @@ private function hasSameOperation(ApiResource $resource, string $operationClass, return false; } - private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource - { - $graphQlOperations = []; - foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $i => $operation) { - [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); - $graphQlOperations[$key] = $operation; - } - - if ($resource->getMercure()) { - [$key, $operation] = $this->getOperationWithDefaults($resource, (new Subscription())->withDescription("Subscribes to the update event of a {$operation->getShortName()}.")); - $graphQlOperations[$key] = $operation; - } - - return $resource->withGraphQlOperations($graphQlOperations); - } - private function getDefaultHttpOperations($resource): iterable { $post = new Post(); diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php index c44358897ca..86c154f7bc4 100644 --- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataCollectionFactory.php @@ -19,7 +19,12 @@ use ApiPlatform\Metadata\Extractor\ResourceExtractorInterface; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\DeleteMutation; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; @@ -88,9 +93,7 @@ private function buildResources(array $nodes, string $resourceClass): array } } - if (isset($node['graphQlOperations'])) { - $resource = $resource->withGraphQlOperations($this->buildGraphQlOperations($node['graphQlOperations'], $resource)); - } + $resource = $resource->withGraphQlOperations($this->buildGraphQlOperations($node['graphQlOperations'] ?? null, $resource)); $resources[] = $resource->withOperations(new Operations($this->buildOperations($node['operations'] ?? null, $resource))); } @@ -148,6 +151,20 @@ private function buildGraphQlOperations(?array $data, ApiResource $resource): ar { $operations = []; + if (null === $data) { + foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $operation) { + $operation = $this->getOperationWithDefaults($resource, $operation); + + if ($operation instanceof Mutation) { + $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}.")); + } + + $operations[$operation->getName()] = $operation; + } + + return $operations; + } + foreach ($data as $attributes) { /** @var HttpOperation $operation */ $operation = (new $attributes['graphql_operation_class']())->withShortName($resource->getShortName()); @@ -175,7 +192,7 @@ private function buildGraphQlOperations(?array $data, ApiResource $resource): ar return $operations; } - private function getOperationWithDefaults(ApiResource $resource, HttpOperation $operation): HttpOperation + private function getOperationWithDefaults(ApiResource $resource, Operation $operation): Operation { foreach (($this->defaults['attributes'] ?? []) as $key => $value) { $key = $this->camelCaseToSnakeCaseNameConverter->denormalize($key); diff --git a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php index bb68448c4c2..10a711fe71a 100644 --- a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php @@ -50,17 +50,21 @@ public function create(string $resourceClass): ResourceMetadataCollection $filters = array_keys($this->readFilterAttributes($reflectionClass)); foreach ($resourceMetadataCollection as $i => $resource) { - foreach ($operations = $resource->getOperations() as $operationName => $operation) { + foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) { $operations->add($operationName, $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)))); } - $resourceMetadataCollection[$i] = $resource->withOperations($operations); + if ($operations) { + $resourceMetadataCollection[$i] = $resource->withOperations($operations); + } foreach ($graphQlOperations = $resource->getGraphQlOperations() ?? [] as $operationName => $operation) { $graphQlOperations[$operationName] = $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters))); } - $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); + if ($graphQlOperations) { + $resourceMetadataCollection[$i] = $resource->withGraphQlOperations($graphQlOperations); + } } return $resourceMetadataCollection; diff --git a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php new file mode 100644 index 00000000000..01f3bdc346f --- /dev/null +++ b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php @@ -0,0 +1,160 @@ + + * + * 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\Metadata\Resource\Factory; + +use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\DeleteMutation; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\GraphQl\Subscription; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use Psr\Log\LoggerInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +trait OperationDefaultsTrait +{ + private CamelCaseToSnakeCaseNameConverter $camelCaseToSnakeCaseNameConverter; + private array $defaults = []; + private LoggerInterface $logger; + + private function addGlobalDefaults(ApiResource|Operation $operation): ApiResource|Operation + { + $extraProperties = []; + foreach ($this->defaults as $key => $value) { + $upperKey = ucfirst($this->camelCaseToSnakeCaseNameConverter->denormalize($key)); + $getter = 'get'.$upperKey; + + if (!method_exists($operation, $getter)) { + if (!isset($extraProperties[$key])) { + $extraProperties[$key] = $value; + } + } else { + $currentValue = $operation->{$getter}(); + + if (\is_array($currentValue) && $currentValue) { + $operation = $operation->{'with'.$upperKey}(array_merge($value, $currentValue)); + } + + if (null !== $currentValue) { + continue; + } + + $operation = $operation->{'with'.$upperKey}($value); + } + } + + return $operation->withExtraProperties(array_merge($extraProperties, $operation->getExtraProperties())); + } + + private function getResourceWithDefaults(string $resourceClass, string $shortName, ApiResource $resource): ApiResource + { + $resource = $resource + ->withShortName($resource->getShortName() ?? $shortName) + ->withClass($resourceClass); + + return $this->addGlobalDefaults($resource); + } + + private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource + { + $graphQlOperations = []; + foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $i => $operation) { + [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); + $graphQlOperations[$key] = $operation; + } + + if ($resource->getMercure()) { + [$key, $operation] = $this->getOperationWithDefaults($resource, (new Subscription())->withDescription("Subscribes to the update event of a {$operation->getShortName()}.")); + $graphQlOperations[$key] = $operation; + } + + return $resource->withGraphQlOperations($graphQlOperations); + } + + private function getOperationWithDefaults(ApiResource $resource, Operation $operation, bool $generated = false): array + { + // Inherit from resource defaults + foreach (get_class_methods($resource) as $methodName) { + if (!str_starts_with($methodName, 'get')) { + continue; + } + + if (!method_exists($operation, $methodName) || null !== $operation->{$methodName}()) { + continue; + } + + if (null === ($value = $resource->{$methodName}())) { + continue; + } + + $operation = $operation->{'with'.substr($methodName, 3)}($value); + } + + $operation = $operation->withExtraProperties(array_merge( + $resource->getExtraProperties(), + $operation->getExtraProperties(), + $generated ? ['generated_operation' => true] : [] + )); + + // Add global defaults attributes to the operation + $operation = $this->addGlobalDefaults($operation); + + if ($operation instanceof GraphQlOperation) { + if (!$operation->getName()) { + throw new RuntimeException('No GraphQL operation name.'); + } + + if ($operation instanceof Mutation) { + $operation = $operation->withDescription(ucfirst("{$operation->getName()}s a {$resource->getShortName()}.")); + } + + return [$operation->getName(), $operation]; + } + + if (!$operation instanceof HttpOperation) { + throw new RuntimeException(sprintf('Operation should be an instance of "%s"', HttpOperation::class)); + } + + if ($operation->getRouteName()) { + /** @var HttpOperation $operation */ + $operation = $operation->withName($operation->getRouteName()); + } + + // Check for name conflict + if ($operation->getName()) { + if (null !== $resource->getOperations() && !$resource->getOperations()->has($operation->getName())) { + return [$operation->getName(), $operation]; + } + + $this->logger->warning(sprintf('The operation "%s" already exists on the resource "%s", pick a different name or leave it empty. In the meantime we will generate a unique name.', $operation->getName(), $resource->getClass())); + /** @var HttpOperation $operation */ + $operation = $operation->withName(''); + } + + return [ + sprintf( + '_api_%s_%s%s', + $operation->getUriTemplate() ?: $operation->getShortName(), + strtolower($operation->getMethod() ?? HttpOperation::METHOD_GET), + $operation instanceof CollectionOperationInterface ? '_collection' : '', + ), + $operation, + ]; + } +} diff --git a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 4cecc9d339a..29f9a7a761f 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -156,6 +156,11 @@ + + + + + diff --git a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml index a46540ab0a9..0b6949bb9b8 100644 --- a/src/Symfony/Bundle/Resources/config/doctrine_orm.xml +++ b/src/Symfony/Bundle/Resources/config/doctrine_orm.xml @@ -166,6 +166,12 @@ + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/graphql.xml b/src/Symfony/Bundle/Resources/config/graphql.xml index b5a8e4e80e5..0f713b273b9 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Symfony/Bundle/Resources/config/graphql.xml @@ -133,6 +133,7 @@ %api_platform.graphql.nesting_separator% + @@ -274,6 +275,14 @@ + + + %api_platform.defaults% + + + + + diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index 5e02353fcfb..37aaa67205b 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -286,10 +286,10 @@ public function thereArePaginationEntities(int $nb): void public function thereAreOfTheseSoManyObjects(int $nb): void { for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->isOrm() ? new SoMany() : new SoManyDocument(); - $dummy->content = 'Many #'.$i; + $soMany = $this->buildSoMany(); + $soMany->content = 'Many #'.$i; - $this->manager->persist($dummy); + $this->manager->persist($soMany); } $this->manager->flush(); @@ -340,6 +340,12 @@ public function thereAreFooDummyObjectsWithFakeNames($nb): void $foo = $this->buildFooDummy(); $foo->setName($names[$i]); $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = $this->buildSoMany(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } $this->manager->persist($foo); } @@ -2200,6 +2206,11 @@ private function buildRelatedSecureDummy(): RelatedSecuredDummy|RelatedSecuredDu return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument(); } + private function buildSoMany(): SoMany|SoManyDocument + { + return $this->isOrm() ? new SoMany() : new SoManyDocument(); + } + private function buildThirdLevel(): ThirdLevel|ThirdLevelDocument { return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument(); diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index cc9fe959f25..8736509e855 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -15,6 +15,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; /** @@ -42,6 +44,17 @@ class FooDummy #[ODM\ReferenceOne(targetDocument: Dummy::class, cascade: ['persist'], storeAs: 'id')] private ?Dummy $dummy = null; + /** + * @var Collection + */ + #[ODM\ReferenceMany(targetDocument: SoMany::class, cascade: ['persist'], storeAs: 'id')] + public Collection $soManies; + + public function __construct() + { + $this->soManies = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index b744c0c2a55..92596dad682 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -15,6 +15,8 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GraphQl\QueryCollection; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; /** @@ -44,6 +46,17 @@ class FooDummy #[ORM\ManyToOne(targetEntity: Dummy::class, cascade: ['persist'])] private ?Dummy $dummy = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: SoMany::class, mappedBy: 'fooDummy', cascade: ['persist'])] + public Collection $soManies; + + public function __construct() + { + $this->soManies = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index d3f0126dccb..a6e60f79169 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -35,7 +35,15 @@ * * @author Kévin Dunglas */ -#[ApiResource(graphQlOperations: [new Query(name: 'item_query'), new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']])], types: ['https://schema.org/Product'], normalizationContext: ['groups' => ['friends']], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'])] +#[ApiResource( + graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), + ], + types: ['https://schema.org/Product'], + normalizationContext: ['groups' => ['friends']], + filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'] +)] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] diff --git a/tests/Fixtures/TestBundle/Entity/SoMany.php b/tests/Fixtures/TestBundle/Entity/SoMany.php index 65a2ad93790..e3770b8007d 100644 --- a/tests/Fixtures/TestBundle/Entity/SoMany.php +++ b/tests/Fixtures/TestBundle/Entity/SoMany.php @@ -31,4 +31,7 @@ class SoMany public $id; #[ORM\Column(nullable: true)] public $content; + + #[ORM\ManyToOne] + public ?FooDummy $fooDummy; } diff --git a/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php b/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php new file mode 100644 index 00000000000..65c4b65c51f --- /dev/null +++ b/tests/GraphQl/Metadata/Factory/GraphQlNestedOperationResourceMetadataFactoryTest.php @@ -0,0 +1,44 @@ + + * + * 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\GraphQl\Metadata\Factory; + +use ApiPlatform\GraphQl\Metadata\Factory\GraphQlNestedOperationResourceMetadataFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +final class GraphQlNestedOperationResourceMetadataFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testCreate(): void + { + $decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $decorated->create('someClass')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('someClass')); + + $metadataFactory = new GraphQlNestedOperationResourceMetadataFactory(['status' => 500], $decorated->reveal()); + $apiResource = $metadataFactory->create('someClass')[0]; + $this->assertCount(5, $apiResource->getGraphQlOperations()); + } + + public function testCreateWithResource(): void + { + $metadataFactory = new GraphQlNestedOperationResourceMetadataFactory(['status' => 500]); + $apiResource = $metadataFactory->create(RelatedDummy::class)[0]; + $this->assertNotEmpty($apiResource->getFilters()); + $this->assertEquals('RelatedDummy', $apiResource->getShortName()); + } +} diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index c7bb3ba897a..c5b59ad50b7 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -56,29 +56,18 @@ class FieldsBuilderTest extends TestCase use ProphecyTrait; private ObjectProphecy $propertyNameCollectionFactoryProphecy; - private ObjectProphecy $propertyMetadataFactoryProphecy; - private ObjectProphecy $resourceMetadataCollectionFactoryProphecy; - + private ObjectProphecy $graphQlNestedOperationResourceMetadataFactoryProphecy; private ObjectProphecy $typesContainerProphecy; - private ObjectProphecy $typeBuilderProphecy; - private ObjectProphecy $typeConverterProphecy; - private ObjectProphecy $itemResolverFactoryProphecy; - private ObjectProphecy $collectionResolverFactoryProphecy; - private ObjectProphecy $itemMutationResolverFactoryProphecy; - private ObjectProphecy $itemSubscriptionResolverFactoryProphecy; - private ObjectProphecy $filterLocatorProphecy; - private ObjectProphecy $resourceClassResolverProphecy; - private FieldsBuilder $fieldsBuilder; /** @@ -98,12 +87,13 @@ protected function setUp(): void $this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $this->resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $this->graphQlNestedOperationResourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $this->fieldsBuilder = $this->buildFieldsBuilder(); } private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder { - return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__'); + return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->resourceClassResolverProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination(), $advancedNameConverter ?? new CustomConverter(), '__', $this->graphQlNestedOperationResourceMetadataFactoryProphecy->reveal()); } public function testGetNodeQueryFields(): void @@ -139,7 +129,6 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati $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(false); - $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); $this->itemResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver); $queryFields = $this->fieldsBuilder->getItemQueryFields($resourceClass, $operation, $configuration); @@ -150,8 +139,8 @@ public function testGetItemQueryFields(string $resourceClass, Operation $operati public function itemQueryFieldsProvider(): array { return [ - 'no resource field configuration' => ['resourceClass', (new Query())->withName('action'), [], null, null, []], - 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new Query())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], GraphQLType::string(), null, + 'no resource field configuration' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action'), [], null, null, []], + 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], GraphQLType::string(), null, [ 'actionShortName' => [ 'type' => GraphQLType::string(), @@ -164,7 +153,7 @@ public function itemQueryFieldsProvider(): array ], ], ], - 'nominal item case' => ['resourceClass', (new Query())->withName('action')->withShortName('ShortName'), [], $graphqlType = new ObjectType(['name' => 'item']), $resolver = function (): void { + 'nominal item case' => ['resourceClass', (new Query())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), [], $graphqlType = new ObjectType(['name' => 'item']), $resolver = function (): void { }, [ 'actionShortName' => [ @@ -179,7 +168,7 @@ public function itemQueryFieldsProvider(): array ], ], 'empty overridden args and add fields' => [ - 'resourceClass', (new Query())->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], GraphQLType::string(), null, + 'resourceClass', (new Query())->withClass('resourceClass')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], GraphQLType::string(), null, [ 'shortName' => [ 'type' => GraphQLType::string(), @@ -192,7 +181,7 @@ public function itemQueryFieldsProvider(): array ], ], 'override args with custom ones' => [ - 'resourceClass', (new Query())->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], GraphQLType::string(), null, + 'resourceClass', (new Query())->withClass('resourceClass')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], GraphQLType::string(), null, [ 'shortName' => [ 'type' => GraphQLType::string(), @@ -220,7 +209,6 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o $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->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); $filterProphecy = $this->prophesize(FilterInterface::class); @@ -244,8 +232,8 @@ public function testGetCollectionQueryFields(string $resourceClass, Operation $o public function collectionQueryFieldsProvider(): array { return [ - 'no resource field configuration' => ['resourceClass', (new QueryCollection())->withName('action'), [], null, null, []], - 'nominal collection case with deprecation reason and description' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { + 'no resource field configuration' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action'), [], null, null, []], + 'nominal collection case with deprecation reason and description' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful')->withDescription('Custom description.'), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { }, [ 'actionShortNames' => [ @@ -274,7 +262,7 @@ public function collectionQueryFieldsProvider(): array ], ], ], - 'collection with filters' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { + 'collection with filters' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { }, [ 'actionShortNames' => [ @@ -308,7 +296,7 @@ public function collectionQueryFieldsProvider(): array ], ], 'collection empty overridden args and add fields' => [ - 'resourceClass', (new QueryCollection())->withArgs([])->withName('action')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { + 'resourceClass', (new QueryCollection())->withArgs([])->withClass('resourceClass')->withName('action')->withShortName('ShortName'), ['args' => [], 'name' => 'customActionName'], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { }, [ 'actionShortNames' => [ @@ -322,7 +310,7 @@ public function collectionQueryFieldsProvider(): array ], ], 'collection override args with custom ones' => [ - 'resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { + 'resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), ['args' => ['customArg' => ['type' => 'a type']]], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { }, [ 'actionShortNames' => [ @@ -338,7 +326,7 @@ public function collectionQueryFieldsProvider(): array ], ], ], - 'collection with page-based pagination enabled' => ['resourceClass', (new QueryCollection())->withName('action')->withShortName('ShortName')->withPaginationType('page')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { + 'collection with page-based pagination enabled' => ['resourceClass', (new QueryCollection())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withPaginationType('page')->withFilters(['my_filter']), [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function (): void { }, [ 'actionShortNames' => [ @@ -371,8 +359,6 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio $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->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation->getName())->willReturn($graphqlType); - $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($mutationResolver); $mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $operation); @@ -383,7 +369,7 @@ public function testGetMutationFields(string $resourceClass, Operation $operatio public function mutationFieldsProvider(): array { return [ - 'nominal case with deprecation reason' => ['resourceClass', (new Mutation())->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void { + 'nominal case with deprecation reason' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void { }, [ 'actionShortName' => [ @@ -403,7 +389,7 @@ public function mutationFieldsProvider(): array ], ], ], - 'custom description' => ['resourceClass', (new Mutation())->withName('action')->withShortName('ShortName')->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void { + 'custom description' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function (): void { }, [ 'actionShortName' => [ @@ -435,7 +421,6 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper $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->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operation->getName())->willReturn($graphqlType); $this->resourceMetadataCollectionFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); $this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $operation)->willReturn($subscriptionResolver); @@ -447,9 +432,9 @@ public function testGetSubscriptionFields(string $resourceClass, Operation $oper public function subscriptionFieldsProvider(): array { return [ - 'mercure not enabled' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName'), new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [], + 'mercure not enabled' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName'), new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [], ], - 'nominal case with deprecation reason' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName')->withMercure(true)->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void { + 'nominal case with deprecation reason' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withMercure(true)->withDeprecationReason('not useful'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void { }, [ 'actionShortNameSubscribe' => [ @@ -469,7 +454,7 @@ public function subscriptionFieldsProvider(): array ], ], ], - 'custom description' => ['resourceClass', (new Subscription())->withName('action')->withShortName('ShortName')->withMercure(true)->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void { + 'custom description' => ['resourceClass', (new Subscription())->withClass('resourceClass')->withName('action')->withShortName('ShortName')->withMercure(true)->withDescription('Custom description.'), $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function (): void { }, [ 'actionShortNameSubscribe' => [ @@ -498,6 +483,8 @@ public function subscriptionFieldsProvider(): array public function testGetResourceObjectTypeFields(string $resourceClass, Operation $operation, array $properties, bool $input, int $depth, ?array $ioMetadata, array $expectedResourceObjectTypeFields, ?AdvancedNameConverterInterface $advancedNameConverter = null): void { $this->resourceClassResolverProphecy->isResourceClass($resourceClass)->willReturn(true); + $this->resourceClassResolverProphecy->isResourceClass('nestedResourceClass')->willReturn(true); + $this->resourceClassResolverProphecy->isResourceClass('nestedResourceNoQueryClass')->willReturn(true); $this->resourceClassResolverProphecy->isResourceClass(Argument::type('string'))->willReturn(false); $this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); foreach ($properties as $propertyName => $propertyMetadata) { @@ -505,20 +492,32 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(null); $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn('NotRegisteredType'); $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string())))); + if ('propertyObject' === $propertyName) { $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'objectClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType'])); - $this->resourceMetadataCollectionFactoryProphecy->create('objectClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['item_query' => new Query()])])); $this->itemResolverFactoryProphecy->__invoke('objectClass', $resourceClass, $operation)->willReturn(static function (): void { }); } - $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'anotherResourceClass', $propertyName, $depth + 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), '', $resourceClass, $propertyName, $depth + 1)->willReturn(GraphQLType::nonNull(GraphQLType::listOf(GraphQLType::nonNull(GraphQLType::string())))); + if ('propertyNestedResource' === $propertyName) { + $nestedResourceQueryOperation = new Query(); + $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceClass')->willReturn(new ResourceMetadataCollection('nestedResourceClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])])); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType'])); + $this->itemResolverFactoryProphecy->__invoke('nestedResourceClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void { + }); + } + if ('propertyNestedResourceNoQuery' === $propertyName) { + $nestedResourceQueryOperation = new Query(); + $this->resourceMetadataCollectionFactoryProphecy->create('nestedResourceNoQueryClass')->willReturn(new ResourceMetadataCollection('nestedResourceNoQueryClass', [(new ApiResource())->withDescription('A description.')->withGraphQlOperations([])])); + $this->graphQlNestedOperationResourceMetadataFactoryProphecy->create('nestedResourceNoQueryClass')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('nestedResourceNoQueryClass', [(new ApiResource())->withGraphQlOperations(['item_query' => $nestedResourceQueryOperation])])); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), Argument::that(static fn (Operation $arg): bool => $arg->getName() === $operation->getName()), 'nestedResourceNoQueryClass', $resourceClass, $propertyName, $depth + 1)->willReturn(new ObjectType(['name' => 'objectType'])); + $this->itemResolverFactoryProphecy->__invoke('nestedResourceNoQueryClass', $resourceClass, $nestedResourceQueryOperation)->willReturn(static function (): void { + }); + } } $this->typesContainerProphecy->has('NotRegisteredType')->willReturn(false); $this->typesContainerProphecy->all()->willReturn([]); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('resourceClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations([$operation->getName() => $operation])])); - $this->resourceMetadataCollectionFactoryProphecy->create('anotherResourceClass')->willReturn(new ResourceMetadataCollection($resourceClass, [(new ApiResource())->withGraphQlOperations(['item_query' => new Query()])])); $fieldsBuilder = $this->fieldsBuilder; if ($advancedNameConverter) { @@ -535,7 +534,7 @@ public function resourceObjectTypeFieldsProvider(): array $advancedNameConverter->normalize('field', 'resourceClass')->willReturn('normalizedField'); return [ - 'query' => ['resourceClass', new Query(), + 'query' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(true)->withWritable(false), @@ -563,7 +562,7 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], - 'query with advanced name converter' => ['resourceClass', new Query(), + 'query with advanced name converter' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'field' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(false), ], @@ -582,7 +581,7 @@ public function resourceObjectTypeFieldsProvider(): array ], $advancedNameConverter->reveal(), ], - 'query input' => ['resourceClass', new Query(), + 'query input' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(false), @@ -601,7 +600,7 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], - 'query with simple non-null string array property' => ['resourceClass', new Query(), + 'query with simple non-null string array property' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'property' => (new ApiProperty())->withBuiltinTypes([ new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_STRING)), @@ -621,12 +620,40 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], - 'mutation non input' => ['resourceClass', (new Mutation())->withName('mutation'), + 'query with nested resources' => ['resourceClass', (new Query())->withClass('resourceClass'), + [ + 'propertyNestedResource' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'nestedResourceClass')])->withReadable(true)->withWritable(true), + 'propertyNestedResourceNoQuery' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'nestedResourceNoQueryClass')])->withReadable(true)->withWritable(true), + ], + false, 0, null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'propertyNestedResource' => [ + 'type' => GraphQLType::nonNull(new ObjectType(['name' => 'objectType'])), + 'description' => null, + 'args' => [], + 'resolve' => static function (): void { + }, + 'deprecationReason' => null, + ], + 'propertyNestedResourceNoQuery' => [ + 'type' => GraphQLType::nonNull(new ObjectType(['name' => 'objectType'])), + 'description' => null, + 'args' => [], + 'resolve' => static function (): void { + }, + 'deprecationReason' => null, + ], + ], + ], + 'mutation non input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), 'propertyReadable' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(true)->withWritable(true), - 'propertyObject' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL, false, 'objectClass')])->withReadable(true)->withWritable(true), + 'propertyObject' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, false, 'objectClass')])->withReadable(true)->withWritable(true), ], false, 0, null, [ @@ -650,7 +677,7 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], - 'mutation input' => ['resourceClass', (new Mutation())->withName('mutation'), + 'mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true)->withDeprecationReason('not useful'), @@ -686,7 +713,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'mutation nested input' => ['resourceClass', (new Mutation())->withName('mutation'), + 'mutation nested input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('mutation'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], @@ -705,7 +732,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'delete mutation input' => ['resourceClass', (new Mutation())->withName('delete'), + 'delete mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('delete'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], @@ -717,7 +744,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'create mutation input' => ['resourceClass', (new Mutation())->withName('create'), + 'create mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('create'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], @@ -733,7 +760,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'update mutation input' => ['resourceClass', (new Mutation())->withName('update'), + 'update mutation input' => ['resourceClass', (new Mutation())->withClass('resourceClass')->withName('update'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], @@ -752,7 +779,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'subscription non input' => ['resourceClass', new Subscription(), + 'subscription non input' => ['resourceClass', (new Subscription())->withClass('resourceClass'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), @@ -772,7 +799,7 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], - 'subscription input' => ['resourceClass', new Subscription(), + 'subscription input' => ['resourceClass', (new Subscription())->withClass('resourceClass'), [ 'property' => new ApiProperty(), 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withDescription('propertyBool description')->withReadable(false)->withWritable(true)->withDeprecationReason('not useful'), @@ -787,13 +814,13 @@ public function resourceObjectTypeFieldsProvider(): array 'clientSubscriptionId' => GraphQLType::string(), ], ], - 'null io metadata non input' => ['resourceClass', new Query(), + 'null io metadata non input' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], false, 0, ['class' => null], [], ], - 'null io metadata input' => ['resourceClass', new Query(), + 'null io metadata input' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'propertyBool' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_BOOL)])->withReadable(false)->withWritable(true), ], @@ -802,7 +829,7 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], - 'invalid types' => ['resourceClass', new Query(), + 'invalid types' => ['resourceClass', (new Query())->withClass('resourceClass'), [ 'propertyInvalidType' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_NULL)])->withReadable(true)->withWritable(false), 'propertyNotRegisteredType' => (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_CALLABLE)])->withReadable(true)->withWritable(false), diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index 66b0e7cd855..77ba4f7e842 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -483,7 +483,7 @@ public function testCursorBasedGetResourcePaginatedCollectionType(): void $this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled(); /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', $operation); + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation); $this->assertSame('StringCursorConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Cursor connection for String.', $resourcePaginatedCollectionType->description); @@ -538,7 +538,7 @@ public function testPageBasedGetResourcePaginatedCollectionType(): void $this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled(); /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', $operation); + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'test', $operation); $this->assertSame('StringPageConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Page connection for String.', $resourcePaginatedCollectionType->description); diff --git a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php index 1cd97c28814..3c59cc9c12c 100644 --- a/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php +++ b/tests/Metadata/Extractor/ResourceMetadataCompatibilityTest.php @@ -20,10 +20,13 @@ use ApiPlatform\Metadata\Extractor\YamlResourceExtractor; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\DeleteMutation; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; @@ -453,7 +456,16 @@ private function buildApiResources(): array $operations[$operationName] = $this->getOperationWithDefaults($resource, $operation)->withName($operationName); } - $resources[] = $resource->withOperations(new Operations($operations)); + $resource = $resource->withOperations(new Operations($operations)); + + // Build default GraphQL operations + $graphQlOperations = []; + foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $graphQlOperation) { + $description = $graphQlOperation instanceof Mutation ? ucfirst("{$graphQlOperation->getName()}s a {$resource->getShortName()}.") : null; + $graphQlOperations[$graphQlOperation->getName()] = $this->getOperationWithDefaults($resource, $graphQlOperation)->withName($graphQlOperation->getName())->withDescription($description); + } + + $resources[] = $resource->withGraphQlOperations($graphQlOperations); continue; } @@ -591,7 +603,7 @@ private function withGraphQlOperations(array $values, ?array $fixtures): array return $operations; } - private function getOperationWithDefaults(ApiResource $resource, HttpOperation $operation): HttpOperation + private function getOperationWithDefaults(ApiResource $resource, Operation $operation): Operation { foreach (get_class_methods($resource) as $methodName) { if (!str_starts_with($methodName, 'get')) { diff --git a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 006ad7e788d..6b4ede1efd7 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -671,6 +671,8 @@ 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_metadata.resource.metadata_collection_factory', + 'api_platform.graphql_metadata.resource.metadata_collection_factory.filters' ]; $aliases = [