From 3c7d2a3ec9fdde027ffd642bffb084395a87114d Mon Sep 17 00:00:00 2001 From: Oscar Nord Date: Thu, 4 Jun 2026 20:01:31 +0200 Subject: [PATCH] Emit named GraphQL operations for generated tools --- .changeset/named-graphql-operations.md | 5 ++++ .../plugins/graphql/src/sdk/plugin.test.ts | 23 +++++++++++++++++++ packages/plugins/graphql/src/sdk/plugin.ts | 11 +++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .changeset/named-graphql-operations.md diff --git a/.changeset/named-graphql-operations.md b/.changeset/named-graphql-operations.md new file mode 100644 index 000000000..5e7e4bb86 --- /dev/null +++ b/.changeset/named-graphql-operations.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +GraphQL sources now emit named operations (e.g. `query Hello { ... }`) instead of anonymous ones. This fixes invocation against servers that reject anonymous operations, and gives APM tooling that keys on the operation name a meaningful value. The operation name is derived from the root field name. diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 67c003028..c35b57624 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -506,6 +506,29 @@ describe("graphqlPlugin real protocol server", () => { }), ); + it.effect("sends named operations derived from the field name", () => + Effect.gen(function* () { + const server = yield* serveGreetingServer; + const executor = yield* createExecutor( + makeTestConfig({ plugins: [graphqlPlugin()] as const }), + ); + + yield* executor.graphql.addSource({ + endpoint: server.endpoint, + scope: TEST_SCOPE, + namespace: "named_ops", + }); + yield* server.clearRequests; + + yield* executor.tools.invoke("named_ops.query.hello", { name: "Ada" }); + yield* executor.tools.invoke("named_ops.mutation.setGreeting", { message: "hi" }); + + const requests = yield* server.requests; + expect(requests[0]?.payload.query).toMatch(/^query Hello\b/); + expect(requests[1]?.payload.query).toMatch(/^mutation SetGreeting\b/); + }), + ); + it.effect("surfaces non-2xx invocation responses as ToolResult.fail", () => Effect.gen(function* () { const server = yield* serveTestHttpApp((request) => diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 83ad197b1..babc0121c 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -317,12 +317,19 @@ const buildSelectionSet = ( return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : ""; }; +// Name every generated operation: some servers reject anonymous operations, and +// APM tooling keys traces off the operation name. Field names are already valid +// GraphQL name tokens, so the upper-cased field name is a safe operation name. +const operationNameForField = (fieldName: string): string => + fieldName.charAt(0).toUpperCase() + fieldName.slice(1); + const buildOperationStringForField = ( kind: GraphqlOperationKind, field: IntrospectionField, types: ReadonlyMap, ): string => { const opType = kind === "query" ? "query" : "mutation"; + const opName = operationNameForField(field.name); const varDefs = field.args.map((arg) => { const typeName = formatTypeRef(arg.type); @@ -335,7 +342,7 @@ const buildOperationStringForField = ( const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : ""; const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : ""; - return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`; + return `${opType} ${opName}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`; }; interface PreparedOperation { @@ -380,7 +387,7 @@ const prepareOperations = ( const entry = fieldMap.get(key); const operationString = entry ? buildOperationStringForField(entry.kind, entry.field, typeMap) - : `${extracted.kind} { ${extracted.fieldName} }`; + : `${extracted.kind} ${operationNameForField(extracted.fieldName)} { ${extracted.fieldName} }`; const binding = OperationBinding.make({ kind: extracted.kind,