289 lines · 9.5 KB · TypeScript
RawDownload
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}`,  };};