diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 074728a53a9..302bdea66eb 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\GraphQl\Resolver\Factory; use ApiPlatform\GraphQl\State\Provider\NoopProvider; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\DeleteOperationInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GraphQl\Mutation; @@ -49,14 +50,18 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul // special treatment for nested resources without a resolver/provider if ($operation instanceof Query && $operation->getNested() && !$operation->getResolver() && (!$operation->getProvider() || NoopProvider::class === $operation->getProvider())) { - return \is_array($body) ? $this->resolve( - $source, - $args, - $info, - $rootClass, - $operation, - new ArrayPaginator($body, 0, \count($body)) - ) : $body; + if ($operation instanceof CollectionOperationInterface && \is_array($body)) { + return $this->resolve( + $source, + $args, + $info, + $rootClass, + $operation, + new ArrayPaginator($body, 0, \count($body)) + ); + } + + return $body; } $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null; diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 571f3dae9b8..9f332c5954a 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -99,7 +99,13 @@ private function replaceIdKeys(array $fields, ?string $resourceClass, array $con continue; } - $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = \is_array($value) ? $this->replaceIdKeys($value, $resourceClass, $context) : $value; + if (\is_array($value)) { + // Unwrap nested pagination structures so the attribute tree mirrors the resource shape. + $value = $value['edges']['node'] ?? $value['collection'] ?? $value; + $value = \is_array($value) ? $this->replaceIdKeys($value, $resourceClass, $context) : $value; + } + + $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = $value; } return $denormalizedFields; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php new file mode 100644 index 00000000000..6482b835c2d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Facility.php @@ -0,0 +1,30 @@ + + * + * 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\ApiResource\Issue8076; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(operations: [], graphQlOperations: [])] +final class Facility +{ + /** + * @param Variant[] $variants + */ + public function __construct( + public string $id, + public string $name, + public array $variants, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php new file mode 100644 index 00000000000..5091b4f629f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Product.php @@ -0,0 +1,60 @@ + + * + * 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\ApiResource\Issue8076; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [], + provider: [self::class, 'provide'], + graphQlOperations: [ + new Query(), + new QueryCollection(paginationEnabled: false), + ], +)] +final class Product +{ + public function __construct( + public string $id, + public string $name, + public Facility $facility, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self|array + { + $product = new self( + id: '1', + name: 'a product', + facility: new Facility( + id: 'f1', + name: 'a facility', + variants: [ + new Variant(sku: 'sku-1', on: true), + new Variant(sku: 'sku-2', on: false), + ], + ), + ); + + if ($operation instanceof CollectionOperationInterface) { + return [$product]; + } + + return $product; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php new file mode 100644 index 00000000000..11b38dbfe43 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8076/Variant.php @@ -0,0 +1,26 @@ + + * + * 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\ApiResource\Issue8076; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(operations: [], graphQlOperations: [])] +final class Variant +{ + public function __construct( + public string $sku, + public bool $on, + ) { + } +} diff --git a/tests/Functional/GraphQl/Issue8076Test.php b/tests/Functional/GraphQl/Issue8076Test.php new file mode 100644 index 00000000000..87fa04cc9d5 --- /dev/null +++ b/tests/Functional/GraphQl/Issue8076Test.php @@ -0,0 +1,80 @@ + + * + * 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\Functional\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\Facility; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\Product; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8076\Variant; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * A root resource with a custom provider returns nested resources whose own + * {@see \ApiPlatform\Metadata\ApiResource::$graphQlOperations} is an empty array. + * The framework auto-adds `nested: true` Query/QueryCollection operations on those + * nested resources, with no provider. The resolver must reuse the data already + * loaded by the root provider through {@see $source} instead of trying to call the + * (non-existent) nested provider, which would raise + * `Provider not found on operation "collection_query"`. + * + * @see https://github.com/api-platform/core/issues/8076 + */ +final class Issue8076Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Product::class, Facility::class, Variant::class]; + } + + public function testNestedCollectionWithoutGraphQlOperationsUsesParentProviderData(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +{ + product(id: "/products/1") { + id + name + facility { + name + variants { + edges { + node { + sku + } + } + } + } + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame('a product', $json['data']['product']['name']); + $this->assertSame('a facility', $json['data']['product']['facility']['name']); + $edges = $json['data']['product']['facility']['variants']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('sku-1', $edges[0]['node']['sku']); + $this->assertSame('sku-2', $edges[1]['node']['sku']); + } +}