import { Effect, Option } from "effect"; import { OpenApiExtractionError } from "./errors";import type { ParsedDocument } from "./parse";import { declaredContents, DocResolver, preferredResponseContent, type OperationObject, type ParameterObject, type PathItemObject, type RequestBodyObject, type ResponseObject, type ServerObject,} from "./openapi-utils";import { EncodingObject, ExtractedOperation, ExtractionResult, type HttpMethod, MediaBinding, 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(media.schema), 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 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; 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]) => 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;}; // ---------------------------------------------------------------------------// 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.", };}; const buildInputSchema = ( parameters: readonly OperationParameter[], requestBody: OperationRequestBody | undefined, servers: readonly ServerInfo[],): Record<string, unknown> | undefined => { const properties: Record<string, unknown> = {}; const required: string[] = []; 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) { properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" })); if (requestBody.required) required.push("body"); // When the spec declares multiple media types for this requestBody, // expose `contentType` so the model can pick. Default = first declared. // `body` schema tracks the default; the model is responsible for // supplying a body shape that matches whichever contentType it picks. const contents = Option.getOrUndefined(requestBody.contents); 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 } : {}), 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, "") || `${method}_operation`); const explicitToolPath = (operation: OperationObject): string | undefined => { const value = (operation as Record<string, unknown>)["x-executor-toolPath"]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;}; const explicitPathTemplate = (operation: OperationObject): string | undefined => { const value = (operation as Record<string, unknown>)["x-executor-pathTemplate"]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;}; // ---------------------------------------------------------------------------// Server extraction// --------------------------------------------------------------------------- const extractServerList = (servers: readonly ServerObject[] | undefined): ServerInfo[] => (servers ?? []).flatMap((server) => { if (!server.url) return []; const serverVariables = server.variables as | Record< string, { readonly default?: string; readonly enum?: readonly string[]; readonly description?: string; } > | undefined; const vars = serverVariables ? Object.fromEntries( Object.entries(serverVariables).flatMap(([name, v]) => { if (v.default === undefined || v.default === null) return []; const enumValues = Array.isArray(v.enum) ? v.enum.filter((x): x is string => typeof x === "string") : undefined; return [ [ name, ServerVariable.make({ default: String(v.default), enum: enumValues && enumValues.length > 0 ? Option.some(enumValues) : Option.none(), description: Option.fromNullishOr(v.description), }), ], ]; }), ) : undefined; return [ ServerInfo.make({ url: server.url, description: Option.fromNullishOr(server.description), variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(), }), ]; }); const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers); const operationServers = ( pathItem: PathItemObject, operation: OperationObject, docServers: readonly ServerInfo[],): readonly ServerInfo[] => { const operationLevel = extractServerList(operation.servers); if (operationLevel.length > 0) return operationLevel; const pathLevel = extractServerList(pathItem.servers); if (pathLevel.length > 0) return pathLevel; return docServers;}; // ---------------------------------------------------------------------------// Main extraction// --------------------------------------------------------------------------- /** Extract all operations from a bundled OpenAPI 3.x document */export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocument) { const paths = doc.paths; if (!paths) { return yield* new OpenApiExtractionError({ message: "OpenAPI document has no paths defined", }); } const r = new DocResolver(doc); const docServers = extractServers(doc); const operations: ExtractedOperation[] = []; for (const [pathTemplate, pathItem] of Object.entries(paths).sort(([a], [b]) => a.localeCompare(b), )) { if (!pathItem) continue; for (const method of HTTP_METHODS) { const operation = pathItem[method]; if (!operation) continue; const parameters = extractParameters(pathItem, operation, r); const requestBody = extractRequestBody(operation, r); const responseBody = extractResponseBody(operation, r); const servers = operationServers(pathItem, operation, docServers); const inputSchema = buildInputSchema(parameters, requestBody, servers); const outputSchema = responseBody ? Option.getOrUndefined(responseBody.schema) : undefined; const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0); const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate; operations.push( ExtractedOperation.make({ operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)), toolPath: Option.fromNullishOr(explicitToolPath(operation)), method, servers, pathTemplate: operationPathTemplate, summary: Option.fromNullishOr(operation.summary), description: Option.fromNullishOr(operation.description), tags, parameters, requestBody: Option.fromNullishOr(requestBody), responseBody: Option.fromNullishOr(responseBody), inputSchema: Option.fromNullishOr(inputSchema), outputSchema: Option.fromNullishOr(outputSchema), deprecated: operation.deprecated === true, }), ); } } return ExtractionResult.make({ title: Option.fromNullishOr(doc.info?.title), description: Option.fromNullishOr(doc.info?.description), version: Option.fromNullishOr(doc.info?.version), servers: docServers, operations, });});
import { Effect, Option } from "effect";
import { OpenApiExtractionError } from "./errors";
import type { ParsedDocument } from "./parse";
import {
declaredContents,
DocResolver,
preferredResponseContent,
type OperationObject,
type ParameterObject,
type PathItemObject,
type RequestBodyObject,
type ResponseObject,
type ServerObject,
} from "./openapi-utils";
import {
EncodingObject,
ExtractedOperation,
ExtractionResult,
type HttpMethod,
MediaBinding,
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(media.schema),
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 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;
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]) => 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;
};
// ---------------------------------------------------------------------------
// 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.",
};
};
const buildInputSchema = (
parameters: readonly OperationParameter[],
requestBody: OperationRequestBody | undefined,
servers: readonly ServerInfo[],
): Record<string, unknown> | undefined => {
const properties: Record<string, unknown> = {};
const required: string[] = [];
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) {
properties.body = Option.getOrElse(requestBody.schema, () => ({ type: "object" }));
if (requestBody.required) required.push("body");
// When the spec declares multiple media types for this requestBody,
// expose `contentType` so the model can pick. Default = first declared.
// `body` schema tracks the default; the model is responsible for
// supplying a body shape that matches whichever contentType it picks.
const contents = Option.getOrUndefined(requestBody.contents);
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 } : {}),
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, "") ||
`${method}_operation`);
const explicitToolPath = (operation: OperationObject): string | undefined => {
const value = (operation as Record<string, unknown>)["x-executor-toolPath"];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
};
const explicitPathTemplate = (operation: OperationObject): string | undefined => {
const value = (operation as Record<string, unknown>)["x-executor-pathTemplate"];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
};
// ---------------------------------------------------------------------------
// Server extraction
// ---------------------------------------------------------------------------
const extractServerList = (servers: readonly ServerObject[] | undefined): ServerInfo[] =>
(servers ?? []).flatMap((server) => {
if (!server.url) return [];
const serverVariables = server.variables as
| Record<
string,
{
readonly default?: string;
readonly enum?: readonly string[];
readonly description?: string;
}
>
| undefined;
const vars = serverVariables
? Object.fromEntries(
Object.entries(serverVariables).flatMap(([name, v]) => {
if (v.default === undefined || v.default === null) return [];
const enumValues = Array.isArray(v.enum)
? v.enum.filter((x): x is string => typeof x === "string")
: undefined;
return [
[
name,
ServerVariable.make({
default: String(v.default),
enum:
enumValues && enumValues.length > 0 ? Option.some(enumValues) : Option.none(),
description: Option.fromNullishOr(v.description),
}),
],
];
}),
)
: undefined;
return [
ServerInfo.make({
url: server.url,
description: Option.fromNullishOr(server.description),
variables: vars && Object.keys(vars).length > 0 ? Option.some(vars) : Option.none(),
}),
];
});
const extractServers = (doc: ParsedDocument): ServerInfo[] => extractServerList(doc.servers);
const operationServers = (
pathItem: PathItemObject,
operation: OperationObject,
docServers: readonly ServerInfo[],
): readonly ServerInfo[] => {
const operationLevel = extractServerList(operation.servers);
if (operationLevel.length > 0) return operationLevel;
const pathLevel = extractServerList(pathItem.servers);
if (pathLevel.length > 0) return pathLevel;
return docServers;
};
// ---------------------------------------------------------------------------
// Main extraction
// ---------------------------------------------------------------------------
/** Extract all operations from a bundled OpenAPI 3.x document */
export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocument) {
const paths = doc.paths;
if (!paths) {
return yield* new OpenApiExtractionError({
message: "OpenAPI document has no paths defined",
});
}
const r = new DocResolver(doc);
const docServers = extractServers(doc);
const operations: ExtractedOperation[] = [];
for (const [pathTemplate, pathItem] of Object.entries(paths).sort(([a], [b]) =>
a.localeCompare(b),
)) {
if (!pathItem) continue;
for (const method of HTTP_METHODS) {
const operation = pathItem[method];
if (!operation) continue;
const parameters = extractParameters(pathItem, operation, r);
const requestBody = extractRequestBody(operation, r);
const responseBody = extractResponseBody(operation, r);
const servers = operationServers(pathItem, operation, docServers);
const inputSchema = buildInputSchema(parameters, requestBody, servers);
const outputSchema = responseBody ? Option.getOrUndefined(responseBody.schema) : undefined;
const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
operations.push(
ExtractedOperation.make({
operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
toolPath: Option.fromNullishOr(explicitToolPath(operation)),
method,
servers,
pathTemplate: operationPathTemplate,
summary: Option.fromNullishOr(operation.summary),
description: Option.fromNullishOr(operation.description),
tags,
parameters,
requestBody: Option.fromNullishOr(requestBody),
responseBody: Option.fromNullishOr(responseBody),
inputSchema: Option.fromNullishOr(inputSchema),
outputSchema: Option.fromNullishOr(outputSchema),
deprecated: operation.deprecated === true,
}),
);
}
}
return ExtractionResult.make({
title: Option.fromNullishOr(doc.info?.title),
description: Option.fromNullishOr(doc.info?.description),
version: Option.fromNullishOr(doc.info?.version),
servers: docServers,
operations,
});
});