From ca4fb3b5a093e9f087643b256212e0ee814973c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 10:42:14 +0000 Subject: [PATCH] Add operation arguments to inject extra params into generated operations Some operations need to forward something to the client that is not a GraphQL variable: an actor, a tenant, a correlation or idempotency id. Until now the generated execute()/executeOrThrow() signatures were derived solely from the operation's variables, so there was no way to thread such transport-level context through the generated code. withOperationArgument() lets you declare an extra, typed parameter that is prepended to the generated execute()/executeOrThrow() methods and forwarded to the client's graphql() call as a named argument (e.g. actor: $actor): Config::create(...) ->withOperationArgument( name: 'actor', type: Type::object(Actor::class), directive: 'requiresActor', ); Targeting is flexible: - With a directive, the parameter is opt-in: only operations tagged with that marker (e.g. mutation Foo @requiresActor) receive it. Without one, it applies to every operation. - operations restricts the argument to specific operation types; it defaults to an empty list, meaning any type. - Call it multiple times to inject more than one argument. The marker directive is a code-generation concern, not part of the server's schema, so it is handled end to end: auto-registered on the schema for the configured operation types (so documents validate and KnownDirectives rejects misuse such as putting it on a field), and stripped from the operation before it is sent to the server. Running --update-schema writes these directive definitions into the schema file. Components: - OperationArgument value object + Config::withOperationArgument() - OperationArgumentDirectiveSchemaExtender registers the marker directive(s) - OperationArgumentDirectiveRemover strips them before the query is printed - Planner resolves the applicable arguments per operation; OperationClassGenerator prepends the parameters and forwards them as named arguments to graphql() Covered by the tests/OperationArgument fixture (opt-in mutation, opt-in query, untouched query) plus a behavioural test asserting the actor reaches the client, and documented in the README. --- README.md | 130 ++++++++++++++++++ src/Config/Config.php | 44 ++++++ src/Config/OperationArgument.php | 27 ++++ src/Console/GenerateCommand.php | 5 + src/DirectiveProcessor.php | 14 ++ src/Generator/OperationClassGenerator.php | 20 ++- ...erationArgumentDirectiveSchemaExtender.php | 78 +++++++++++ src/Planner.php | 40 +++++- src/Planner/Plan/OperationClassPlan.php | 3 + .../OperationArgumentDirectiveRemover.php | 52 +++++++ tests/OperationArgument/Actor.php | 12 ++ tests/OperationArgument/ActorTestClient.php | 54 ++++++++ tests/OperationArgument/CreateThing.graphql | 6 + .../CreateThing/CreateThingMutation.php | 70 ++++++++++ .../CreateThingMutationFailedException.php | 24 ++++ .../Generated/Mutation/CreateThing/Data.php | 47 +++++++ .../Mutation/CreateThing/Data/CreateThing.php | 29 ++++ .../Generated/Mutation/CreateThing/Error.php | 24 ++++ .../Generated/Query/Ping/Data.php | 38 +++++ .../Generated/Query/Ping/Error.php | 24 ++++ .../Generated/Query/Ping/PingQuery.php | 57 ++++++++ .../Query/Ping/PingQueryFailedException.php | 24 ++++ .../Generated/Query/PingAsActor/Data.php | 38 +++++ .../Generated/Query/PingAsActor/Error.php | 24 ++++ .../Query/PingAsActor/PingAsActorQuery.php | 62 +++++++++ .../PingAsActorQueryFailedException.php | 24 ++++ .../OperationArgumentTest.php | 66 +++++++++ tests/OperationArgument/Ping.graphql | 3 + tests/OperationArgument/PingAsActor.graphql | 3 + tests/OperationArgument/Schema.graphql | 12 ++ 30 files changed, 1051 insertions(+), 3 deletions(-) create mode 100644 src/Config/OperationArgument.php create mode 100644 src/GraphQL/OperationArgumentDirectiveSchemaExtender.php create mode 100644 src/Visitor/OperationArgumentDirectiveRemover.php create mode 100644 tests/OperationArgument/Actor.php create mode 100644 tests/OperationArgument/ActorTestClient.php create mode 100644 tests/OperationArgument/CreateThing.graphql create mode 100644 tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutation.php create mode 100644 tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutationFailedException.php create mode 100644 tests/OperationArgument/Generated/Mutation/CreateThing/Data.php create mode 100644 tests/OperationArgument/Generated/Mutation/CreateThing/Data/CreateThing.php create mode 100644 tests/OperationArgument/Generated/Mutation/CreateThing/Error.php create mode 100644 tests/OperationArgument/Generated/Query/Ping/Data.php create mode 100644 tests/OperationArgument/Generated/Query/Ping/Error.php create mode 100644 tests/OperationArgument/Generated/Query/Ping/PingQuery.php create mode 100644 tests/OperationArgument/Generated/Query/Ping/PingQueryFailedException.php create mode 100644 tests/OperationArgument/Generated/Query/PingAsActor/Data.php create mode 100644 tests/OperationArgument/Generated/Query/PingAsActor/Error.php create mode 100644 tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQuery.php create mode 100644 tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQueryFailedException.php create mode 100644 tests/OperationArgument/OperationArgumentTest.php create mode 100644 tests/OperationArgument/Ping.graphql create mode 100644 tests/OperationArgument/PingAsActor.graphql create mode 100644 tests/OperationArgument/Schema.graphql diff --git a/README.md b/README.md index c35596c..efef474 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,7 @@ enum SearchType: string - **Custom `@indexBy` directive** for O(1) lookups instead of O(n) searching - **Custom `@throwWhenNull` directive** - drop nullability per-field and throw a typed exception when the server returns null - **Custom `@hook` directive** - enrich responses with local data resolved lazily at access time +- **Operation arguments** via `withOperationArgument()` - inject extra typed parameters (e.g. an actor) into `execute()` and forward them to your client, optionally gated by a custom directive - **Fragment dependency injection** - automatically includes required fragments - **Automatic query optimization** - merges fragments, simplifies inline fragments @@ -1023,6 +1024,135 @@ Notes: - Omitting `batched` (or `batched: false`) keeps the existing per-instance behaviour — this is not a breaking change. Legacy and batched hooks can be mixed in one query. +### 🎭 Inject Extra Arguments with `withOperationArgument()` + +Sometimes a generated operation needs to forward something to your client that is *not* a GraphQL +variable — an actor, a tenant, a correlation id, an idempotency key. `withOperationArgument()` +prepends an extra typed parameter to the generated `execute()` / `executeOrThrow()` methods and +forwards it positionally to your client's `graphql()` call (right after the operation name): + +```php +use Symfony\Component\TypeInfo\Type; + +Config::create(/* ... */) + ->withOperationArgument( + name: 'actor', + type: Type::object(Actor::class), + directive: 'requiresActor', // opt-in: only operations tagged @requiresActor + ); +``` + +- **Opt-in via a directive** — pass `directive: 'requiresActor'` and only operations carrying + `@requiresActor` receive the extra parameter. The directive is local to your operation: it is + registered automatically (so validation passes) and stripped from the query sent to the server. +- **Apply to every operation** — omit `directive` and the parameter is injected into *all* + operations. +- **Restrict by operation type** — pass `operations: ['mutation']` (or `['query']`) to limit which + operation types the argument may target. The default is an empty array, which allows any type. +- Call `withOperationArgument()` multiple times to inject more than one argument; they are emitted + in registration order, before the GraphQL variables. + + +```graphql +mutation CreateThing($name: String!) @requiresActor { + createThing(name: $name) { + id + name + } +} +``` + +**Generated code** — `Actor $actor` leads the signature and is forwarded to `graphql()`, while the +`@requiresActor` directive is gone from `OPERATION_DEFINITION`: + + +```php +client->graphql( + self::OPERATION_DEFINITION, + [ + 'name' => (string) $name, + ], + self::OPERATION_NAME, + actor: $actor, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [] // @phpstan-ignore argument.type + ); + } + + /** + * @api + * @throws CreateThingMutationFailedException + */ + public function executeOrThrow( + Actor $actor, + Stringable|string $name, + ) : Data { + $data = $this->execute( + $actor, + $name, + ); + + if ($data->errors !== []) { + throw new CreateThingMutationFailedException($data); + } + + return $data; + } +} +``` + +Your client's `graphql()` method receives the extra argument(s) positionally, so add them to its +signature (make them optional if only some operations forward them): + +```php +public function graphql( + string $query, + array $variables = [], + ?string $operationName = null, + ?Actor $actor = null, +) : array { + // forward $actor to the transport — e.g. as a header +} +``` + ## Requirements - **PHP 8.4+** (uses property hooks, readonly classes, and other modern features) diff --git a/src/Config/Config.php b/src/Config/Config.php index b6d116f..bb9a4b0 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -32,6 +32,7 @@ * @param list $inlineProcessingDirectories * @param list $twigProcessingDirectories * @param array $hooks + * @param list $operationArguments */ private function __construct( public Schema | string $schema, @@ -65,6 +66,7 @@ private function __construct( public bool $formatOperationFiles = false, public array $hooks = [], public bool $symfonyAutowireHooks = false, + public array $operationArguments = [], ) {} public static function create( @@ -372,6 +374,48 @@ public function withHook(string $class) : self ]); } + /** + * Register an extra parameter to inject into generated operation methods. + * + * The parameter is prepended to `execute()`/`executeOrThrow()` and forwarded + * positionally to the client's `graphql()` call (after the operation name). + * + * When `$directive` is given, the parameter only applies to operations carrying + * that directive (e.g. `mutation Foo @requiresActor`). When `$directive` is null, + * it applies to every operation whose type is listed in `$operations`. + * + * `$operations` restricts which operation types the argument may target. Leave it + * empty (the default) to allow any operation type. + * + * @param list<'query'|'mutation'> $operations + * @throws \Webmozart\Assert\InvalidArgumentException + */ + public function withOperationArgument( + string $name, + Type $type, + ?string $directive = null, + array $operations = [], + ) : self { + Assert::regex($name, '/^[a-zA-Z_][a-zA-Z0-9_]*$/', sprintf( + 'Operation argument name "%s" must be a valid PHP identifier.', + $name, + )); + + if ($directive !== null) { + Assert::regex($directive, '/^[a-zA-Z_][a-zA-Z0-9_]*$/', sprintf( + 'Operation argument directive "%s" must be a valid GraphQL directive name.', + $directive, + )); + } + + $operationArguments = $this->operationArguments; + $operationArguments[] = new OperationArgument($name, $type, $directive, $operations); + + return clone ($this, [ + 'operationArguments' => $operationArguments, + ]); + } + /** * A batched hook's single parameter must be `array` (or `iterable`/untyped) — it * receives the whole batch of input tuples. diff --git a/src/Config/OperationArgument.php b/src/Config/OperationArgument.php new file mode 100644 index 0000000..36da103 --- /dev/null +++ b/src/Config/OperationArgument.php @@ -0,0 +1,27 @@ + $operations The operation types this argument may target. + */ + public function __construct( + public string $name, + public Type $type, + public ?string $directive, + public array $operations, + ) {} +} diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php index abc4f88..bc71275 100644 --- a/src/Console/GenerateCommand.php +++ b/src/Console/GenerateCommand.php @@ -14,6 +14,7 @@ use Ruudk\GraphQLCodeGenerator\Executor\PlanExecutor; use Ruudk\GraphQLCodeGenerator\GraphQL\HookDirectiveSchemaExtender; use Ruudk\GraphQLCodeGenerator\GraphQL\IndexByDirectiveSchemaExtender; +use Ruudk\GraphQLCodeGenerator\GraphQL\OperationArgumentDirectiveSchemaExtender; use Ruudk\GraphQLCodeGenerator\GraphQL\ThrowWhenNullDirectiveSchemaExtender; use Ruudk\GraphQLCodeGenerator\Planner; use SebastianBergmann\Diff\Differ; @@ -127,6 +128,10 @@ public function __invoke( $schema = ThrowWhenNullDirectiveSchemaExtender::extend($schema); } + if ($configItem->operationArguments !== []) { + $schema = OperationArgumentDirectiveSchemaExtender::extend($schema, $configItem->operationArguments); + } + $this->filesystem->dumpFile( $configItem->schema, SchemaPrinter::doPrint( diff --git a/src/DirectiveProcessor.php b/src/DirectiveProcessor.php index 4c2ef18..72d7203 100644 --- a/src/DirectiveProcessor.php +++ b/src/DirectiveProcessor.php @@ -59,6 +59,20 @@ public function hasThrowWhenNullDirective(NodeList $directives) : bool return false; } + /** + * @param NodeList $directives + */ + public function hasDirective(NodeList $directives, string $name) : bool + { + foreach ($directives as $directive) { + if ($directive->name->value === $name) { + return true; + } + } + + return false; + } + /** * Extract the @hook directive's `name` and `input` arguments. * diff --git a/src/Generator/OperationClassGenerator.php b/src/Generator/OperationClassGenerator.php index aaa0757..be2219a 100644 --- a/src/Generator/OperationClassGenerator.php +++ b/src/Generator/OperationClassGenerator.php @@ -111,6 +111,14 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) { yield ') {}'; $parameters = $generator->indent(function () use ($plan, $generator) { + foreach ($plan->extraParameters as $extraParameter) { + yield sprintf( + '%s $%s,', + $this->dumpPHPType($extraParameter->type, $generator->import(...)), + $extraParameter->name, + ); + } + foreach ($plan->variables as $name => $phpType) { yield sprintf( '%s $%s%s,', @@ -138,7 +146,7 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) { } }); - if ($plan->variables !== []) { + if ($plan->variables !== [] || $plan->extraParameters !== []) { yield 'public function execute('; yield $parameters; yield sprintf( @@ -186,6 +194,10 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) { }); yield '],'; yield 'self::OPERATION_NAME,'; + + foreach ($plan->extraParameters as $extraParameter) { + yield sprintf('%s: $%s,', $extraParameter->name, $extraParameter->name); + } }); yield ');'; yield ''; @@ -226,7 +238,7 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) { yield sprintf('@throws %s', $generator->import($failedException)); }); - if ($plan->variables !== []) { + if ($plan->variables !== [] || $plan->extraParameters !== []) { yield 'public function executeOrThrow('; yield $parameters; yield sprintf( @@ -244,6 +256,10 @@ function () use ($plan, $failedException, $namespace, $generator, $usedHooks) { yield $generator->indent(function () use ($plan, $failedException, $generator) { yield '$data = $this->execute('; yield $generator->indent(function () use ($plan) { + foreach ($plan->extraParameters as $extraParameter) { + yield sprintf('$%s,', $extraParameter->name); + } + foreach ($plan->variables as $name => $phpType) { yield sprintf('$%s,', $name); } diff --git a/src/GraphQL/OperationArgumentDirectiveSchemaExtender.php b/src/GraphQL/OperationArgumentDirectiveSchemaExtender.php new file mode 100644 index 0000000..26dc301 --- /dev/null +++ b/src/GraphQL/OperationArgumentDirectiveSchemaExtender.php @@ -0,0 +1,78 @@ + $operationArguments + * @throws \GraphQL\Error\SyntaxError + * @throws JsonException + * @throws InvalidArgumentException + * @throws InvariantViolation + * @throws Exception + */ + public static function extend(Schema $schema, array $operationArguments) : Schema + { + /** + * @var array> $locationsByDirective + */ + $locationsByDirective = []; + + foreach ($operationArguments as $operationArgument) { + if ($operationArgument->directive === null) { + continue; + } + + // Leave directives that the user's schema already defines untouched. + if ($schema->getDirective($operationArgument->directive) !== null) { + continue; + } + + // An empty operations list means the argument may target any operation type. + $operations = $operationArgument->operations === [] + ? ['query', 'mutation'] + : $operationArgument->operations; + + foreach ($operations as $operation) { + $location = match ($operation) { + 'query' => 'QUERY', + 'mutation' => 'MUTATION', + }; + + if (in_array($location, $locationsByDirective[$operationArgument->directive] ?? [], true)) { + continue; + } + + $locationsByDirective[$operationArgument->directive][] = $location; + } + } + + foreach ($locationsByDirective as $name => $locations) { + sort($locations); + + $schema = SchemaExtender::extend( + $schema, + Parser::parse(sprintf('directive @%s on %s', $name, implode(' | ', $locations))), + ); + } + + return $schema; + } +} diff --git a/src/Planner.php b/src/Planner.php index d79f36c..358185a 100644 --- a/src/Planner.php +++ b/src/Planner.php @@ -63,6 +63,7 @@ use Ruudk\GraphQLCodeGenerator\GraphQL\AST\Printer; use Ruudk\GraphQLCodeGenerator\GraphQL\DocumentNodeWithSource; use Ruudk\GraphQLCodeGenerator\GraphQL\FragmentDefinitionNodeWithSource; +use Ruudk\GraphQLCodeGenerator\GraphQL\OperationArgumentDirectiveSchemaExtender; use Ruudk\GraphQLCodeGenerator\GraphQL\PossibleTypesFinder; use Ruudk\GraphQLCodeGenerator\PHP\Visitor\ClassConstantFinder; use Ruudk\GraphQLCodeGenerator\PHP\Visitor\FragmentFinder; @@ -91,6 +92,7 @@ use Ruudk\GraphQLCodeGenerator\Visitor\DefinedFragmentsVisitor; use Ruudk\GraphQLCodeGenerator\Visitor\HookFieldRemover; use Ruudk\GraphQLCodeGenerator\Visitor\IndexByRemover; +use Ruudk\GraphQLCodeGenerator\Visitor\OperationArgumentDirectiveRemover; use Ruudk\GraphQLCodeGenerator\Visitor\ThrowWhenNullRemover; use Ruudk\GraphQLCodeGenerator\Visitor\UsedFragmentsVisitor; use Stringable; @@ -167,7 +169,15 @@ public function __construct( ]; $this->schemaLoader = new SchemaLoader(new Filesystem()); - $this->schema = $this->schemaLoader->load($config->schema, $config->indexByDirective, $config->hooks !== [], $config->throwWhenNullDirective); + $schema = $this->schemaLoader->load($config->schema, $config->indexByDirective, $config->hooks !== [], $config->throwWhenNullDirective); + + // Auto-inject the marker directives (e.g. @requiresActor) into the schema so operations + // using them validate. They are stripped from the operation before it is sent to the server. + if ($config->operationArguments !== []) { + $schema = OperationArgumentDirectiveSchemaExtender::extend($schema, $config->operationArguments); + } + + $this->schema = $schema; $this->optimizer = new Optimizer($this->schema); $this->possibleTypesFinder = new PossibleTypesFinder($this->schema); @@ -1006,6 +1016,21 @@ private function planOperation(DocumentNodeWithSource $document, string $path, P $operationType = ucfirst($operation->operation); + $extraParameters = []; + foreach ($this->config->operationArguments as $operationArgument) { + if ($operationArgument->operations !== [] + && ! in_array($operation->operation, $operationArgument->operations, true)) { + continue; + } + + if ($operationArgument->directive !== null + && ! $this->directiveProcessor->hasDirective($operation->directives, $operationArgument->directive)) { + continue; + } + + $extraParameters[] = $operationArgument; + } + if ($this->config->indexByDirective) { $document = new IndexByRemover()->__invoke($document); } @@ -1018,6 +1043,18 @@ private function planOperation(DocumentNodeWithSource $document, string $path, P $document = new ThrowWhenNullRemover()->__invoke($document); } + $directiveNames = array_values(array_unique(array_filter( + array_map( + static fn($operationArgument) => $operationArgument->directive, + $this->config->operationArguments, + ), + static fn(?string $directive) => $directive !== null, + ))); + + if ($directiveNames !== []) { + $document = new OperationArgumentDirectiveRemover($directiveNames)->__invoke($document); + } + $operationDefinition = Printer::doPrint($document); $operationDir = Path::join($this->config->outputDir, $operationType, $operationNamespaceName); @@ -1071,6 +1108,7 @@ private function planOperation(DocumentNodeWithSource $document, string $path, P $operationDefinition, $variables, $source, + $extraParameters, ); // Create the error class plan diff --git a/src/Planner/Plan/OperationClassPlan.php b/src/Planner/Plan/OperationClassPlan.php index 748f647..f4a6f74 100644 --- a/src/Planner/Plan/OperationClassPlan.php +++ b/src/Planner/Plan/OperationClassPlan.php @@ -4,6 +4,7 @@ namespace Ruudk\GraphQLCodeGenerator\Planner\Plan; +use Ruudk\GraphQLCodeGenerator\Config\OperationArgument; use Ruudk\GraphQLCodeGenerator\Planner\Source\GraphQLFileSource; use Ruudk\GraphQLCodeGenerator\Planner\Source\InlineSource; use Symfony\Component\TypeInfo\Type as SymfonyType; @@ -12,6 +13,7 @@ { /** * @param array $variables + * @param list $extraParameters */ public function __construct( public string $path, @@ -22,5 +24,6 @@ public function __construct( public string $operationDefinition, public array $variables, public GraphQLFileSource | InlineSource $source, + public array $extraParameters = [], ) {} } diff --git a/src/Visitor/OperationArgumentDirectiveRemover.php b/src/Visitor/OperationArgumentDirectiveRemover.php new file mode 100644 index 0000000..b4c881b --- /dev/null +++ b/src/Visitor/OperationArgumentDirectiveRemover.php @@ -0,0 +1,52 @@ + $directiveNames + */ + public function __construct( + private array $directiveNames, + ) {} + + /** + * @template T of Node + * @param T $node + * + * @throws InvalidArgumentException + * @throws Exception + * @return T + */ + public function __invoke(Node $node) : Node + { + $new = Visitor::visit($node, [ + NodeKind::DIRECTIVE => function (Node $node) : ?VisitorRemoveNode { + Assert::isInstanceOf($node, DirectiveNode::class); + + if ( ! in_array($node->name->value, $this->directiveNames, true)) { + return null; + } + + return Visitor::removeNode(); + }, + ]); + + Assert::isInstanceOf($new, Node::class); + Assert::isAOf($new, $node::class); + + return $new; + } +} diff --git a/tests/OperationArgument/Actor.php b/tests/OperationArgument/Actor.php new file mode 100644 index 0000000..bc087b4 --- /dev/null +++ b/tests/OperationArgument/Actor.php @@ -0,0 +1,12 @@ + $variables + * + * @throws InvalidArgumentException + * @throws ClientExceptionInterface + * @throws JsonException + * @return array + */ + public function graphql(string $query, array $variables = [], ?string $operationName = null, ?Actor $actor = null) : array + { + $request = new Request( + 'POST', + 'https://api.github.com/graphql', + array_filter([ + 'Content-type' => 'application/json', + 'X-Actor' => $actor?->id, + ], fn($value) => ! is_null($value)), + json_encode(array_filter([ + 'query' => $query, + 'operationName' => $operationName, + 'variables' => $variables, + ], fn($value) => ! is_null($value)), JSON_THROW_ON_ERROR), + ); + $response = $this->client->sendRequest($request); + Assert::same(200, $response->getStatusCode(), 'GraphQL server responded with a %2$s status code.'); + $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + Assert::isArray($data, 'GraphQL server did not return an array.'); + + return $data; + } +} diff --git a/tests/OperationArgument/CreateThing.graphql b/tests/OperationArgument/CreateThing.graphql new file mode 100644 index 0000000..5dd070a --- /dev/null +++ b/tests/OperationArgument/CreateThing.graphql @@ -0,0 +1,6 @@ +mutation CreateThing($name: String!) @requiresActor { + createThing(name: $name) { + id + name + } +} diff --git a/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutation.php b/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutation.php new file mode 100644 index 0000000..77da1ee --- /dev/null +++ b/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutation.php @@ -0,0 +1,70 @@ +client->graphql( + self::OPERATION_DEFINITION, + [ + 'name' => (string) $name, + ], + self::OPERATION_NAME, + actor: $actor, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [] // @phpstan-ignore argument.type + ); + } + + /** + * @api + * @throws CreateThingMutationFailedException + */ + public function executeOrThrow( + Actor $actor, + Stringable|string $name, + ) : Data { + $data = $this->execute( + $actor, + $name, + ); + + if ($data->errors !== []) { + throw new CreateThingMutationFailedException($data); + } + + return $data; + } +} diff --git a/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutationFailedException.php b/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutationFailedException.php new file mode 100644 index 0000000..586fd8d --- /dev/null +++ b/tests/OperationArgument/Generated/Mutation/CreateThing/CreateThingMutationFailedException.php @@ -0,0 +1,24 @@ +errors !== [] ? sprintf(': %s', $data->errors[0]->message) : '', + )); + } +} diff --git a/tests/OperationArgument/Generated/Mutation/CreateThing/Data.php b/tests/OperationArgument/Generated/Mutation/CreateThing/Data.php new file mode 100644 index 0000000..60dc91b --- /dev/null +++ b/tests/OperationArgument/Generated/Mutation/CreateThing/Data.php @@ -0,0 +1,47 @@ + $this->createThing ??= new CreateThing($this->data['createThing']); + } + + /** + * @var list + */ + public readonly array $errors; + + /** + * @param array{ + * 'createThing': array{ + * 'id': string, + * 'name': string, + * ..., + * }, + * ..., + * } $data + * @param list $errors + */ + public function __construct( + private readonly array $data, + array $errors, + ) { + $this->errors = array_map(fn(array $error) => new Error($error), $errors); + } +} diff --git a/tests/OperationArgument/Generated/Mutation/CreateThing/Data/CreateThing.php b/tests/OperationArgument/Generated/Mutation/CreateThing/Data/CreateThing.php new file mode 100644 index 0000000..2fdb593 --- /dev/null +++ b/tests/OperationArgument/Generated/Mutation/CreateThing/Data/CreateThing.php @@ -0,0 +1,29 @@ + $this->id ??= $this->data['id']; + } + + public string $name { + get => $this->name ??= $this->data['name']; + } + + /** + * @param array{ + * 'id': string, + * 'name': string, + * ..., + * } $data + */ + public function __construct( + private readonly array $data, + ) {} +} diff --git a/tests/OperationArgument/Generated/Mutation/CreateThing/Error.php b/tests/OperationArgument/Generated/Mutation/CreateThing/Error.php new file mode 100644 index 0000000..ad62b6a --- /dev/null +++ b/tests/OperationArgument/Generated/Mutation/CreateThing/Error.php @@ -0,0 +1,24 @@ +message = $error['debugMessage'] ?? $error['message']; + } +} diff --git a/tests/OperationArgument/Generated/Query/Ping/Data.php b/tests/OperationArgument/Generated/Query/Ping/Data.php new file mode 100644 index 0000000..ec01101 --- /dev/null +++ b/tests/OperationArgument/Generated/Query/Ping/Data.php @@ -0,0 +1,38 @@ + $this->ping ??= $this->data['ping']; + } + + /** + * @var list + */ + public readonly array $errors; + + /** + * @param array{ + * 'ping': string, + * ..., + * } $data + * @param list $errors + */ + public function __construct( + private readonly array $data, + array $errors, + ) { + $this->errors = array_map(fn(array $error) => new Error($error), $errors); + } +} diff --git a/tests/OperationArgument/Generated/Query/Ping/Error.php b/tests/OperationArgument/Generated/Query/Ping/Error.php new file mode 100644 index 0000000..7523443 --- /dev/null +++ b/tests/OperationArgument/Generated/Query/Ping/Error.php @@ -0,0 +1,24 @@ +message = $error['debugMessage'] ?? $error['message']; + } +} diff --git a/tests/OperationArgument/Generated/Query/Ping/PingQuery.php b/tests/OperationArgument/Generated/Query/Ping/PingQuery.php new file mode 100644 index 0000000..d28c77d --- /dev/null +++ b/tests/OperationArgument/Generated/Query/Ping/PingQuery.php @@ -0,0 +1,57 @@ +client->graphql( + self::OPERATION_DEFINITION, + [ + ], + self::OPERATION_NAME, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [] // @phpstan-ignore argument.type + ); + } + + /** + * @api + * @throws PingQueryFailedException + */ + public function executeOrThrow() : Data + { + $data = $this->execute( + ); + + if ($data->errors !== []) { + throw new PingQueryFailedException($data); + } + + return $data; + } +} diff --git a/tests/OperationArgument/Generated/Query/Ping/PingQueryFailedException.php b/tests/OperationArgument/Generated/Query/Ping/PingQueryFailedException.php new file mode 100644 index 0000000..3faa92e --- /dev/null +++ b/tests/OperationArgument/Generated/Query/Ping/PingQueryFailedException.php @@ -0,0 +1,24 @@ +errors !== [] ? sprintf(': %s', $data->errors[0]->message) : '', + )); + } +} diff --git a/tests/OperationArgument/Generated/Query/PingAsActor/Data.php b/tests/OperationArgument/Generated/Query/PingAsActor/Data.php new file mode 100644 index 0000000..a715962 --- /dev/null +++ b/tests/OperationArgument/Generated/Query/PingAsActor/Data.php @@ -0,0 +1,38 @@ + $this->ping ??= $this->data['ping']; + } + + /** + * @var list + */ + public readonly array $errors; + + /** + * @param array{ + * 'ping': string, + * ..., + * } $data + * @param list $errors + */ + public function __construct( + private readonly array $data, + array $errors, + ) { + $this->errors = array_map(fn(array $error) => new Error($error), $errors); + } +} diff --git a/tests/OperationArgument/Generated/Query/PingAsActor/Error.php b/tests/OperationArgument/Generated/Query/PingAsActor/Error.php new file mode 100644 index 0000000..913fa83 --- /dev/null +++ b/tests/OperationArgument/Generated/Query/PingAsActor/Error.php @@ -0,0 +1,24 @@ +message = $error['debugMessage'] ?? $error['message']; + } +} diff --git a/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQuery.php b/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQuery.php new file mode 100644 index 0000000..22dddd0 --- /dev/null +++ b/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQuery.php @@ -0,0 +1,62 @@ +client->graphql( + self::OPERATION_DEFINITION, + [ + ], + self::OPERATION_NAME, + actor: $actor, + ); + + return new Data( + $data['data'] ?? [], // @phpstan-ignore argument.type + $data['errors'] ?? [] // @phpstan-ignore argument.type + ); + } + + /** + * @api + * @throws PingAsActorQueryFailedException + */ + public function executeOrThrow( + Actor $actor, + ) : Data { + $data = $this->execute( + $actor, + ); + + if ($data->errors !== []) { + throw new PingAsActorQueryFailedException($data); + } + + return $data; + } +} diff --git a/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQueryFailedException.php b/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQueryFailedException.php new file mode 100644 index 0000000..f28e1fa --- /dev/null +++ b/tests/OperationArgument/Generated/Query/PingAsActor/PingAsActorQueryFailedException.php @@ -0,0 +1,24 @@ +errors !== [] ? sprintf(': %s', $data->errors[0]->message) : '', + )); + } +} diff --git a/tests/OperationArgument/OperationArgumentTest.php b/tests/OperationArgument/OperationArgumentTest.php new file mode 100644 index 0000000..4da6042 --- /dev/null +++ b/tests/OperationArgument/OperationArgumentTest.php @@ -0,0 +1,66 @@ +withQueriesDir(__DIR__) + ->enableDumpOrThrowMethods() + ->withOperationArgument( + name: 'actor', + type: Type::object(Actor::class), + directive: 'requiresActor', + ); + } + + public function testGenerate() : void + { + $this->assertActualMatchesExpected(); + } + + public function testExecuteForwardsActorToClient() : void + { + $mock = new Client(); + $mock->addResponse(new Response(200, [ + 'Content-Type' => 'application/json', + ], json_encode([ + 'data' => [ + 'createThing' => [ + 'id' => '1', + 'name' => 'Thing', + ], + ], + ], flags: JSON_THROW_ON_ERROR))); + + $mutation = new CreateThingMutation(new ActorTestClient($mock)); + $data = $mutation->executeOrThrow(new Actor('actor-42'), 'Thing'); + + self::assertSame('1', $data->createThing->id); + self::assertSame('Thing', $data->createThing->name); + + $request = $mock->getLastRequest(); + self::assertInstanceOf(RequestInterface::class, $request); + self::assertSame('actor-42', $request->getHeaderLine('X-Actor')); + } +} diff --git a/tests/OperationArgument/Ping.graphql b/tests/OperationArgument/Ping.graphql new file mode 100644 index 0000000..cffedfd --- /dev/null +++ b/tests/OperationArgument/Ping.graphql @@ -0,0 +1,3 @@ +query Ping { + ping +} diff --git a/tests/OperationArgument/PingAsActor.graphql b/tests/OperationArgument/PingAsActor.graphql new file mode 100644 index 0000000..4fb69d6 --- /dev/null +++ b/tests/OperationArgument/PingAsActor.graphql @@ -0,0 +1,3 @@ +query PingAsActor @requiresActor { + ping +} diff --git a/tests/OperationArgument/Schema.graphql b/tests/OperationArgument/Schema.graphql new file mode 100644 index 0000000..4accd80 --- /dev/null +++ b/tests/OperationArgument/Schema.graphql @@ -0,0 +1,12 @@ +type Query { + ping: String! +} + +type Mutation { + createThing(name: String!): Thing! +} + +type Thing { + id: ID! + name: String! +}