1123 lines · 43.6 KB · TypeScript
RawDownload
import { Effect, Option } from "effect";import { ToolFileJsonSchema } from "@executor-js/sdk/core"; import { planToolPaths, type OperationPathInput, type PlannedToolPath } from "./definitions";import { OpenApiExtractionError } from "./errors";import type { ParsedDocument } from "./parse";import {  parseEntry,  parseHead,  parseSmallComponents,  type ByteRange,  type KeepPathItem,  type SpecStructure,} from "./split";import {  declaredContents,  DocResolver,  isNdjsonMediaType,  ndjsonArrayOutputSchema,  preferredResponseContent,  type OperationObject,  type ParameterObject,  type PathItemObject,  type RequestBodyObject,  type ResponseObject,  type ServerObject,} from "./openapi-utils";import {  EncodingObject,  ExtractedOperation,  ExtractionResult,  type HttpMethod,  MediaBinding,  OperationBinding,  OperationFileHint,  OperationId,  OperationParameter,  OperationRequestBody,  OperationResponseBody,  type ParameterLocation,  ServerInfo,  ServerVariable,} from "./types"; // ---------------------------------------------------------------------------// Constants// --------------------------------------------------------------------------- const HTTP_METHODS: readonly HttpMethod[] = [  "get",  "put",  "post",  "delete",  "patch",  "head",  "options",  "trace",]; const VALID_PARAM_LOCATIONS = new Set<string>(["path", "query", "header", "cookie"]); // ---------------------------------------------------------------------------// Parameter extraction// --------------------------------------------------------------------------- const extractParameters = (  pathItem: PathItemObject,  operation: OperationObject,  r: DocResolver,): OperationParameter[] => {  const merged = new Map<string, ParameterObject>();   for (const raw of pathItem.parameters ?? []) {    const p = r.resolve<ParameterObject>(raw);    if (!p) continue;    merged.set(`${p.in}:${p.name}`, p);  }  for (const raw of operation.parameters ?? []) {    const p = r.resolve<ParameterObject>(raw);    if (!p) continue;    merged.set(`${p.in}:${p.name}`, p);  }   return [...merged.values()]    .filter((p) => VALID_PARAM_LOCATIONS.has(p.in))    .map((p) =>      OperationParameter.make({        name: p.name,        location: p.in as ParameterLocation,        required: p.in === "path" ? true : p.required === true,        schema: Option.fromNullishOr(p.schema),        style: Option.fromNullishOr(p.style),        explode: Option.fromNullishOr(p.explode),        allowReserved: Option.fromNullishOr("allowReserved" in p ? p.allowReserved : undefined),        description: Option.fromNullishOr(p.description),      }),    );}; // ---------------------------------------------------------------------------// Request body extraction// --------------------------------------------------------------------------- const buildEncodingRecord = (  encoding: Record<string, unknown> | undefined,): Record<string, EncodingObject> | undefined => {  if (!encoding) return undefined;  const out: Record<string, EncodingObject> = {};  for (const [prop, raw] of Object.entries(encoding)) {    if (typeof raw !== "object" || raw === null) continue;    const e = raw as {      contentType?: string;      style?: string;      explode?: boolean;      allowReserved?: boolean;    };    out[prop] = EncodingObject.make({      contentType: Option.fromNullishOr(e.contentType),      style: Option.fromNullishOr(e.style),      explode: Option.fromNullishOr(e.explode),      allowReserved: Option.fromNullishOr(e.allowReserved),    });  }  return Object.keys(out).length > 0 ? out : undefined;}; const extractRequestBody = (  operation: OperationObject,  r: DocResolver,): OperationRequestBody | undefined => {  if (!operation.requestBody) return undefined;   const body = r.resolve<RequestBodyObject>(operation.requestBody);  if (!body) return undefined;   const contents = declaredContents(body.content).map(({ mediaType, media }) =>    MediaBinding.make({      contentType: mediaType,      schema: Option.fromNullishOr(multipartFileInputSchema(media.schema, mediaType)),      encoding: Option.fromNullishOr(        buildEncodingRecord((media as { encoding?: Record<string, unknown> }).encoding),      ),    }),  );  if (contents.length === 0) return undefined;   // Default = first declared (spec author's preferred order). Callers can  // override at invoke time with a `contentType` arg.  const defaultContent = contents[0]!;   return OperationRequestBody.make({    required: body.required === true,    contentType: defaultContent.contentType,    schema: defaultContent.schema,    contents: Option.some(contents),  });}; // ---------------------------------------------------------------------------// Response schema extraction// --------------------------------------------------------------------------- const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); const stringType = (schema: Record<string, unknown>): boolean =>  schema.type === "string" || (Array.isArray(schema.type) && schema.type.includes("string")); const numericType = (schema: Record<string, unknown>): boolean =>  schema.type === "integer" ||  schema.type === "number" ||  (Array.isArray(schema.type) &&    (schema.type.includes("integer") || schema.type.includes("number"))); const normalizedMediaType = (mediaType: string): string =>  mediaType.split(";")[0]?.trim().toLowerCase() ?? ""; const isJsonMediaType = (mediaType: string): boolean => {  const normalized = normalizedMediaType(mediaType);  return (    normalized === "application/json" || normalized.includes("+json") || normalized.includes("json")  );}; const binaryStringSchema = (schema: Record<string, unknown>): boolean =>  stringType(schema) && (schema.format === "binary" || schema.format === "byte"); const arrayType = (schema: Record<string, unknown>): boolean =>  schema.type === "array" || (Array.isArray(schema.type) && schema.type.includes("array")); const nullableType = (schema: Record<string, unknown>): boolean =>  Array.isArray(schema.type) && schema.type.includes("null"); const isMultipartMediaType = (mediaType: string): boolean =>  normalizedMediaType(mediaType) === "multipart/form-data"; /** * Replace one binary/byte string node with the tool-file schema, carrying the * spec author's own annotations across. A `type: ["string", "null"]` node stays * nullable as `anyOf: [<file>, { type: "null" }]`, since the tool-file schema is * an object and cannot express null through a type array. */const toolFileSchemaFor = (node: Record<string, unknown>): Record<string, unknown> => {  const annotations: Record<string, unknown> = {};  if (typeof node.title === "string") annotations.title = node.title;  if (typeof node.description === "string") annotations.description = node.description;   return nullableType(node)    ? { anyOf: [ToolFileJsonSchema, { type: "null" }], ...annotations }    : { ...(ToolFileJsonSchema as Record<string, unknown>), ...annotations };}; /** * Rewrite one multipart property. Deliberately limited to the two shapes the * invoke-side form encoder can actually deliver: a property that IS a binary * string, and an array property whose direct items are binary strings. */const multipartFileProperty = (property: unknown): unknown => {  if (!isRecord(property)) return property;  if (binaryStringSchema(property)) return toolFileSchemaFor(property);   const items = property.items;  if (arrayType(property) && isRecord(items) && binaryStringSchema(items)) {    return { ...property, items: toolFileSchemaFor(items) };  }   return property;}; /** * Advertise `multipart/form-data` binary fields as tool files. * * The rewrite is scoped to the request schema's own `properties` map — never a * blind walk of every object key — so `default`, `example`, and vendor * extensions that happen to look like a binary string schema are left alone. * * Two shapes are NOT rewritten, because the invoke-side encoder cannot honor * them and advertising an input it would silently JSON-stringify is worse than * not advertising it at all: *   - binary fields nested inside an object property (only top-level properties *     and direct array items become form parts); *   - a body schema, or a property, behind a `$ref`. Component schemas are *     carried through unresolved by design — the streaming compile path never *     materializes `components.schemas` — so there is nothing to inspect here. */const multipartFileInputSchema = (schema: unknown, mediaType: string): unknown => {  if (!isMultipartMediaType(mediaType) || !isRecord(schema)) return schema;   const properties = schema.properties;  if (!isRecord(properties)) return schema;   let changed = false;  const out: Record<string, unknown> = {};  for (const [name, property] of Object.entries(properties)) {    const next = multipartFileProperty(property);    if (next !== property) changed = true;    out[name] = next;  }   return changed ? { ...schema, properties: out } : schema;}; const base64EncodingFromDescription = (schema: Record<string, unknown>): "base64" | "base64url" =>  typeof schema.description === "string" &&  /base64url|base64-url|url[- ]safe/i.test(schema.description)    ? "base64url"    : "base64"; const detectFileHint = (  schema: unknown,  mediaType: string,  r: DocResolver,): OperationFileHint | undefined => {  const resolved = isRecord(schema) ? r.resolve<Record<string, unknown>>(schema) : null;  if (!resolved) return undefined;   if (!isJsonMediaType(mediaType) && binaryStringSchema(resolved)) {    return OperationFileHint.make({      kind: "binaryResponse",      mimeType: Option.some(mediaType),      dataField: Option.none(),      sizeField: Option.none(),      encoding: Option.none(),    });  }   if (!isJsonMediaType(mediaType)) return undefined;   const properties = resolved.properties;  if (!isRecord(properties)) return undefined;  const data = properties.data;  const dataSchema = isRecord(data) ? r.resolve<Record<string, unknown>>(data) : null;  if (!dataSchema || !binaryStringSchema(dataSchema)) return undefined;   const size = properties.size;  const sizeSchema = isRecord(size) ? r.resolve<Record<string, unknown>>(size) : null;  const sizeField = sizeSchema && numericType(sizeSchema) ? "size" : undefined;   return OperationFileHint.make({    kind: "byteField",    mimeType: Option.some("application/octet-stream"),    dataField: Option.some("data"),    sizeField: sizeField ? Option.some(sizeField) : Option.none(),    encoding: Option.some(base64EncodingFromDescription(dataSchema)),  });}; const extractResponseBody = (  operation: OperationObject,  r: DocResolver,): OperationResponseBody | undefined => {  if (!operation.responses) return undefined;   // Success responses may use exact codes ("200"), the OpenAPI wildcard status  // key ("2XX" — Microsoft Graph declares every success response this way), or  // fall through to "default". Prefer exact codes, then the wildcard, then default.  const entries = Object.entries(operation.responses);  const preferred = [    ...entries.filter(([s]) => /^2\d\d$/.test(s)).sort(([a], [b]) => a.localeCompare(b)),    ...entries.filter(([s]) => /^2xx$/i.test(s)),    ...entries.filter(([s]) => s === "default"),  ];   for (const [, ref] of preferred) {    const resp = r.resolve<ResponseObject>(ref);    if (!resp) continue;    const content = preferredResponseContent(resp.content);    if (content?.media.schema) {      return OperationResponseBody.make({        contentType: content.mediaType,        schema: Option.some(content.media.schema),        fileHint: Option.fromNullishOr(detectFileHint(content.media.schema, content.mediaType, r)),      });    }  }   return undefined;}; /** * Derive an operation's output schema from its response body. NDJSON bodies * (`application/stream+json` and friends) are spec'd per LINE but returned by * the invoke path as an array of parsed lines, so the advertised schema wraps * the line schema in an array; otherwise describe previews promise a single * object that invocations never return. Used by both the whole-tree extract * and the serve path's stored-binding rebuild so the two stay in lockstep. */export const outputSchemaFromResponseBody = (  responseBody: OperationResponseBody,): unknown | undefined => {  const schema = Option.getOrUndefined(responseBody.schema);  if (schema === undefined) return undefined;  return isNdjsonMediaType(responseBody.contentType) ? ndjsonArrayOutputSchema(schema) : schema;}; // ---------------------------------------------------------------------------// Input schema builder// --------------------------------------------------------------------------- // Optional `server` input — host selection + server-URL variables. Undefined// when there's nothing to configure (a single server with no variables).const buildServerInputProperty = (  servers: readonly ServerInfo[],): Record<string, unknown> | undefined => {  const variableDefs: Record<string, ServerVariable> = {};  for (const server of servers) {    for (const [name, v] of Object.entries(Option.getOrUndefined(server.variables) ?? {})) {      if (!(name in variableDefs)) variableDefs[name] = v;    }  }  const hasMultiple = servers.length > 1;  const variableNames = Object.keys(variableDefs);  if (!hasMultiple && variableNames.length === 0) return undefined;   const properties: Record<string, unknown> = {};  if (hasMultiple) {    properties.url = {      type: "string",      enum: servers.map((server) => server.url),      default: servers[0]!.url,      description: "Which of the spec's servers to send the request to.",    };  }  if (variableNames.length > 0) {    properties.variables = {      type: "object",      additionalProperties: false,      properties: Object.fromEntries(        Object.entries(variableDefs).map(([name, v]) => [          name,          {            type: "string",            default: v.default,            ...(Option.isSome(v.enum) ? { enum: v.enum.value } : {}),            ...(Option.isSome(v.description) ? { description: v.description.value } : {}),          },        ]),      ),      description: "Values for the server URL `{variables}`; spec defaults apply when omitted.",    };  }  return {    type: "object",    additionalProperties: false,    properties,    description: "Optional host selection and server-URL variables for this request.",  };}; export const buildInputSchema = (  parameters: readonly OperationParameter[],  requestBody: OperationRequestBody | undefined,  servers: readonly ServerInfo[],): Record<string, unknown> | undefined => {  const properties: Record<string, unknown> = {};  const required: string[] = [];  let requiredBodyAlternatives: readonly { readonly required: readonly string[] }[] | undefined;   for (const param of parameters) {    properties[param.name] = Option.getOrElse(param.schema, () => ({ type: "string" }));    if (param.required) required.push(param.name);  }   // A path/query parameter named `server` takes precedence over the host input.  const serverProperty = buildServerInputProperty(servers);  if (serverProperty && !("server" in properties)) properties.server = serverProperty;   if (requestBody) {    // When the spec declares multiple media types for this requestBody,    // expose `contentType` so the model can pick. Default = first declared.    // For mixed bodies, `body` schema tracks the default; the model is    // responsible for supplying a body shape that matches whichever    // contentType it picks. Octet-only operations use `bodyBase64` instead.    const contents = Option.getOrUndefined(requestBody.contents);    const defaultIsOctetStream =      requestBody.contentType.split(";")[0]?.trim().toLowerCase() === "application/octet-stream";    const acceptsOctetStream =      defaultIsOctetStream ||      contents?.some(        (content) =>          content.contentType.split(";")[0]?.trim().toLowerCase() === "application/octet-stream",      ) === true;    const acceptsBody =      !defaultIsOctetStream ||      contents?.some(        (content) =>          content.contentType.split(";")[0]?.trim().toLowerCase() !== "application/octet-stream",      ) === true;    if (acceptsBody) {      properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));    }    if (acceptsOctetStream) {      properties.bodyBase64 = {        type: "string",        contentEncoding: "base64",        contentMediaType: "application/octet-stream",        description:          "Base64-encoded bytes for application/octet-stream request bodies. When contentType is omitted, this selects application/octet-stream.",      };    }    if (requestBody.required) {      if (acceptsOctetStream && acceptsBody) {        requiredBodyAlternatives = [{ required: ["body"] }, { required: ["bodyBase64"] }];      } else {        required.push(acceptsOctetStream ? "bodyBase64" : "body");      }    }    if (contents && contents.length > 1) {      properties.contentType = {        type: "string",        enum: contents.map((c) => c.contentType),        default: requestBody.contentType,        description:          "Content-Type for the request body. Declared media types for this operation, in spec order.",      };    }  }   if (Object.keys(properties).length === 0) return undefined;   return {    type: "object",    properties,    ...(required.length > 0 ? { required } : {}),    ...(requiredBodyAlternatives ? { anyOf: requiredBodyAlternatives } : {}),    additionalProperties: false,  };}; // ---------------------------------------------------------------------------// Operation ID derivation// --------------------------------------------------------------------------- const deriveOperationId = (  method: HttpMethod,  pathTemplate: string,  operation: OperationObject,): string =>  operation.operationId ??  (`${method}_${pathTemplate.replace(/[^a-zA-Z0-9]+/g, "_")}`.replace(/^_+|_+$/g, "") ||

Lines 1–500 of 1123.