import { Effect, Option } from "effect"; import { FetchHttpClient, HttpClient } from "@effect/platform"; import type { Layer } from "effect"; import { definePlugin, type ExecutorPlugin, type PluginContext, ToolId, type ToolRegistration, } from "@executor/sdk"; import { parse } from "./parse"; import { extract } from "./extract"; import { makeOpenApiInvoker } from "./invoke"; import { resolveBaseUrl } from "./openapi-utils"; import { makeInMemoryOperationStore, type OpenApiOperationStore, } from "./operation-store"; import { previewSpec, type SpecPreview } from "./preview"; import { type ExtractedOperation, InvocationConfig, OperationBinding, } from "./types"; // --------------------------------------------------------------------------- // Plugin config // --------------------------------------------------------------------------- /** A header value — either a static string or a reference to a secret */ export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string }; export interface OpenApiSpecConfig { readonly spec: string; readonly baseUrl?: string; readonly namespace?: string; /** Headers applied to every request. Values can reference secrets. */ readonly headers?: Record; } // --------------------------------------------------------------------------- // Plugin extension // --------------------------------------------------------------------------- export interface OpenApiPluginExtension { /** Preview a spec without registering — returns metadata, auth strategies, header presets */ readonly previewSpec: ( specText: string, ) => Effect.Effect; /** Add an OpenAPI spec and register its operations as tools */ readonly addSpec: ( config: OpenApiSpecConfig, ) => Effect.Effect<{ readonly toolCount: number }, Error>; /** Remove all tools from a previously added spec by namespace */ readonly removeSpec: (namespace: string) => Effect.Effect; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const operationDescription = (op: ExtractedOperation): string => Option.getOrElse(op.description, () => Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`, ), ); const toRegistration = ( op: ExtractedOperation, namespace: string, ): ToolRegistration => ({ id: ToolId.make(`${namespace}.${op.operationId}`), pluginKey: "openapi", name: op.operationId as string, description: operationDescription(op), tags: [...op.tags, "openapi", namespace], inputSchema: Option.getOrUndefined(op.inputSchema), outputSchema: Option.getOrUndefined(op.outputSchema), }); const toBinding = (op: ExtractedOperation): OperationBinding => new OperationBinding({ method: op.method, pathTemplate: op.pathTemplate, parameters: [...op.parameters], requestBody: op.requestBody, }); // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- export const openApiPlugin = (options?: { readonly httpClientLayer?: Layer.Layer; readonly operationStore?: OpenApiOperationStore; }): ExecutorPlugin<"openapi", OpenApiPluginExtension> => { const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer; const operationStore = options?.operationStore ?? makeInMemoryOperationStore(); return definePlugin({ key: "openapi", init: (ctx: PluginContext) => Effect.gen(function* () { yield* ctx.tools.registerInvoker( "openapi", makeOpenApiInvoker({ operationStore, httpClientLayer, secrets: ctx.secrets, scopeId: ctx.scope.id, }), ); return { extension: { previewSpec: (specText: string) => previewSpec(specText), addSpec: (config: OpenApiSpecConfig) => Effect.gen(function* () { const doc = yield* parse(config.spec); const result = yield* extract(doc); const namespace = config.namespace ?? Option.getOrElse(result.title, () => "api") .toLowerCase() .replace(/[^a-z0-9]+/g, "_"); if (doc.components?.schemas) { yield* ctx.tools.registerDefinitions(doc.components.schemas); } const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers); const invocationConfig = new InvocationConfig({ baseUrl, headers: config.headers ?? {}, }); const registrations = result.operations.map((op) => toRegistration(op, namespace), ); yield* Effect.forEach( result.operations, (op) => operationStore.put( ToolId.make(`${namespace}.${op.operationId}`), namespace, toBinding(op), invocationConfig, ), { discard: true }, ); yield* ctx.tools.register(registrations); return { toolCount: registrations.length }; }), removeSpec: (namespace: string) => Effect.gen(function* () { const toolIds = yield* operationStore.removeByNamespace(namespace); if (toolIds.length > 0) { yield* ctx.tools.unregister(toolIds); } }), }, close: () => Effect.gen(function* () { const allTools = yield* ctx.tools.list({ tags: ["openapi"] }); if (allTools.length > 0) { yield* ctx.tools.unregister(allTools.map((t) => t.id)); } }), }; }), }); };