feat: improve generator reliability and usability
This commit is contained in:
parent
614e3acdd4
commit
167527dd2a
443
README.md
443
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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue