1383 lines · 50.5 KB · TypeScript
RawDownload
import { Effect, Match, Option, Schema } from "effect";import type { Layer } from "effect";import { HttpClient } from "effect/unstable/http"; import {  type CredentialBindingRef,  type CredentialBindingValue,  definePlugin,  tool,  defaultSourceInstallScopeId,  ScopeId,  SourceDetectionResult,  StorageError,  ToolResult,  authToolFailure,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,  type ToolRow,} from "@executor-js/sdk/core";import {  compileHttpNamedCredentialMap,  OAuth2SourceConfig,  httpCredentialInputToBindingValue,  type HttpConfiguredValueInput,} from "@executor-js/sdk/http-source"; 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 {  GraphqlAuthRequiredError,  GraphqlIntrospectionError,  GraphqlInvocationError,} from "./errors";import { invokeWithLayer } from "./invoke";import { graphqlPresets } from "./presets";import {  graphqlSchema,  makeDefaultGraphqlStore,  type GraphqlStore,  type StoredGraphqlSource,  type StoredOperation,} from "./store";import {  ExtractedField,  GraphqlConfiguredValueInput as GraphqlConfiguredValueInputSchema,  GRAPHQL_OAUTH_CONNECTION_SLOT,  GraphqlCredentialInput as GraphqlCredentialInputSchema,  GraphqlSourceAuthInput as GraphqlSourceAuthInputSchema,  graphqlHeaderSlot,  graphqlQueryParamSlot,  OperationBinding,  type ConfiguredGraphqlCredentialValue,  type GraphqlConfiguredValueInput,  type GraphqlCredentialInput,  type GraphqlSourceAuth,  type HeaderValue as HeaderValueValue,  type GraphqlSourceAuthInput,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- const GraphqlErrorBody = Schema.Struct({ message: Schema.String });const GraphqlErrorsBody = Schema.Array(Schema.Unknown);const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>  Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>  errors    .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)    .find((message) => message !== undefined && message.length > 0); export type HeaderValue = HeaderValueValue;export type GraphqlCredentialValue = ConfiguredGraphqlCredentialValue; 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. */  readonly name: string;  /** Optional: introspection JSON text (if endpoint doesn't support introspection) */  readonly introspectionJson?: string;  /** Namespace for the tools. */  readonly namespace: string;  /** Headers applied to every request. Secret entries declare source-owned slots. */  readonly headers?: Record<string, GraphqlConfiguredValueInput>;  /** Query parameters applied to every request. Secret entries declare source-owned slots. */  readonly queryParams?: Record<string, GraphqlConfiguredValueInput>;  /** Optional OAuth2 credential used as a Bearer token for every request. */  readonly oauth2?: OAuth2SourceConfig;  /** Initial credential bindings used while adding and introspecting this source. */  readonly credentials?: GraphqlInitialCredentialsInput;} const GraphqlInitialCredentialsInputSchema = Schema.Struct({  scope: Schema.String,  headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),  queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),  auth: Schema.optional(GraphqlSourceAuthInputSchema),});type GraphqlInitialCredentialsInput = typeof GraphqlInitialCredentialsInputSchema.Type; const StaticAddSourceInputSchema = Schema.Struct({  endpoint: Schema.String,  name: Schema.String,  introspectionJson: Schema.optional(Schema.String),  namespace: Schema.String,  headers: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)),  queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlConfiguredValueInputSchema)),  oauth2: Schema.optional(OAuth2SourceConfig),  credentials: Schema.optional(GraphqlInitialCredentialsInputSchema),});const SourceConfigureInputSchema = Schema.Struct({  name: Schema.optional(Schema.String),  endpoint: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),  queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)),  auth: Schema.optional(GraphqlSourceAuthInputSchema),});const StaticConfigureSourceInputSchema = Schema.Struct({  source: Schema.Struct({    id: Schema.String,    scope: Schema.String,  }),  scope: Schema.String,  ...SourceConfigureInputSchema.fields,});const StaticConfigureSourceOutputSchema = Schema.Struct({  configured: Schema.Boolean,});const StaticGetSourceInputSchema = Schema.Struct({  namespace: Schema.String,  scope: Schema.String,});const StaticGetSourceOutputSchema = Schema.Struct({  source: Schema.NullOr(Schema.Unknown),}); const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticAddSourceInputSchema),);const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(    Schema.Struct({      namespace: Schema.String,      source: Schema.Struct({        id: Schema.String,        scope: Schema.String,      }),      toolCount: Schema.Number,    }),  ),);const StaticGetSourceInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetSourceInputSchema),);const StaticGetSourceOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetSourceOutputSchema),);const StaticConfigureSourceInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticConfigureSourceInputSchema),);const StaticConfigureSourceOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticConfigureSourceOutputSchema),); const graphqlToolFailure = (code: string, message: string, details?: unknown) =>  ToolResult.fail({    code,    message,    ...(details === undefined ? {} : { details }),  }); const graphqlAuthToolFailure = (failure: GraphqlAuthRequiredError) =>  authToolFailure({    code: failure.code,    message: failure.message,    source: { id: failure.sourceId, scope: failure.sourceScope },    credential: {      kind: failure.credentialKind,      ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),      ...(failure.slotKey ? { slotKey: failure.slotKey } : {}),      ...(failure.secretId ? { secretId: failure.secretId } : {}),      ...(failure.connectionId ? { connectionId: failure.connectionId } : {}),    },    ...(failure.status !== undefined ? { status: failure.status } : {}),    ...(failure.details !== undefined      ? {          upstream: {            ...(failure.status !== undefined ? { status: failure.status } : {}),            details: failure.details,          },        }      : {}),    recovery: { configureSourceTool: "executor.graphql.configureSource" },  }); const resolveStaticScopeInput = (  ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] },  value: string,): string =>  String(    ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value,  ); // ---------------------------------------------------------------------------// Plugin extension// --------------------------------------------------------------------------- export interface GraphqlSourceRef {  readonly id: string;  readonly scope: string;} export interface GraphqlConfigureSourceInput {  readonly scope: string;  readonly name?: string;  readonly endpoint?: string;  readonly headers?: Record<string, GraphqlCredentialInput>;  readonly queryParams?: Record<string, GraphqlCredentialInput>;  readonly auth?: GraphqlSourceAuthInput;} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Match `token` as a separator-bounded run inside a URL hostname or path, *  used as a low-confidence detection hint when introspection fails. *  Boundary chars are everything non-alphanumeric, so `/api/graphql`, *  `graphql.example.com`, `graphql-api`, and `graphql_v2` all match while *  `graphqlserver` and `/graphqlite` do not. */const urlMatchesToken = (url: URL, token: string): boolean => {  const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i");  return re.test(url.hostname) || re.test(url.pathname);}; /** 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 =>  Match.value(ref.kind).pipe(    Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")),    Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")),    Match.option,    Option.getOrElse(() => 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 = OperationBinding.make({      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 => {  const headers: Record<string, HeaderValue> = {};  for (const [name, value] of Object.entries(config.headers ?? {})) {    if (typeof value === "string" || !("kind" in value)) {      headers[name] = value;    }  }  return {    kind: "graphql",    endpoint: config.endpoint,    introspectionJson: config.introspectionJson,    namespace,    headers: headersToConfigValues(Object.keys(headers).length > 0 ? headers : undefined),  };}; const GRAPHQL_PLUGIN_ID = "graphql"; const scopeRanks = (ctx: PluginCtx<GraphqlStore>): ReadonlyMap<string, number> =>  new Map(ctx.scopes.map((scope, index) => [String(scope.id), index])); const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: string): number =>  ranks.get(scopeId) ?? Infinity; const resolveGraphqlSourceBinding = (  ctx: PluginCtx<GraphqlStore>,  sourceId: string,  sourceScope: string,  slot: string,): Effect.Effect<CredentialBindingRef | null, StorageFailure> =>  Effect.gen(function* () {    const ranks = scopeRanks(ctx);    const sourceSourceRank = scopeRank(ranks, sourceScope);    if (sourceSourceRank === Infinity) return null;    const bindings = yield* ctx.credentialBindings.listForSource({      pluginId: GRAPHQL_PLUGIN_ID,      sourceId,      sourceScope: ScopeId.make(sourceScope),    });    const binding = bindings      .filter(        (candidate) =>          candidate.slotKey === slot && scopeRank(ranks, candidate.scopeId) <= sourceSourceRank,      )      .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0];    return binding ?? null;  }); const validateGraphqlBindingTarget = (  ctx: PluginCtx<GraphqlStore>,  input: {    readonly sourceScope: string;    readonly targetScope: string;    readonly sourceId: string;  },): Effect.Effect<void, StorageFailure> =>  Effect.gen(function* () {    const ranks = scopeRanks(ctx);    const sourceSourceRank = scopeRank(ranks, input.sourceScope);    const targetRank = scopeRank(ranks, input.targetScope);    const scopeList = `[${ctx.scopes.map((s) => s.id).join(", ")}]`;    if (sourceSourceRank === Infinity) {      return yield* new StorageError({        message:          `GraphQL source binding references source scope "${input.sourceScope}" ` +          `which is not in the executor's scope stack ${scopeList}.`,        cause: undefined,      });    }    if (targetRank === Infinity) {      return yield* new StorageError({        message:          `GraphQL source binding targets scope "${input.targetScope}" which is not ` +          `in the executor's scope stack ${scopeList}.`,        cause: undefined,

Lines 1–500 of 1383.