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<string, readonly string[]> = { path: ["path", "pathParams", "params"], query: ["query", "queryParams", "params"], header: ["headers", "header"], cookie: ["cookies", "cookie"],}; const readParamValue = (args: Record<string, unknown>, 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<string, unknown>)[param.name]; if (nested !== undefined) return nested; } } return undefined;}; // ---------------------------------------------------------------------------// Path resolution// --------------------------------------------------------------------------- const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( pathTemplate: string, args: Record<string, unknown>, 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<string, HeaderValue>, secrets: { readonly get: ( id: string, ) => Effect.Effect<string | null, StorageFailure>; },): Effect.Effect< Record<string, string>, 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<string, string> = {}; for (const { name, value } of values) resolved[name] = value; return resolved; }); const applyHeaders = ( request: HttpClientRequest.HttpClientRequest, headers: Record<string, string>,): 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<string, unknown>, resolvedHeaders: Record<string, string>,) { 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<string, string> = { ...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<string, unknown>, baseUrl: string, resolvedHeaders: Record<string, string>, httpClientLayer: Layer.Layer<HttpClient.HttpClient>,) => { 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}`, };};
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<string, readonly string[]> = {
path: ["path", "pathParams", "params"],
query: ["query", "queryParams", "params"],
header: ["headers", "header"],
cookie: ["cookies", "cookie"],
};
const readParamValue = (args: Record<string, unknown>, 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<string, unknown>)[param.name];
if (nested !== undefined) return nested;
}
}
return undefined;
};
// ---------------------------------------------------------------------------
// Path resolution
// ---------------------------------------------------------------------------
const resolvePath = Effect.fn("OpenApi.resolvePath")(function* (
pathTemplate: string,
args: Record<string, unknown>,
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<string, HeaderValue>,
secrets: {
readonly get: (
id: string,
) => Effect.Effect<string | null, StorageFailure>;
},
): Effect.Effect<
Record<string, string>,
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<string, string> = {};
for (const { name, value } of values) resolved[name] = value;
return resolved;
});
const applyHeaders = (
request: HttpClientRequest.HttpClientRequest,
headers: Record<string, string>,
): 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<string, unknown>,
resolvedHeaders: Record<string, string>,
) {
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<string, string> = { ...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<string, unknown>,
baseUrl: string,
resolvedHeaders: Record<string, string>,
httpClientLayer: Layer.Layer<HttpClient.HttpClient>,
) => {
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}`,
};
};