foca-openapi
Version:
根据openapi文档生成请求客户端
609 lines (584 loc) • 21.1 kB
JavaScript
// src/bin.ts
import timers from "node:timers/promises";
import { Listr } from "listr2";
import minimist from "minimist";
import colors from "yoctocolors";
import "openapi-types";
// src/lib/path-to-openapi.ts
import { readFile } from "fs/promises";
import path from "node:path";
import YAML from "yaml";
import axios from "foca-axios";
var pathToOpenapi = async (uri, onLoaded) => {
let originContent;
if (uri.startsWith("http:") || uri.startsWith("https:")) {
originContent = await axios.get(uri, { responseType: "text" });
} else {
originContent = await readFile(path.resolve(uri), "utf8");
}
let document;
try {
document = JSON.parse(originContent);
} catch {
document = YAML.parse(originContent);
}
return onLoaded?.(document) || document;
};
// src/lib/save-to-file.ts
import path2 from "node:path";
import { kebabCase } from "lodash-es";
import { mkdir, writeFile } from "node:fs/promises";
var saveToFile = async (config, projects) => {
const outputFile = config.outputFile?.replaceAll(path2.sep, path2.posix.sep) || path2.posix.join(
"src",
"openapi",
`${config.projectName ? kebabCase(config.projectName) : "openapi"}.ts`
);
const fullPath = path2.posix.resolve(outputFile);
const projectContent = projects[config.projectName ?? ""];
await mkdir(path2.posix.dirname(fullPath), { recursive: true });
await writeFile(fullPath, projectContent);
return outputFile;
};
// src/lib/generate-template.ts
import { upperFirst, camelCase, snakeCase as snakeCase2 } from "lodash-es";
// src/lib/adapter.ts
var methods = ["get", "post", "put", "patch", "delete"];
// src/lib/document-to-meta.ts
import { snakeCase } from "lodash-es";
// src/lib/ref-to-object.ts
var refToObject = (docs, data) => {
if ("$ref" in data) {
const target = data.$ref.split("/").reduce((carry, pathName) => {
if (pathName === "#") return carry;
return carry[pathName] || carry[decodeURIComponent(pathName)];
}, docs);
return target;
}
return data;
};
// src/lib/generate-comments.ts
var generateComments = (opts) => {
let comments = [];
if (opts.description) comments.push(`* ${opts.description}`);
if (opts.uri) comments.push(` * @uri ${opts.uri}`);
if (opts.method) comments.push(` * @method ${opts.method.toUpperCase()}`);
if (opts.deprecated) comments.push("* @deprecated");
return comments.length ? `
/**
${comments.join("\n").replaceAll("*/", "*\\/")}
*/
` : "";
};
// src/lib/parse-schema.ts
var parseSchema = (docs, schema) => {
const parsed = refToObject(docs, schema);
const { ofType, nullable } = parseOf(docs, parsed);
if (ofType) return `(${ofType}${nullable})`;
if (parsed.enum?.length) {
return `(${parsed.enum.map((item) => typeof item === "number" || typeof item === "boolean" ? item : `"${item}"`).join(" | ")}${nullable})`;
}
switch (parsed.type) {
case "boolean":
return `(boolean${nullable})`;
case "integer":
case "number":
if (parsed.format === "int64") {
return `(string.BigInt${nullable})`;
}
return `(number${nullable})`;
case "string":
switch (parsed.format) {
case "binary":
return `(Blob${nullable})`;
case "date":
return `(string.Date${nullable})`;
case "date-time":
return `(string.DateTime${nullable})`;
case "time":
return `(string.Time${nullable})`;
case "email":
return `(string.Email${nullable})`;
case "uri":
return `(string.Uri${nullable})`;
case "ipv4":
return `(string.IPv4${nullable})`;
case "ipv6":
return `(string.IPv6${nullable})`;
default:
return `(string${nullable})`;
}
case "array":
return `((${parseSchema(docs, parsed.items)})[]${nullable})`;
case "object": {
const requiredProperties = parsed.required || [];
const properties = Object.entries(parsed.properties || {}).map(([key, schema2]) => {
const schemaObj = refToObject(docs, schema2);
return `${generateComments(schemaObj)}"${key}"${requiredProperties.includes(key) ? "" : "?"}: ${parseSchema(docs, schemaObj)}`;
});
let additionalType = "";
if (parsed.additionalProperties === true) {
additionalType = `{ [key: string]: unknown; }`;
} else if (typeof parsed.additionalProperties === "object") {
additionalType = `{ [key: string]: ${parseSchema(docs, refToObject(docs, parsed.additionalProperties))} }`;
}
if (additionalType) {
additionalType = " & " + additionalType;
}
return `({ ${properties.join(";")} }${additionalType}${nullable})`;
}
default:
return "unknown";
}
};
var parseOf = (docs, schema) => {
if (!schema.oneOf?.length && !schema.anyOf?.length && !schema.allOf?.length) {
return {
nullable: schema.nullable ? " | null" : "",
ofType: ""
};
}
const { oneOf = [], anyOf = [], allOf = [], nullable = false, ...rest } = schema;
const oneTypes = /* @__PURE__ */ new Set();
const anyTypes = /* @__PURE__ */ new Set();
const allTypes = /* @__PURE__ */ new Set();
const nullableSet = new Set(
oneOf.concat(anyOf).concat(allOf).map((item) => {
const { nullable: itemNullable } = refToObject(docs, item);
if (itemNullable !== void 0) return itemNullable;
return nullable;
})
);
const additionalMergeOpts = nullableSet.size > 1 ? { nullable } : {};
for (const one of oneOf) {
oneTypes.add(
parseSchema(docs, { ...rest, ...additionalMergeOpts, ...refToObject(docs, one) })
);
}
for (const any of anyOf) {
anyTypes.add(
parseSchema(docs, { ...rest, ...additionalMergeOpts, ...refToObject(docs, any) })
);
}
for (const all of allOf) {
allTypes.add(
parseSchema(docs, { ...rest, ...additionalMergeOpts, ...refToObject(docs, all) })
);
}
let oneType = oneTypes.size ? `(${[...oneTypes].join(" | ")})` : "";
let anyType = anyTypes.size ? `(${[...anyTypes].join(" | ")})` : "";
let allType = allTypes.size ? `(${[...allTypes].join(" & ")})` : "";
if (oneType && oneType === anyType) anyType = "";
if (oneType && oneType === allType) {
allType = "";
anyType = "";
} else if (anyType && anyType === allType) {
allType = "";
oneType = "";
}
let unionType = [oneType, anyType].filter(Boolean).join(" | ");
if (unionType) unionType = `(${unionType})`;
let finalType = [unionType, allType].filter(Boolean).join(" & ");
if (finalType) finalType = `(${finalType})`;
return {
nullable: nullableSet.size === 1 && [...nullableSet][0] === true ? " | null" : "",
ofType: finalType
};
};
// src/lib/parse-parameters.ts
var parseParameters = (docs, pathItem, methodItem, key, looseInputNumber) => {
const parameters = (methodItem.parameters || []).concat(pathItem.parameters || []).map((parameter) => refToObject(docs, parameter)).filter((parameter) => parameter.in === key);
const types = parameters.map((parameter) => {
if (!parameter.schema) return "";
return `${generateComments(parameter)}${parameter.name}${parameter.required ? "" : "?"}: ${parseSchema(docs, parameter.schema)}
`;
}).filter(Boolean).map((type) => {
if (!looseInputNumber) return type;
return type.replaceAll("(number)", "(number | string.Number)").replaceAll("(number | null)", "(number | string.Number | null)");
});
return {
optional: parameters.every((parameter) => !parameter.required),
types: types.length ? [`{ ${types.join(";\n")} }`] : []
};
};
// src/lib/parse-request-body.ts
var parseRequestBody = (docs, methodItem) => {
const requestBody = refToObject(
docs,
methodItem.requestBody || { content: {} }
);
const contentTypes = Object.keys(requestBody.content).filter(
(item) => !!requestBody.content[item].schema
);
const types = contentTypes.map((contentType) => {
const { schema } = requestBody.content[contentType];
return parseSchema(docs, schema);
});
return {
contentTypes: contentTypes.filter((item) => item !== "*/*"),
body: {
types,
optional: contentTypes.length === 0
}
};
};
// src/lib/parse-response.ts
var parseResponse = (docs, methodItem) => {
const response = refToObject(docs, methodItem.responses || {});
const filteredResponse = Object.entries(response).filter(([code]) => code.startsWith("2") || code === "default").map(([_, schema]) => refToObject(docs, schema));
const contentTypes = filteredResponse.flatMap((item) => Object.keys(item.content || {})).filter((item) => item !== "*/*");
const types = filteredResponse.flatMap((item) => Object.values(item.content || {})).map((item) => item.schema && refToObject(docs, item.schema)).filter(Boolean).map((schema) => parseSchema(docs, schema || {}));
return {
responseTypes: [...new Set(contentTypes)],
response: { types }
};
};
// src/lib/document-to-meta.ts
var documentToMeta = (docs, rpcName, looseInputNumber) => {
const metas = {
get: [],
post: [],
put: [],
patch: [],
delete: []
};
Object.entries(docs.paths).forEach(([uri, pathItem]) => {
methods.forEach((method) => {
if (!pathItem || !pathItem[method]) return;
const methodItem = pathItem[method];
metas[method].push({
uri,
method,
key: snakeCase(
rpcName === "operationId" && methodItem.operationId ? methodItem.operationId : `${method}_${uri.replaceAll(/{(.+?)}/g, "_by_$1")}`
),
query: parseParameters(docs, pathItem, methodItem, "query", looseInputNumber),
params: parseParameters(docs, pathItem, methodItem, "path", looseInputNumber),
...parseRequestBody(docs, methodItem),
...parseResponse(docs, methodItem),
deprecated: methodItem.deprecated,
description: methodItem.description,
tags: methodItem.tags && methodItem.tags.length ? methodItem.tags : ["default"]
});
});
});
return metas;
};
// src/lib/generate-template.ts
import prettier from "prettier";
// src/lib/template.ts
var pickContentTypes = `
protected override pickContentTypes(uri: string, method: string) {
return contentTypes[method + " " + uri] || [void 0, void 0];
}
`;
// src/lib/generate-template.ts
var generateTemplate = async (docs, config) => {
const {
projectName = "",
classMode = "rest",
rpcName = "method+uri",
looseInputNumber = false
} = config;
const className = `OpenapiClient${upperFirst(camelCase(projectName))}`;
const metas = documentToMeta(docs, rpcName, looseInputNumber);
const classTpl = classMode === "rest" ? generateMethodModeClass(className, metas) : classMode === "rpc-group" ? generateRpcModelClassWithGroup(className, metas) : generateRpcModelClass(className, metas);
let content = `
${generateNamespaceTpl(className, metas)}
${classTpl}
${generatePathRelationTpl(className, metas)}
${generateContentTypeTpl(metas)}
`;
if (content.includes("string.")) {
content = `import { BaseOpenapiClient, type string } from 'foca-openapi';
${content}`;
} else {
content = `import { BaseOpenapiClient } from 'foca-openapi';
${content}`;
}
content = `/* Autogenerated file. Do not edit manually */
/* eslint-disable */
/* @ts-nocheck */
${content}`;
let configuration;
try {
configuration = await prettier.resolveConfig(import.meta.filename);
} catch {
configuration = null;
}
return {
[projectName]: await prettier.format(content, {
...configuration,
parser: "typescript"
})
};
};
var generateNamespaceTpl = (className, metas) => {
return `
export namespace ${className} {
${methods.flatMap((method) => {
let content = metas[method].flatMap((meta) => {
let opts = [];
["query", "params", "body", "response"].forEach((key) => {
const interfaceName = upperFirst(camelCase(meta.key + "_" + key));
if (meta[key].types.length) {
opts.push(`export type ${interfaceName} = ${meta[key].types.join(" | ")}
`);
}
});
return opts;
});
return content;
}).join("")}
}`;
};
var generateMethodModeClass = (className, metas) => {
return `
export class ${className}<T extends object = object> extends BaseOpenapiClient<T> {
${methods.map((method) => {
if (!metas[method].length) return "";
const uris = metas[method].map((meta) => meta.uri);
const optionalUris = metas[method].filter(
(meta) => meta.query.optional && meta.params.optional && meta.body.optional
).map((meta) => meta.uri);
let opts;
const optType = `${className}_${method}_paths[K]['request'] & BaseOpenapiClient.UserInputOpts<T>`;
if (optionalUris.length === uris.length) {
opts = `[opts?: ${optType}]`;
} else if (optionalUris.length === 0) {
opts = `[opts: ${optType}]`;
} else {
opts = `K extends '${optionalUris.join("' | '")}' ? [opts?: ${optType}] : [opts: ${optType}]`;
}
return `${method}<K extends keyof ${className}_${method}_paths>(
uri: K, ...rest: ${opts}
): Promise<${className}_${method}_paths[K]['response']> {
return this.request(uri, "${method}", ...rest);
}
`;
}).join("\n")}
${pickContentTypes}
}`;
};
var generateRpcModelClass = (className, metas) => {
return `
export class ${className}<T extends object = object> extends BaseOpenapiClient<T> {
${methods.flatMap((method) => {
return metas[method].map((meta) => {
const optional = meta.query.optional && meta.params.optional && meta.body.optional;
return `
${generateComments(meta)}${camelCase(meta.key)}(opts${optional ? "?" : ""}: ${className}_${method}_paths['${meta.uri}']['request'] & BaseOpenapiClient.UserInputOpts<T>): Promise<${className}_${method}_paths['${meta.uri}']['response']> {
return this.request('${meta.uri}', "${method}", opts);
}
`;
});
}).join("\n")}
${pickContentTypes}
}`;
};
var generateRpcModelClassWithGroup = (className, metas) => {
const namespaces = [
...new Set(
methods.flatMap((method) => metas[method].flatMap((meta) => meta.tags || []))
)
];
return `
export class ${className}<T extends object = object> extends BaseOpenapiClient<T> {
${namespaces.map((ns) => {
return `readonly ${snakeCase2(ns)} = {
${methods.flatMap((method) => {
return metas[method].filter((meta) => meta.tags.includes(ns)).map((meta) => {
const optional = meta.query.optional && meta.params.optional && meta.body.optional;
return `${generateComments(meta)}${camelCase(meta.key)}: (opts${optional ? "?" : ""}: ${className}_${method}_paths['${meta.uri}']['request'] & BaseOpenapiClient.UserInputOpts<T>): Promise<${className}_${method}_paths['${meta.uri}']['response']> => {
return this.request('${meta.uri}', '${method}', opts);
}`;
});
}).join(",\n")}
}`;
}).join("\n")}
${pickContentTypes}
}`;
};
var generateContentTypeTpl = (metas) => {
return `
const contentTypes: Record<string, [BaseOpenapiClient.UserInputOpts['requestBodyType'],
BaseOpenapiClient.UserInputOpts['responseType']]> = {
${methods.map((method) => {
if (!metas[method].length) return "";
return `
${metas[method].map(({ uri, contentTypes, responseTypes }) => {
const requestContentType = contentTypes[0] || "application/json";
const responseContentType = responseTypes.some(
(item) => item.startsWith("text/")
) ? "text" : "json";
const isJSONRequest = requestContentType === "application/json";
const isJSONResponse = responseContentType === "json";
if (isJSONRequest && isJSONResponse) return "";
return `'${method} ${uri}': [${isJSONRequest ? "void 0" : `'${requestContentType}'`}, ${isJSONResponse ? "void 0" : `'${responseContentType}'`}],`;
}).join("\n")}
`;
}).join("")}
};
`;
};
var generatePathRelationTpl = (className, metas) => {
return methods.map((method) => {
if (!metas[method].length) return "";
return `
interface ${className}_${method}_paths {
${metas[method].map((meta) => {
return `'${meta.uri}': BaseOpenapiClient.Prettify<{
request: {
${meta.query.types.length ? `query${meta.query.optional ? "?" : ""}: ${className}.${upperFirst(camelCase(meta.key + "_query"))};` : "query?: object;"}
${meta.params.types.length ? `params${meta.params.optional ? "?" : ""}: ${className}.${upperFirst(camelCase(meta.key + "_params"))};` : ""}
${meta.body.types.length ? `body${meta.body.optional ? "?" : ""}: ${className}.${upperFirst(camelCase(meta.key + "_body"))};` : ""}
};
response: ${meta.response.types.length ? `${className}.${upperFirst(camelCase(meta.key + "_response"))}` : "unknown"}
}>`;
}).join("\n")}
}`;
}).join("");
};
// src/lib/filter-tag.ts
import { intersection } from "lodash-es";
var filterTag = (docs, config) => {
if (!config.includeTag) return;
const tags = Array.isArray(config.includeTag) ? config.includeTag : [config.includeTag];
if (!tags.length) return;
Object.keys(docs.paths).forEach((uri) => {
const pathItem = docs.paths[uri];
methods.forEach((method) => {
const methodItem = pathItem[method];
if (!methodItem || !methodItem.tags || !intersection(methodItem.tags, tags).length) {
Reflect.deleteProperty(pathItem, method);
}
});
});
};
// src/lib/filter-uri.ts
var filterUri = (docs, config) => {
if (!config.includeUriPrefix) return;
const patterns = Array.isArray(config.includeUriPrefix) ? config.includeUriPrefix : [config.includeUriPrefix];
if (!patterns.length) return;
Object.keys(docs.paths).forEach((uri) => {
const keep = patterns.some((pattern) => {
return typeof pattern === "string" ? uri.startsWith(pattern) : pattern.test(uri);
});
keep || Reflect.deleteProperty(docs.paths, uri);
});
};
// src/lib/read-config.ts
import path3 from "node:path";
import { pathToFileURL } from "node:url";
import { require as require2 } from "tsx/cjs/api";
var readConfig = (configFile2 = "openapi.config.ts") => {
const { default: content } = require2(pathToFileURL(
path3.resolve(configFile2)
).toString(), import.meta.url);
return content;
};
// src/silent-spinner.ts
var SilentSpinner = class {
constructor(tasks) {
this.tasks = tasks;
}
add(task) {
this.tasks.push(task);
}
async run(ctx = {}) {
for (const task of this.tasks) {
if (task.skip) {
if (task.skip === true) continue;
if (await task.skip(ctx)) continue;
}
await task.task(ctx, { title: task.title });
}
}
};
// src/bin.ts
var argv = minimist(process.argv.slice(2), {
alias: { config: ["c"], env: ["e"] }
});
var silent = Boolean(argv["silent"]);
var env = argv["env"] || process.env["NODE_ENV"] || "development";
var configFile = argv["config"];
var sleep = () => timers.setTimeout(300);
var toArray = (value) => Array.isArray(value) ? value : [value];
var spinner = silent ? new SilentSpinner([]) : new Listr([]);
spinner.add({
title: "\u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6openapi.config.ts",
task: async (ctx, task) => {
const userConfig = readConfig(configFile);
if (typeof userConfig === "function") {
task.title += ` ${colors.gray(env)}`;
ctx.configs = toArray(await userConfig(env));
} else {
ctx.configs = toArray(userConfig);
}
await sleep();
}
});
spinner.add({
title: "\u83B7\u53D6openapi\u6587\u6863",
task: async (ctx) => {
ctx.docs = await Promise.all(
ctx.configs.map((config) => pathToOpenapi(config.url, config.onDocumentLoaded))
);
await sleep();
}
});
spinner.add({
title: "\u8FC7\u6EE4\u6307\u5B9A\u6807\u7B7E",
skip: (ctx) => {
return ctx.configs.every(
({ includeTag: value }) => !value || Array.isArray(value) && !value.length
);
},
task: async (ctx) => {
ctx.configs.forEach((config, i) => {
filterTag(ctx.docs[i], config);
});
await sleep();
}
});
spinner.add({
title: "\u8FC7\u6EE4\u6307\u5B9A\u524D\u7F00",
skip: (ctx) => {
return ctx.configs.every(
({ includeUriPrefix: value }) => !value || Array.isArray(value) && !value.length
);
},
task: async (ctx) => {
ctx.configs.forEach((config, i) => {
filterUri(ctx.docs[i], config);
});
await sleep();
}
});
spinner.add({
title: "\u751F\u6210\u5BA2\u6237\u7AEF",
task: async (ctx) => {
ctx.projects = {};
await Promise.all(
ctx.configs.map(async (config, i) => {
const project = await generateTemplate(ctx.docs[i], config);
ctx.projects = { ...ctx.projects, ...project };
})
);
await sleep();
}
});
spinner.add({
title: "\u5199\u5165\u6307\u5B9A\u6587\u4EF6",
task: async (ctx, task) => {
const files = await Promise.all(
ctx.configs.map((config) => {
return saveToFile(config, ctx.projects);
})
);
task.title += " " + colors.gray(files.join(", "));
}
});
await spinner.run();
//# sourceMappingURL=bin.mjs.map