1120 lines · 44.9 KB · TypeScript
RawDownload
import { Effect, Match, Option, Schema } from "effect";import type { Layer } from "effect";import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import {  authToolFailure,  AuthTemplateSlug,  definePlugin,  IntegrationAlreadyExistsError,  IntegrationDetectionResult,  IntegrationSlug,  mergeAuthTemplates,  sha256Hex,  ToolName,  ToolResult,  type AuthMethodDescriptor,  type IntegrationConfig,  type IntegrationRecord,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,  type ToolDef,} from "@executor-js/sdk/core"; import {  TOKEN_VARIABLE,  describeApiKeyAuthMethod,  describeNoneAuthMethod,  oauthBearerPlacement,  renderAuthPlacements,  requiredPlacementVariables,  type RenderedAuthPlacements,} from "@executor-js/sdk/http-auth"; 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 { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store";import {  GraphqlAuthMethodInput,  decodeGraphqlIntegrationConfig,  decodeGraphqlIntegrationConfigOption,  ExtractedField,  GraphqlIntegrationConfig,  expandGraphqlAuthMethodInputs,  normalizeGraphqlAuthMethods,  OperationBinding,  type GraphqlAuthMethod,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// GraphQL error-body decoding (for invocation responses)// --------------------------------------------------------------------------- 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); const GRAPHQL_PLUGIN_ID = "graphql"; // ---------------------------------------------------------------------------// Extension input shapes// --------------------------------------------------------------------------- /** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL; *  `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson` *  supplies the schema when the endpoint disables live introspection; `headers` *  / `queryParams` are static and also applied to add-time introspection; *  `authenticationTemplate` declares the auth methods a connection can apply *  through. */const GraphqlAddIntegrationInputSchema = Schema.Struct({  endpoint: Schema.String,  slug: Schema.optional(Schema.String),  name: Schema.optional(Schema.String),  introspectionJson: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),  queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),  authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),});export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type; const GraphqlConfigureInputSchema = Schema.Struct({  name: Schema.optional(Schema.String),  endpoint: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),  queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),  authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),});export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type; /** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/ *  :slug/config`). Unlike `configure` (which REPLACES the whole config for the *  generic repair path), `configureAuth` MERGE-APPENDS these methods onto the *  integration's existing `authenticationTemplate`, mirroring OpenAPI's *  `configure`. */const GraphqlConfigureAuthInputSchema = Schema.Struct({  authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),  mode: Schema.optional(Schema.Literals(["merge", "replace"])),});export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type; // ---------------------------------------------------------------------------// Static control-tool schemas// --------------------------------------------------------------------------- const StaticAddIntegrationOutputSchema = Schema.Struct({  slug: Schema.String,  name: Schema.String,});const StaticGetIntegrationInputSchema = Schema.Struct({  slug: Schema.String,});const StaticGetIntegrationOutputSchema = Schema.Struct({  integration: Schema.NullOr(Schema.Unknown),}); const StaticAddIntegrationInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(GraphqlAddIntegrationInputSchema),);const StaticAddIntegrationOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticAddIntegrationOutputSchema),);const StaticGetIntegrationInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetIntegrationInputSchema),);const StaticGetIntegrationOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetIntegrationOutputSchema),); 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.integration, scope: failure.owner },    credential: {      kind: failure.credentialKind,      ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),      connectionId: failure.connection,    },    ...(failure.status !== undefined ? { status: failure.status } : {}),    ...(failure.details !== undefined      ? {          upstream: {            ...(failure.status !== undefined ? { status: failure.status } : {}),            details: failure.details,          },        }      : {}),  }); // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Match `token` as a separator-bounded run inside a URL hostname or path, *  used as a low-confidence detection hint when introspection fails. */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 an integration slug from an endpoint URL. */const slugFromEndpoint = (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 slug  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: IntrospectionField) => !f.name.startsWith("__"))    .slice(0, 12)    .map((f: IntrospectionField) => {      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(" ")} }` : "";}; // Name every generated operation: some servers reject anonymous operations, and// APM tooling keys traces off the operation name. Field names are already valid// GraphQL name tokens, so the upper-cased field name is a safe operation name.const operationNameForField = (fieldName: string): string =>  fieldName.charAt(0).toUpperCase() + fieldName.slice(1); const buildOperationStringForField = (  kind: GraphqlOperationKind,  field: IntrospectionField,  types: ReadonlyMap<string, IntrospectionType>,): string => {  const opType = kind === "query" ? "query" : "mutation";  const opName = operationNameForField(field.name);   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} ${opName}${varDefsStr} { ${field.name}${argPassStr}${selectionSet ? ` ${selectionSet}` : ""} }`;}; interface PreparedOperation {  readonly toolName: 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";    // A tool's name keeps its `<kind>.<field>` path (e.g. `query.hello`,    // `mutation.setGreeting`). The address grammar treats `<tool>` as the    // trailing remainder (see parseToolAddress), so the dot nests naturally.    const toolName = `${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} ${operationNameForField(extracted.fieldName)} { ${extracted.fieldName} }`;     const binding = OperationBinding.make({      kind: extracted.kind,      fieldName: extracted.fieldName,      operationString,      variableNames: extracted.arguments.map((a) => a.name),    });     return {      toolName,      description,      inputSchema: Option.getOrUndefined(extracted.inputSchema),      binding,    };  });}; const annotationsFor = (binding: OperationBinding): ToolAnnotations => {  if (binding.kind === "mutation") {    return {      requiresApproval: true,      approvalDescription: `mutation ${binding.fieldName}`,    };  }  return {};}; // ---------------------------------------------------------------------------// Auth method rendering (D11) — apply the connection's resolved values through// the method the connection references. An oauth2 method is the conventional// bearer placement (with the method's optional header/prefix override) over// the resolved access token; an apikey method renders its declared placements.// --------------------------------------------------------------------------- const renderGraphqlAuthMethod = (  method: GraphqlAuthMethod,  values: Record<string, string | null>,): RenderedAuthPlacements => {  if (method.kind === "apikey") return renderAuthPlacements(method.placements, values);  if (method.kind === "oauth2") {    return renderAuthPlacements([oauthBearerPlacement(method.header, method.prefix)], values);  }  return { headers: {}, queryParams: {} };}; // ---------------------------------------------------------------------------// Introspection — produce operations from a config (live or stored JSON).// --------------------------------------------------------------------------- const buildToolDefs = (prepared: readonly PreparedOperation[]): readonly ToolDef[] =>  prepared.map((p) => ({    name: ToolName.make(p.toolName),    description: p.description,    inputSchema: p.inputSchema,    annotations: annotationsFor(p.binding),  })); const toStoredOperations = (  slug: IntegrationSlug,  prepared: readonly PreparedOperation[],): StoredOperation[] =>  prepared.map((p) => ({    toolName: p.toolName,    integration: String(slug),    binding: p.binding,  })); /** Render an integration's static + resolved-credential auth onto introspection *  headers/query params. Connection-create / tool-generation introspection runs *  with the connection's credential (exactly how its tools are invoked), so an *  auth-required endpoint introspects successfully here rather than at add-time. */const introspectHeadersForConnection = (  config: GraphqlIntegrationConfig,  values: Record<string, string | null>,  templateSlug: AuthTemplateSlug | null,): RenderedAuthPlacements => {  const headers: Record<string, string> = { ...(config.headers ?? {}) };  const queryParams: Record<string, string> = { ...(config.queryParams ?? {}) };  // Render the exact method the connection references; with no slug  // (connection row not yet persisted) fall back to the first declared.  const method =    (templateSlug !== null      ? config.authenticationTemplate.find(          (m: GraphqlAuthMethod) => m.slug === String(templateSlug),        )      : undefined) ?? config.authenticationTemplate[0];  if (method) {    const rendered = renderGraphqlAuthMethod(method, values);    Object.assign(headers, rendered.headers);    Object.assign(queryParams, rendered.queryParams);  }  return { headers, queryParams };}; /** Resolve a config's introspection snapshot text: legacy rows inline it in *  `introspectionJson`; new rows point at the plugin blob store via *  `introspectionHash`. Null when the integration has no snapshot (live *  introspection territory). */const loadIntrospectionJson = (  storage: GraphqlStore,  config: GraphqlIntegrationConfig,): Effect.Effect<string | null, StorageFailure> => {  if (config.introspectionJson != null) return Effect.succeed(config.introspectionJson);  if (config.introspectionHash != null) return storage.getIntrospection(config.introspectionHash);  return Effect.succeed(null);}; /** Introspect a config live or from its stored snapshot, applying connection *  auth. A non-null `introspectionJson` (loaded via `loadIntrospectionJson`) *  short-circuits the network; otherwise this introspects the endpoint with *  the rendered credential. */const introspectForConnection = (  config: GraphqlIntegrationConfig,  introspectionJson: string | null,  values: Record<string, string | null>,  templateSlug: AuthTemplateSlug | null,  httpClientLayer: Layer.Layer<HttpClient.HttpClient>,): Effect.Effect<IntrospectionResult, GraphqlIntrospectionError> => {  if (introspectionJson != null) {    return parseIntrospectionJson(introspectionJson);  }  const auth = introspectHeadersForConnection(config, values, templateSlug);  return introspect(    config.endpoint,    Object.keys(auth.headers).length > 0 ? auth.headers : undefined,    Object.keys(auth.queryParams).length > 0 ? auth.queryParams : undefined,  ).pipe(Effect.provide(httpClientLayer));}; /** Introspect an integration's endpoint (with this connection's credential), *  prepare its operations, persist the bindings, and return them. Invoked from *  `invokeTool` on a cache miss — i.e. when an integration was registered *  without an add-time schema and its bindings haven't been produced yet. */const materializeOperations = (  ctx: PluginCtx<GraphqlStore>,  integration: string,  config: GraphqlIntegrationConfig,  credential: {    readonly template: AuthTemplateSlug;    readonly values: Record<string, string | null>;  },  httpClientLayer: Layer.Layer<HttpClient.HttpClient>,): Effect.Effect<readonly StoredOperation[], GraphqlIntrospectionError | StorageFailure> =>  Effect.gen(function* () {    // Render the exact method this connection references (we have its slug    // here, unlike `resolveTools`) so an auth-required endpoint introspects.    const method = config.authenticationTemplate.find(      (m: GraphqlAuthMethod) => m.slug === String(credential.template),    );    const headers: Record<string, string> = { ...(config.headers ?? {}) };    const queryParams: Record<string, string> = {      ...(config.queryParams ?? {}),    };    if (method) {      const rendered = renderGraphqlAuthMethod(method, credential.values);      Object.assign(headers, rendered.headers);      Object.assign(queryParams, rendered.queryParams);    }     const introspectionJson = yield* loadIntrospectionJson(ctx.storage, config);    const introspection =      introspectionJson != null        ? yield* parseIntrospectionJson(introspectionJson)        : yield* introspect(            config.endpoint,            Object.keys(headers).length > 0 ? headers : undefined,            Object.keys(queryParams).length > 0 ? queryParams : undefined,          ).pipe(Effect.provide(httpClientLayer));     const { result } = yield* extract(introspection).pipe(      Effect.catch(() =>        Effect.succeed({          result: { fields: [] as readonly ExtractedField[] },        } as {          readonly result: { readonly fields: readonly ExtractedField[] };        }),      ),    );

Lines 1–500 of 1120.