import { Effect, Layer, Option } from "effect"; import { HttpClient, HttpClientRequest } from "@effect/platform"; import { type ToolId, type ToolInvoker, ToolInvocationResult, ToolInvocationError, type ScopeId, type SecretId, } from "@executor/sdk"; import { OpenApiInvocationError } from "./errors"; import type { OpenApiOperationStore } from "./operation-store"; import { type HeaderValue, type OperationBinding, InvocationConfig, InvocationResult, type OperationParameter, } from "./types"; // --------------------------------------------------------------------------- // Parameter reading // --------------------------------------------------------------------------- const CONTAINER_KEYS: Record = { path: ["path", "pathParams", "params"], query: ["query", "queryParams", "params"], header: ["headers", "header"], cookie: ["cookies", "cookie"], }; const readParamValue = (args: Record, param: OperationParameter): unknown => { const direct = args[param.name]; if (direct !== undefined) return direct; for (const key of CONTAINER_KEYS[param.location] ?? []) { const container = args[key]; if (typeof container === "object" && container !== null && !Array.isArray(container)) { const nested = (container as Record)[param.name]; if (nested !== undefined) return nested; } } return undefined; }; // --------------------------------------------------------------------------- // Path resolution // --------------------------------------------------------------------------- const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( pathTemplate: string, args: Record, parameters: readonly OperationParameter[], ) { let resolved = pathTemplate; // Resolve declared path parameters for (const param of parameters) { if (param.location !== "path") continue; const value = readParamValue(args, param); if (value === undefined || value === null) { if (param.required) { return yield* new OpenApiInvocationError({ message: `Missing required path parameter: ${param.name}`, statusCode: Option.none(), }); } continue; } resolved = resolved.replaceAll(`{${param.name}}`, encodeURIComponent(String(value))); } // Resolve remaining placeholders from raw args (handles specs that // don't explicitly list path parameters) const remaining = [...resolved.matchAll(/\{([^{}]+)\}/g)] .map((m) => m[1]) .filter((v): v is string => typeof v === "string"); for (const name of remaining) { const value = args[name]; if (value !== undefined && value !== null) { resolved = resolved.replaceAll(`{${name}}`, encodeURIComponent(String(value))); } } const unresolved = [...resolved.matchAll(/\{([^{}]+)\}/g)] .map((m) => m[1]) .filter((v): v is string => typeof v === "string"); if (unresolved.length > 0) { return yield* new OpenApiInvocationError({ message: `Unresolved path parameters: ${[...new Set(unresolved)].join(", ")}`, statusCode: Option.none(), }); } return resolved; }); // --------------------------------------------------------------------------- // Header resolution — resolves secret refs at invocation time // --------------------------------------------------------------------------- const resolveHeaders = ( headers: Record, secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect; }, scopeId: ScopeId, ): Effect.Effect, ToolInvocationError> => Effect.gen(function* () { const resolved: Record = {}; for (const [name, value] of Object.entries(headers)) { if (typeof value === "string") { resolved[name] = value; } else { const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe( Effect.mapError( () => new ToolInvocationError({ toolId: "" as ToolId, message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, cause: undefined, }), ), ); resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; } } return resolved; }); const applyHeaders = ( request: HttpClientRequest.HttpClientRequest, headers: Record, ): HttpClientRequest.HttpClientRequest => { let req = request; for (const [name, value] of Object.entries(headers)) { req = HttpClientRequest.setHeader(req, name, value); } return req; }; // --------------------------------------------------------------------------- // Response helpers // --------------------------------------------------------------------------- const isJsonContentType = (ct: string | null | undefined): boolean => { if (!ct) return false; const normalized = ct.split(";")[0]?.trim().toLowerCase() ?? ""; return ( normalized === "application/json" || normalized.includes("+json") || normalized.includes("json") ); }; // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** Invoke an OpenAPI operation binding. Requires HttpClient in the context. */ export const invoke = Effect.fn("OpenApi.invoke")(function* ( operation: OperationBinding, args: Record, config: InvocationConfig, /** Pre-resolved headers (secrets already resolved) */ resolvedHeaders?: Record, ) { const client = yield* HttpClient.HttpClient; const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters); const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`; // Build the base request — use just the path; baseUrl is applied to the client let request = HttpClientRequest.make(operation.method.toUpperCase() as "GET")(path); // Query parameters for (const param of operation.parameters) { if (param.location !== "query") continue; const value = readParamValue(args, param); if (value === undefined || value === null) continue; request = HttpClientRequest.setUrlParam(request, param.name, String(value)); } // Header parameters for (const param of operation.parameters) { if (param.location !== "header") continue; const value = readParamValue(args, param); if (value === undefined || value === null) continue; request = HttpClientRequest.setHeader(request, param.name, String(value)); } // Request body if (Option.isSome(operation.requestBody)) { const rb = operation.requestBody.value; const bodyValue = args.body ?? args.input; if (bodyValue !== undefined) { if (isJsonContentType(rb.contentType)) { request = HttpClientRequest.bodyUnsafeJson(request, bodyValue); } else { request = HttpClientRequest.bodyText(request, String(bodyValue), rb.contentType); } } } // Static headers (auth, custom headers, etc.) — use pre-resolved if available request = applyHeaders(request, resolvedHeaders ?? {}); // Execute const response = yield* client.execute(request).pipe( Effect.mapError( (err) => new OpenApiInvocationError({ message: `HTTP request failed: ${err.message}`, statusCode: Option.none(), cause: err, }), ), ); const status = response.status; const responseHeaders: Record = { ...response.headers }; // Decode body const contentType = response.headers["content-type"] ?? null; const responseBody: unknown = status === 204 ? null : isJsonContentType(contentType) ? yield* response.json.pipe(Effect.catchAll(() => response.text)) : yield* response.text; const ok = status >= 200 && status < 300; return new InvocationResult({ status, headers: responseHeaders, data: ok ? responseBody : null, error: ok ? null : responseBody, }); }); // --------------------------------------------------------------------------- // ToolInvoker — bridges operation store + HTTP client into SDK invoker // --------------------------------------------------------------------------- const SAFE_METHODS = new Set(["get", "head", "options"]); /** * Derive tool annotations from the HTTP method and path. */ export const annotationsForOperation = ( method: string, pathTemplate: string, ): { requiresApproval?: boolean; approvalDescription?: string; } => { if (SAFE_METHODS.has(method.toLowerCase())) return {}; return { requiresApproval: true, approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, }; }; export const makeOpenApiInvoker = (opts: { readonly operationStore: OpenApiOperationStore; readonly httpClientLayer: Layer.Layer; readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect; }; readonly scopeId: ScopeId; }): ToolInvoker => ({ resolveAnnotations: (toolId: ToolId) => Effect.gen(function* () { const entry = yield* opts.operationStore.get(toolId); if (!entry) return undefined; return annotationsForOperation(entry.binding.method, entry.binding.pathTemplate); }), invoke: (toolId: ToolId, args: unknown) => Effect.gen(function* () { const entry = yield* opts.operationStore.get(toolId); if (!entry) { return yield* new ToolInvocationError({ toolId, message: `No operation found for tool "${toolId}"`, cause: undefined, }); } const { binding, config } = entry; const baseUrl = config.baseUrl; // Resolve secret-backed headers const resolvedHeaders = yield* resolveHeaders(config.headers, opts.secrets, opts.scopeId); const clientWithBaseUrl = baseUrl ? Layer.effect( HttpClient.HttpClient, Effect.map( HttpClient.HttpClient, HttpClient.mapRequest(HttpClientRequest.prependUrl(baseUrl)), ), ).pipe(Layer.provide(opts.httpClientLayer)) : opts.httpClientLayer; const result = yield* invoke( binding, (args ?? {}) as Record, config, resolvedHeaders, ).pipe(Effect.provide(clientWithBaseUrl)); return new ToolInvocationResult({ data: result.data, error: result.error, status: result.status, }); }).pipe( Effect.catchAll((err) => { if ( typeof err === "object" && err !== null && "_tag" in err && (err as { _tag: string })._tag === "ToolInvocationError" ) { return Effect.fail(err as ToolInvocationError); } return Effect.fail( new ToolInvocationError({ toolId, message: `OpenAPI invocation failed: ${err instanceof Error ? err.message : String(err)}`, cause: err, }), ); }), ), });