669 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,  SourceDetectionResult,  type StorageFailure,  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 { GraphqlExtractionError, GraphqlIntrospectionError } 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;} /** * Errors any GraphQL extension method may surface. `GraphqlIntrospectionError` * and `GraphqlExtractionError` are plugin-domain tagged errors that flow * directly to clients (4xx, each carrying its own `HttpApiSchema` status). * `StorageFailure` covers raw backend failures (`StorageError` plus * `UniqueViolationError`); the HTTP edge (`@executor-js/api`'s `withCapture`) * translates `StorageError` to the opaque `InternalError({ traceId })` at * Layer composition. */export type GraphqlExtensionFailure =  | GraphqlIntrospectionError  | GraphqlExtractionError  | StorageFailure; export interface GraphqlPluginExtension {  /** Add a GraphQL endpoint and register its operations as tools */  readonly addSource: (    config: GraphqlSourceConfig,  ) => Effect.Effect<    { readonly toolCount: number; readonly namespace: string },    GraphqlExtensionFailure  >;   /** Remove all tools from a previously added GraphQL source by namespace.   *  `scope` pins the cleanup to the exact row — without it a shadowed   *  outer-scope source with the same namespace could be wiped instead. */  readonly removeSource: (    namespace: string,    scope: string,  ) => Effect.Effect<void, StorageFailure>;   /** Fetch the full stored source by namespace (or null if missing).   *  `scope` returns the exact row at that scope. For fall-through   *  reads across the executor's scope stack, use `executor.sources.*`. */  readonly getSource: (    namespace: string,    scope: string,  ) => Effect.Effect<StoredGraphqlSource | null, StorageFailure>;   /** Update config (endpoint, headers) for an existing GraphQL source.   *  Does NOT re-introspect or re-register tools — just patches the   *  stored endpoint/headers used at invoke time. `scope` pins the   *  mutation to a single row so shadowed rows at other scopes are   *  untouched. */  readonly updateSource: (    namespace: string,    scope: string,    input: GraphqlUpdateSourceInput,  ) => Effect.Effect<void, StorageFailure>;} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** 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 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),}); 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) => {      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(                (err) =>                  new GraphqlIntrospectionError({                    message: `Failed to resolve OAuth connection "${auth.connectionId}": ${                      "message" in err                        ? (err as { message: string }).message                        : String(err)                    }`,                  }),              ),            );          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;             // Persist the source + per-operation bindings first so any            // subsequent core-source register collision rolls back both.            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 };          }),        );       const configFile = options?.configFile;       return {        addSource: (config) =>          addSourceInternal(config).pipe(            Effect.tap((result) =>              configFile                ? configFile.upsertSource(                    toGraphqlConfigEntry(result.namespace, config),                  )                : Effect.void,            ),          ),         removeSource: (namespace, scope) =>          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, scope) =>          ctx.storage.getSource(namespace, scope),         updateSource: (namespace, scope, input) =>          ctx.storage.updateSourceMeta(namespace, scope, {            name: input.name?.trim() || undefined,            endpoint: input.endpoint,            headers: input.headers,            queryParams: input.queryParams,            auth: input.auth,          }),      } satisfies GraphqlPluginExtension;    },     staticSources: (self) => [      {        id: "graphql",        kind: "control",        name: "GraphQL",

Lines 1–500 of 669.