952 lines · 35.3 KB · TypeScript
RawDownload
import { Effect, Layer, Option } from "effect";import { HttpClient, HttpClientRequest } from "effect/unstable/http";import type { ToolFileValue } from "@executor-js/sdk/core"; import { OpenApiInvocationError } from "./errors";import { resolveServerUrl } from "./openapi-utils";import {  type EncodingObject,  type OperationFileHint,  type OperationBinding,  InvocationResult,  type MediaBinding,  type OperationParameter,  type ServerInfo,} 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;}; const primitiveToString = (value: unknown): string =>  typeof value === "object" && value !== null ? JSON.stringify(value) : String(value); // RFC 3986 §2.2 reserved chars. `allowReserved: true` leaves these// unencoded; default OAS behavior encodes everything non-unreserved.const RESERVED_UNENCODED_RE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/; const encodeReservedAware = (raw: string, allowReserved: boolean): string => {  if (!allowReserved) return encodeURIComponent(raw);  // Walk char-by-char so the reserved set passes through as-is.  let out = "";  for (const ch of raw) {    out += RESERVED_UNENCODED_RE.test(ch) ? ch : encodeURIComponent(ch);  }  return out;}; const queryParamValues = (value: unknown, param: OperationParameter): string[] => {  if (value === undefined || value === null) return [];  if (!Array.isArray(value)) return [primitiveToString(value)];   const style = Option.getOrUndefined(param.style) ?? "form";  const explode = Option.getOrElse(param.explode, () => true);   if (explode) return value.map(primitiveToString);   const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";  return [value.map(primitiveToString).join(separator)];}; // ---------------------------------------------------------------------------// 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;    }    const encoded = encodeReservedAware(      String(value),      Option.getOrElse(param.allowReserved, () => false),    );    resolved = resolved.replaceAll(`{${param.name}}`, encoded);    resolved = resolved.replaceAll(`{+${param.name}}`, encoded);  }   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;}); // GitHub (and some other upstreams) reject requests that lack a User-Agent// header with a 403 ("Request forbidden by administrative rules"), which is// indistinguishable from a credential rejection downstream. Send a default so// those calls succeed. It is applied before operation header params and// resolved auth headers, so a spec- or connection-provided User-Agent wins.const DEFAULT_USER_AGENT = "executor"; 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 normalizeContentType = (ct: string | null | undefined): string =>  ct?.split(";")[0]?.trim().toLowerCase() ?? ""; const isJsonContentType = (ct: string | null | undefined): boolean => {  const normalized = normalizeContentType(ct);  if (!normalized) return false;  return (    normalized === "application/json" || normalized.includes("+json") || normalized.includes("json")  );}; const isFormUrlEncoded = (ct: string | null | undefined): boolean =>  normalizeContentType(ct) === "application/x-www-form-urlencoded"; const isMultipartFormData = (ct: string | null | undefined): boolean =>  normalizeContentType(ct).startsWith("multipart/form-data"); const isXmlContentType = (ct: string | null | undefined): boolean => {  const normalized = normalizeContentType(ct);  if (!normalized) return false;  return (    normalized === "application/xml" || normalized === "text/xml" || normalized.endsWith("+xml")  );}; const isTextContentType = (ct: string | null | undefined): boolean =>  normalizeContentType(ct).startsWith("text/"); const isOctetStream = (ct: string | null | undefined): boolean =>  normalizeContentType(ct) === "application/octet-stream"; const bytesToBase64 = (bytes: Uint8Array): string => {  let binary = "";  const chunkSize = 0x8000;  for (let i = 0; i < bytes.length; i += chunkSize) {    binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));  }  return btoa(binary);}; const normalizeBase64 = (value: string, encoding: "base64" | "base64url"): string => {  const compact = value.replace(/\s/g, "");  const alphabet =    encoding === "base64url" ? compact.replace(/-/g, "+").replace(/_/g, "/") : compact;  const remainder = alphabet.length % 4;  return remainder === 0 ? alphabet : `${alphabet}${"=".repeat(4 - remainder)}`;}; const byteLengthFromBase64 = (base64: string): number => {  const compact = base64.replace(/\s/g, "");  const padding = compact.endsWith("==") ? 2 : compact.endsWith("=") ? 1 : 0;  return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);}; const isGenericMimeType = (mimeType: string): boolean =>  normalizeContentType(mimeType) === "application/octet-stream"; const startsWithBytes = (bytes: Uint8Array, prefix: readonly number[]): boolean =>  prefix.every((byte, index) => bytes[index] === byte); const isLikelyUtf8Text = (bytes: Uint8Array): boolean => {  if (bytes.length === 0) return false;  let text: string;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: TextDecoder throws while probing arbitrary binary content  try {    text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);  } catch {    return false;  }  let suspicious = 0;  for (let index = 0; index < text.length; index += 1) {    const code = text.charCodeAt(index);    const allowedControl = code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d;    if (code === 0x00) return false;    if (code < 0x20 && !allowedControl) suspicious += 1;  }  return suspicious / Math.max(1, text.length) <= 0.02;}; const sniffMimeType = (bytes: Uint8Array): string | null => {  if (startsWithBytes(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";  if (startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {    return "image/png";  }  if (    startsWithBytes(bytes, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||    startsWithBytes(bytes, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])  ) {    return "image/gif";  }  if (    startsWithBytes(bytes, [0x52, 0x49, 0x46, 0x46]) &&    bytes[8] === 0x57 &&    bytes[9] === 0x45 &&    bytes[10] === 0x42 &&    bytes[11] === 0x50  ) {    return "image/webp";  }  if (startsWithBytes(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf";  if (    startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]) ||    startsWithBytes(bytes, [0x50, 0x4b, 0x05, 0x06]) ||    startsWithBytes(bytes, [0x50, 0x4b, 0x07, 0x08])  ) {    return "application/zip";  }  if (isLikelyUtf8Text(bytes)) return "text/plain";  return null;}; const bytesFromBase64Prefix = (base64: string): Uint8Array => {  const prefix = base64.slice(0, Math.min(base64.length, 64));  const binary = atob(prefix);  const bytes = new Uint8Array(binary.length);  for (let index = 0; index < binary.length; index += 1) {    bytes[index] = binary.charCodeAt(index);  }  return bytes;}; const sniffMimeTypeFromBase64 = (base64: string): string | null =>  sniffMimeType(bytesFromBase64Prefix(base64)); type DecodedBase64Body = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false }; const base64ToUint8Array = (value: string): Uint8Array | null => {  let binary = "";  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: atob throws for invalid base64; invalid shapes are treated as non-byte input  try {    binary = atob(normalizeBase64(value, "base64"));  } catch {    return null;  }  const bytes = new Uint8Array(binary.length);  for (let index = 0; index < binary.length; index += 1) {    bytes[index] = binary.charCodeAt(index);  }  return bytes;}; const decodeBase64Body = (value: string): DecodedBase64Body => {  const bytes = base64ToUint8Array(value);  return bytes ? { ok: true, bytes } : { ok: false };}; const toUint8Array = (value: unknown): Uint8Array | null => {  if (value instanceof Uint8Array) return value;  if (value instanceof ArrayBuffer) return new Uint8Array(value);  if (ArrayBuffer.isView(value)) {    const view = value as ArrayBufferView;    return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);  }  if (Array.isArray(value) && value.every((v) => typeof v === "number")) {    return new Uint8Array(value as readonly number[]);  }  return null;}; const readNestedBodyBase64 = (value: unknown): unknown =>  typeof value === "object" &&  value !== null &&  !Array.isArray(value) &&  Object.prototype.hasOwnProperty.call(value, "bodyBase64")    ? (value as Record<string, unknown>).bodyBase64    : undefined; const readHintString = (option: OperationFileHint["dataField"], fallback: string): string =>  Option.getOrElse(option, () => fallback); const readHintMimeType = (hint: OperationFileHint, fallback: string): string =>  Option.getOrElse(hint.mimeType, () => fallback); const readHintEncoding = (hint: OperationFileHint): "base64" | "base64url" =>  Option.getOrElse(hint.encoding, () => "base64"); const fileFromByteField = (body: unknown, hint: OperationFileHint): ToolFileValue | null => {  if (typeof body !== "object" || body === null || Array.isArray(body)) return null;  const record = body as Record<string, unknown>;  const dataField = readHintString(hint.dataField, "data");  const rawData = record[dataField];  if (typeof rawData !== "string") return null;   const data = normalizeBase64(rawData, readHintEncoding(hint));  const sizeField = Option.getOrUndefined(hint.sizeField);  const byteLength =    sizeField && typeof record[sizeField] === "number"      ? record[sizeField]      : byteLengthFromBase64(data);  const hintedMimeType = readHintMimeType(hint, "application/octet-stream");   return {    _tag: "ToolFile",    mimeType: isGenericMimeType(hintedMimeType)      ? (sniffMimeTypeFromBase64(data) ?? hintedMimeType)      : hintedMimeType,    encoding: "base64",    data,    byteLength,  };}; const fileFromBinaryBytes = (  bytes: Uint8Array,  hint: OperationFileHint,  contentType: string | null | undefined,): ToolFileValue => {  const hintedMimeType = contentType ?? readHintMimeType(hint, "application/octet-stream");  return {    _tag: "ToolFile",    mimeType: isGenericMimeType(hintedMimeType)      ? (sniffMimeType(bytes) ?? hintedMimeType)      : hintedMimeType,    encoding: "base64",    data: bytesToBase64(bytes),    byteLength: bytes.byteLength,  };}; type FormDataRecord = Parameters<typeof HttpClientRequest.bodyFormDataRecord>[1];type FormDataCoercible = FormDataRecord[string]; // Pull a plain ArrayBuffer out of a Uint8Array — `new Blob([u8])` rejects// views whose `.buffer` is `SharedArrayBuffer | ArrayBuffer` under strict// lib.dom typings.const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {  const copy = new ArrayBuffer(bytes.byteLength);  new Uint8Array(copy).set(bytes);  return copy;}; // ---------------------------------------------------------------------------// OpenAPI 3.x encoding — per-property style/explode/allowReserved/contentType// for multipart/form-data and application/x-www-form-urlencoded bodies.// Spec ref: https://spec.openapis.org/oas/v3.1.0#encoding-object// --------------------------------------------------------------------------- type StyleExplode = {  readonly style: string;  readonly explode: boolean;  readonly allowReserved: boolean;}; const DEFAULT_FORM_STYLE: StyleExplode = {  style: "form",  explode: true,  allowReserved: false,}; const resolveStyleExplode = (e: EncodingObject | undefined): StyleExplode => {  if (!e) return DEFAULT_FORM_STYLE;  return {    style: Option.getOrElse(e.style, () => DEFAULT_FORM_STYLE.style),    explode: Option.getOrElse(e.explode, () => DEFAULT_FORM_STYLE.explode),    allowReserved: Option.getOrElse(e.allowReserved, () => DEFAULT_FORM_STYLE.allowReserved),  };}; const encodeFormValue = (v: unknown, allowReserved: boolean): string => {  const raw = typeof v === "object" && v !== null ? JSON.stringify(v) : String(v);  return encodeReservedAware(raw, allowReserved);}; /** * Serialize a record to application/x-www-form-urlencoded with OAS3 style * rules honored per-field. Supports `form` (default), `deepObject`, * `pipeDelimited`, `spaceDelimited` styles with `explode` true / false. */const serializeFormUrlEncoded = (  value: Record<string, unknown>,  encoding: Record<string, EncodingObject> | undefined,): string => {  const parts: string[] = [];  for (const [key, raw] of Object.entries(value)) {    if (raw === undefined || raw === null) continue;    const { style, explode, allowReserved } = resolveStyleExplode(encoding?.[key]);    const encKey = encodeURIComponent(key);     if (Array.isArray(raw)) {      if (explode) {        for (const v of raw) {          parts.push(`${encKey}=${encodeFormValue(v, allowReserved)}`);        }      } else {        const sep = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";        parts.push(          `${encKey}=${encodeFormValue(            raw.map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(sep),            allowReserved,          )}`,        );      }      continue;    }     if (typeof raw === "object") {      const entries = Object.entries(raw as Record<string, unknown>).filter(        ([, v]) => v !== undefined && v !== null,      );      if (style === "deepObject") {        for (const [subkey, subval] of entries) {          // Encode the whole `key[subkey]` fragment so `[` / `]` become          // `%5B` / `%5D`. Matches swagger-client's behaviour and remains          // accepted by common server-side parsers (qs, Rails, etc.).          parts.push(            `${encodeURIComponent(`${key}[${subkey}]`)}=${encodeFormValue(subval, allowReserved)}`,          );        }      } else if (explode) {        // form + explode=true on object: sub-keys become top-level fields.        for (const [subkey, subval] of entries) {          parts.push(`${encodeURIComponent(subkey)}=${encodeFormValue(subval, allowReserved)}`);        }      } else {        // form + explode=false on object: flatten to csv key,val,key,val.        const flat = entries.flatMap(([k, v]) => [          k,          typeof v === "object" ? JSON.stringify(v) : String(v),        ]);        parts.push(`${encKey}=${encodeFormValue(flat.join(","), allowReserved)}`);      }      continue;    }     parts.push(`${encKey}=${encodeFormValue(raw, allowReserved)}`);  }  return parts.join("&");}; /** * Best-effort build of a multipart FormData entry record. * * If `encoding[key].contentType` is declared (OAS3 §4.8.15), wrap the value * in a `Blob` with that type so the runtime multipart framer emits the * per-part `Content-Type` header (e.g. `application/json` for a metadata * part whose server expects parsed JSON). * * Otherwise: primitives pass through, arrays handle their item types, byte * shapes wrap as Blob, nested objects JSON-stringify (never `[object Object]`). */const coerceFormDataRecord = (  value: Record<string, unknown>,  encoding: Record<string, EncodingObject> | undefined,): FormDataRecord => {  const out: Record<string, FormDataCoercible> = {};  for (const [key, raw] of Object.entries(value)) {    if (raw === undefined || raw === null) continue;     const partType = encoding?.[key]      ? Option.getOrUndefined(encoding[key]!.contentType)

Lines 1–500 of 952.