import { Effect, Layer, Option } from "effect"; import { HttpClient, HttpClientRequest } from "@effect/platform"; import type { StorageFailure } from "@executor/sdk"; import { OpenApiInvocationError } from "./errors"; import { type HeaderValue, type OperationBinding, 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; 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))); } 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 // --------------------------------------------------------------------------- export const resolveHeaders = ( headers: Record, secrets: { readonly get: ( id: string, ) => Effect.Effect; }, ): Effect.Effect< Record, OpenApiInvocationError | StorageFailure > => Effect.gen(function* () { const entries = Object.entries(headers); // Fan out secret lookups: on every invocation, one or two headers // typically each hit the secret store. Resolving them in parallel // is a free wall-clock win — preserved order is only needed for // the final assembly, not the fetches. const values = yield* Effect.all( entries.map(([name, value]) => typeof value === "string" ? Effect.succeed({ name, value }) : secrets.get(value.secretId).pipe( Effect.flatMap((secret) => secret === null ? Effect.fail( new OpenApiInvocationError({ message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, statusCode: Option.none(), }), ) : Effect.succeed({ name, value: value.prefix ? `${value.prefix}${secret}` : secret, }), ), ), ), { concurrency: "unbounded" }, ); const resolved: Record = {}; for (const { name, value } of values) resolved[name] = value; 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 a single operation // --------------------------------------------------------------------------- export const invoke = Effect.fn("OpenApi.invoke")(function* ( operation: OperationBinding, args: Record, resolvedHeaders: Record, ) { const client = yield* HttpClient.HttpClient; const resolvedPath = yield* resolvePath(operation.pathTemplate, args, operation.parameters); const path = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`; let request = HttpClientRequest.make(operation.method.toUpperCase() as "GET")(path); 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)); } 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)); } 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); } } } request = applyHeaders(request, resolvedHeaders); 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 }; const contentType = response.headers["content-type"] ?? null; const mapBodyError = Effect.mapError( (err: { readonly message?: string }) => new OpenApiInvocationError({ message: `Failed to read response body: ${err.message ?? String(err)}`, statusCode: Option.some(status), cause: err, }), ); const responseBody: unknown = status === 204 ? null : isJsonContentType(contentType) ? yield* response.json.pipe( Effect.catchAll(() => response.text), mapBodyError, ) : yield* response.text.pipe(mapBodyError); const ok = status >= 200 && status < 300; return new InvocationResult({ status, headers: responseHeaders, data: ok ? responseBody : null, error: ok ? null : responseBody, }); }); // --------------------------------------------------------------------------- // Invoke with a provided HttpClient layer + optional baseUrl prefix // --------------------------------------------------------------------------- export const invokeWithLayer = ( operation: OperationBinding, args: Record, baseUrl: string, resolvedHeaders: Record, httpClientLayer: Layer.Layer, ) => { const clientWithBaseUrl = baseUrl ? Layer.effect( HttpClient.HttpClient, Effect.map( HttpClient.HttpClient, HttpClient.mapRequest(HttpClientRequest.prependUrl(baseUrl)), ), ).pipe(Layer.provide(httpClientLayer)) : httpClientLayer; return invoke(operation, args, resolvedHeaders).pipe(Effect.provide(clientWithBaseUrl)); }; // --------------------------------------------------------------------------- // Derive annotations from HTTP method // --------------------------------------------------------------------------- const SAFE_METHODS = new Set(["get", "head", "options"]); 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}`, }; };