import { Effect, Layer, Option } from "effect"; import { HttpClient, HttpClientRequest } from "@effect/platform"; import { type ToolId, type ToolInvoker, ToolInvocationResult, ToolInvocationError, type ScopeId, type SecretId, } from "@executor/sdk"; import { GraphqlInvocationError } from "./errors"; import type { GraphqlOperationStore } from "./operation-store"; import { type HeaderValue, type OperationBinding, InvocationConfig, InvocationResult, } from "./types"; // --------------------------------------------------------------------------- // Header resolution — resolves secret refs at invocation time // --------------------------------------------------------------------------- const resolveHeaders = ( headers: Record, secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect }, scopeId: ScopeId, ): Effect.Effect, ToolInvocationError> => Effect.gen(function* () { const resolved: Record = {}; for (const [name, value] of Object.entries(headers)) { if (typeof value === "string") { resolved[name] = value; } else { const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe( Effect.mapError(() => new ToolInvocationError({ toolId: "" as ToolId, message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, cause: undefined, }), ), ); resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; } } return resolved; }); // --------------------------------------------------------------------------- // Response helpers // --------------------------------------------------------------------------- const isJsonContentType = (ct: string | null | undefined): boolean => { if (!ct) return false; const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? ""; return ( normalized === "application/json" || normalized.includes("+json") || normalized.includes("json") ); }; // --------------------------------------------------------------------------- // Public API — execute a GraphQL operation // --------------------------------------------------------------------------- export const invoke = Effect.fn("GraphQL.invoke")(function* ( operation: OperationBinding, args: Record, config: InvocationConfig, resolvedHeaders?: Record, ) { const client = yield* HttpClient.HttpClient; // Build the GraphQL request body const variables: Record = {}; for (const varName of operation.variableNames) { if (args[varName] !== undefined) { variables[varName] = args[varName]; } } // Also pick up any variables from a "variables" container if (typeof args.variables === "object" && args.variables !== null) { Object.assign(variables, args.variables); } let request = HttpClientRequest.post(config.endpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), HttpClientRequest.bodyUnsafeJson({ query: operation.operationString, variables: Object.keys(variables).length > 0 ? variables : undefined, }), ); // Apply resolved headers if (resolvedHeaders) { for (const [name, value] of Object.entries(resolvedHeaders)) { request = HttpClientRequest.setHeader(request, name, value); } } const response = yield* client.execute(request).pipe( Effect.mapError( (err) => new GraphqlInvocationError({ message: `GraphQL request failed: ${err.message}`, statusCode: Option.none(), error: err, }), ), ); const status = response.status; const contentType = response.headers["content-type"] ?? null; const body: unknown = isJsonContentType(contentType) ? yield* response.json.pipe(Effect.catchAll(() => response.text)) : yield* response.text; // GraphQL responses are always 200 with { data, errors } const gqlBody = body as { data?: unknown; errors?: unknown[] } | null; const hasErrors = Array.isArray(gqlBody?.errors) && gqlBody.errors.length > 0; return new InvocationResult({ status, data: gqlBody?.data ?? null, errors: hasErrors ? gqlBody!.errors : null, }); }); // --------------------------------------------------------------------------- // ToolInvoker — bridges operation store + HTTP client into SDK invoker // --------------------------------------------------------------------------- export const makeGraphqlInvoker = (opts: { readonly operationStore: GraphqlOperationStore; readonly httpClientLayer: Layer.Layer; readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect }; readonly scopeId: ScopeId; }): ToolInvoker => ({ resolveAnnotations: (toolId: ToolId) => Effect.gen(function* () { const entry = yield* opts.operationStore.get(toolId); if (!entry) return undefined; // Mutations require approval, queries don't if (entry.binding.kind === "mutation") { return { requiresApproval: true, approvalDescription: `mutation ${entry.binding.fieldName}`, }; } return {}; }), invoke: (toolId: ToolId, args: unknown) => Effect.gen(function* () { const entry = yield* opts.operationStore.get(toolId); if (!entry) { return yield* new ToolInvocationError({ toolId, message: `No GraphQL operation found for tool "${toolId}"`, cause: undefined, }); } const { binding, config } = entry; // Resolve secret-backed headers const resolvedHeaders = yield* resolveHeaders( config.headers, opts.secrets, opts.scopeId, ); const result = yield* invoke( binding, (args ?? {}) as Record, config, resolvedHeaders, ).pipe(Effect.provide(opts.httpClientLayer)); return new ToolInvocationResult({ data: result.data, error: result.errors, status: result.status, }); }).pipe( Effect.catchAll((err) => { if ( typeof err === "object" && err !== null && "_tag" in err && (err as { _tag: string })._tag === "ToolInvocationError" ) { return Effect.fail(err as ToolInvocationError); } return Effect.fail( new ToolInvocationError({ toolId, message: `GraphQL invocation failed: ${err instanceof Error ? err.message : String(err)}`, cause: err, }), ); }), ), });