diff --git a/.changeset/named-graphql-operations.md b/.changeset/named-graphql-operations.md new file mode 100644 index 000000000..a916da6f8 --- /dev/null +++ b/.changeset/named-graphql-operations.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-graphql": 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 5b9a62bde..8da4c1ff3 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -358,6 +358,36 @@ 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* makeExecutor(); + + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "named_ops", + }); + yield* createOrgConnection(executor, { + integration: "named_ops", + name: "main", + template: "none", + value: "unused", + }); + + yield* executor.execute(toolAddr("named_ops", "main", "query.hello"), { name: "Ada" }); + yield* server.clearRequests; + + yield* executor.execute(toolAddr("named_ops", "main", "query.hello"), { name: "Ada" }); + yield* executor.execute(toolAddr("named_ops", "main", "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 e2d9ba8e2..8096ecc70 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -233,12 +233,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); @@ -251,7 +258,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 { @@ -299,7 +306,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,