430 lines · 14.4 KB · TypeScript
RawDownload
import { Effect, Option, Schema } from "effect";import { FetchHttpClient, HttpClient } from "@effect/platform";import type { Layer } from "effect"; import {  Source,  SourceDetectionResult,  definePlugin,  registerRuntimeTools,  runtimeTool,  type ExecutorPlugin,  type PluginContext,  ToolId,  type SecretId,  type ToolRegistration,} from "@executor/sdk"; import { introspect, parseIntrospectionJson, type IntrospectionResult, type IntrospectionType, type IntrospectionField } from "./introspect";import { extract } from "./extract";import {  GraphqlExtractionError,} from "./errors";import { makeGraphqlInvoker } from "./invoke";import type { GraphqlOperationStore } from "./operation-store";import { makeInMemoryOperationStore } from "./kv-operation-store";import {  ExtractedField,  HeaderValue as HeaderValueSchema,  InvocationConfig,  OperationBinding,  type HeaderValue as HeaderValueValue,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig {  /** The GraphQL endpoint URL */  readonly endpoint: string;  /** Optional: introspection JSON text (if endpoint doesn't support introspection) */  readonly introspectionJson?: string;  /** Namespace for the tools (derived from endpoint if not provided) */  readonly namespace?: string;  /** Headers applied to every request. Values can reference secrets. */  readonly headers?: Record<string, HeaderValue>;} // ---------------------------------------------------------------------------// Plugin extension// --------------------------------------------------------------------------- export interface GraphqlPluginExtension {  /** Add a GraphQL endpoint and register its operations as tools */  readonly addSource: (    config: GraphqlSourceConfig,  ) => Effect.Effect<{ readonly toolCount: number }, Error>;   /** Remove all tools from a previously added GraphQL source by namespace */  readonly removeSource: (namespace: string) => Effect.Effect<void>;} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- const AddSourceInputSchema = Schema.Struct({  endpoint: Schema.String,  introspectionJson: Schema.optional(Schema.String),  namespace: Schema.optional(Schema.String),  headers: Schema.optional(    Schema.Record({ key: Schema.String, value: HeaderValueSchema }),  ),});type AddSourceInput = typeof AddSourceInputSchema.Type; const AddSourceOutputSchema = Schema.Struct({  sourceId: Schema.String,  toolCount: Schema.Number,}); /** Derive a namespace from an endpoint URL */const namespaceFromEndpoint = (endpoint: string): string => {  try {    const url = new URL(endpoint);    return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase();  } catch {    return "graphql";  }}; const buildOperationStringForField = (  kind: GraphqlOperationKind,  field: IntrospectionField,  types: ReadonlyMap<string, IntrospectionType>,): string => {  const opType = kind === "query" ? "query" : "mutation";   const varDefs = field.args.map((arg) => {    const typeName = formatTypeRef(arg.type);    return `$${arg.name}: ${typeName}`;  });   const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`);  const selectionSet = buildSelectionSet(field.type, types, 0, new Set());   const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : "";  const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : "";   return `${opType}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;}; import type { IntrospectionTypeRef } from "./introspect"; const formatTypeRef = (ref: IntrospectionTypeRef): string => {  switch (ref.kind) {    case "NON_NULL":      return ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!";    case "LIST":      return ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]";    default:      return ref.name ?? "Unknown";  }}; const unwrapTypeName = (ref: IntrospectionTypeRef): string => {  if (ref.name) return ref.name;  if (ref.ofType) return unwrapTypeName(ref.ofType);  return "Unknown";}; const buildSelectionSet = (  ref: IntrospectionTypeRef,  types: ReadonlyMap<string, IntrospectionType>,  depth: number,  seen: Set<string>,): string => {  if (depth > 2) return "";   const leafName = unwrapTypeName(ref);  if (seen.has(leafName)) return "";   const objectType = types.get(leafName);  if (!objectType?.fields) return "";   const kind = objectType.kind;  if (kind === "SCALAR" || kind === "ENUM") return "";   seen.add(leafName);   const subFields = objectType.fields    .filter((f) => !f.name.startsWith("__"))    .slice(0, 12)    .map((f) => {      const sub = buildSelectionSet(f.type, types, depth + 1, seen);      return sub ? `${f.name} ${sub}` : f.name;    });   seen.delete(leafName);   return subFields.length > 0 ? `{ ${subFields.join(" ")} }` : "";}; const toRegistration = (  field: ExtractedField,  namespace: string,): ToolRegistration => {  const prefix = field.kind === "mutation" ? "mutation" : "query";  const toolPath = `${prefix}.${field.fieldName}`;  const description = Option.getOrElse(field.description, () =>    `GraphQL ${field.kind}: ${field.fieldName} -> ${field.returnTypeName}`,  );   return {    id: ToolId.make(`${namespace}.${toolPath}`),    pluginKey: "graphql",    sourceId: namespace,    name: toolPath,    description,    inputSchema: Option.getOrUndefined(field.inputSchema),    outputSchema: undefined,  };}; // ---------------------------------------------------------------------------// Plugin factory// --------------------------------------------------------------------------- export const graphqlPlugin = (options?: {  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  readonly operationStore?: GraphqlOperationStore;}): ExecutorPlugin<"graphql", GraphqlPluginExtension> => {  const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;  const operationStore =    options?.operationStore ?? makeInMemoryOperationStore();   return definePlugin({    key: "graphql",    init: (ctx: PluginContext) =>      Effect.gen(function* () {        yield* ctx.tools.registerInvoker(          "graphql",          makeGraphqlInvoker({            operationStore,            httpClientLayer,            secrets: ctx.secrets,            scopeId: ctx.scope.id,          }),        );         yield* ctx.sources.addManager({          kind: "graphql",           list: () =>            operationStore.listSources().pipe(              Effect.map((metas) =>                metas.map(                  (s) =>                    new Source({                      id: s.namespace,                      name: s.name,                      kind: "graphql",                      runtime: false,                      canRemove: true,                      canRefresh: false,                    }),                ),              ),            ),           remove: (sourceId: string) =>            Effect.gen(function* () {              yield* operationStore.removeByNamespace(sourceId);              yield* operationStore.removeSource(sourceId);              yield* ctx.tools.unregisterBySource(sourceId);            }),           detect: (url: string) =>            Effect.gen(function* () {              const trimmed = url.trim();              if (!trimmed) return null;              const parsed = yield* Effect.try(() => new URL(trimmed)).pipe(Effect.option);              if (parsed._tag === "None") return null;               const ok = yield* introspect(trimmed).pipe(                Effect.provide(httpClientLayer),                Effect.map(() => true),                Effect.catchAll(() => Effect.succeed(false)),              );               if (!ok) return null;               const name = namespaceFromEndpoint(trimmed);              return new SourceDetectionResult({                kind: "graphql",                confidence: "high",                endpoint: trimmed,                name,                namespace: name,              });            }),        });         const addSourceInternal = (config: GraphqlSourceConfig) =>          Effect.gen(function* () {            // Get introspection result — either by querying the endpoint or parsing provided JSON            let introspectionResult: IntrospectionResult;            if (config.introspectionJson) {              introspectionResult = yield* parseIntrospectionJson(config.introspectionJson);            } else {              // Resolve all headers (including secret refs) for introspection              const resolvedHeaders: Record<string, string> = {};              if (config.headers) {                for (const [name, value] of Object.entries(config.headers)) {                  if (typeof value === "string") {                    resolvedHeaders[name] = value;                  } else {                    const secret = yield* ctx.secrets                      .resolve(value.secretId as SecretId, ctx.scope.id)                      .pipe(Effect.catchAll(() => Effect.succeed("")));                    if (secret) {                      resolvedHeaders[name] = value.prefix                        ? `${value.prefix}${secret}`                        : secret;                    }                  }                }              }               introspectionResult = yield* introspect(                config.endpoint,                Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined,              ).pipe(Effect.provide(httpClientLayer));            }             const { result, definitions } = yield* extract(introspectionResult);            const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint);             // Register shared JSON Schema definitions ($ref targets)            if (Object.keys(definitions).length > 0) {              yield* ctx.tools.registerDefinitions(definitions);            }             const invocationConfig = new InvocationConfig({              endpoint: config.endpoint,              headers: config.headers ?? {},            });             // Build type map for operation string generation            const typeMap = new Map<string, IntrospectionType>();            for (const t of introspectionResult.__schema.types) {              typeMap.set(t.name, t);            }             // Build field map for operation strings            const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>();            const schema = introspectionResult.__schema;             for (const rootKind of ["query", "mutation"] as const) {              const typeName = rootKind === "query"                ? schema.queryType?.name                : schema.mutationType?.name;              if (!typeName) continue;              const rootType = typeMap.get(typeName);              if (!rootType?.fields) continue;              for (const f of rootType.fields) {                if (!f.name.startsWith("__")) {                  fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f });                }              }            }             const registrations: ToolRegistration[] = [];             yield* Effect.forEach(              result.fields,              (extractedField) => {                const reg = toRegistration(extractedField, namespace);                registrations.push(reg);                 const key = `${extractedField.kind}.${extractedField.fieldName}`;                const entry = fieldMap.get(key);                 const operationString = entry                  ? buildOperationStringForField(entry.kind, entry.field, typeMap)                  : `${extractedField.kind} { ${extractedField.fieldName} }`;                 const binding = new OperationBinding({                  kind: extractedField.kind,                  fieldName: extractedField.fieldName,                  operationString,                  variableNames: extractedField.arguments.map((a) => a.name),                });                 return operationStore.put(reg.id, namespace, binding, invocationConfig);              },              { discard: true },            );             yield* ctx.tools.register(registrations);             yield* operationStore.putSource({              namespace,              name: namespace,              config: {                endpoint: config.endpoint,                introspectionJson: config.introspectionJson,                namespace: config.namespace,                headers: config.headers,              },            });             return { sourceId: namespace, toolCount: registrations.length };          });         const runtimeTools = yield* registerRuntimeTools({          registry: ctx.tools,          sources: ctx.sources,          pluginKey: "graphql",          source: {            id: "built-in",            name: "Built In",            kind: "built-in",          },          tools: [            runtimeTool({              id: "graphql.addSource",              name: "graphql.addSource",              description:                "Add a GraphQL endpoint and register its operations as tools",              inputSchema: AddSourceInputSchema,              outputSchema: AddSourceOutputSchema,              handler: (input: AddSourceInput) => addSourceInternal(input),            }),          ],        });         return {          extension: {            addSource: (config: GraphqlSourceConfig) =>              addSourceInternal(config).pipe(                Effect.map(({ toolCount }) => ({ toolCount })),                Effect.mapError(                  (err) =>                    new GraphqlExtractionError({                      message:                        err instanceof Error ? err.message : String(err),                    }),                ),              ),             removeSource: (namespace: string) =>              Effect.gen(function* () {                const toolIds =                  yield* operationStore.removeByNamespace(namespace);                if (toolIds.length > 0) {                  yield* ctx.tools.unregister(toolIds);                }                yield* operationStore.removeSource(namespace);              }),          },           close: () => runtimeTools.close(),        };      }),  });};