UNPKG

foca-openapi

Version:

根据openapi文档生成请求客户端

609 lines (584 loc) 21.1 kB
#!/usr/bin/env node // 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