diff --git a/README.md b/README.md index 9bb6228..1827b66 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,442 @@ # yx-generate-api -`yx-generate-api` 是一个独立的 Node CLI,用来把 Swagger/OpenAPI 接口生成为前端 API 文件,并把 `generated/index.js` 的导出同步到外部入口文件中。 +`yx-generate-api` 是一个面向前端项目的 Node CLI,用来从 Swagger/OpenAPI JSON 生成 API 文件,并把生成目录的导出同步到你项目里的统一入口文件。 -## 能力 +它主要解决两件事: -- 根据 Swagger/OpenAPI JSON 生成模块化 API 文件 -- 自动生成 `generated/index.js` -- 把生成目录的导出同步到外部 `index.js` -- 支持把内部 `generated/index.js` 原文写入注释区块 -- 支持 `generate`、`sync`、`gen`、`init` -- 通过配置文件复用到不同项目 +- 根据接口文档自动生成 `generated/*.js` +- 自动维护业务侧 `index.js` 里的导出区块,避免手写和漏改 + +## 先理解这 4 个命令 + +如果你先记住这四个命令,基本就会用了: + +- `init` + 在当前目录生成模板配置文件和 Windows 启动脚本。 +- `generate` + 只生成 API 文件,不改外部入口文件。 +- `sync` + 只同步外部 `index.js` 的受管导出区块。 +- `gen` + 先执行 `generate`,再执行 `sync`。日常最常用。 + +## 环境要求 + +- Node `^20.19.0 || >=22.12.0` +- 业务项目里要有一个默认导出的 `request` 模块,生成代码会按 `requestImport` 指向它 +- Swagger 源可以是 `http(s)` 地址、`file://` URL,或者本地 JSON 文件路径 ## 安装 -可以直接从 Git 安装到业务项目: +在业务项目中直接安装: ```bash npm install -D git+https://gitea.23544.com/wangyang/yx_generate_api_js.git ``` -也可以先在本仓库开发,再通过 `npm pack` 或私有 npm 发布给其他项目。 +安装完成后,通常通过 `npx yx-generate-api ...` 调用。 ## 快速开始 -在你的业务项目根目录执行: +### 1. 生成模板文件 + +在业务项目根目录执行: ```bash npx yx-generate-api init ``` -这个命令会生成: +会创建两个文件: - `yx-generate-api.config.mjs` - `run-yx-generate-api.bat` -然后根据你的项目结构修改配置文件。 - -## 配置示例 - -```js -export default { - swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json', - outputDir: 'src/api/aixue/generated', - externalIndexFile: 'src/api/aixue/index.js', - requestImport: '../request', - paramStyle: 'object', - sync: { - enabled: true, - includeGeneratedIndexSnapshot: true, - }, -} -``` - -## 命令 - -### 1. 初始化模板 - -```bash -npx yx-generate-api init -``` - -可选参数: +如果文件已存在,使用 `--force` 覆盖: ```bash npx yx-generate-api init --force ``` -### 2. 生成 API +### 2. 修改配置 -```bash -npx yx-generate-api generate +下面是一个更接近真实项目的示例: + +```js +export default { + swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json', + swaggerTimeoutMs: 20000, + outputDir: 'src/api/aixue/generated', + externalIndexFile: 'src/api/aixue/index.js', + requestImport: '../request', + paramStyle: 'object', + cleanOutput: true, + sync: { + enabled: true, + includeGeneratedIndexSnapshot: true, + blockStart: '// AUTO-GENERATED API EXPORTS START', + blockEnd: '// AUTO-GENERATED API EXPORTS END', + }, +} ``` -只生成单个模块: +### 3. 执行生成 ```bash -npx yx-generate-api generate Curriculum +npx yx-generate-api gen ``` -生成多个模块: +这条命令会: + +1. 拉取 `swaggerUrl` +2. 在 `outputDir` 下生成 API 文件 +3. 生成 `outputDir/index.js` +4. 把导出同步到 `externalIndexFile` + +## 一个完整的日常流程 + +第一次接入时: ```bash -npx yx-generate-api generate class-assignment Ranking +npx yx-generate-api init +npx yx-generate-api gen ``` -带参数: +后端接口更新后,通常只需要再执行一次: ```bash -npx yx-generate-api generate --modules=Curriculum,class-assignment -npx yx-generate-api generate --url=http://xxx/swagger/v1/swagger.json -npx yx-generate-api generate --paramStyle=positional +npx yx-generate-api gen ``` -### 3. 只同步外部导出 +如果你只是想重新整理外部入口文件,而不重新拉 Swagger: ```bash npx yx-generate-api sync ``` -### 4. 先生成再同步 +## 配置说明 + +所有写在配置文件里的路径,默认都相对于配置文件所在目录,而不是命令执行目录。 + +### 顶层配置 + +- `swaggerUrl` + Swagger/OpenAPI JSON 来源。支持 `http(s)`、`file://`、本地 JSON 文件路径。生成时必填。 +- `swaggerTimeoutMs` + 拉取远程 Swagger 的超时时间,单位毫秒,默认 `20000`。 +- `outputDir` + 生成目录。默认是 `src/api/generated`。 +- `externalIndexFile` + 业务侧统一导出文件,例如 `src/api/index.js`。如果不填,默认不会执行同步。 +- `requestImport` + 生成模块里 `import request from '...'` 的路径。它应该相对于每个生成出来的模块文件。 +- `paramStyle` + 函数参数风格,可选 `object` 或 `positional`,默认 `object`。 +- `cleanOutput` + 是否在“全量生成”时清理当前输出目录里已经过期的自动生成模块文件。默认 `true`。 +- `sync` + 控制 `sync` / `gen` 如何维护外部入口文件。 + +### sync 配置 + +- `sync.enabled` + 是否启用同步。默认值等于 `Boolean(externalIndexFile)`。 +- `sync.blockStart` + 受管区块开始标记。 +- `sync.blockEnd` + 受管区块结束标记。 +- `sync.includeGeneratedIndexSnapshot` + 是否把 `generated/index.js` 的内容以注释形式写进受管区块。默认 `true`。 +- `sync.snapshotTitle` + 快照标题。默认是 `// generated/index.js content:`。 +- `sync.exportFrom` + 自定义 `export * from '...'` 的路径。不填时会自动根据 `externalIndexFile` 和 `outputDir` 计算。 + +## 命令详解 + +### `init` + +用途:在当前目录创建模板配置文件和 Windows 启动脚本。 + +```bash +npx yx-generate-api init +npx yx-generate-api init --force +``` + +### `generate` + +用途:只生成 API 文件,不同步外部入口。 + +```bash +npx yx-generate-api generate +``` + +常用写法: + +```bash +npx yx-generate-api generate Curriculum +npx yx-generate-api generate class-assignment Ranking +npx yx-generate-api generate --modules=Curriculum,class-assignment +npx yx-generate-api generate --config ./yx-generate-api.config.mjs +npx yx-generate-api generate --url=http://127.0.0.1:8080/swagger/v1/swagger.json +npx yx-generate-api generate --url ./swagger/swagger.json +npx yx-generate-api generate --outDir=src/api/tmp-generated +npx yx-generate-api generate --requestImport=../request +npx yx-generate-api generate --timeout 10000 +npx yx-generate-api generate --no-clean +npx yx-generate-api generate --paramStyle=positional +``` + +支持的参数: + +- `--config=...` + 指定配置文件路径。 +- `--url=...` + 临时覆盖 `swaggerUrl`。 +- `--timeout=...` + 临时覆盖 Swagger 拉取超时,单位毫秒。 +- `--outDir=...` + 临时覆盖输出目录。 +- `--requestImport=...` + 临时覆盖生成文件里的 request 导入路径。 +- `--modules=...` + 逗号分隔的模块列表。 +- `--clean` + 全量生成时清理过期的自动生成模块文件。 +- `--no-clean` + 保留已有生成模块文件。 +- `--paramStyle=object|positional` + 临时覆盖参数风格。 + +命令行参数既支持 `--key=value`,也支持 `--key value`。 + +### `sync` + +用途:只同步外部入口文件。 + +```bash +npx yx-generate-api sync +npx yx-generate-api sync --config=./yx-generate-api.config.mjs +``` + +如果 `sync.enabled=false`,命令会直接跳过。 + +### `gen` + +用途:先 `generate`,再 `sync`。这是推荐的日常命令。 ```bash npx yx-generate-api gen +npx yx-generate-api gen Curriculum +npx yx-generate-api gen --modules=Curriculum,class-assignment ``` -## Windows 双击使用 +`gen` 接受和 `generate` 相同的运行时参数,例如 `--config`、`--url`、`--timeout`、`--outDir`、`--requestImport`、`--paramStyle`、`--modules`、`--clean`。 -`init` 默认会创建 `run-yx-generate-api.bat`,你可以直接双击它。 +## 模块筛选规则 -它内部会执行: +如果不传模块名,会生成 Swagger 里的全部模块。 + +如果传了模块名,只会生成匹配到的模块,例如: + +```bash +npx yx-generate-api generate Curriculum +npx yx-generate-api generate class-assignment Ranking +npx yx-generate-api generate --modules=Curriculum,class-assignment +``` + +模块匹配时会同时参考这些值: + +- 推导出的模块名 +- 模块文件名的 kebab-case 形式 +- Swagger operation 的 `tags` + +匹配时会忽略大小写和大部分分隔符,所以下面这些通常都能匹配到同一个模块: + +- `Curriculum` +- `curriculum` +- `class-assignment` +- `classAssignment` + +### 模块名是怎么推导的 + +生成器会优先从接口路径推导模块名: + +- `/api/v1/Curriculum/list` -> `Curriculum` +- `/api/Curriculum/list` -> `Curriculum` +- 其他路径会优先使用第一个路径段 +- 如果路径不合适,会退回到 Swagger `tags[0]` + +## 生成结果长什么样 + +假设配置如下: + +```js +export default { + outputDir: 'src/api/aixue/generated', + externalIndexFile: 'src/api/aixue/index.js', + requestImport: '../request', +} +``` + +执行 `npx yx-generate-api gen` 后,通常会得到这样的结构: + +```text +src/api/aixue/ + index.js + request.js + generated/ + shared.js + curriculum.js + class-assignment.js + index.js +``` + +其中: + +- `shared.js` + 提供 `buildUrl`、`stringifyParams` 等公共方法,不再依赖额外的 `qs` 包。 +- `curriculum.js` + 某个模块的 API 方法集合。 +- `generated/index.js` + 汇总导出每个模块,同时提供“命名空间导出”和“直接函数导出”。 +- `src/api/aixue/index.js` + 业务侧入口文件,`sync` 会在里面维护一个受管区块。 + +`generated/index.js` 的内容类似这样: + +```js +export * as classAssignmentApi from './class-assignment' +export * as curriculumApi from './curriculum' + +export { getClassAssignmentListApi } from './class-assignment' +export { getCurriculumListApi } from './curriculum' +``` + +如果不同模块里恰好生成了同名函数,`generated/index.js` 会自动为冲突项补上模块前缀别名,避免导出冲突。 + +## 参数风格 + +`paramStyle` 决定生成函数的签名长什么样。 + +### `object` + +默认值。路径参数和查询参数统一放到 `params`,请求体放到 `data`。 + +```js +const detailApi = (params = {}) => request.get(buildUrl(`/api/v1/course/{id}`, params)) + +const createApi = (params = {}, data) => request.post(buildUrl(`/api/v1/course/{id}`, params), data) +``` + +适合大多数前端项目,调用时更稳定,也更适合参数经常变动的接口。 + +### `positional` + +路径参数和查询参数会展开成位置参数,请求体仍然放最后一个 `data`。 + +```js +const detailApi = (id, tab) => request.get(buildUrl(`/api/v1/course/{id}`, { id, tab })) + +const createApi = (id, data) => request.post(buildUrl(`/api/v1/course/{id}`, { id }), data) +``` + +适合你明确想要“函数参数看起来更直接”的场景。 + +## `sync` 会怎么改外部入口文件 + +`sync` 不会粗暴覆盖整个 `externalIndexFile`,它只维护一段带开始和结束标记的受管区块。 + +默认写进去的内容类似这样: + +```js +// AUTO-GENERATED API EXPORTS START +// Synced from 'src/api/aixue/generated/index.js'. Do not edit manually. +// generated/index.js content: +// export * as curriculumApi from './curriculum' +// export * as rankingApi from './ranking' +export * from './generated' +// AUTO-GENERATED API EXPORTS END +``` + +规则是: + +- 如果外部文件里已经有这段标记,`sync` 会替换这段区块 +- 如果还没有,`sync` 会把区块追加到文件末尾 +- 标记外的内容会保留 + +如果你不想把 `generated/index.js` 的快照写进注释,可以把: + +```js +sync: { + includeGeneratedIndexSnapshot: false, +} +``` + +## Windows 双击运行 + +`init` 会同时创建 `run-yx-generate-api.bat`。它会先切到脚本所在目录,再执行: ```bat -npx yx-generate-api gen +npx yx-generate-api gen %* ``` -也可以在命令行里带参数: +适合给不常开命令行的同事直接双击执行。 + +也可以在命令行里继续传参: ```bat run-yx-generate-api.bat Curriculum run-yx-generate-api.bat --modules=Curriculum,class-assignment ``` -## 配置说明 +## 常见问题 -### 顶层配置 +### 1. 为什么 `gen` 没有同步外部 `index.js` -- `swaggerUrl`: Swagger/OpenAPI JSON 地址 -- `outputDir`: 生成目录,相对配置文件所在目录 -- `externalIndexFile`: 外部入口文件路径,相对配置文件所在目录 -- `requestImport`: 生成文件中的 `request` 导入路径 -- `paramStyle`: `object` 或 `positional` -- `sync`: 同步外部入口文件的规则 +通常是下面几种情况: -### sync 配置 +- 没有配置 `externalIndexFile` +- `sync.enabled=false` +- 你执行的是 `generate`,不是 `gen` -- `enabled`: 是否启用同步 -- `blockStart`: 受管注释块开始标记 -- `blockEnd`: 受管注释块结束标记 -- `includeGeneratedIndexSnapshot`: 是否把 `generated/index.js` 内容写入注释 -- `snapshotTitle`: 快照注释标题 -- `exportFrom`: 手动指定外部入口中的 `export * from '...'` +### 2. 为什么生成文件里的 `request` 路径不对 -## 当前约定 +`requestImport` 会原样写进生成文件,所以它必须相对于生成后的模块文件来写,而不是相对于 `externalIndexFile`。 -默认会: +例如生成目录是 `src/api/aixue/generated`,请求封装在 `src/api/aixue/request.js`,那么应该写: -1. 生成 `outputDir/index.js` -2. 在 `externalIndexFile` 里写入受管区块 -3. 受管区块包含: - - 同步来源注释 - - `generated/index.js` 内容快照注释 - - `export * from '...'` +```js +requestImport: '../request' +``` + +### 3. 为什么命令行传的 `--outDir` 看起来和配置文件规则不一样 + +配置文件里的路径相对于“配置文件所在目录”。 + +命令行传入的 `--config`、`--outDir`,相对于“当前执行命令的目录”。 + +### 4. 为什么提示模块找不到 + +说明你传入的模块名没有匹配到任何已解析模块。可以先不带模块参数跑一次全量生成,观察生成出来的模块文件名,再按那个名字筛选。 + +### 5. 为什么有些旧模块文件没有被删掉 + +只有“全量生成”时,`cleanOutput=true` 才会清理过期的自动生成模块文件。 + +如果你这次是只生成部分模块,例如: + +```bash +npx yx-generate-api generate Curriculum +``` + +工具会保留其他已有模块,避免误删。 ## 本地开发 -在工具仓库执行: +在工具仓库里直接查看帮助: ```bash node ./bin/yx-generate-api.js --help +node ./bin/yx-generate-api.js generate --help +node ./bin/yx-generate-api.js sync --help ``` diff --git a/package.json b/package.json index 4b13677..84b1336 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "node": "^20.19.0 || >=22.12.0" }, "scripts": { - "start": "node ./bin/yx-generate-api.js --help" + "start": "node ./bin/yx-generate-api.js --help", + "test": "node --test" }, "dependencies": { "prettier": "3.6.2" diff --git a/src/cli.js b/src/cli.js index c377a58..5994e1b 100644 --- a/src/cli.js +++ b/src/cli.js @@ -49,6 +49,7 @@ Examples: yx-generate-api generate yx-generate-api generate Curriculum yx-generate-api generate --modules=Curriculum,class-assignment + yx-generate-api generate --config ./yx-generate-api.config.mjs --timeout 10000 yx-generate-api sync yx-generate-api gen ` diff --git a/src/commands/gen.js b/src/commands/gen.js index 560301a..2443f5a 100644 --- a/src/commands/gen.js +++ b/src/commands/gen.js @@ -39,5 +39,6 @@ Examples: yx-generate-api gen yx-generate-api gen Curriculum yx-generate-api gen --modules=Curriculum,class-assignment + yx-generate-api gen --config ./yx-generate-api.config.mjs --timeout 10000 `) } diff --git a/src/commands/generate.js b/src/commands/generate.js index b4e4700..a84cce3 100644 --- a/src/commands/generate.js +++ b/src/commands/generate.js @@ -1,7 +1,12 @@ import process from 'node:process' import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js' -import { loadProjectConfig, normalizeParamStyle, resolveCwdPath } from '../core/config.js' +import { + loadProjectConfig, + normalizeParamStyle, + resolveCwdPath, + resolveSwaggerSource, +} from '../core/config.js' import { generateApiFiles } from '../core/generate.js' export const runGenerateCommand = async (args) => { @@ -40,7 +45,10 @@ export const resolveGenerateCommandContext = async (args) => { projectConfig, runtimeConfig: { projectRoot: projectConfig.rootDir, - swaggerUrl: getFlagValue(parsedArgs.flags, 'url') || projectConfig.swaggerUrl, + swaggerUrl: getFlagValue(parsedArgs.flags, 'url') + ? resolveSwaggerSource(process.cwd(), getFlagValue(parsedArgs.flags, 'url')) + : projectConfig.swaggerUrl, + swaggerTimeoutMs: resolveSwaggerTimeoutMs(parsedArgs.flags, projectConfig.swaggerTimeoutMs), outputDir: getFlagValue(parsedArgs.flags, 'outDir') ? resolveCwdPath(process.cwd(), getFlagValue(parsedArgs.flags, 'outDir')) : projectConfig.outputDir, @@ -48,6 +56,9 @@ export const resolveGenerateCommandContext = async (args) => { paramStyle: getFlagValue(parsedArgs.flags, 'paramStyle') ? normalizeParamStyle(getFlagValue(parsedArgs.flags, 'paramStyle')) : projectConfig.paramStyle, + cleanOutput: hasFlag(parsedArgs.flags, 'clean') + ? Boolean(getFlagValue(parsedArgs.flags, 'clean')) + : projectConfig.cleanOutput, modules: moduleArgs, }, } @@ -62,10 +73,14 @@ Usage: Options: --config=... Config file path --url=... Swagger/OpenAPI JSON URL + Supports http(s), file://, or a local JSON file path --outDir=... Output directory --requestImport=... request import path inside generated files --modules=... Comma-separated module list --paramStyle=... object / positional + --timeout=... Swagger fetch timeout in milliseconds + --clean Remove stale auto-generated module files on full generation + --no-clean Keep previously generated module files --help Show help Examples: @@ -73,6 +88,7 @@ Examples: yx-generate-api generate Curriculum yx-generate-api generate class-assignment Ranking yx-generate-api generate --modules=Curriculum,class-assignment + yx-generate-api generate --config ./yx-generate-api.config.mjs --timeout 10000 `) } @@ -82,3 +98,19 @@ const splitModuleArgs = (value) => { .map((item) => item.trim()) .filter(Boolean) } + +const resolveSwaggerTimeoutMs = (flags, fallbackValue) => { + const rawValue = getFlagValue(flags, 'timeout') || getFlagValue(flags, 'timeoutMs') + + if (rawValue === undefined) { + return fallbackValue + } + + const parsedValue = Number.parseInt(String(rawValue), 10) + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error(`--timeout must be a positive integer. Received: ${rawValue}`) + } + + return parsedValue +} diff --git a/src/core/args.js b/src/core/args.js index 2ffe410..415f7e2 100644 --- a/src/core/args.js +++ b/src/core/args.js @@ -2,7 +2,14 @@ export const parseCliArgs = (args) => { const flags = new Map() const positionals = [] - for (const arg of args) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + + if (arg === '--') { + positionals.push(...args.slice(index + 1)) + break + } + if (!arg.startsWith('--')) { positionals.push(arg) continue @@ -12,6 +19,19 @@ export const parseCliArgs = (args) => { const separatorIndex = normalizedArg.indexOf('=') if (separatorIndex === -1) { + if (normalizedArg.startsWith('no-')) { + pushFlagValue(flags, normalizedArg.slice(3), false) + continue + } + + const nextArg = args[index + 1] + + if (nextArg !== undefined && nextArg !== '--' && !nextArg.startsWith('--')) { + pushFlagValue(flags, normalizedArg, nextArg) + index += 1 + continue + } + pushFlagValue(flags, normalizedArg, true) continue } diff --git a/src/core/config.js b/src/core/config.js index 6136e46..4a8ecad 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -15,6 +15,7 @@ export const PARAM_STYLE = { } export const SUPPORTED_PARAM_STYLES = Object.values(PARAM_STYLE) +export const DEFAULT_SWAGGER_TIMEOUT_MS = 20_000 export const DEFAULT_SYNC_OPTIONS = { blockStart: '// AUTO-GENERATED API EXPORTS START', @@ -43,13 +44,19 @@ export const loadProjectConfig = async ({ configPath, cwd = process.cwd() } = {} return { configPath: resolvedConfigPath, rootDir, - swaggerUrl: normalizeString(rawConfig.swaggerUrl), + swaggerUrl: rawConfig.swaggerUrl ? resolveSwaggerSource(rootDir, rawConfig.swaggerUrl) : '', + swaggerTimeoutMs: normalizePositiveInteger( + rawConfig.swaggerTimeoutMs, + 'swaggerTimeoutMs', + DEFAULT_SWAGGER_TIMEOUT_MS, + ), outputDir: rawConfig.outputDir ? resolveProjectPath(rootDir, rawConfig.outputDir) : resolveProjectPath(rootDir, 'src/api/generated'), externalIndexFile, requestImport: normalizeString(rawConfig.requestImport) || '../request', paramStyle: normalizeParamStyle(rawConfig.paramStyle || PARAM_STYLE.OBJECT), + cleanOutput: rawConfig.cleanOutput ?? true, sync: { enabled: syncConfig.enabled ?? Boolean(externalIndexFile), blockStart: normalizeString(syncConfig.blockStart) || DEFAULT_SYNC_OPTIONS.blockStart, @@ -92,6 +99,20 @@ export const resolveProjectPath = (projectRoot, targetPath) => { return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRoot, targetPath) } +export const resolveSwaggerSource = (baseDir, source) => { + const normalizedSource = normalizeString(source) + + if (!normalizedSource) { + return '' + } + + if (/^https?:\/\//i.test(normalizedSource) || /^file:\/\//i.test(normalizedSource)) { + return normalizedSource + } + + return resolveProjectPath(baseDir, normalizedSource) +} + export const normalizeParamStyle = (value) => { const normalizedValue = normalizeString(value).toLowerCase() @@ -108,6 +129,20 @@ const normalizeString = (value) => { return String(value || '').trim() } +const normalizePositiveInteger = (value, label, fallbackValue) => { + if (value === undefined || value === null || String(value).trim() === '') { + return fallbackValue + } + + const parsedValue = Number.parseInt(String(value), 10) + + if (!Number.isInteger(parsedValue) || parsedValue <= 0) { + throw new Error(`${label} must be a positive integer. Received: ${value}`) + } + + return parsedValue +} + const fileExists = async (targetPath) => { try { await fs.access(targetPath) diff --git a/src/core/generate.js b/src/core/generate.js index b362fe9..870d8aa 100644 --- a/src/core/generate.js +++ b/src/core/generate.js @@ -5,6 +5,7 @@ import prettier from 'prettier' const DEFAULT_SHARED_IMPORT = './shared' const DEFAULT_SHARED_FILE_NAME = 'shared.js' const DEFAULT_INDEX_FILE_NAME = 'index.js' +const AUTO_GENERATED_BANNER = '// Auto-generated. Do not edit manually.' const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch'] const JS_RESERVED_WORDS = new Set([ 'await', @@ -58,16 +59,29 @@ const JS_RESERVED_WORDS = new Set([ export const generateApiFiles = async ({ projectRoot, swaggerUrl, + swaggerTimeoutMs, outputDir, requestImport, paramStyle, modules, + cleanOutput, }) => { - const swagger = await fetchSwaggerJson(swaggerUrl) + const swagger = await fetchSwaggerJson(swaggerUrl, swaggerTimeoutMs) const moduleMap = buildModuleMap(swagger) const selectedModules = resolveSelectedModules(moduleMap, modules) await fs.mkdir(outputDir, { recursive: true }) + + if (cleanOutput && modules.length === 0) { + await cleanStaleGeneratedFiles({ + outputDir, + nextModuleFileNames: selectedModules.map((moduleInfo) => `${moduleInfo.fileName}.js`), + projectRoot, + }) + } else if (cleanOutput && modules.length > 0) { + console.log('clean skipped: module subset generation') + } + await writeGeneratedFile( path.join(outputDir, DEFAULT_SHARED_FILE_NAME), generateSharedFile({ @@ -102,14 +116,89 @@ export const generateApiFiles = async ({ console.log(`done: ${selectedModules.length} module(s)`) } -const fetchSwaggerJson = async (url) => { - const response = await fetch(url) +const fetchSwaggerJson = async (source, timeoutMs = 20_000) => { + const normalizedSource = String(source || '').trim() - if (!response.ok) { - throw new Error(`Swagger fetch failed: ${response.status} ${response.statusText}`) + if (!normalizedSource) { + throw new Error('swaggerUrl is required') } - return response.json() + if (/^https?:\/\//i.test(normalizedSource)) { + return fetchSwaggerJsonFromHttp(normalizedSource, timeoutMs) + } + + if (/^file:\/\//i.test(normalizedSource)) { + return readSwaggerJsonFile(new URL(normalizedSource)) + } + + return readSwaggerJsonFile(normalizedSource) +} + +const fetchSwaggerJsonFromHttp = async (url, timeoutMs) => { + let response + + try { + response = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (error) { + if (error?.name === 'TimeoutError') { + throw new Error(`Swagger fetch timed out after ${timeoutMs}ms: ${url}`) + } + + throw new Error(`Swagger fetch failed: ${url}\n${error.message}`) + } + + if (!response.ok) { + throw new Error(`Swagger fetch failed: ${response.status} ${response.statusText} (${url})`) + } + + try { + return await response.json() + } catch (error) { + throw new Error(`Swagger response is not valid JSON: ${url}\n${error.message}`) + } +} + +const readSwaggerJsonFile = async (filePath) => { + let content + + try { + content = await fs.readFile(filePath, 'utf8') + } catch (error) { + throw new Error(`Swagger file read failed: ${String(filePath)}\n${error.message}`) + } + + try { + return JSON.parse(content) + } catch (error) { + throw new Error(`Swagger file is not valid JSON: ${String(filePath)}\n${error.message}`) + } +} + +const cleanStaleGeneratedFiles = async ({ outputDir, nextModuleFileNames, projectRoot }) => { + const dirEntries = await fs.readdir(outputDir, { withFileTypes: true }) + const preservedFileNames = new Set([ + DEFAULT_INDEX_FILE_NAME, + DEFAULT_SHARED_FILE_NAME, + ...nextModuleFileNames, + ]) + + for (const entry of dirEntries) { + if (!entry.isFile() || !entry.name.endsWith('.js') || preservedFileNames.has(entry.name)) { + continue + } + + const filePath = path.join(outputDir, entry.name) + const fileContent = await fs.readFile(filePath, 'utf8') + + if (!fileContent.startsWith(AUTO_GENERATED_BANNER)) { + continue + } + + await fs.rm(filePath) + console.log(`removed: ${path.relative(projectRoot, filePath)}`) + } } const buildModuleMap = (swagger) => { @@ -213,16 +302,47 @@ const writeGeneratedFile = async (filePath, content) => { } const generateSharedFile = ({ swaggerUrl }) => { - return `// Auto-generated. Do not edit manually. + return `${AUTO_GENERATED_BANNER} // Swagger: ${swaggerUrl} // Shared helpers: URL builder -import qs from 'qs' +const appendQueryEntries = (entries, key, value) => { + if (value === undefined || value === null) { + return + } -export const stringifyParams = params => { - return qs.stringify(params, { - arrayFormat: 'repeat', - }) + if (Array.isArray(value)) { + for (const item of value) { + appendQueryEntries(entries, key, item) + } + return + } + + if (value instanceof Date) { + entries.push([key, value.toISOString()]) + return + } + + if (Object.prototype.toString.call(value) === '[object Object]') { + for (const [nestedKey, nestedValue] of Object.entries(value)) { + appendQueryEntries(entries, \`\${key}[\${nestedKey}]\`, nestedValue) + } + return + } + + entries.push([key, value]) +} + +export const stringifyParams = (params = {}) => { + const entries = [] + + for (const [key, value] of Object.entries(params)) { + appendQueryEntries(entries, key, value) + } + + return entries + .map(([key, value]) => \`\${encodeURIComponent(key)}=\${encodeURIComponent(String(value))}\`) + .join('&') } export const buildUrl = (url, params = {}) => { @@ -265,24 +385,105 @@ const generateIndexFile = async ({ outDir, swaggerUrl }) => { }) .map((entry) => entry.name.replace(/\.js$/i, '')) .sort((left, right) => left.localeCompare(right)) - const lines = ['// Auto-generated. Do not edit manually.', `// Swagger: ${swaggerUrl}`, '// Module aggregation exports', ''] + const moduleExports = await Promise.all( + moduleFileNames.map(async (fileName) => { + return { + fileName, + exportNames: await readGeneratedModuleExportNames(path.join(outDir, `${fileName}.js`)), + } + }), + ) + const namedExportEntries = resolveIndexNamedExportEntries(moduleExports) + const lines = [ + AUTO_GENERATED_BANNER, + `// Swagger: ${swaggerUrl}`, + '// Module entrypoint with namespace and named exports', + '', + ] - for (const fileName of moduleFileNames) { + for (const { fileName } of moduleExports) { lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`) } + if (moduleExports.length && namedExportEntries.length) { + lines.push('') + } + + for (const entry of namedExportEntries) { + lines.push(buildIndexNamedExportLine(entry)) + } + lines.push('') return `${lines.join('\n')}` } +const readGeneratedModuleExportNames = async (filePath) => { + const content = await fs.readFile(filePath, 'utf8') + const match = content.match(/export\s*\{([\s\S]*?)\}\s*;?\s*$/) + + if (!match) { + return [] + } + + return match[1] + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +const resolveIndexNamedExportEntries = (moduleExports) => { + const originalNameCounts = new Map() + const flatEntries = moduleExports.flatMap(({ fileName, exportNames }) => { + return exportNames.map((exportName) => ({ + exportName, + fileName, + })) + }) + + for (const entry of flatEntries) { + originalNameCounts.set(entry.exportName, (originalNameCounts.get(entry.exportName) || 0) + 1) + } + + const usedTargetNames = new Set() + + return flatEntries.map((entry) => { + const hasCollision = (originalNameCounts.get(entry.exportName) || 0) > 1 + const baseTargetName = hasCollision + ? buildModuleScopedExportName(entry.fileName, entry.exportName) + : entry.exportName + let targetName = baseTargetName + let sequence = 2 + + while (usedTargetNames.has(targetName)) { + targetName = `${baseTargetName}${sequence}` + sequence += 1 + } + + usedTargetNames.add(targetName) + + return { + ...entry, + targetName, + } + }) +} + +const buildIndexNamedExportLine = ({ fileName, exportName, targetName }) => { + if (exportName === targetName) { + return `export { ${exportName} } from './${fileName}'` + } + + return `export { ${exportName} as ${targetName} } from './${fileName}'` +} + const generateModuleFile = ({ moduleInfo, paramStyle, schemas, requestImport, swaggerUrl }) => { const needsBuildUrl = moduleInfo.operations.some((item) => { return getOperationParameters(item.operation).length > 0 }) const exportNames = moduleInfo.operations.map((item) => item.functionName) const lines = [ - '// Auto-generated. Do not edit manually.', + AUTO_GENERATED_BANNER, `// Swagger: ${swaggerUrl}`, `// Module: ${moduleInfo.moduleName}`, `// Param style: ${paramStyle}`, @@ -853,6 +1054,10 @@ const buildModuleNamespaceExportName = (fileName) => { return `${toCamelCase(fileName)}Api` } +const buildModuleScopedExportName = (fileName, exportName) => { + return `${toCamelCase(fileName)}${capitalize(exportName)}` +} + const splitWords = (value) => { return String(value || '') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') diff --git a/templates/yx-generate-api.config.mjs b/templates/yx-generate-api.config.mjs index 50c112b..33ab9e9 100644 --- a/templates/yx-generate-api.config.mjs +++ b/templates/yx-generate-api.config.mjs @@ -1,9 +1,11 @@ export default { swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json', + swaggerTimeoutMs: 20000, outputDir: 'src/api/generated', externalIndexFile: 'src/api/index.js', requestImport: '../request', paramStyle: 'object', + cleanOutput: true, sync: { enabled: true, includeGeneratedIndexSnapshot: true, diff --git a/test/args.test.js b/test/args.test.js new file mode 100644 index 0000000..afd3df2 --- /dev/null +++ b/test/args.test.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { getFlagValue, getFlagValues, parseCliArgs } from '../src/core/args.js' + +test('parseCliArgs supports --key value syntax and boolean negation', () => { + const parsed = parseCliArgs([ + '--config', + './yx-generate-api.config.mjs', + '--modules', + 'Curriculum,Ranking', + '--clean', + '--no-snapshot', + 'EnglishWord', + '--', + '--literal', + ]) + + assert.equal(getFlagValue(parsed.flags, 'config'), './yx-generate-api.config.mjs') + assert.deepEqual(getFlagValues(parsed.flags, 'modules'), ['Curriculum,Ranking']) + assert.equal(getFlagValue(parsed.flags, 'clean'), true) + assert.equal(getFlagValue(parsed.flags, 'snapshot'), false) + assert.deepEqual(parsed.positionals, ['EnglishWord', '--literal']) +}) diff --git a/test/cli.integration.test.js b/test/cli.integration.test.js new file mode 100644 index 0000000..cf0180f --- /dev/null +++ b/test/cli.integration.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import { execFile as execFileCallback } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' +import test from 'node:test' + +const execFile = promisify(execFileCallback) + +const createTempDir = async () => { + return fs.mkdtemp(path.join(os.tmpdir(), 'yx-generate-api-cli-')) +} + +test('cli gen supports spaced flags and syncs the external index file', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const configPath = path.join(tempDir, 'yx-generate-api.config.mjs') + const outputDir = path.join(tempDir, 'generated') + const externalIndexFile = path.join(tempDir, 'api-index.js') + const cliPath = path.resolve('bin/yx-generate-api.js') + + await fs.writeFile( + swaggerPath, + JSON.stringify( + { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + summary: 'Get alpha thing', + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }, + null, + 2, + ), + 'utf8', + ) + + await fs.writeFile( + configPath, + `export default { + swaggerUrl: './swagger.json', + swaggerTimeoutMs: 1000, + outputDir: './generated', + externalIndexFile: './api-index.js', + requestImport: '../request', + cleanOutput: true, + sync: { + enabled: true, + }, +} +`, + 'utf8', + ) + + await execFile(process.execPath, [cliPath, 'gen', '--config', configPath, '--timeout', '1000'], { + cwd: tempDir, + }) + + const generatedIndexContent = await fs.readFile(path.join(outputDir, 'index.js'), 'utf8') + const externalIndexContent = await fs.readFile(externalIndexFile, 'utf8') + + assert.match(generatedIndexContent, /export \* as alphaApi from ["']\.\/alpha["']/) + assert.match(generatedIndexContent, /export \{ getThingApi \} from ["']\.\/alpha["']/) + assert.match(externalIndexContent, /AUTO-GENERATED API EXPORTS START/) + assert.match(externalIndexContent, /export \* from ["']\.\/generated["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) diff --git a/test/generate.test.js b/test/generate.test.js new file mode 100644 index 0000000..d9f75f0 --- /dev/null +++ b/test/generate.test.js @@ -0,0 +1,252 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { generateApiFiles } from '../src/core/generate.js' + +const createTempDir = async () => { + return fs.mkdtemp(path.join(os.tmpdir(), 'yx-generate-api-')) +} + +const writeJson = async (filePath, value) => { + await fs.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8') +} + +const readFile = async (filePath) => { + return fs.readFile(filePath, 'utf8') +} + +test('full generation cleans stale auto-generated files and keeps manual files', async () => { + const tempDir = await createTempDir() + + try { + const initialSwaggerPath = path.join(tempDir, 'swagger-initial.json') + const updatedSwaggerPath = path.join(tempDir, 'swagger-updated.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(initialSwaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + summary: 'Get alpha thing', + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + summary: 'Get beta list', + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await writeJson(updatedSwaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + summary: 'Get alpha thing', + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: initialSwaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }) + + await fs.writeFile(path.join(outputDir, 'manual-helper.js'), 'export const keepMe = true\n', 'utf8') + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: updatedSwaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }) + + await assert.doesNotReject(fs.access(path.join(outputDir, 'alpha.js'))) + await assert.rejects(fs.access(path.join(outputDir, 'beta.js'))) + await assert.doesNotReject(fs.access(path.join(outputDir, 'manual-helper.js'))) + + const sharedFile = await readFile(path.join(outputDir, 'shared.js')) + assert.doesNotMatch(sharedFile, /from 'qs'/) + assert.match(sharedFile, /appendQueryEntries/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('partial generation keeps other generated modules even when cleanOutput is enabled', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetThing': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: ['Alpha'], + cleanOutput: true, + }) + + await assert.doesNotReject(fs.access(path.join(outputDir, 'beta.js'))) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('generated index includes namespace exports and collision-safe named exports', async () => { + const tempDir = await createTempDir() + + try { + const swaggerPath = path.join(tempDir, 'swagger.json') + const outputDir = path.join(tempDir, 'generated') + + await writeJson(swaggerPath, { + openapi: '3.0.0', + paths: { + '/api/v1/Alpha/GetList': { + get: { + tags: ['Alpha'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetList': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + '/api/v1/Beta/GetDetail': { + get: { + tags: ['Beta'], + responses: { + 200: { description: 'OK' }, + }, + }, + }, + }, + }) + + await generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: swaggerPath, + swaggerTimeoutMs: 1000, + outputDir, + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }) + + const indexContent = await readFile(path.join(outputDir, 'index.js')) + + assert.match(indexContent, /export \* as alphaApi from ["']\.\/alpha["']/) + assert.match(indexContent, /export \* as betaApi from ["']\.\/beta["']/) + assert.match(indexContent, /export \{ getListApi as alphaGetListApi \} from ["']\.\/alpha["']/) + assert.match(indexContent, /export \{ getListApi as betaGetListApi \} from ["']\.\/beta["']/) + assert.match(indexContent, /export \{ getDetailApi \} from ["']\.\/beta["']/) + } finally { + await fs.rm(tempDir, { recursive: true, force: true }) + } +}) + +test('swagger timeout surfaces a clear error message', async () => { + const tempDir = await createTempDir() + const server = http.createServer((_, response) => { + setTimeout(() => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ openapi: '3.0.0', paths: {} })) + }, 100) + }) + + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() + + await assert.rejects( + generateApiFiles({ + projectRoot: tempDir, + swaggerUrl: `http://127.0.0.1:${port}/swagger.json`, + swaggerTimeoutMs: 10, + outputDir: path.join(tempDir, 'generated'), + requestImport: '../request', + paramStyle: 'object', + modules: [], + cleanOutput: true, + }), + /timed out/, + ) + } finally { + server.close() + await fs.rm(tempDir, { recursive: true, force: true }) + } +})