From 036df7bd5a7c374ddf44b8dbe153e00380b70ad9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 12 Jun 2026 17:15:32 +0200 Subject: [PATCH] fix(graphql): honor custom mutation output class in payload type A custom GraphQL mutation declaring its own output class reused the item_query type for its wrapped payload when normalization contexts matched, exposing the resource fields instead of the output DTO. Force a dedicated wrapped type when the mutation output class differs from the query output class. Fixes #2754 --- src/GraphQl/Type/TypeBuilder.php | 9 ++- .../TestBundle/ApiResource/Issue2754/Sum.php | 45 ++++++++++++++ .../ApiResource/Issue2754/SumResolver.php | 30 ++++++++++ .../ApiResource/Issue2754/SumResult.php | 22 +++++++ tests/Fixtures/app/config/config_common.yml | 5 ++ tests/Functional/GraphQl/Issue2754Test.php | 59 +++++++++++++++++++ 6 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue2754/Sum.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResolver.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResult.php create mode 100644 tests/Functional/GraphQl/Issue2754Test.php diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 289bb8762a..a0f346a54d 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -329,7 +329,8 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM 'resolveField' => $this->defaultFieldResolver, 'fields' => function () use ($resourceClass, $operation, $operationName, $resourceMetadataCollection, $input, $wrapData, $depth, $ioMetadata) { if ($wrapData) { - $queryNormalizationContext = $this->getQueryOperation($resourceMetadataCollection)?->getNormalizationContext() ?? []; + $queryOperation = $this->getQueryOperation($resourceMetadataCollection); + $queryNormalizationContext = $queryOperation?->getNormalizationContext() ?? []; try { $mutationNormalizationContext = $operation instanceof Mutation || $operation instanceof Subscription ? ($resourceMetadataCollection->getOperation($operationName)->getNormalizationContext() ?? []) : []; @@ -340,6 +341,12 @@ private function getResourceObjectTypeConfiguration(string $shortName, ResourceM // If not, use the query type in order to ensure the client cache could be used. $useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext; + // The query type can only be reused when both operations produce the same output class. + // A mutation declaring its own output class must expose that class on its payload. + if (!$useWrappedType && ($operation->getOutput()['class'] ?? null) !== ($queryOperation?->getOutput()['class'] ?? null)) { + $useWrappedType = true; + } + $wrappedOperationName = $operationName; if (!$useWrappedType) { diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue2754/Sum.php b/tests/Fixtures/TestBundle/ApiResource/Issue2754/Sum.php new file mode 100644 index 0000000000..7cb64ba93a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue2754/Sum.php @@ -0,0 +1,45 @@ + + * + * 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\Issue2754; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Mutation; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + graphQlOperations: [ + new Query( + name: 'item_query', + provider: [self::class, 'provide'], + ), + new Mutation( + name: 'sum', + resolver: 'app.graphql.mutation_resolver.issue2754_sum', + output: SumResult::class, + args: ['operandA' => ['type' => 'Int!'], 'operandB' => ['type' => 'Int!']], + ), + ] +)] +class Sum +{ + public function __construct(public ?int $id = null, public ?int $operandA = null, public ?int $operandB = null) + { + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + return new self(1); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResolver.php b/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResolver.php new file mode 100644 index 0000000000..91b4bf4ce8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResolver.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\Issue2754; + +use ApiPlatform\GraphQl\Resolver\MutationResolverInterface; + +final class SumResolver implements MutationResolverInterface +{ + /** + * @param object|null $item + * @param mixed[] $context + */ + public function __invoke($item, array $context): SumResult + { + $input = $context['args']['input'] ?? []; + + return new SumResult(1, ($input['operandA'] ?? 0) + ($input['operandB'] ?? 0)); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResult.php b/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResult.php new file mode 100644 index 0000000000..08ecf027d5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue2754/SumResult.php @@ -0,0 +1,22 @@ + + * + * 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\Issue2754; + +// Fields deliberately differ from Sum: the mutation payload exposes them only if it honors output. +class SumResult +{ + public function __construct(public ?int $id = null, public ?int $sum = null) + { + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 77b2d7cbc1..0e7cbcba1c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -506,6 +506,11 @@ services: tags: - name: 'api_platform.graphql.resolver' + app.graphql.mutation_resolver.issue2754_sum: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue2754\SumResolver' + tags: + - name: 'api_platform.graphql.resolver' + app.graphql.query_resolver.security_after_resolver: class: 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6427\SecurityAfterResolverResolver' tags: diff --git a/tests/Functional/GraphQl/Issue2754Test.php b/tests/Functional/GraphQl/Issue2754Test.php new file mode 100644 index 0000000000..bd71a4c0de --- /dev/null +++ b/tests/Functional/GraphQl/Issue2754Test.php @@ -0,0 +1,59 @@ + + * + * 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\Issue2754\Sum; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * A custom GraphQL mutation declaring an explicit output DTO must expose the + * output DTO's fields on its payload type, not the resource's fields. + * + * @see https://github.com/api-platform/core/issues/2754 + */ +final class Issue2754Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Sum::class]; + } + + public function testCustomMutationHonorsOutputClassFields(): void + { + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => <<<'GRAPHQL' +mutation { + sumSum(input: {operandA: 2, operandB: 3}) { + sum { + sum + } + } +} +GRAPHQL, + ]]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertSame(5, $json['data']['sumSum']['sum']['sum']); + } +}