648 lines · 22.2 KB · TypeScript
RawDownload
import { Effect, Option } from "effect";import type { Layer } from "effect";import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { GraphqlGroup } from "../api/group";import { GraphqlExtensionService, GraphqlHandlers } from "../api/handlers"; import {  definePlugin,  ScopeId,  SourceDetectionResult,  Usage,  type PluginCtx,  type ToolAnnotations,  type ToolRow,} from "@executor-js/sdk/core"; import {  headersToConfigValues,  type ConfigFileSink,  type GraphqlSourceConfig as GraphqlConfigEntry,} from "@executor-js/config"; import {  introspect,  parseIntrospectionJson,  type IntrospectionResult,  type IntrospectionType,  type IntrospectionField,  type IntrospectionTypeRef,} from "./introspect";import { extract } from "./extract";import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors";import { invokeWithLayer, resolveHeaders } from "./invoke";import {  graphqlSchema,  makeDefaultGraphqlStore,  type GraphqlStore,  type StoredGraphqlSource,  type StoredOperation,} from "./store";import {  ExtractedField,  type GraphqlSourceAuth,  OperationBinding,  type HeaderValue as HeaderValueValue,  type QueryParamValue,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig {  /** The GraphQL endpoint URL */  readonly endpoint: string;  /**   * Executor scope id that owns this source row. Must be one of the   * executor's configured scopes. Typical shape: an admin adds the   * source at the outermost (organization) scope so it's visible to   * every inner (per-user) scope via fall-through reads.   */  readonly scope: string;  /** Display name for the source. Falls back to namespace if not provided. */  readonly name?: 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>;  /** Query parameters applied to every request. Values can reference secrets. */  readonly queryParams?: Record<string, QueryParamValue>;  /** Optional OAuth2 connection used as a Bearer token for every request. */  readonly auth?: GraphqlSourceAuth;} // ---------------------------------------------------------------------------// Plugin extension// --------------------------------------------------------------------------- export interface GraphqlUpdateSourceInput {  readonly name?: string;  readonly endpoint?: string;  readonly headers?: Record<string, HeaderValue>;  readonly queryParams?: Record<string, QueryParamValue>;  readonly auth?: GraphqlSourceAuth;} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Derive a namespace from an endpoint URL */const namespaceFromEndpoint = (endpoint: string): string => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL construction throws; this helper intentionally falls back to the stable default namespace  try {    const url = new URL(endpoint);    return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase();  } catch {    return "graphql";  }}; 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 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}` : ""} }`;}; interface PreparedOperation {  readonly toolPath: string;  readonly description: string;  readonly inputSchema: unknown;  readonly binding: OperationBinding;} const prepareOperations = (  fields: readonly ExtractedField[],  introspection: IntrospectionResult,): readonly PreparedOperation[] => {  const typeMap = new Map<string, IntrospectionType>();  for (const t of introspection.__schema.types) {    typeMap.set(t.name, t);  }   const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>();  const schema = introspection.__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 });      }    }  }   return fields.map((extracted) => {    const prefix = extracted.kind === "mutation" ? "mutation" : "query";    const toolPath = `${prefix}.${extracted.fieldName}`;    const description = Option.getOrElse(      extracted.description,      () => `GraphQL ${extracted.kind}: ${extracted.fieldName} -> ${extracted.returnTypeName}`,    );     const key = `${extracted.kind}.${extracted.fieldName}`;    const entry = fieldMap.get(key);    const operationString = entry      ? buildOperationStringForField(entry.kind, entry.field, typeMap)      : `${extracted.kind} { ${extracted.fieldName} }`;     const binding = new OperationBinding({      kind: extracted.kind,      fieldName: extracted.fieldName,      operationString,      variableNames: extracted.arguments.map((a) => a.name),    });     return {      toolPath,      description,      inputSchema: Option.getOrUndefined(extracted.inputSchema),      binding,    };  });}; const annotationsFor = (binding: OperationBinding): ToolAnnotations => {  if (binding.kind === "mutation") {    return {      requiresApproval: true,      approvalDescription: `mutation ${binding.fieldName}`,    };  }  return {};}; // ---------------------------------------------------------------------------// Plugin factory// --------------------------------------------------------------------------- export interface GraphqlPluginOptions {  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  /** If provided, source add/remove is mirrored to executor.jsonc   *  (best-effort — file errors are logged, not raised). */  readonly configFile?: ConfigFileSink;} const toGraphqlConfigEntry = (  namespace: string,  config: GraphqlSourceConfig,): GraphqlConfigEntry => ({  kind: "graphql",  endpoint: config.endpoint,  introspectionJson: config.introspectionJson,  namespace,  headers: headersToConfigValues(config.headers),}); const makeGraphqlExtension = (  ctx: PluginCtx<GraphqlStore>,  httpClientLayer: Layer.Layer<HttpClient.HttpClient>,  configFile: ConfigFileSink | undefined,) => {  const resolveConfigValues = (values: Record<string, HeaderValue> | undefined) =>    Effect.gen(function* () {      if (!values) return undefined;      const resolved = yield* resolveHeaders(values, ctx.secrets);      return Object.keys(resolved).length > 0 ? resolved : undefined;    });   const resolveOAuthHeader = (auth: GraphqlSourceAuth | undefined) =>    Effect.gen(function* () {      if (!auth || auth.kind === "none") return undefined;      const accessToken = yield* ctx.connections.accessToken(auth.connectionId).pipe(        Effect.mapError(          () =>            new GraphqlIntrospectionError({              message: `Failed to resolve OAuth connection "${auth.connectionId}"`,            }),        ),      );      return { Authorization: `Bearer ${accessToken}` };    });   const resolveRequestHeaders = (    headers: Record<string, HeaderValue> | undefined,    auth: GraphqlSourceAuth | undefined,  ) =>    Effect.gen(function* () {      const resolvedHeaders = yield* resolveConfigValues(headers);      const oauthHeader = yield* resolveOAuthHeader(auth);      return { ...(resolvedHeaders ?? {}), ...(oauthHeader ?? {}) };    });   const addSourceInternal = (config: GraphqlSourceConfig) =>    ctx.transaction(      Effect.gen(function* () {        let introspectionResult: IntrospectionResult;        if (config.introspectionJson) {          introspectionResult = yield* parseIntrospectionJson(config.introspectionJson);        } else {          const resolvedHeaders = yield* resolveRequestHeaders(config.headers, config.auth);          const resolvedQueryParams = yield* resolveConfigValues(config.queryParams);          introspectionResult = yield* introspect(            config.endpoint,            Object.keys(resolvedHeaders).length > 0 ? resolvedHeaders : undefined,            resolvedQueryParams,          ).pipe(Effect.provide(httpClientLayer));        }         const { result, definitions } = yield* extract(introspectionResult);        const namespace = config.namespace ?? namespaceFromEndpoint(config.endpoint);        const prepared = prepareOperations(result.fields, introspectionResult);         const displayName = config.name?.trim() || namespace;         const storedSource: StoredGraphqlSource = {          namespace,          scope: config.scope,          name: displayName,          endpoint: config.endpoint,          headers: config.headers ?? {},          queryParams: config.queryParams ?? {},          auth: config.auth ?? { kind: "none" },        };         const storedOps: StoredOperation[] = prepared.map((p) => ({          toolId: `${namespace}.${p.toolPath}`,          sourceId: namespace,          binding: p.binding,        }));         yield* ctx.storage.upsertSource(storedSource, storedOps);         yield* ctx.core.sources.register({          id: namespace,          scope: config.scope,          kind: "graphql",          name: displayName,          url: config.endpoint,          canRemove: true,          canRefresh: false,          canEdit: true,          tools: prepared.map((p) => ({            name: p.toolPath,            description: p.description,            inputSchema: p.inputSchema,          })),        });         if (Object.keys(definitions).length > 0) {          yield* ctx.core.definitions.register({            sourceId: namespace,            scope: config.scope,            definitions,          });        }         return { toolCount: prepared.length, namespace };      }),    );   return {    addSource: (config: GraphqlSourceConfig) =>      addSourceInternal(config).pipe(        Effect.tap((result) =>          configFile            ? configFile.upsertSource(toGraphqlConfigEntry(result.namespace, config))            : Effect.void,        ),      ),     removeSource: (namespace: string, scope: string) =>      Effect.gen(function* () {        yield* ctx.transaction(          Effect.gen(function* () {            yield* ctx.storage.removeSource(namespace, scope);            yield* ctx.core.sources.unregister(namespace);          }),        );        if (configFile) {          yield* configFile.removeSource(namespace);        }      }),     getSource: (namespace: string, scope: string) => ctx.storage.getSource(namespace, scope),     updateSource: (namespace: string, scope: string, input: GraphqlUpdateSourceInput) =>      ctx.storage.updateSourceMeta(namespace, scope, {        name: input.name?.trim() || undefined,        endpoint: input.endpoint,        headers: input.headers,        queryParams: input.queryParams,        auth: input.auth,      }),  };}; export type GraphqlPluginExtension = ReturnType<typeof makeGraphqlExtension>; export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => {  const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;   return {    id: "graphql" as const,    packageName: "@executor-js/plugin-graphql",    schema: graphqlSchema,    storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps),     extension: (ctx) => makeGraphqlExtension(ctx, httpClientLayer, options?.configFile),     staticSources: (self) => [      {        id: "graphql",        kind: "control",        name: "GraphQL",        tools: [          {            name: "addSource",            description: "Add a GraphQL endpoint and register its operations as tools",            inputSchema: {              type: "object",              properties: {                endpoint: { type: "string" },                name: { type: "string" },                introspectionJson: { type: "string" },                namespace: { type: "string" },                headers: { type: "object" },                queryParams: { type: "object" },                auth: { type: "object" },              },              required: ["endpoint"],            },            outputSchema: {              type: "object",              properties: {                toolCount: { type: "number" },              },              required: ["toolCount"],            },            // Static-tool callers don't name a scope. Default to the            // outermost scope in the executor's stack — for a single-            // scope executor that's the only scope; for a per-user            // stack `[user, org]` it writes at `org` so the source is            // visible across every user.            handler: ({ ctx, args }) =>              self.addSource({                ...(args as Omit<GraphqlSourceConfig, "scope">),                scope: ctx.scopes.at(-1)!.id,              }),          },        ],      },    ],     invokeTool: ({ ctx, toolRow, args }) =>      Effect.gen(function* () {        // toolRow.scope_id is the resolved owning scope of the tool        // (innermost-wins from the executor's stack). The matching        // graphql_operation + graphql_source rows live at the same        // scope, so pin every store lookup to it instead of relying        // on the scoped adapter's stack-wide fall-through.        const toolScope = toolRow.scope_id;        const op = yield* ctx.storage.getOperationByToolId(toolRow.id, toolScope);        if (!op) {          return yield* new GraphqlInvocationError({            message: `No GraphQL operation found for tool "${toolRow.id}"`,            statusCode: Option.none(),          });        }        const source = yield* ctx.storage.getSource(op.sourceId, toolScope);        if (!source) {          return yield* new GraphqlInvocationError({            message: `No GraphQL source found for "${op.sourceId}"`,            statusCode: Option.none(),          });        }         const resolvedHeaders = yield* resolveHeaders(source.headers, ctx.secrets);        const resolvedQueryParams = yield* resolveHeaders(source.queryParams, ctx.secrets);        if (source.auth.kind === "oauth2") {          const accessToken = yield* ctx.connections.accessToken(source.auth.connectionId);          resolvedHeaders.Authorization = `Bearer ${accessToken}`;        }         const result = yield* invokeWithLayer(          op.binding,          (args ?? {}) as Record<string, unknown>,          source.endpoint,

Lines 1–500 of 648.