UNPKG

cis-api-tool

Version:

根据 swagger/yapi/apifox 的接口定义生成 TypeScript/JavaScript 的接口类型及其请求函数代码。

535 lines (519 loc) 25 kB
import { __require, getCachedPrettierOptions, getNormalizedPathWithAlias, getOutputFilePath, getPrettier, getReponseDataTypeName, getRequestDataJsonSchema, getRequestDataTypeName, getRequestFunctionName, getResponseDataJsonSchema, httpGet, jsonSchemaToType, sortByWeights, throwError, transformPaths } from "./utils-CZBtzc0I.mjs"; import { ApifoxToYApiServer } from "./ApifoxToYApiServer-CfOiIB-I.mjs"; import { dedent } from "./function-CBpTcEnR.mjs"; import { SwaggerToYApiServer } from "./SwaggerToYApiServer-DSt3fmoP.mjs"; import isEmpty from "lodash/isEmpty"; import fs from "fs-extra"; import path from "path"; import * as changeCase from "change-case"; import { exec } from "child_process"; import castArray from "lodash/castArray"; import cloneDeep from "lodash/cloneDeep"; import groupBy from "lodash/groupBy"; import isFunction from "lodash/isFunction"; import last from "lodash/last"; import memoize from "lodash/memoize"; import noop from "lodash/noop"; import omit from "lodash/omit"; import uniq from "lodash/uniq"; import values from "lodash/values"; //#region src/Generator.ts var Generator = class { /** 配置 */ config = []; disposes = []; constructor(config, options = { cwd: process.cwd() }) { this.options = options; this.config = castArray(config); } async prepare() { this.config = await Promise.all(this.config.map(async (item) => { if (item.serverType === "swagger") { const swaggerToYApiServer = new SwaggerToYApiServer({ swaggerJsonUrl: item.serverUrl }); item.serverUrl = await swaggerToYApiServer.start(); this.disposes.push(() => swaggerToYApiServer.stop()); } if (item.serverType === "apifox") { const firstProject = item.projects[0]; const firstToken = firstProject ? castArray(firstProject.token)[0] : ""; const apifoxToYApiServer = new ApifoxToYApiServer({ serverUrl: item.serverUrl, token: firstToken, projectId: item.apifoxProjectId || "6720131" }); item.serverUrl = await apifoxToYApiServer.start(); this.disposes.push(() => apifoxToYApiServer.stop()); } if (item.serverUrl) item.serverUrl = item.serverUrl.replace(/\/+$/, ""); return item; })); } /** * 清理输出目录,删除之前生成的文件但保留requestFunctionFilePath指定的文件 */ async cleanOutputDirectory() { try { const config = this.config[0]; if (!config) return; const outputDir = typeof config.outputDir === "string" ? config.outputDir : "src/service"; const requestFunctionFilePath = config.requestFunctionFilePath || "src/service/request.ts"; const fullOutputDir = path.resolve(this.options.cwd, outputDir); if (!await fs.pathExists(fullOutputDir)) return; const fullRequestFilePath = path.resolve(this.options.cwd, requestFunctionFilePath); const isRequestFileInOutputDir = fullRequestFilePath.startsWith(fullOutputDir + path.sep) || fullRequestFilePath === fullOutputDir; if (!isRequestFileInOutputDir) { await fs.emptyDir(fullOutputDir); return; } let requestFileContent = ""; if (await fs.pathExists(fullRequestFilePath)) requestFileContent = await fs.readFile(fullRequestFilePath, "utf-8"); await fs.emptyDir(fullOutputDir); if (requestFileContent) await fs.outputFile(fullRequestFilePath, requestFileContent); } catch (error) { console.warn("清理输出目录时出现警告:", error); } } async generate() { const outputFileList = Object.create(null); await Promise.all(this.config.map(async (serverConfig, serverIndex) => { const projects = serverConfig.projects.reduce((projects$1, project) => { projects$1.push(...castArray(project.token).map((token) => ({ ...project, token }))); return projects$1; }, []); return Promise.all(projects.map(async (projectConfig, projectIndex) => { const projectInfo = await this.fetchProjectInfo({ ...serverConfig, ...projectConfig }); await Promise.all(projectConfig.categories.map(async (categoryConfig, categoryIndex) => { let categoryIds = castArray(categoryConfig.id); if (categoryIds.includes(0)) categoryIds.push(...projectInfo.cats.map((cat) => cat._id)); categoryIds = uniq(categoryIds); const excludedCategoryIds = categoryIds.filter((id) => id < 0).map(Math.abs); categoryIds = categoryIds.filter((id) => !excludedCategoryIds.includes(Math.abs(id))); categoryIds = categoryIds.filter((id) => !!projectInfo.cats.find((cat) => cat._id === id)); categoryIds = categoryIds.sort(); const codes = (await Promise.all(categoryIds.map(async (id, categoryIndex2) => { categoryConfig = { ...categoryConfig, id }; const syntheticalConfig = { ...serverConfig, ...projectConfig, ...categoryConfig }; syntheticalConfig.target = syntheticalConfig.target || "typescript"; let interfaceList = await this.fetchInterfaceList(syntheticalConfig); interfaceList = interfaceList.map((interfaceInfo) => { interfaceInfo._project = omit(projectInfo, "cats", "getMockUrl", "getDevUrl", "getProdUrl"); let _interfaceInfo = isFunction(syntheticalConfig.preproccessInterface) ? syntheticalConfig.preproccessInterface?.(cloneDeep(interfaceInfo), changeCase, syntheticalConfig) : interfaceInfo; if (_interfaceInfo && syntheticalConfig.pathPrefix) { const pathPrefix = syntheticalConfig.pathPrefix; if (_interfaceInfo.path.startsWith(pathPrefix)) { _interfaceInfo.path = _interfaceInfo.path.substring(pathPrefix.length); if (!_interfaceInfo.path.startsWith("/")) _interfaceInfo.path = "/" + _interfaceInfo.path; } } return _interfaceInfo; }).filter(Boolean); interfaceList.sort((a, b) => a._id - b._id); const interfaceCodes = await Promise.all(interfaceList.map(async (interfaceInfo) => { const _filePath = typeof syntheticalConfig.outputDir === "function" ? syntheticalConfig.outputDir(interfaceInfo, changeCase) : getOutputFilePath(interfaceInfo, changeCase, syntheticalConfig.outputDir || "src/service"); const outputFilePath = path.resolve(this.options.cwd, _filePath); syntheticalConfig.fileDirectory = _filePath; const categoryUID = `_${serverIndex}_${projectIndex}_${categoryIndex}_${categoryIndex2}`; const code = await this.generateInterfaceCode(syntheticalConfig, interfaceInfo, categoryUID); const weights = [ serverIndex, projectIndex, categoryIndex, categoryIndex2 ]; return { categoryUID, outputFilePath, weights, code, relativeFilePath: _filePath }; })); const groupedInterfaceCodes = groupBy(interfaceCodes, (item) => item.outputFilePath); return Object.keys(groupedInterfaceCodes).map((outputFilePath) => { const categoryCode = groupedInterfaceCodes[outputFilePath].map((item) => item.code).filter(Boolean).join("\n\n"); if (!outputFileList[outputFilePath]) outputFileList[outputFilePath] = { syntheticalConfig, content: [], requestFunctionFilePath: syntheticalConfig.requestFunctionFilePath ? path.isAbsolute(syntheticalConfig.requestFunctionFilePath) ? path.resolve(this.options.cwd, syntheticalConfig.requestFunctionFilePath) : path.resolve(this.options.cwd, syntheticalConfig.requestFunctionFilePath) : path.join(path.dirname(outputFilePath), "request.ts"), requestHookMakerFilePath: syntheticalConfig.reactHooks && syntheticalConfig.reactHooks.enabled ? syntheticalConfig.reactHooks.requestHookMakerFilePath ? path.isAbsolute(syntheticalConfig.reactHooks.requestHookMakerFilePath) ? path.resolve(this.options.cwd, syntheticalConfig.reactHooks.requestHookMakerFilePath) : path.resolve(this.options.cwd, syntheticalConfig.reactHooks.requestHookMakerFilePath) : path.join(path.dirname(outputFilePath), "makeRequestHook.ts") : "" }; return { outputFilePath, code: categoryCode, weights: last(sortByWeights(groupedInterfaceCodes[outputFilePath])).weights }; }); }))).flat(); for (const groupedCodes of values(groupBy(codes, (item) => item.outputFilePath))) { sortByWeights(groupedCodes); outputFileList[groupedCodes[0].outputFilePath].content.push(...groupedCodes.map((item) => item.code)); } })); })); })); return outputFileList; } /** * 生成index.ts文件,将目录中的所有方法和interface类型导出 * @param directoryPaths 目录路径 * @param outputDir 输出目录,默认为 'src/service' */ async generateIndexFile(directoryPaths, outputDir = "src/service") { const indexPath = path.resolve(this.options.cwd, outputDir, "index.ts"); if (!await fs.pathExists(indexPath)) await fs.writeFile(indexPath, ""); const allDirectories = /* @__PURE__ */ new Set(); for (const dir of directoryPaths) { const rootIndexPath = path.resolve(this.options.cwd, dir, "index.ts"); if (await fs.pathExists(rootIndexPath)) allDirectories.add(dir); await this.collectSubDirectories(dir, allDirectories); } let content = "/* prettier-ignore-start */\n/* tslint:disable */\n/* eslint-disable */\n\n/* 该文件由 cis-api-tool 自动生成,请勿直接修改!!! */\n\n"; const indexContent = transformPaths(Array.from(allDirectories), outputDir).join("\n"); content += indexContent; content += "\n/* prettier-ignore-end */"; await fs.writeFile(indexPath, content); } async collectSubDirectories(dirPath, allDirectories) { try { const fullPath = path.resolve(this.options.cwd, dirPath); if (await fs.pathExists(fullPath)) { const items = await fs.readdir(fullPath); for (const item of items) { const itemPath = path.join(fullPath, item); const stat = await fs.stat(itemPath); if (stat.isDirectory()) { const subDirPath = path.join(dirPath, item); const indexFilePath = path.join(itemPath, "index.ts"); if (await fs.pathExists(indexFilePath)) allDirectories.add(subDirPath); await this.collectSubDirectories(subDirPath, allDirectories); } } } } catch (error) { console.warn(`Warning: Failed to collect subdirectories for ${dirPath}:`, error); } } async write(outputFileList) { const result = await Promise.all(Object.keys(outputFileList).map(async (outputFilePath) => { let { content, requestFunctionFilePath, requestHookMakerFilePath, syntheticalConfig } = outputFileList[outputFilePath]; const rawRequestFunctionFilePath = requestFunctionFilePath; const rawRequestHookMakerFilePath = requestHookMakerFilePath; outputFilePath = outputFilePath.replace(/\.js(x)?$/, ".ts$1"); requestFunctionFilePath = requestFunctionFilePath.replace(/\.js(x)?$/, ".ts$1"); requestHookMakerFilePath = requestHookMakerFilePath.replace(/\.js(x)?$/, ".ts$1"); if (!syntheticalConfig.typesOnly) { if (!await fs.pathExists(rawRequestFunctionFilePath)) await fs.outputFile(requestFunctionFilePath, dedent` import type { RequestFunctionParams } from 'cis-api-tool' export interface RequestOptions { /** * 使用的服务器。 * * - \`prod\`: 生产服务器 * - \`dev\`: 测试服务器 * - \`mock\`: 模拟服务器 * * @default prod */ server?: 'prod' | 'dev' | 'mock', } export default function request<TResponseData>( payload: RequestFunctionParams, options: RequestOptions = { server: 'prod', }, ): Promise<TResponseData> { return new Promise<TResponseData>((resolve, reject) => { // 基本地址 const baseUrl = options.server === 'mock' ? payload.mockUrl : options.server === 'dev' ? payload.devUrl : payload.prodUrl // 请求地址 const url = \`\${baseUrl}\${payload.path}\` // 具体请求逻辑 }) } `); if (syntheticalConfig.reactHooks && syntheticalConfig.reactHooks.enabled && !await fs.pathExists(rawRequestHookMakerFilePath)) await fs.outputFile(requestHookMakerFilePath, dedent` import { useState, useEffect } from 'react' import type { RequestConfig } from 'cis-api-tool' import type { Request } from ${JSON.stringify(getNormalizedPathWithAlias(requestHookMakerFilePath, outputFilePath, typeof syntheticalConfig.outputDir === "string" ? syntheticalConfig.outputDir : "src/service"))} import baseRequest from ${JSON.stringify(getNormalizedPathWithAlias(requestHookMakerFilePath, requestFunctionFilePath, typeof syntheticalConfig.outputDir === "string" ? syntheticalConfig.outputDir : "src/service"))} export default function makeRequestHook<TRequestData, TRequestConfig extends RequestConfig, TRequestResult extends ReturnType<typeof baseRequest>>(request: Request<TRequestData, TRequestConfig, TRequestResult>) { type Data = TRequestResult extends Promise<infer R> ? R : TRequestResult return function useRequest(requestData: TRequestData) { // 一个简单的 Hook 实现,实际项目可结合其他库使用,比如: // @umijs/hooks 的 useRequest (https://github.com/umijs/hooks) // swr (https://github.com/zeit/swr) const [loading, setLoading] = useState(true) const [data, setData] = useState<Data>() useEffect(() => { request(requestData).then(data => { setLoading(false) setData(data as any) }) }, [JSON.stringify(requestData)]) return { loading, data, } } } `); } const rawOutputContent = dedent` /* tslint:disable */ /* eslint-disable */ /* 该文件由 cis-api-tool 自动生成,请勿直接修改!!! */ ${syntheticalConfig.typesOnly ? dedent` // @ts-ignore type FileData = File ${content.join("\n\n").trim()} ` : dedent` // @ts-ignore import request from ${JSON.stringify(getNormalizedPathWithAlias(outputFilePath, requestFunctionFilePath, typeof syntheticalConfig.outputDir === "string" ? syntheticalConfig.outputDir : "src/service"))} ${content.join("\n\n").trim()} `} `; const prettier = await getPrettier(this.options.cwd); const prettyOutputContent = await prettier.format(rawOutputContent, { ...await getCachedPrettierOptions(), filepath: outputFilePath }); const outputContent = `${dedent` /* prettier-ignore-start */ ${prettyOutputContent} /* prettier-ignore-end */ `}\n`; await fs.outputFile(outputFilePath, outputContent); if (syntheticalConfig.target === "javascript") { await this.tsc(outputFilePath); await Promise.all([ fs.remove(requestFunctionFilePath).catch(noop), fs.remove(requestHookMakerFilePath).catch(noop), fs.remove(outputFilePath).catch(noop) ]); } return outputFilePath; })); const directories = /* @__PURE__ */ new Set(); result.forEach((outputFilePath) => { const dirPath = path.dirname(outputFilePath); directories.add(dirPath); }); const rootDirs = Array.from(directories).filter((dir) => { return !Array.from(directories).some((otherDir) => { return dir !== otherDir && dir.startsWith(otherDir + path.sep); }); }); let outputDir = "src/service"; if (this.config[0]?.outputDir) if (typeof this.config[0].outputDir === "string") outputDir = this.config[0].outputDir; else outputDir = "src/service"; await this.generateIndexFile(rootDirs, outputDir); return outputFileList; } async tsc(file) { return new Promise((resolve) => { const command = `${__require("os").platform() === "win32" ? "node " : ""}${JSON.stringify(__require.resolve(`typescript/bin/tsc`))}`; exec(`${command} --target ES2019 --module ESNext --jsx preserve --declaration --esModuleInterop ${JSON.stringify(file)}`, { cwd: this.options.cwd, env: process.env }, () => resolve()); }); } async fetchApi(url, query) { const res = await httpGet(url, query); /* istanbul ignore next */ if (res && res.errcode) throwError(`${res.errmsg} [请求地址: ${url}] [请求参数: ${new URLSearchParams(query).toString()}]`); return res.data || res; } fetchProject = memoize(async ({ serverUrl, token }) => { const projectInfo = await this.fetchApi(`${serverUrl}/api/project/get`, { token }); const basePath = `/${projectInfo.basepath || "/"}`.replace(/\/+$/, "").replace(/^\/+/, "/"); projectInfo.basepath = basePath; projectInfo._url = `${serverUrl}/project/${projectInfo._id}/interface/api`; return projectInfo; }); fetchExport = memoize(async ({ serverUrl, token }) => { const projectInfo = await this.fetchProject({ serverUrl, token }); const categoryList = await this.fetchApi(`${serverUrl}/api/plugin/export`, { type: "json", status: "all", isWiki: "false", token }); return categoryList.map((cat) => { const projectId = cat.list?.[0]?.project_id || 0; const catId = cat.list?.[0]?.catid || 0; cat._url = `${serverUrl}/project/${projectId}/interface/api/cat_${catId}`; cat.list = (cat.list || []).map((item) => { const interfaceId = item._id; item._url = `${serverUrl}/project/${projectId}/interface/api/${interfaceId}`; item.path = `${projectInfo.basepath}${item.path}`; return item; }); return cat; }); }); /** 获取分类的接口列表 */ async fetchInterfaceList({ serverUrl, token, id }) { const category = (await this.fetchExport({ serverUrl, token }) || []).find((cat) => !isEmpty(cat) && !isEmpty(cat.list) && cat.list[0].catid === id); if (category) category.list.forEach((interfaceInfo) => { interfaceInfo._category = omit(category, "list"); }); return category ? category.list : []; } /** 获取项目信息 */ async fetchProjectInfo(syntheticalConfig) { const projectInfo = await this.fetchProject(syntheticalConfig); const projectCats = await this.fetchApi(`${syntheticalConfig.serverUrl}/api/interface/getCatMenu`, { token: syntheticalConfig.token, project_id: projectInfo._id }); return { ...projectInfo, cats: projectCats, getMockUrl: () => `${syntheticalConfig.serverUrl}/mock/${projectInfo._id}`, getDevUrl: (devEnvName) => { const env = projectInfo.env.find((e) => e.name === devEnvName); return env && env.domain || ""; }, getProdUrl: (prodEnvName) => { const env = projectInfo.env.find((e) => e.name === prodEnvName); return env && env.domain || ""; } }; } /** 生成接口代码 */ async generateInterfaceCode(syntheticalConfig, interfaceInfo, categoryUID) { const extendedInterfaceInfo = { ...interfaceInfo, parsedPath: path.parse(interfaceInfo.path) }; const requestFunctionName = isFunction(syntheticalConfig.getRequestFunctionName) ? await syntheticalConfig.getRequestFunctionName?.(extendedInterfaceInfo, changeCase) : getRequestFunctionName(extendedInterfaceInfo, changeCase); const requestDataTypeName = isFunction(syntheticalConfig.getRequestDataTypeName) ? await syntheticalConfig.getRequestDataTypeName?.(extendedInterfaceInfo, changeCase) : getRequestDataTypeName(extendedInterfaceInfo, changeCase); const responseDataTypeName = isFunction(syntheticalConfig.getResponseDataTypeName) ? await syntheticalConfig.getResponseDataTypeName?.(extendedInterfaceInfo, changeCase) : getReponseDataTypeName(extendedInterfaceInfo, changeCase); const requestDataJsonSchema = getRequestDataJsonSchema(extendedInterfaceInfo, syntheticalConfig.customTypeMapping || {}); const requestDataType = await jsonSchemaToType(requestDataJsonSchema, requestDataTypeName); const responseDataJsonSchema = getResponseDataJsonSchema(extendedInterfaceInfo, syntheticalConfig.customTypeMapping || {}, syntheticalConfig.dataKey); const responseDataType = await jsonSchemaToType(responseDataJsonSchema, responseDataTypeName); /(\{\}|any)$/g.test(requestDataType); syntheticalConfig.reactHooks && syntheticalConfig.reactHooks.enabled ? isFunction(syntheticalConfig.reactHooks.getRequestHookName) ? await syntheticalConfig.reactHooks.getRequestHookName?.(extendedInterfaceInfo, changeCase) : `use${changeCase.pascalCase(requestFunctionName)}` : ""; const processPathParams = (path$1) => { const hasPathParams = /\{[^}]+\}/.test(path$1); if (!hasPathParams) return { processedPath: path$1, useTemplate: false, pathParamNames: [], originalPathParamNames: [] }; const pathParamNames$1 = []; const originalPathParamNames$1 = []; const processedPath$1 = path$1.replace(/\{([^}]+)\}/g, (match, paramName) => { originalPathParamNames$1.push(paramName); const validParamName = /^\d+$/.test(paramName) ? `param_${paramName}` : paramName; pathParamNames$1.push(validParamName); return "${params." + validParamName + "}"; }); return { processedPath: processedPath$1, useTemplate: true, pathParamNames: pathParamNames$1, originalPathParamNames: originalPathParamNames$1 }; }; const genComment = (genTitle) => { const { enabled: isEnabled = true, title: hasTitle = true, category: hasCategory = true, tag: hasTag = true, requestHeader: hasRequestHeader = true, updateTime: hasUpdateTime = true, link: hasLink = true, extraTags } = { ...syntheticalConfig.comment, ...syntheticalConfig.serverType === "swagger" ? { tag: false, updateTime: false, link: false } : {} }; if (!isEnabled) return ""; const escapedTitle = String(extendedInterfaceInfo.title).replace(/\//g, "\\/"); const description = escapedTitle; const summary = [ hasCategory && { label: "category", value: extendedInterfaceInfo._category.name }, hasTag && { label: "tags", value: extendedInterfaceInfo.tag.map((tag) => `${tag}`) }, hasRequestHeader && { label: "method", value: `${extendedInterfaceInfo.method.toUpperCase()}` }, hasRequestHeader && { label: "path", value: `${extendedInterfaceInfo.path}` } ]; if (typeof extraTags === "function") { const tags = extraTags(extendedInterfaceInfo); for (const tag of tags) (tag.position === "start" ? summary.unshift : summary.push).call(summary, { label: tag.name, value: tag.value }); } const titleComment = hasTitle ? dedent` * ${genTitle(description)} * ` : ""; const extraComment = summary.filter((item) => typeof item !== "boolean" && !isEmpty(item.value)).map((item) => { const _item = item; return `* @${_item.label} ${castArray(_item.value).join(", ")}`; }).join("\n"); return dedent` /** ${[titleComment, extraComment].filter(Boolean).join("\n")} */ `; }; const { processedPath, useTemplate, pathParamNames, originalPathParamNames } = processPathParams(extendedInterfaceInfo.path); return dedent` ${genComment((title) => `@description 接口 ${title} 的 **请求类型**`)} ${requestDataType.trim()} ${genComment((title) => `@description 接口 ${title} 的 **返回类型**`)} ${responseDataType.trim()} ${syntheticalConfig.typesOnly ? "" : dedent` ${genComment((title) => `@description 接口 ${title} 的 **请求函数**`)} export const ${requestFunctionName || "ErrorRequestFunctionName"} = ( params: ${requestDataTypeName} ) => { return request.${extendedInterfaceInfo.method.toLowerCase()}<${responseDataTypeName}>( ${useTemplate ? `\`${processedPath}\`` : JSON.stringify(processedPath)}${originalPathParamNames.length > 0 ? `` : `, params`} ) } `} `; } async destroy() { return Promise.all(this.disposes.map(async (dispose) => dispose())); } }; //#endregion export { Generator };