1121 lines · 40.6 KB · TypeScript
RawDownload
import { Effect, Exit, Layer, Option, Schema, Stream } 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() ?? ""; export const STREAM_MAX_BYTES = 1_000_000;export const STREAM_MAX_MS = 10_000; class StreamCapReached extends Error {  readonly _tag = "StreamCapReached";} export interface StreamingResponseCaps {  readonly maxBytes?: number;  readonly maxMs?: number;} const STREAMING_RESPONSE_CONTENT_TYPES = new Set([  "application/stream+json",  "application/x-ndjson",  "application/jsonl",  "text/event-stream",]); const isStreamingResponseContentType = (ct: string | null | undefined): boolean =>  STREAMING_RESPONSE_CONTENT_TYPES.has(normalizeContentType(ct)); 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 decodeJsonLine = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const parseStreamingJsonLines = (text: string, truncated: boolean): unknown => {  const lines = text.split(/\r?\n/);  let lastNonEmptyIndex = -1;  for (let index = lines.length - 1; index >= 0; index--) {    const line = lines[index];    if (line !== undefined && line.trim() !== "") {      lastNonEmptyIndex = index;      break;    }  }  const rows: unknown[] = [];   for (let index = 0; index < lines.length; index++) {    const line = lines[index];    if (line === undefined || line.trim() === "") continue;    const parsed = decodeJsonLine(line);    if (Exit.isSuccess(parsed)) {      rows.push(parsed.value);      continue;    }    // A trailing fragment cut mid-line by the byte/time cap is expected; any    // other unparseable line means the body is not NDJSON after all.    if (truncated && index === lastNonEmptyIndex) continue;    return text;  }   return rows;}; const NDJSON_CONTENT_TYPES = new Set([  "application/stream+json",  "application/x-ndjson",  "application/jsonl",]); const decodeStreamingResponseBody = (  contentType: string | null,  text: string,  truncated: boolean,): unknown =>  NDJSON_CONTENT_TYPES.has(normalizeContentType(contentType))    ? parseStreamingJsonLines(text, truncated)    : text; export const collectStreamingBody = (  stream: Stream.Stream<Uint8Array, unknown, never>,  contentType: string | null,  caps: StreamingResponseCaps = {},) =>  Effect.gen(function* () {    const maxBytes = caps.maxBytes ?? STREAM_MAX_BYTES;    const maxMs = caps.maxMs ?? STREAM_MAX_MS;    const chunks: Uint8Array[] = [];    let bytes = 0;    let truncated = false;    const startedAt = Date.now();     const completed = yield* stream.pipe(      Stream.timeout(maxMs),      Stream.runForEach((chunk) =>        Effect.gen(function* () {          const remaining = maxBytes - bytes;          if (remaining <= 0) {            truncated = true;            return yield* Effect.fail(new StreamCapReached());          }           if (chunk.byteLength > remaining) {            chunks.push(chunk.subarray(0, remaining));            bytes += remaining;            truncated = true;            return yield* Effect.fail(new StreamCapReached());          }           chunks.push(chunk);          bytes += chunk.byteLength;          if (bytes >= maxBytes) {            truncated = true;            return yield* Effect.fail(new StreamCapReached());          }        }),      ),      Effect.timeoutOption(maxMs + 1_000),      Effect.catch((error: unknown) =>        error instanceof StreamCapReached          ? Effect.succeed(Option.some(undefined))          : Effect.fail(error),      ),    );    if (Option.isNone(completed)) truncated = true;     const durationMs = Date.now() - startedAt;    if (durationMs >= maxMs) truncated = true;    const collected = new Uint8Array(bytes);    let offset = 0;    for (const chunk of chunks) {      collected.set(chunk, offset);      offset += chunk.byteLength;    }    const text = new TextDecoder("utf-8").decode(collected);     return {      data: decodeStreamingResponseBody(contentType, text, truncated),      headers: {        "x-executor-stream": truncated ? "truncated" : "complete",        "x-executor-stream-bytes": String(bytes),        "x-executor-stream-duration-ms": String(durationMs),      },    };  }); 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),

Lines 1–500 of 1121.