feat: initialize yx-generate-api CLI
This commit is contained in:
commit
614e3acdd4
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
# yx-generate-api
|
||||||
|
|
||||||
|
`yx-generate-api` 是一个独立的 Node CLI,用来把 Swagger/OpenAPI 接口生成为前端 API 文件,并把 `generated/index.js` 的导出同步到外部入口文件中。
|
||||||
|
|
||||||
|
## 能力
|
||||||
|
|
||||||
|
- 根据 Swagger/OpenAPI JSON 生成模块化 API 文件
|
||||||
|
- 自动生成 `generated/index.js`
|
||||||
|
- 把生成目录的导出同步到外部 `index.js`
|
||||||
|
- 支持把内部 `generated/index.js` 原文写入注释区块
|
||||||
|
- 支持 `generate`、`sync`、`gen`、`init`
|
||||||
|
- 通过配置文件复用到不同项目
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
可以直接从 Git 安装到业务项目:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D git+https://gitea.23544.com/wangyang/yx_generate_api_js.git
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以先在本仓库开发,再通过 `npm pack` 或私有 npm 发布给其他项目。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
在你的业务项目根目录执行:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api init --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 生成 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api generate
|
||||||
|
```
|
||||||
|
|
||||||
|
只生成单个模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api generate Curriculum
|
||||||
|
```
|
||||||
|
|
||||||
|
生成多个模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api generate class-assignment Ranking
|
||||||
|
```
|
||||||
|
|
||||||
|
带参数:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 只同步外部导出
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api sync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 先生成再同步
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx yx-generate-api gen
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows 双击使用
|
||||||
|
|
||||||
|
`init` 默认会创建 `run-yx-generate-api.bat`,你可以直接双击它。
|
||||||
|
|
||||||
|
它内部会执行:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
npx yx-generate-api gen
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在命令行里带参数:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
run-yx-generate-api.bat Curriculum
|
||||||
|
run-yx-generate-api.bat --modules=Curriculum,class-assignment
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 顶层配置
|
||||||
|
|
||||||
|
- `swaggerUrl`: Swagger/OpenAPI JSON 地址
|
||||||
|
- `outputDir`: 生成目录,相对配置文件所在目录
|
||||||
|
- `externalIndexFile`: 外部入口文件路径,相对配置文件所在目录
|
||||||
|
- `requestImport`: 生成文件中的 `request` 导入路径
|
||||||
|
- `paramStyle`: `object` 或 `positional`
|
||||||
|
- `sync`: 同步外部入口文件的规则
|
||||||
|
|
||||||
|
### sync 配置
|
||||||
|
|
||||||
|
- `enabled`: 是否启用同步
|
||||||
|
- `blockStart`: 受管注释块开始标记
|
||||||
|
- `blockEnd`: 受管注释块结束标记
|
||||||
|
- `includeGeneratedIndexSnapshot`: 是否把 `generated/index.js` 内容写入注释
|
||||||
|
- `snapshotTitle`: 快照注释标题
|
||||||
|
- `exportFrom`: 手动指定外部入口中的 `export * from '...'`
|
||||||
|
|
||||||
|
## 当前约定
|
||||||
|
|
||||||
|
默认会:
|
||||||
|
|
||||||
|
1. 生成 `outputDir/index.js`
|
||||||
|
2. 在 `externalIndexFile` 里写入受管区块
|
||||||
|
3. 受管区块包含:
|
||||||
|
- 同步来源注释
|
||||||
|
- `generated/index.js` 内容快照注释
|
||||||
|
- `export * from '...'`
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
在工具仓库执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ./bin/yx-generate-api.js --help
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { runCli } from '../src/cli.js'
|
||||||
|
|
||||||
|
runCli(process.argv.slice(2)).catch((error) => {
|
||||||
|
console.error(error.message)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "yx-generate-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "yx-generate-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "3.6.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"yx-generate-api": "bin/yx-generate-api.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "yx-generate-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Config-driven Swagger API generator and export sync tool for frontend projects.",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"yx-generate-api": "./bin/yx-generate-api.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"bin",
|
||||||
|
"src",
|
||||||
|
"templates",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node ./bin/yx-generate-api.js --help"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"prettier": "3.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { runGenerateCommand } from './commands/generate.js'
|
||||||
|
import { runGenCommand } from './commands/gen.js'
|
||||||
|
import { runInitCommand } from './commands/init.js'
|
||||||
|
import { runSyncCommand } from './commands/sync.js'
|
||||||
|
|
||||||
|
const COMMANDS = new Map([
|
||||||
|
['init', runInitCommand],
|
||||||
|
['generate', runGenerateCommand],
|
||||||
|
['sync', runSyncCommand],
|
||||||
|
['gen', runGenCommand],
|
||||||
|
])
|
||||||
|
|
||||||
|
export const runCli = async (argv) => {
|
||||||
|
const [inputCommand = 'help', ...restArgs] = argv
|
||||||
|
|
||||||
|
if (inputCommand === '--help' || inputCommand === '-h' || inputCommand === 'help') {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = COMMANDS.get(inputCommand)
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
throw new Error(`Unknown command: ${inputCommand}\n\n${buildHelpText()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await command(restArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHelp = () => {
|
||||||
|
console.log(buildHelpText())
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildHelpText = () => {
|
||||||
|
return `yx-generate-api
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
yx-generate-api <command> [options]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init Create config and Windows launcher templates in the current project
|
||||||
|
generate Generate API files from Swagger/OpenAPI
|
||||||
|
sync Sync generated/index.js exports into the external index file
|
||||||
|
gen Run generate first, then sync
|
||||||
|
help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
yx-generate-api init
|
||||||
|
yx-generate-api generate
|
||||||
|
yx-generate-api generate Curriculum
|
||||||
|
yx-generate-api generate --modules=Curriculum,class-assignment
|
||||||
|
yx-generate-api sync
|
||||||
|
yx-generate-api gen
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { generateApiFiles } from '../core/generate.js'
|
||||||
|
import { syncExternalIndex } from '../core/sync.js'
|
||||||
|
import { resolveGenerateCommandContext } from './generate.js'
|
||||||
|
|
||||||
|
export const runGenCommand = async (args) => {
|
||||||
|
const context = await resolveGenerateCommandContext(args)
|
||||||
|
|
||||||
|
if (context.help) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateApiFiles(context.runtimeConfig)
|
||||||
|
|
||||||
|
if (!context.projectConfig.sync.enabled) {
|
||||||
|
console.log('sync skipped: sync.enabled=false')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncExternalIndex({
|
||||||
|
projectRoot: context.projectConfig.rootDir,
|
||||||
|
outputDir: context.runtimeConfig.outputDir,
|
||||||
|
externalIndexFile: context.projectConfig.externalIndexFile,
|
||||||
|
syncOptions: context.projectConfig.sync,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHelp = () => {
|
||||||
|
console.log(`yx-generate-api gen
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
yx-generate-api gen [moduleName...] [options]
|
||||||
|
|
||||||
|
This command runs:
|
||||||
|
1. generate
|
||||||
|
2. sync
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
yx-generate-api gen
|
||||||
|
yx-generate-api gen Curriculum
|
||||||
|
yx-generate-api gen --modules=Curriculum,class-assignment
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import { parseCliArgs, getFlagValue, getFlagValues, hasFlag } from '../core/args.js'
|
||||||
|
import { loadProjectConfig, normalizeParamStyle, resolveCwdPath } from '../core/config.js'
|
||||||
|
import { generateApiFiles } from '../core/generate.js'
|
||||||
|
|
||||||
|
export const runGenerateCommand = async (args) => {
|
||||||
|
const context = await resolveGenerateCommandContext(args)
|
||||||
|
|
||||||
|
if (context.help) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await generateApiFiles(context.runtimeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveGenerateCommandContext = async (args) => {
|
||||||
|
const parsedArgs = parseCliArgs(args)
|
||||||
|
const help = hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h')
|
||||||
|
|
||||||
|
if (help) {
|
||||||
|
return {
|
||||||
|
help: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = getFlagValue(parsedArgs.flags, 'config')
|
||||||
|
const projectConfig = await loadProjectConfig({
|
||||||
|
configPath,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
const moduleArgs = [
|
||||||
|
...parsedArgs.positionals,
|
||||||
|
...getFlagValues(parsedArgs.flags, 'modules').flatMap(splitModuleArgs),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
help: false,
|
||||||
|
projectConfig,
|
||||||
|
runtimeConfig: {
|
||||||
|
projectRoot: projectConfig.rootDir,
|
||||||
|
swaggerUrl: getFlagValue(parsedArgs.flags, 'url') || projectConfig.swaggerUrl,
|
||||||
|
outputDir: getFlagValue(parsedArgs.flags, 'outDir')
|
||||||
|
? resolveCwdPath(process.cwd(), getFlagValue(parsedArgs.flags, 'outDir'))
|
||||||
|
: projectConfig.outputDir,
|
||||||
|
requestImport: getFlagValue(parsedArgs.flags, 'requestImport') || projectConfig.requestImport,
|
||||||
|
paramStyle: getFlagValue(parsedArgs.flags, 'paramStyle')
|
||||||
|
? normalizeParamStyle(getFlagValue(parsedArgs.flags, 'paramStyle'))
|
||||||
|
: projectConfig.paramStyle,
|
||||||
|
modules: moduleArgs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHelp = () => {
|
||||||
|
console.log(`yx-generate-api generate
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
yx-generate-api generate [moduleName...] [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config=... Config file path
|
||||||
|
--url=... Swagger/OpenAPI JSON URL
|
||||||
|
--outDir=... Output directory
|
||||||
|
--requestImport=... request import path inside generated files
|
||||||
|
--modules=... Comma-separated module list
|
||||||
|
--paramStyle=... object / positional
|
||||||
|
--help Show help
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
yx-generate-api generate
|
||||||
|
yx-generate-api generate Curriculum
|
||||||
|
yx-generate-api generate class-assignment Ranking
|
||||||
|
yx-generate-api generate --modules=Curriculum,class-assignment
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitModuleArgs = (value) => {
|
||||||
|
return String(value || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import { parseCliArgs, hasFlag } from '../core/args.js'
|
||||||
|
|
||||||
|
const CONFIG_TEMPLATE_PATH = new URL('../../templates/yx-generate-api.config.mjs', import.meta.url)
|
||||||
|
const BAT_TEMPLATE_PATH = new URL('../../templates/run-yx-generate-api.bat', import.meta.url)
|
||||||
|
|
||||||
|
export const runInitCommand = async (args) => {
|
||||||
|
const parsedArgs = parseCliArgs(args)
|
||||||
|
|
||||||
|
if (hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h')) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = hasFlag(parsedArgs.flags, 'force')
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const configPath = path.join(cwd, 'yx-generate-api.config.mjs')
|
||||||
|
const batPath = path.join(cwd, 'run-yx-generate-api.bat')
|
||||||
|
const filesToWrite = [
|
||||||
|
{
|
||||||
|
label: 'config',
|
||||||
|
filePath: configPath,
|
||||||
|
templateUrl: CONFIG_TEMPLATE_PATH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'bat',
|
||||||
|
filePath: batPath,
|
||||||
|
templateUrl: BAT_TEMPLATE_PATH,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const existingFiles = []
|
||||||
|
|
||||||
|
for (const item of filesToWrite) {
|
||||||
|
if (await fileExists(item.filePath)) {
|
||||||
|
existingFiles.push(item.filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingFiles.length && !force) {
|
||||||
|
throw new Error(
|
||||||
|
`Init aborted because files already exist:\n${existingFiles.join('\n')}\n\nUse --force to overwrite.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of filesToWrite) {
|
||||||
|
const content = await fs.readFile(item.templateUrl, 'utf8')
|
||||||
|
await fs.writeFile(item.filePath, content, 'utf8')
|
||||||
|
console.log(`created: ${path.relative(cwd, item.filePath)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExists = async (targetPath) => {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHelp = () => {
|
||||||
|
console.log(`yx-generate-api init
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
yx-generate-api init [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--force Overwrite existing template files
|
||||||
|
--help Show help
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import { parseCliArgs, getFlagValue, hasFlag } from '../core/args.js'
|
||||||
|
import { loadProjectConfig } from '../core/config.js'
|
||||||
|
import { syncExternalIndex } from '../core/sync.js'
|
||||||
|
|
||||||
|
export const runSyncCommand = async (args) => {
|
||||||
|
const parsedArgs = parseCliArgs(args)
|
||||||
|
|
||||||
|
if (hasFlag(parsedArgs.flags, 'help') || hasFlag(parsedArgs.flags, 'h')) {
|
||||||
|
printHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfig = await loadProjectConfig({
|
||||||
|
configPath: getFlagValue(parsedArgs.flags, 'config'),
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!projectConfig.sync.enabled) {
|
||||||
|
console.log('sync skipped: sync.enabled=false')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncExternalIndex({
|
||||||
|
projectRoot: projectConfig.rootDir,
|
||||||
|
outputDir: projectConfig.outputDir,
|
||||||
|
externalIndexFile: projectConfig.externalIndexFile,
|
||||||
|
syncOptions: projectConfig.sync,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const printHelp = () => {
|
||||||
|
console.log(`yx-generate-api sync
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
yx-generate-api sync [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config=... Config file path
|
||||||
|
--help Show help
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
export const parseCliArgs = (args) => {
|
||||||
|
const flags = new Map()
|
||||||
|
const positionals = []
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (!arg.startsWith('--')) {
|
||||||
|
positionals.push(arg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArg = arg.slice(2)
|
||||||
|
const separatorIndex = normalizedArg.indexOf('=')
|
||||||
|
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
pushFlagValue(flags, normalizedArg, true)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizedArg.slice(0, separatorIndex)
|
||||||
|
const value = normalizedArg.slice(separatorIndex + 1)
|
||||||
|
pushFlagValue(flags, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
flags,
|
||||||
|
positionals,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFlagValue = (flags, key) => {
|
||||||
|
const value = flags.get(key)
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.at(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFlagValues = (flags, key) => {
|
||||||
|
const value = flags.get(key)
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(value) ? value : [value]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasFlag = (flags, key) => {
|
||||||
|
return flags.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushFlagValue = (flags, key, value) => {
|
||||||
|
if (!flags.has(key)) {
|
||||||
|
flags.set(key, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = flags.get(key)
|
||||||
|
|
||||||
|
if (Array.isArray(currentValue)) {
|
||||||
|
currentValue.push(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flags.set(key, [currentValue, value])
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
export const CONFIG_FILE_NAMES = [
|
||||||
|
'yx-generate-api.config.mjs',
|
||||||
|
'yx-generate-api.config.js',
|
||||||
|
'yx-generate-api.config.cjs',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PARAM_STYLE = {
|
||||||
|
OBJECT: 'object',
|
||||||
|
POSITIONAL: 'positional',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_PARAM_STYLES = Object.values(PARAM_STYLE)
|
||||||
|
|
||||||
|
export const DEFAULT_SYNC_OPTIONS = {
|
||||||
|
blockStart: '// AUTO-GENERATED API EXPORTS START',
|
||||||
|
blockEnd: '// AUTO-GENERATED API EXPORTS END',
|
||||||
|
includeGeneratedIndexSnapshot: true,
|
||||||
|
snapshotTitle: '// generated/index.js content:',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadProjectConfig = async ({ configPath, cwd = process.cwd() } = {}) => {
|
||||||
|
const resolvedConfigPath = await resolveConfigPath({ configPath, cwd })
|
||||||
|
const importedConfig = await import(
|
||||||
|
`${pathToFileURL(resolvedConfigPath).href}?cacheBust=${Date.now()}`
|
||||||
|
)
|
||||||
|
const rawConfig = importedConfig.default ?? importedConfig
|
||||||
|
|
||||||
|
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||||
|
throw new Error(`Config file must export an object: ${resolvedConfigPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootDir = path.dirname(resolvedConfigPath)
|
||||||
|
const syncConfig = rawConfig.sync || {}
|
||||||
|
const externalIndexFile = rawConfig.externalIndexFile
|
||||||
|
? resolveProjectPath(rootDir, rawConfig.externalIndexFile)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
configPath: resolvedConfigPath,
|
||||||
|
rootDir,
|
||||||
|
swaggerUrl: normalizeString(rawConfig.swaggerUrl),
|
||||||
|
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),
|
||||||
|
sync: {
|
||||||
|
enabled: syncConfig.enabled ?? Boolean(externalIndexFile),
|
||||||
|
blockStart: normalizeString(syncConfig.blockStart) || DEFAULT_SYNC_OPTIONS.blockStart,
|
||||||
|
blockEnd: normalizeString(syncConfig.blockEnd) || DEFAULT_SYNC_OPTIONS.blockEnd,
|
||||||
|
includeGeneratedIndexSnapshot:
|
||||||
|
syncConfig.includeGeneratedIndexSnapshot ??
|
||||||
|
DEFAULT_SYNC_OPTIONS.includeGeneratedIndexSnapshot,
|
||||||
|
snapshotTitle:
|
||||||
|
normalizeString(syncConfig.snapshotTitle) || DEFAULT_SYNC_OPTIONS.snapshotTitle,
|
||||||
|
exportFrom: normalizeString(syncConfig.exportFrom) || null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveConfigPath = async ({ configPath, cwd = process.cwd() } = {}) => {
|
||||||
|
if (configPath) {
|
||||||
|
const explicitPath = path.resolve(cwd, configPath)
|
||||||
|
await assertFileExists(explicitPath, `Config file not found: ${explicitPath}`)
|
||||||
|
return explicitPath
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileName of CONFIG_FILE_NAMES) {
|
||||||
|
const candidatePath = path.resolve(cwd, fileName)
|
||||||
|
|
||||||
|
if (await fileExists(candidatePath)) {
|
||||||
|
return candidatePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Config file not found in ${cwd}. Expected one of: ${CONFIG_FILE_NAMES.join(', ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveCwdPath = (cwd, targetPath) => {
|
||||||
|
return path.resolve(cwd, targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveProjectPath = (projectRoot, targetPath) => {
|
||||||
|
return path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRoot, targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeParamStyle = (value) => {
|
||||||
|
const normalizedValue = normalizeString(value).toLowerCase()
|
||||||
|
|
||||||
|
if (SUPPORTED_PARAM_STYLES.includes(normalizedValue)) {
|
||||||
|
return normalizedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported paramStyle: ${value}. Expected one of: ${SUPPORTED_PARAM_STYLES.join(' / ')}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeString = (value) => {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExists = async (targetPath) => {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertFileExists = async (targetPath, errorMessage) => {
|
||||||
|
if (!(await fileExists(targetPath))) {
|
||||||
|
throw new Error(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,890 @@
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import prettier from 'prettier'
|
||||||
|
|
||||||
|
const DEFAULT_SHARED_IMPORT = './shared'
|
||||||
|
const DEFAULT_SHARED_FILE_NAME = 'shared.js'
|
||||||
|
const DEFAULT_INDEX_FILE_NAME = 'index.js'
|
||||||
|
const HTTP_METHOD_ORDER = ['get', 'post', 'put', 'delete', 'patch']
|
||||||
|
const JS_RESERVED_WORDS = new Set([
|
||||||
|
'await',
|
||||||
|
'break',
|
||||||
|
'case',
|
||||||
|
'catch',
|
||||||
|
'class',
|
||||||
|
'const',
|
||||||
|
'continue',
|
||||||
|
'debugger',
|
||||||
|
'default',
|
||||||
|
'delete',
|
||||||
|
'do',
|
||||||
|
'else',
|
||||||
|
'enum',
|
||||||
|
'export',
|
||||||
|
'extends',
|
||||||
|
'false',
|
||||||
|
'finally',
|
||||||
|
'for',
|
||||||
|
'function',
|
||||||
|
'if',
|
||||||
|
'implements',
|
||||||
|
'import',
|
||||||
|
'in',
|
||||||
|
'instanceof',
|
||||||
|
'interface',
|
||||||
|
'let',
|
||||||
|
'new',
|
||||||
|
'null',
|
||||||
|
'package',
|
||||||
|
'private',
|
||||||
|
'protected',
|
||||||
|
'public',
|
||||||
|
'return',
|
||||||
|
'super',
|
||||||
|
'switch',
|
||||||
|
'static',
|
||||||
|
'this',
|
||||||
|
'throw',
|
||||||
|
'true',
|
||||||
|
'try',
|
||||||
|
'typeof',
|
||||||
|
'var',
|
||||||
|
'void',
|
||||||
|
'while',
|
||||||
|
'with',
|
||||||
|
'yield',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const generateApiFiles = async ({
|
||||||
|
projectRoot,
|
||||||
|
swaggerUrl,
|
||||||
|
outputDir,
|
||||||
|
requestImport,
|
||||||
|
paramStyle,
|
||||||
|
modules,
|
||||||
|
}) => {
|
||||||
|
const swagger = await fetchSwaggerJson(swaggerUrl)
|
||||||
|
const moduleMap = buildModuleMap(swagger)
|
||||||
|
const selectedModules = resolveSelectedModules(moduleMap, modules)
|
||||||
|
|
||||||
|
await fs.mkdir(outputDir, { recursive: true })
|
||||||
|
await writeGeneratedFile(
|
||||||
|
path.join(outputDir, DEFAULT_SHARED_FILE_NAME),
|
||||||
|
generateSharedFile({
|
||||||
|
swaggerUrl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const moduleInfo of selectedModules) {
|
||||||
|
const filePath = path.join(outputDir, `${moduleInfo.fileName}.js`)
|
||||||
|
await writeGeneratedFile(
|
||||||
|
filePath,
|
||||||
|
generateModuleFile({
|
||||||
|
moduleInfo,
|
||||||
|
paramStyle,
|
||||||
|
schemas: swagger.components?.schemas || {},
|
||||||
|
requestImport,
|
||||||
|
swaggerUrl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
console.log(`generated: ${path.relative(projectRoot, filePath)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexFilePath = path.join(outputDir, DEFAULT_INDEX_FILE_NAME)
|
||||||
|
await writeGeneratedFile(
|
||||||
|
indexFilePath,
|
||||||
|
await generateIndexFile({
|
||||||
|
outDir: outputDir,
|
||||||
|
swaggerUrl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
console.log(`generated: ${path.relative(projectRoot, indexFilePath)}`)
|
||||||
|
console.log(`done: ${selectedModules.length} module(s)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSwaggerJson = async (url) => {
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Swagger fetch failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildModuleMap = (swagger) => {
|
||||||
|
const moduleMap = new Map()
|
||||||
|
|
||||||
|
for (const [apiPath, pathItem] of Object.entries(swagger.paths || {})) {
|
||||||
|
for (const method of HTTP_METHOD_ORDER) {
|
||||||
|
const operation = pathItem?.[method]
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleName = extractModuleName(apiPath, operation)
|
||||||
|
const moduleKey = normalizeLookupKey(moduleName)
|
||||||
|
const aliases = new Set([moduleName, toKebabCase(moduleName), ...(operation.tags || [])])
|
||||||
|
|
||||||
|
if (!moduleMap.has(moduleKey)) {
|
||||||
|
moduleMap.set(moduleKey, {
|
||||||
|
aliases,
|
||||||
|
fileName: toKebabCase(moduleName),
|
||||||
|
moduleName,
|
||||||
|
operations: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleInfo = moduleMap.get(moduleKey)
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
moduleInfo.aliases.add(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleInfo.operations.push({
|
||||||
|
method,
|
||||||
|
operation,
|
||||||
|
path: apiPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const moduleInfo of moduleMap.values()) {
|
||||||
|
moduleInfo.operations.sort(compareOperations)
|
||||||
|
assignFunctionNames(moduleInfo.operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSelectedModules = (moduleMap, requestedModules) => {
|
||||||
|
if (!requestedModules.length) {
|
||||||
|
return [...moduleMap.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedModules = []
|
||||||
|
const selectedKeys = new Set()
|
||||||
|
|
||||||
|
for (const requestedModule of requestedModules) {
|
||||||
|
const lookupKey = normalizeLookupKey(requestedModule)
|
||||||
|
let matchedEntry = null
|
||||||
|
|
||||||
|
for (const [moduleKey, moduleInfo] of moduleMap.entries()) {
|
||||||
|
if (moduleKey === lookupKey) {
|
||||||
|
matchedEntry = moduleInfo
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([...moduleInfo.aliases].some((alias) => normalizeLookupKey(alias) === lookupKey)) {
|
||||||
|
matchedEntry = moduleInfo
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedEntry) {
|
||||||
|
const availableModules = [...moduleMap.values()]
|
||||||
|
.map((item) => item.moduleName)
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
throw new Error(`Module "${requestedModule}" not found. Available modules: ${availableModules}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedKey = normalizeLookupKey(matchedEntry.moduleName)
|
||||||
|
|
||||||
|
if (!selectedKeys.has(selectedKey)) {
|
||||||
|
selectedModules.push(matchedEntry)
|
||||||
|
selectedKeys.add(selectedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedModules
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeGeneratedFile = async (filePath, content) => {
|
||||||
|
const prettierOptions = (await prettier.resolveConfig(filePath)) || {}
|
||||||
|
const formattedContent = await prettier.format(content, {
|
||||||
|
...prettierOptions,
|
||||||
|
filepath: filePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, formattedContent, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSharedFile = ({ swaggerUrl }) => {
|
||||||
|
return `// Auto-generated. Do not edit manually.
|
||||||
|
// Swagger: ${swaggerUrl}
|
||||||
|
// Shared helpers: URL builder
|
||||||
|
|
||||||
|
import qs from 'qs'
|
||||||
|
|
||||||
|
export const stringifyParams = params => {
|
||||||
|
return qs.stringify(params, {
|
||||||
|
arrayFormat: 'repeat',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildUrl = (url, params = {}) => {
|
||||||
|
const pathParamNames = []
|
||||||
|
|
||||||
|
const resolvedUrl = url.replace(/\\{([^}]+)\\}/g, (_, key) => {
|
||||||
|
pathParamNames.push(key)
|
||||||
|
const value = params[key]
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
throw new Error(\`Missing path param: \${key}\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeURIComponent(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const query = stringifyParams(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(params).filter(
|
||||||
|
([key, value]) => !pathParamNames.includes(key) && value !== undefined && value !== null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return query ? \`\${resolvedUrl}?\${query}\` : resolvedUrl
|
||||||
|
}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateIndexFile = async ({ outDir, swaggerUrl }) => {
|
||||||
|
const dirEntries = await fs.readdir(outDir, { withFileTypes: true })
|
||||||
|
const moduleFileNames = dirEntries
|
||||||
|
.filter((entry) => {
|
||||||
|
return (
|
||||||
|
entry.isFile() &&
|
||||||
|
entry.name.endsWith('.js') &&
|
||||||
|
entry.name !== DEFAULT_INDEX_FILE_NAME &&
|
||||||
|
entry.name !== DEFAULT_SHARED_FILE_NAME
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.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', '']
|
||||||
|
|
||||||
|
for (const fileName of moduleFileNames) {
|
||||||
|
lines.push(`export * as ${buildModuleNamespaceExportName(fileName)} from './${fileName}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
return `${lines.join('\n')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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.',
|
||||||
|
`// Swagger: ${swaggerUrl}`,
|
||||||
|
`// Module: ${moduleInfo.moduleName}`,
|
||||||
|
`// Param style: ${paramStyle}`,
|
||||||
|
'',
|
||||||
|
`import request from '${requestImport}'`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (needsBuildUrl) {
|
||||||
|
lines.push(`import { buildUrl } from '${DEFAULT_SHARED_IMPORT}'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const operationBlocks = moduleInfo.operations.flatMap((item) => {
|
||||||
|
return generateOperationBlock(item, schemas, paramStyle)
|
||||||
|
})
|
||||||
|
const exportBlock = buildGroupedExportBlock(exportNames)
|
||||||
|
|
||||||
|
return `${[...lines, ...operationBlocks, ...exportBlock].join('\n')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateOperationBlock = (
|
||||||
|
{ functionName, method, operation, path: apiPath },
|
||||||
|
schemas,
|
||||||
|
paramStyle,
|
||||||
|
) => {
|
||||||
|
const queryParameters = getParameters(operation, 'query')
|
||||||
|
const pathParameters = getParameters(operation, 'path')
|
||||||
|
const allParams = [...pathParameters, ...queryParameters]
|
||||||
|
const bodySchemaInfo = getRequestBodySchemaInfo(operation.requestBody, schemas)
|
||||||
|
const hasParams = allParams.length > 0
|
||||||
|
const hasBody = Boolean(bodySchemaInfo)
|
||||||
|
const summary = operation.summary || `${method.toUpperCase()} ${apiPath}`
|
||||||
|
const jsDocLines = ['/**', ` * ${escapeComment(summary)}`]
|
||||||
|
const signatureInfo = buildSignatureInfo({
|
||||||
|
hasBody,
|
||||||
|
parameters: allParams,
|
||||||
|
paramStyle,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasParams) {
|
||||||
|
if (paramStyle === 'positional') {
|
||||||
|
for (const parameter of signatureInfo.parameterBindings) {
|
||||||
|
jsDocLines.push(` * ${buildPositionalParameterDocLine(parameter, schemas)}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
jsDocLines.push(
|
||||||
|
` * @param {Object} [params={}] ${pathParameters.length ? 'Path and query params' : 'Query params'}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const parameter of allParams) {
|
||||||
|
jsDocLines.push(` * ${buildParameterDocLine('params', parameter, schemas)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBody) {
|
||||||
|
const bodyType = getJsDocType(bodySchemaInfo.schema, schemas)
|
||||||
|
jsDocLines.push(` * @param {${bodyType}} ${signatureInfo.bodyArgName} Request body`)
|
||||||
|
|
||||||
|
for (const propertyLine of buildSchemaPropertyDocLines(
|
||||||
|
signatureInfo.bodyArgName,
|
||||||
|
bodySchemaInfo.schema,
|
||||||
|
schemas,
|
||||||
|
)) {
|
||||||
|
jsDocLines.push(` * ${propertyLine}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseSchemaInfo = getResponseSchemaInfo(operation.responses, schemas)
|
||||||
|
const returnType = `Promise<${getExpandedResponseJsDocType(responseSchemaInfo?.schema, schemas)}>`
|
||||||
|
const returnDescription = responseSchemaInfo?.displayName
|
||||||
|
? ` Returns ${responseSchemaInfo.displayName}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
jsDocLines.push(` * @returns {${returnType}}${returnDescription}`)
|
||||||
|
|
||||||
|
for (const propertyLine of buildResponsePropertyDocLines(responseSchemaInfo?.schema, schemas)) {
|
||||||
|
jsDocLines.push(` * ${propertyLine}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsDocLines.push(' */')
|
||||||
|
|
||||||
|
const signature = signatureInfo.signature
|
||||||
|
const urlExpression = buildUrlExpression(apiPath, signatureInfo)
|
||||||
|
const requestExpression = buildRequestExpression(
|
||||||
|
method,
|
||||||
|
urlExpression,
|
||||||
|
hasBody,
|
||||||
|
signatureInfo.bodyArgName,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [...jsDocLines, `const ${functionName}Api = ${signature}${requestExpression}`, '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFunctionSignature = (hasParams, hasBody) => {
|
||||||
|
if (hasParams && hasBody) {
|
||||||
|
return '(params = {}, data) => '
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasParams) {
|
||||||
|
return '(params = {}) => '
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBody) {
|
||||||
|
return '(data) => '
|
||||||
|
}
|
||||||
|
|
||||||
|
return '() => '
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSignatureInfo = ({ hasBody, parameters, paramStyle }) => {
|
||||||
|
const bodyArgName = 'data'
|
||||||
|
|
||||||
|
if (paramStyle !== 'positional') {
|
||||||
|
return {
|
||||||
|
bodyArgName,
|
||||||
|
parameterBindings: [],
|
||||||
|
signature: buildFunctionSignature(parameters.length > 0, hasBody),
|
||||||
|
urlParamsExpression: parameters.length > 0 ? 'params' : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameterBindings = buildParameterBindings(parameters, hasBody ? [bodyArgName] : [])
|
||||||
|
const signatureArgs = [...parameterBindings.map((item) => item.variableName)]
|
||||||
|
|
||||||
|
if (hasBody) {
|
||||||
|
signatureArgs.push(bodyArgName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bodyArgName,
|
||||||
|
parameterBindings,
|
||||||
|
signature: signatureArgs.length ? `(${signatureArgs.join(', ')}) => ` : '() => ',
|
||||||
|
urlParamsExpression: parameterBindings.length
|
||||||
|
? `{ ${parameterBindings.map(renderParamBindingEntry).join(', ')} }`
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRequestExpression = (method, urlExpression, hasBody, bodyArgName = 'data') => {
|
||||||
|
switch (method) {
|
||||||
|
case 'get':
|
||||||
|
return `request.get(${urlExpression})`
|
||||||
|
case 'post':
|
||||||
|
return hasBody
|
||||||
|
? `request.post(${urlExpression}, ${bodyArgName})`
|
||||||
|
: `request.post(${urlExpression})`
|
||||||
|
case 'put':
|
||||||
|
return hasBody
|
||||||
|
? `request.put(${urlExpression}, ${bodyArgName})`
|
||||||
|
: `request.put(${urlExpression})`
|
||||||
|
case 'patch':
|
||||||
|
return hasBody
|
||||||
|
? `request.patch(${urlExpression}, ${bodyArgName})`
|
||||||
|
: `request.patch(${urlExpression})`
|
||||||
|
case 'delete':
|
||||||
|
return hasBody
|
||||||
|
? `request.delete(${urlExpression}, { data: ${bodyArgName} })`
|
||||||
|
: `request.delete(${urlExpression})`
|
||||||
|
default:
|
||||||
|
return hasBody
|
||||||
|
? `request.${method}(${urlExpression}, ${bodyArgName})`
|
||||||
|
: `request.${method}(${urlExpression})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildUrlExpression = (apiPath, signatureInfo) => {
|
||||||
|
return signatureInfo.urlParamsExpression
|
||||||
|
? `buildUrl(\`${apiPath}\`, ${signatureInfo.urlParamsExpression})`
|
||||||
|
: `\`${apiPath}\``
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOperationParameters = (operation) => {
|
||||||
|
return [...getParameters(operation, 'path'), ...getParameters(operation, 'query')]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getParameters = (operation, location) => {
|
||||||
|
return (operation?.parameters || []).filter((parameter) => parameter.in === location)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequestBodySchemaInfo = (requestBody, schemas) => {
|
||||||
|
if (!requestBody?.content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentEntries = Object.entries(requestBody.content)
|
||||||
|
const preferredContent =
|
||||||
|
contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0]
|
||||||
|
|
||||||
|
if (!preferredContent) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, contentValue] = preferredContent
|
||||||
|
|
||||||
|
return {
|
||||||
|
schema: contentValue.schema,
|
||||||
|
resolvedSchema: resolveSchema(contentValue.schema, schemas),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResponseSchemaInfo = (responses, schemas) => {
|
||||||
|
if (!responses) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredResponse =
|
||||||
|
responses['200'] ||
|
||||||
|
responses['201'] ||
|
||||||
|
responses.default ||
|
||||||
|
Object.values(responses).find((response) => response?.content)
|
||||||
|
|
||||||
|
if (!preferredResponse?.content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentEntries = Object.entries(preferredResponse.content)
|
||||||
|
const preferredContent =
|
||||||
|
contentEntries.find(([contentType]) => contentType === 'application/json') || contentEntries[0]
|
||||||
|
|
||||||
|
if (!preferredContent?.[1]?.schema) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = preferredContent[1].schema
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: getSchemaDisplayName(schema, schemas),
|
||||||
|
resolvedSchema: resolveSchema(schema, schemas),
|
||||||
|
schema,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildResponsePropertyDocLines = (schema, schemas) => {
|
||||||
|
if (!schema) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
|
||||||
|
if (resolvedSchema?.type === 'object' && resolvedSchema.properties) {
|
||||||
|
return buildResponseObjectPropertyDocLines(schema, schemas, 'Response fields:')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSchema?.type === 'array') {
|
||||||
|
const itemSchema = resolvedSchema.items
|
||||||
|
const resolvedItemSchema = resolveSchema(itemSchema, schemas)
|
||||||
|
|
||||||
|
if (resolvedItemSchema?.type === 'object' && resolvedItemSchema.properties) {
|
||||||
|
return buildResponseObjectPropertyDocLines(itemSchema, schemas, 'Response item fields:')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildResponseObjectPropertyDocLines = (schema, schemas, title) => {
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
const requiredKeys = new Set(resolvedSchema?.required || [])
|
||||||
|
const lines = [title]
|
||||||
|
|
||||||
|
for (const [propertyName, propertySchema] of Object.entries(resolvedSchema?.properties || {})) {
|
||||||
|
const type = getJsDocType(propertySchema, schemas)
|
||||||
|
const description = escapeComment(propertySchema.description || '')
|
||||||
|
const suffix = requiredKeys.has(propertyName) ? '' : '?'
|
||||||
|
|
||||||
|
lines.push(`- ${propertyName}${suffix}: {${type}}${description ? ` ${description}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParameterDocLine = (rootName, parameter, schemas) => {
|
||||||
|
const type = getJsDocType(parameter.schema, schemas)
|
||||||
|
const accessor = parameter.required
|
||||||
|
? `${rootName}.${parameter.name}`
|
||||||
|
: `[${rootName}.${parameter.name}]`
|
||||||
|
const description = escapeComment(parameter.description || '')
|
||||||
|
|
||||||
|
return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPositionalParameterDocLine = (parameter, schemas) => {
|
||||||
|
const type = getJsDocType(parameter.schema, schemas)
|
||||||
|
const accessor = parameter.required ? parameter.variableName : `[${parameter.variableName}]`
|
||||||
|
const description = escapeComment(parameter.description || '')
|
||||||
|
|
||||||
|
return `@param {${type}} ${accessor}${description ? ` ${description}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSchemaPropertyDocLines = (rootName, schema, schemas) => {
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
const requiredKeys = new Set(resolvedSchema?.required || [])
|
||||||
|
const propertyLines = []
|
||||||
|
|
||||||
|
if (!resolvedSchema?.properties) {
|
||||||
|
return propertyLines
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [propertyName, propertySchema] of Object.entries(resolvedSchema.properties)) {
|
||||||
|
const type = getJsDocType(propertySchema, schemas)
|
||||||
|
const accessor = requiredKeys.has(propertyName)
|
||||||
|
? `${rootName}.${propertyName}`
|
||||||
|
: `[${rootName}.${propertyName}]`
|
||||||
|
const description = escapeComment(propertySchema.description || '')
|
||||||
|
|
||||||
|
propertyLines.push(`@param {${type}} ${accessor}${description ? ` ${description}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return propertyLines
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSchemaDisplayName = (schema, schemas) => {
|
||||||
|
if (!schema) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.$ref) {
|
||||||
|
return schema.$ref.split('/').pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.type === 'array') {
|
||||||
|
const itemDisplayName = getSchemaDisplayName(schema.items, schemas)
|
||||||
|
|
||||||
|
if (itemDisplayName) {
|
||||||
|
return `${itemDisplayName}[]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${getJsDocType(schema.items, schemas)}[]`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpandedResponseJsDocType = (schema, schemas) => {
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
|
||||||
|
if (!resolvedSchema) {
|
||||||
|
return 'any'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSchema.type === 'array') {
|
||||||
|
return appendNullable(
|
||||||
|
`Array<${getExpandedResponseObjectJsDocType(resolvedSchema.items, schemas)}>`,
|
||||||
|
schema?.nullable || resolvedSchema.nullable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSchema.type === 'object') {
|
||||||
|
return getExpandedResponseObjectJsDocType(schema, schemas)
|
||||||
|
}
|
||||||
|
|
||||||
|
return getJsDocType(schema, schemas)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpandedResponseObjectJsDocType = (schema, schemas) => {
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
|
||||||
|
if (!resolvedSchema) {
|
||||||
|
return 'any'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedSchema.type !== 'object' || !resolvedSchema.properties) {
|
||||||
|
return getJsDocType(schema, schemas)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredKeys = new Set(resolvedSchema.required || [])
|
||||||
|
const propertyEntries = Object.entries(resolvedSchema.properties).map(
|
||||||
|
([propertyName, propertySchema]) => {
|
||||||
|
const renderedName = renderJsDocObjectPropertyName(propertyName)
|
||||||
|
const optionalToken = requiredKeys.has(propertyName) ? '' : '?'
|
||||||
|
|
||||||
|
return `${renderedName}${optionalToken}: ${getJsDocType(propertySchema, schemas)}`
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return appendNullable(
|
||||||
|
`{ ${propertyEntries.join(', ')} }`,
|
||||||
|
schema?.nullable || resolvedSchema.nullable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJsDocType = (schema, schemas) => {
|
||||||
|
const resolvedSchema = resolveSchema(schema, schemas)
|
||||||
|
|
||||||
|
if (!resolvedSchema) {
|
||||||
|
return 'any'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema?.$ref) {
|
||||||
|
const refType =
|
||||||
|
resolvedSchema.type === 'array'
|
||||||
|
? `Array<${getJsDocType(resolvedSchema.items, schemas)}>`
|
||||||
|
: resolvedSchema.type === 'object'
|
||||||
|
? 'Object'
|
||||||
|
: getJsDocType(resolvedSchema, schemas)
|
||||||
|
|
||||||
|
return appendNullable(refType, schema.nullable || resolvedSchema.nullable)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (resolvedSchema.type) {
|
||||||
|
case 'integer':
|
||||||
|
case 'number':
|
||||||
|
return appendNullable('number', resolvedSchema.nullable)
|
||||||
|
case 'boolean':
|
||||||
|
return appendNullable('boolean', resolvedSchema.nullable)
|
||||||
|
case 'string':
|
||||||
|
return appendNullable('string', resolvedSchema.nullable)
|
||||||
|
case 'array':
|
||||||
|
return appendNullable(
|
||||||
|
`Array<${getJsDocType(resolvedSchema.items, schemas)}>`,
|
||||||
|
resolvedSchema.nullable,
|
||||||
|
)
|
||||||
|
case 'object':
|
||||||
|
return appendNullable('Object', resolvedSchema.nullable)
|
||||||
|
default:
|
||||||
|
return appendNullable('any', resolvedSchema.nullable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderJsDocObjectPropertyName = (propertyName) => {
|
||||||
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(propertyName)
|
||||||
|
? propertyName
|
||||||
|
: JSON.stringify(propertyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendNullable = (type, nullable) => {
|
||||||
|
return nullable ? `${type} | null` : type
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSchema = (schema, schemas) => {
|
||||||
|
if (!schema) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.$ref) {
|
||||||
|
const schemaName = schema.$ref.split('/').pop()
|
||||||
|
return schemas[schemaName] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareOperations = (left, right) => {
|
||||||
|
if (left.path !== right.path) {
|
||||||
|
return left.path.localeCompare(right.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return HTTP_METHOD_ORDER.indexOf(left.method) - HTTP_METHOD_ORDER.indexOf(right.method)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignFunctionNames = (operations) => {
|
||||||
|
const usedNames = new Set()
|
||||||
|
|
||||||
|
for (const item of operations) {
|
||||||
|
const baseName = ensureIdentifier(toCamelCase(getEndpointName(item.path)))
|
||||||
|
let functionName = baseName
|
||||||
|
|
||||||
|
if (usedNames.has(functionName)) {
|
||||||
|
functionName = ensureIdentifier(`${item.method}${capitalize(baseName)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sequence = 2
|
||||||
|
|
||||||
|
while (usedNames.has(functionName)) {
|
||||||
|
functionName = `${baseName}${sequence}`
|
||||||
|
sequence += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
item.functionName = functionName
|
||||||
|
usedNames.add(functionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractModuleName = (apiPath, operation) => {
|
||||||
|
const segments = apiPath.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
if (segments[0] === 'api' && /^v\d+$/i.test(segments[1] || '')) {
|
||||||
|
return segments[2] || operation.tags?.[0] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments[0] === 'api') {
|
||||||
|
return segments[1] || operation.tags?.[0] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
return operation.tags?.[0] || segments[0] || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEndpointName = (apiPath) => {
|
||||||
|
const segments = apiPath.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (!segments[index].startsWith('{')) {
|
||||||
|
return segments[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'api'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLookupKey = (value) => {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureIdentifier = (value) => {
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9_$]/g, '')
|
||||||
|
|
||||||
|
if (!sanitized) {
|
||||||
|
return 'api'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[0-9]/.test(sanitized)) {
|
||||||
|
return `api${capitalize(sanitized)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSafeVariableName = (value) => {
|
||||||
|
const identifier = ensureIdentifier(toCamelCase(value) || 'param')
|
||||||
|
|
||||||
|
if (JS_RESERVED_WORDS.has(identifier)) {
|
||||||
|
return `param${capitalize(identifier)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildParameterBindings = (parameters, reservedNames = []) => {
|
||||||
|
const usedNames = new Set(reservedNames)
|
||||||
|
|
||||||
|
return parameters.map((parameter) => {
|
||||||
|
const baseName = toSafeVariableName(parameter.name)
|
||||||
|
let variableName = baseName
|
||||||
|
let sequence = 2
|
||||||
|
|
||||||
|
while (usedNames.has(variableName)) {
|
||||||
|
variableName = `${baseName}${sequence}`
|
||||||
|
sequence += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(variableName)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parameter,
|
||||||
|
variableName,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderParamBindingEntry = (parameter) => {
|
||||||
|
return `${JSON.stringify(parameter.name)}: ${parameter.variableName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildGroupedExportBlock = (exportNames) => {
|
||||||
|
if (!exportNames.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['export {', ...exportNames.map((exportName) => ` ${exportName}Api,`), '}', '']
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildModuleNamespaceExportName = (fileName) => {
|
||||||
|
return `${toCamelCase(fileName)}Api`
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitWords = (value) => {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||||
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
||||||
|
.split(/[^a-zA-Z0-9]+|\s+/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCamelCase = (value) => {
|
||||||
|
const [firstWord = 'api', ...restWords] = splitWords(value)
|
||||||
|
|
||||||
|
return [
|
||||||
|
firstWord.charAt(0).toLowerCase() + firstWord.slice(1),
|
||||||
|
...restWords.map((word) => capitalize(word)),
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toKebabCase = (value) => {
|
||||||
|
return splitWords(value)
|
||||||
|
.map((word) => word.toLowerCase())
|
||||||
|
.join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (value) => {
|
||||||
|
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeComment = (value) => {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\*\//g, '* /')
|
||||||
|
.replace(/\s*[\r\n]+\s*/g, ' ')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const syncExternalIndex = async ({
|
||||||
|
projectRoot,
|
||||||
|
outputDir,
|
||||||
|
externalIndexFile,
|
||||||
|
syncOptions,
|
||||||
|
}) => {
|
||||||
|
if (!externalIndexFile) {
|
||||||
|
throw new Error('externalIndexFile is required for sync')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generatedIndexPath = path.join(outputDir, 'index.js')
|
||||||
|
const [generatedContent, targetContent] = await Promise.all([
|
||||||
|
fs.readFile(generatedIndexPath, 'utf8'),
|
||||||
|
readFileIfExists(externalIndexFile),
|
||||||
|
])
|
||||||
|
const nextContent = buildTargetFileContent({
|
||||||
|
targetContent,
|
||||||
|
generatedContent,
|
||||||
|
generatedIndexPath,
|
||||||
|
externalIndexFile,
|
||||||
|
exportFrom: syncOptions.exportFrom,
|
||||||
|
includeGeneratedIndexSnapshot: syncOptions.includeGeneratedIndexSnapshot,
|
||||||
|
snapshotTitle: syncOptions.snapshotTitle,
|
||||||
|
blockStart: syncOptions.blockStart,
|
||||||
|
blockEnd: syncOptions.blockEnd,
|
||||||
|
projectRoot,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (normalizeLineEndings(nextContent) === normalizeLineEndings(targetContent)) {
|
||||||
|
console.log(`no changes: ${toProjectRelativePath(projectRoot, externalIndexFile)}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(externalIndexFile), { recursive: true })
|
||||||
|
await fs.writeFile(externalIndexFile, nextContent, 'utf8')
|
||||||
|
console.log(`synced: ${toProjectRelativePath(projectRoot, externalIndexFile)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTargetFileContent = ({
|
||||||
|
targetContent,
|
||||||
|
generatedContent,
|
||||||
|
generatedIndexPath,
|
||||||
|
externalIndexFile,
|
||||||
|
exportFrom,
|
||||||
|
includeGeneratedIndexSnapshot,
|
||||||
|
snapshotTitle,
|
||||||
|
blockStart,
|
||||||
|
blockEnd,
|
||||||
|
projectRoot,
|
||||||
|
}) => {
|
||||||
|
const lineEnding = targetContent.includes('\r\n') ? '\r\n' : '\n'
|
||||||
|
const normalizedTargetContent = normalizeLineEndings(targetContent)
|
||||||
|
const managedBlock = buildManagedBlock({
|
||||||
|
generatedContent,
|
||||||
|
generatedIndexPath,
|
||||||
|
externalIndexFile,
|
||||||
|
exportFrom,
|
||||||
|
includeGeneratedIndexSnapshot,
|
||||||
|
snapshotTitle,
|
||||||
|
blockStart,
|
||||||
|
blockEnd,
|
||||||
|
projectRoot,
|
||||||
|
})
|
||||||
|
const blockPattern = new RegExp(
|
||||||
|
`${escapeRegExp(blockStart)}[\\s\\S]*?${escapeRegExp(blockEnd)}`,
|
||||||
|
'm',
|
||||||
|
)
|
||||||
|
const trimmedTargetContent = normalizedTargetContent.trimEnd()
|
||||||
|
let nextContent = managedBlock
|
||||||
|
|
||||||
|
if (trimmedTargetContent) {
|
||||||
|
nextContent = blockPattern.test(normalizedTargetContent)
|
||||||
|
? trimmedTargetContent.replace(blockPattern, managedBlock)
|
||||||
|
: `${trimmedTargetContent}\n\n${managedBlock}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${nextContent}\n`.replace(/\n/g, lineEnding)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildManagedBlock = ({
|
||||||
|
generatedContent,
|
||||||
|
generatedIndexPath,
|
||||||
|
externalIndexFile,
|
||||||
|
exportFrom,
|
||||||
|
includeGeneratedIndexSnapshot,
|
||||||
|
snapshotTitle,
|
||||||
|
blockStart,
|
||||||
|
blockEnd,
|
||||||
|
projectRoot,
|
||||||
|
}) => {
|
||||||
|
const lines = [
|
||||||
|
blockStart,
|
||||||
|
`// Synced from '${toProjectRelativePath(projectRoot, generatedIndexPath)}'. Do not edit manually.`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (includeGeneratedIndexSnapshot) {
|
||||||
|
lines.push(snapshotTitle, ...buildCommentLines(generatedContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`export * from '${exportFrom || buildExportFrom(externalIndexFile, path.dirname(generatedIndexPath))}'`,
|
||||||
|
)
|
||||||
|
lines.push(blockEnd)
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildExportFrom = (externalIndexFile, generatedDir) => {
|
||||||
|
let relativeImportPath = path
|
||||||
|
.relative(path.dirname(externalIndexFile), generatedDir)
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
|
||||||
|
if (!relativeImportPath.startsWith('.')) {
|
||||||
|
relativeImportPath = `./${relativeImportPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeImportPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildCommentLines = (content) => {
|
||||||
|
return normalizeLineEndings(content)
|
||||||
|
.trimEnd()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => (line ? `// ${line}` : '//'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const readFileIfExists = async (targetPath) => {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(targetPath, 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLineEndings = (content) => {
|
||||||
|
return content.replace(/\r\n/g, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegExp = (value) => {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toProjectRelativePath = (projectRoot, targetPath) => {
|
||||||
|
return path.relative(projectRoot, targetPath).replace(/\\/g, '/')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if errorlevel 1 goto :cd_error
|
||||||
|
|
||||||
|
call npx yx-generate-api gen %*
|
||||||
|
if errorlevel 1 goto :run_error
|
||||||
|
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:cd_error
|
||||||
|
echo.
|
||||||
|
echo Failed to enter project root.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:run_error
|
||||||
|
echo.
|
||||||
|
echo yx-generate-api gen failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default {
|
||||||
|
swaggerUrl: 'http://127.0.0.1:8080/swagger/v1/swagger.json',
|
||||||
|
outputDir: 'src/api/generated',
|
||||||
|
externalIndexFile: 'src/api/index.js',
|
||||||
|
requestImport: '../request',
|
||||||
|
paramStyle: 'object',
|
||||||
|
sync: {
|
||||||
|
enabled: true,
|
||||||
|
includeGeneratedIndexSnapshot: true,
|
||||||
|
blockStart: '// AUTO-GENERATED API EXPORTS START',
|
||||||
|
blockEnd: '// AUTO-GENERATED API EXPORTS END',
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue