feat: improve generator reliability and usability

This commit is contained in:
DESKTOP-I3JPKHK\wy 2026-04-21 15:39:49 +08:00
parent 614e3acdd4
commit 167527dd2a
12 changed files with 1034 additions and 101 deletions

443
README.md
View File

@ -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
```

View File

@ -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"

View File

@ -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
`

View File

@ -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
`)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)

View File

@ -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')

View File

@ -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,

24
test/args.test.js Normal file
View File

@ -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'])
})

View File

@ -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 })
}
})

252
test/generate.test.js Normal file
View File

@ -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 })
}
})