pull-apifox 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -23
- package/lib/index.js +250 -120
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ npm install -g pull-apifox
|
|
|
10
10
|
|
|
11
11
|
## 快速开始
|
|
12
12
|
|
|
13
|
-
### 1.
|
|
13
|
+
### 1. 创建配置文件 `apifox.config.json`
|
|
14
14
|
|
|
15
15
|
```json
|
|
16
16
|
{
|
|
@@ -21,7 +21,11 @@ npm install -g pull-apifox
|
|
|
21
21
|
"importPath": "@/utils/request",
|
|
22
22
|
|
|
23
23
|
"pull": [
|
|
24
|
-
{
|
|
24
|
+
{
|
|
25
|
+
"tags": ["移库单管理"],
|
|
26
|
+
"name": "womeiTransfer",
|
|
27
|
+
"prefix": "/chainova/wms"
|
|
28
|
+
}
|
|
25
29
|
]
|
|
26
30
|
}
|
|
27
31
|
```
|
|
@@ -34,45 +38,146 @@ pull-apifox
|
|
|
34
38
|
pull-apifox ./path/to/apifox.config.json
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
##
|
|
41
|
+
## 配置文件
|
|
42
|
+
|
|
43
|
+
### 顶层配置
|
|
38
44
|
|
|
39
45
|
| 字段 | 类型 | 默认值 | 说明 |
|
|
40
46
|
|------|------|--------|------|
|
|
41
|
-
| `projectId` | string | **必填** | Apifox 项目 ID |
|
|
42
|
-
| `accessToken` | string | — | Apifox
|
|
47
|
+
| `projectId` | string | **必填** | Apifox 项目 ID,从项目 URL 中获取 |
|
|
48
|
+
| `accessToken` | string | — | Apifox 访问令牌,填了走云端 API,不填走本地客户端 |
|
|
43
49
|
| `localPort` | number | `4523` | 本地客户端端口 |
|
|
44
|
-
| `output` | string | `./src/apifox` |
|
|
45
|
-
| `showReturns` | boolean | `false` |
|
|
46
|
-
| `importPath` | string | `@/utils/request` | request
|
|
47
|
-
| `pull` | array | **必填** |
|
|
50
|
+
| `output` | string | `./src/apifox` | 默认输出目录 |
|
|
51
|
+
| `showReturns` | boolean | `false` | 是否在 JSDoc 中展开返回值类型 |
|
|
52
|
+
| `importPath` | string | `@/utils/request` | 生成的 import 语句中的 request 路径 |
|
|
53
|
+
| `pull` | array | **必填** | 拉取任务列表,每项生成一个 JS 文件 |
|
|
54
|
+
|
|
55
|
+
### pull 筛选规则
|
|
56
|
+
|
|
57
|
+
每项支持三个筛选维度,**folder 为 AND 划定范围,tags 与 apis 为 OR 取并集**:
|
|
58
|
+
|
|
59
|
+
| 字段 | 类型 | 说明 | 示例 |
|
|
60
|
+
|------|------|------|------|
|
|
61
|
+
| `folder` | string | Apifox 接口文档 ID(数字)或 URL 路径前缀 | `"83863684"` 或 `"/movement-orders"` |
|
|
62
|
+
| `tags` | string[] | Apifox 标签,支持层级模糊匹配 | `["移库单管理"]` 可匹配 `"沃美/移库单管理"` |
|
|
63
|
+
| `apis` | string[] | 接口名关键词;以 `/` 开头时精确匹配路径 | `["login"]` 或 `["/storage/location/list"]` |
|
|
64
|
+
|
|
65
|
+
### pull 输出配置
|
|
66
|
+
|
|
67
|
+
| 字段 | 类型 | 说明 |
|
|
68
|
+
|------|------|------|
|
|
69
|
+
| `name` | string | 生成的文件名,如 `"user"` → `user.js`。指定后所有命中接口合并到此文件 |
|
|
70
|
+
| `prefix` | string | 请求路径前缀,会加到每个 URL 前面 |
|
|
71
|
+
| `output` | string | 可选的独立输出目录,覆盖顶层 `output` |
|
|
72
|
+
|
|
73
|
+
### 筛选逻辑详解
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
folder AND (tags OR apis)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **folder** 为 AND:先划定范围(接口文档 ID 通过 Apifox API scope 限定,路径字符串通过 URL 前缀匹配)
|
|
80
|
+
- **tags 与 apis** 为 OR:只要满足任一条件即命中
|
|
81
|
+
- 未指定的维度视为「全部匹配」
|
|
82
|
+
|
|
83
|
+
**`apis` 匹配规则:**
|
|
84
|
+
|
|
85
|
+
| 关键词格式 | 匹配方式 | 示例 |
|
|
86
|
+
|-----------|---------|------|
|
|
87
|
+
| 以 `/` 开头 | **精确路径** | `"/storage/location/list"` 只匹配该路径 |
|
|
88
|
+
| 其他 | **模糊匹配** | `"login"` 匹配 operationId、summary、路径末段 |
|
|
89
|
+
|
|
90
|
+
**`tags` 匹配规则:**
|
|
91
|
+
|
|
92
|
+
Apifox 导出的标签通常包含层级路径,如 `沃美/移库单管理`。配置 `"移库单管理"` 即可匹配,支持:
|
|
93
|
+
- 完全相等:`"移库单管理"` = `"移库单管理"`
|
|
94
|
+
- 末级匹配:`"移库单管理"` 匹配 `"沃美/移库单管理"`
|
|
95
|
+
- 中间层级:`"沃美"` 匹配 `"沃美/移库单管理"`
|
|
96
|
+
|
|
97
|
+
**`folder` 规则:**
|
|
98
|
+
|
|
99
|
+
- 纯数字(如 `"83863684"`)→ 作为 Apifox 接口文档 ID,通过 API scope 限定拉取范围;API 不支持时退化到全量拉取
|
|
100
|
+
- 路径字符串(如 `"/movement-orders"`)→ URL 前缀匹配,子路径自动包含
|
|
101
|
+
|
|
102
|
+
## 配置示例
|
|
103
|
+
|
|
104
|
+
### 按接口文档 ID 拉取
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"projectId": "1577893",
|
|
109
|
+
"accessToken": "afxp_xxxxxx",
|
|
110
|
+
"output": "./src/apifox",
|
|
111
|
+
|
|
112
|
+
"pull": [
|
|
113
|
+
{
|
|
114
|
+
"folder": "83863684",
|
|
115
|
+
"tags": ["移库单管理"],
|
|
116
|
+
"apis": ["/storage/location/list"],
|
|
117
|
+
"name": "womeiTransfer",
|
|
118
|
+
"prefix": "/chainova/wms"
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> `folder: "83863684"` 限定接口文档范围,`tags` + `apis` 在范围内取并集。
|
|
125
|
+
|
|
126
|
+
### 按路径前缀拉取
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"pull": [
|
|
131
|
+
{
|
|
132
|
+
"folder": "/movement-orders",
|
|
133
|
+
"name": "movement",
|
|
134
|
+
"prefix": "/chainova/wms"
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
48
139
|
|
|
49
|
-
|
|
140
|
+
> 匹配所有 `/movement-orders/xxx` 下的接口。
|
|
50
141
|
|
|
51
|
-
|
|
142
|
+
### 多组拉取
|
|
52
143
|
|
|
53
144
|
```json
|
|
54
145
|
{
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
146
|
+
"pull": [
|
|
147
|
+
{
|
|
148
|
+
"tags": ["移库单管理"],
|
|
149
|
+
"name": "womeiTransfer",
|
|
150
|
+
"prefix": "/chainova/wms"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"apis": ["/lite/logical/inventory/transaction/page"],
|
|
154
|
+
"name": "transaction",
|
|
155
|
+
"prefix": "/chainova/wms"
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
"tags": ["项目管理"],
|
|
159
|
+
"name": "projectManager",
|
|
160
|
+
"prefix": "/chainova/srm"
|
|
161
|
+
}
|
|
162
|
+
]
|
|
60
163
|
}
|
|
61
164
|
```
|
|
62
165
|
|
|
63
|
-
>
|
|
166
|
+
> 同 folder 的组会复用缓存,避免重复拉取。
|
|
64
167
|
|
|
65
|
-
## 获取 Token
|
|
168
|
+
## 获取 Access Token
|
|
66
169
|
|
|
67
|
-
|
|
170
|
+
1. 打开 [Apifox](https://app.apifox.com)
|
|
171
|
+
2. 右上角头像 → **账号设置** → **访问令牌**
|
|
172
|
+
3. 点击 **新建令牌**,复制填入 `accessToken`
|
|
68
173
|
|
|
69
174
|
## 生成结果
|
|
70
175
|
|
|
71
176
|
```
|
|
72
177
|
src/apifox/
|
|
73
|
-
├── index.js
|
|
178
|
+
├── index.js # 入口,聚合导出
|
|
74
179
|
└── api/
|
|
75
|
-
└── womeiTransfer.js
|
|
180
|
+
└── womeiTransfer.js # 接口函数,带完整 JSDoc
|
|
76
181
|
```
|
|
77
182
|
|
|
78
183
|
### 生成代码示例
|
|
@@ -93,17 +198,45 @@ import request from '@/utils/request';
|
|
|
93
198
|
* @param {number} data.items[].quantity - 数量
|
|
94
199
|
* @returns {Promise<object>} - 响应数据
|
|
95
200
|
* @param {number} returns.code - 状态码
|
|
96
|
-
* @param {string} returns.
|
|
201
|
+
* @param {string} returns.message - 返回消息
|
|
97
202
|
*/
|
|
98
203
|
export function createMovementOrder(data) {
|
|
99
204
|
return request({
|
|
100
|
-
url: '/chainova/
|
|
205
|
+
url: '/chainova/wms/movement-orders/create',
|
|
101
206
|
method: 'POST',
|
|
102
207
|
data,
|
|
103
208
|
});
|
|
104
209
|
}
|
|
105
210
|
```
|
|
106
211
|
|
|
212
|
+
### 函数参数命名规则
|
|
213
|
+
|
|
214
|
+
| 参数位置 | 形参名 | 说明 |
|
|
215
|
+
|----------|--------|------|
|
|
216
|
+
| 路径参数 `{id}` | `id` | 直接展开为独立参数 |
|
|
217
|
+
| 查询参数 `?page=1` | `params` | 所有 query 合并为一个 object |
|
|
218
|
+
| 请求体 | `data` | body 对象 |
|
|
219
|
+
|
|
220
|
+
## 在业务代码中使用
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
// 从入口聚合导入
|
|
224
|
+
import { createMovementOrder, getMovementOrderList } from '@/apifox';
|
|
225
|
+
|
|
226
|
+
// 或按文件导入
|
|
227
|
+
import { createMovementOrder } from '@/apifox/api/womeiTransfer';
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## 更新接口
|
|
231
|
+
|
|
232
|
+
Apifox 上接口变更后,重新运行即可覆盖生成文件:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
pull-apifox
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
生成文件头部已标注 `由 pull-apifox 自动生成,请勿手动修改`。建议通过修改配置文件来调整输出,而不是直接改生成文件。
|
|
239
|
+
|
|
107
240
|
## License
|
|
108
241
|
|
|
109
242
|
MIT
|
package/lib/index.js
CHANGED
|
@@ -53,10 +53,11 @@ async function fetchSpec(projectId, scope, config) {
|
|
|
53
53
|
const token = config.accessToken;
|
|
54
54
|
|
|
55
55
|
if (token) {
|
|
56
|
+
// 云端 API
|
|
56
57
|
const url = `https://api.apifox.com/api/v1/projects/${projectId}/export-openapi`;
|
|
57
58
|
console.log(` 📡 云端 API: ${projectId}`);
|
|
58
59
|
const body = scope
|
|
59
|
-
? { exportFormat: 'OpenAPI3.0', scope }
|
|
60
|
+
? { exportFormat: 'OpenAPI3.0', scope: { type: 'SELECTED_FOLDERS', folderIds: [scope] } }
|
|
60
61
|
: { exportFormat: 'OpenAPI3.0' };
|
|
61
62
|
|
|
62
63
|
return request(url, {
|
|
@@ -68,6 +69,7 @@ async function fetchSpec(projectId, scope, config) {
|
|
|
68
69
|
},
|
|
69
70
|
});
|
|
70
71
|
} else {
|
|
72
|
+
// 本地客户端
|
|
71
73
|
const port = config.localPort || 4523;
|
|
72
74
|
const url = `http://127.0.0.1:${port}/export/openapi/${projectId}?version=3.0`;
|
|
73
75
|
console.log(` 📡 本地客户端: ${url}`);
|
|
@@ -75,7 +77,7 @@ async function fetchSpec(projectId, scope, config) {
|
|
|
75
77
|
try {
|
|
76
78
|
return await request(url, {
|
|
77
79
|
method: 'POST',
|
|
78
|
-
body: scope ? { exportFormat: 'OpenAPI3.0', scope } : { exportFormat: 'OpenAPI3.0' },
|
|
80
|
+
body: scope ? { exportFormat: 'OpenAPI3.0', scope: { type: 'SELECTED_FOLDERS', folderIds: [scope] } } : { exportFormat: 'OpenAPI3.0' },
|
|
79
81
|
});
|
|
80
82
|
} catch {
|
|
81
83
|
return request(url);
|
|
@@ -85,9 +87,20 @@ async function fetchSpec(projectId, scope, config) {
|
|
|
85
87
|
|
|
86
88
|
// ======================== 筛选引擎 ========================
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* 检查接口名是否匹配
|
|
92
|
+
* - 以 / 开头 → 精确路径匹配
|
|
93
|
+
* - 否则 → 模糊匹配 operationId / summary / 路径末段 / 完整路径
|
|
94
|
+
*/
|
|
88
95
|
function matchApiName(operation, route, keyword) {
|
|
89
96
|
const kw = keyword.toLowerCase();
|
|
90
97
|
|
|
98
|
+
// 关键词以 / 开头 → 精确路径匹配
|
|
99
|
+
if (keyword.startsWith('/')) {
|
|
100
|
+
return route.toLowerCase() === kw;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 模糊匹配
|
|
91
104
|
if (operation.operationId && operation.operationId.toLowerCase().includes(kw)) return true;
|
|
92
105
|
if (operation.summary && operation.summary.toLowerCase().includes(kw)) return true;
|
|
93
106
|
|
|
@@ -98,6 +111,10 @@ function matchApiName(operation, route, keyword) {
|
|
|
98
111
|
return false;
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 检查接口是否命中 tags 筛选
|
|
116
|
+
* 支持层级匹配:"移库单管理" → 匹配 "沃美/移库单管理"
|
|
117
|
+
*/
|
|
101
118
|
function matchTags(operation, tags) {
|
|
102
119
|
if (!tags || tags.length === 0) return true;
|
|
103
120
|
const opTags = (operation.tags || []).map(t => t.toLowerCase());
|
|
@@ -107,22 +124,47 @@ function matchTags(operation, tags) {
|
|
|
107
124
|
});
|
|
108
125
|
}
|
|
109
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 检查接口路径是否在指定文件夹下
|
|
129
|
+
* - 数字 ID(如 "83863684")→ 由 API scope 层面处理,此处直接放行
|
|
130
|
+
* - 路径字符串 → URL 前缀匹配(如 folder="/用户/登录" 匹配 /用户/登录/xxx)
|
|
131
|
+
*/
|
|
110
132
|
function matchFolder(route, folder) {
|
|
111
133
|
if (!folder) return true;
|
|
134
|
+
// 数字 ID(Apifox 接口文档 ID),非 URL 路径,放行由 scope 处理
|
|
135
|
+
if (/^\d+$/.test(folder)) return true;
|
|
136
|
+
// URL 路径前缀匹配
|
|
112
137
|
const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
|
|
113
138
|
const normalizedFolder = folder.startsWith('/') ? folder : `/${folder}`;
|
|
114
139
|
return normalizedRoute === normalizedFolder || normalizedRoute.startsWith(normalizedFolder + '/');
|
|
115
140
|
}
|
|
116
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 筛选逻辑:
|
|
144
|
+
* - folder 为 AND(划定范围,数字 ID 由 API scope 处理)
|
|
145
|
+
* - tags 与 apis 之间为 OR(取并集)
|
|
146
|
+
* - 两者都未指定时通过所有接口
|
|
147
|
+
*/
|
|
117
148
|
function matchAll(route, methodObj, operation, filters) {
|
|
149
|
+
// folder 筛选(AND)
|
|
118
150
|
if (filters.folder && !matchFolder(route, filters.folder)) return false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
151
|
+
|
|
152
|
+
const hasTags = filters.tags && filters.tags.length > 0;
|
|
153
|
+
const hasApis = filters.apis && filters.apis.length > 0;
|
|
154
|
+
|
|
155
|
+
// 既没指定 tags 也没指定 apis → 全部通过
|
|
156
|
+
if (!hasTags && !hasApis) return true;
|
|
157
|
+
|
|
158
|
+
// tags 与 apis 取并集(OR)
|
|
159
|
+
const matchT = hasTags && matchTags(operation, filters.tags);
|
|
160
|
+
const matchA = hasApis && filters.apis.some(kw => matchApiName(operation, route, kw));
|
|
161
|
+
|
|
162
|
+
return matchT || matchA;
|
|
124
163
|
}
|
|
125
164
|
|
|
165
|
+
/**
|
|
166
|
+
* 对 OpenAPI spec 执行筛选,返回新的 spec
|
|
167
|
+
*/
|
|
126
168
|
function filterSpec(spec, filters) {
|
|
127
169
|
const result = JSON.parse(JSON.stringify(spec));
|
|
128
170
|
const allCount = Object.keys(result.paths || {}).length;
|
|
@@ -181,6 +223,139 @@ function toFuncName(operation, method, route) {
|
|
|
181
223
|
return toCamel(`${method}_${route.replace(/[\/{}]/g, '_')}`);
|
|
182
224
|
}
|
|
183
225
|
|
|
226
|
+
/**
|
|
227
|
+
* 写入单个接口函数到 lines 数组
|
|
228
|
+
*/
|
|
229
|
+
function writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport) {
|
|
230
|
+
const funcName = toFuncName(operation, method, route);
|
|
231
|
+
|
|
232
|
+
const pathParams = (operation.parameters || []).filter(p => p.in === 'path').map(p => p.name);
|
|
233
|
+
const queryParams = (operation.parameters || []).filter(p => p.in === 'query').map(p => p.name);
|
|
234
|
+
const hasBody = ['POST', 'PUT', 'PATCH'].includes(method) && operation.requestBody;
|
|
235
|
+
|
|
236
|
+
const paramParts = [];
|
|
237
|
+
if (pathParams.length) paramParts.push(pathParams.join(', '));
|
|
238
|
+
if (queryParams.length) paramParts.push('params');
|
|
239
|
+
if (hasBody) paramParts.push('data');
|
|
240
|
+
|
|
241
|
+
lines.push(buildJSDoc(operation, method, route, funcName, spec, showReturns));
|
|
242
|
+
lines.push(`export function ${funcName}(${paramParts.join(', ')}) {`);
|
|
243
|
+
|
|
244
|
+
const fullPath = pathPrefix + route;
|
|
245
|
+
const urlExpr = pathParams.length
|
|
246
|
+
? '`' + fullPath.replace(/\{(\w+)\}/g, '${$1}') + '`'
|
|
247
|
+
: `'${fullPath}'`;
|
|
248
|
+
|
|
249
|
+
lines.push(` return request({`);
|
|
250
|
+
lines.push(` url: ${urlExpr},`);
|
|
251
|
+
lines.push(` method: '${method}',`);
|
|
252
|
+
if (queryParams.length) lines.push(` params,`);
|
|
253
|
+
if (hasBody) {
|
|
254
|
+
const ct = Object.keys(operation.requestBody?.content || { 'application/json': {} })[0];
|
|
255
|
+
lines.push(` data,`);
|
|
256
|
+
if (ct !== 'application/json') lines.push(` headers: { 'Content-Type': '${ct}' },`);
|
|
257
|
+
}
|
|
258
|
+
lines.push(` });`);
|
|
259
|
+
lines.push(`}\n`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function generateJS(spec, outputDir, groupName, prefix, showReturns, importPath) {
|
|
263
|
+
const apiDir = path.join(outputDir, 'api');
|
|
264
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
265
|
+
|
|
266
|
+
const paths = spec.paths || {};
|
|
267
|
+
const modules = {};
|
|
268
|
+
const pathPrefix = prefix || '';
|
|
269
|
+
const reqImport = importPath || '@/utils/request';
|
|
270
|
+
|
|
271
|
+
// 按 tag 分组;无 tag 时按路径首段分组
|
|
272
|
+
for (const [route, methods] of Object.entries(paths)) {
|
|
273
|
+
for (const [method, operation] of Object.entries(methods)) {
|
|
274
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
275
|
+
let tags = operation.tags?.length ? operation.tags : null;
|
|
276
|
+
if (!tags) {
|
|
277
|
+
const seg = route.split('/').filter(Boolean)[0] || 'default';
|
|
278
|
+
tags = [seg];
|
|
279
|
+
}
|
|
280
|
+
for (const tag of tags) {
|
|
281
|
+
if (!modules[tag]) modules[tag] = [];
|
|
282
|
+
modules[tag].push({ route, method: method.toUpperCase(), operation });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const fileNames = [];
|
|
288
|
+
|
|
289
|
+
if (groupName) {
|
|
290
|
+
// 指定了 name → 所有接口合并到一个文件
|
|
291
|
+
const allApis = [];
|
|
292
|
+
const tagNames = [];
|
|
293
|
+
for (const [tag, apis] of Object.entries(modules)) {
|
|
294
|
+
allApis.push(...apis);
|
|
295
|
+
tagNames.push(tag);
|
|
296
|
+
}
|
|
297
|
+
fileNames.push(groupName);
|
|
298
|
+
|
|
299
|
+
const lines = [
|
|
300
|
+
'/**',
|
|
301
|
+
` * ${tagNames.join(' / ')} 模块`,
|
|
302
|
+
' * 由 pull-apifox 自动生成,请勿手动修改',
|
|
303
|
+
' */',
|
|
304
|
+
'',
|
|
305
|
+
`import request from '${reqImport}';`,
|
|
306
|
+
'',
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
for (const { route, method, operation } of allApis) {
|
|
310
|
+
writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fs.writeFileSync(path.join(apiDir, `${groupName}.js`), lines.join('\n'), 'utf-8');
|
|
314
|
+
console.log(` ✅ ${groupName}.js (${allApis.length} 个接口)`);
|
|
315
|
+
} else {
|
|
316
|
+
// 未指定 name → 每个 tag 一个文件
|
|
317
|
+
for (const [tag, apis] of Object.entries(modules)) {
|
|
318
|
+
let fileName;
|
|
319
|
+
if (isAscii(tag) && tag !== 'default') {
|
|
320
|
+
fileName = tag.replace(/[^a-zA-Z0-9]/g, '_');
|
|
321
|
+
} else {
|
|
322
|
+
fileName = deriveModuleName(apis);
|
|
323
|
+
}
|
|
324
|
+
fileNames.push(fileName);
|
|
325
|
+
|
|
326
|
+
const lines = [
|
|
327
|
+
'/**',
|
|
328
|
+
` * ${tag} 模块`,
|
|
329
|
+
' * 由 pull-apifox 自动生成,请勿手动修改',
|
|
330
|
+
' */',
|
|
331
|
+
'',
|
|
332
|
+
`import request from '${reqImport}';`,
|
|
333
|
+
'',
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
for (const { route, method, operation } of apis) {
|
|
337
|
+
writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
fs.writeFileSync(path.join(apiDir, `${fileName}.js`), lines.join('\n'), 'utf-8');
|
|
341
|
+
console.log(` ✅ ${fileName}.js (${apis.length} 个接口)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 入口文件
|
|
346
|
+
if (fileNames.length > 0) {
|
|
347
|
+
const indexLines = fileNames
|
|
348
|
+
.map(name => `export * from './api/${name}';`)
|
|
349
|
+
.join('\n');
|
|
350
|
+
fs.writeFileSync(path.join(outputDir, 'index.js'), indexLines + '\n', 'utf-8');
|
|
351
|
+
} else {
|
|
352
|
+
console.log(' ⚠ 没有匹配到接口,请检查筛选条件(folder/tags/apis)是否正确');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 解析 $ref 引用(支持 '#/components/schemas/XXX')
|
|
358
|
+
*/
|
|
184
359
|
function resolveRef(schema, fullSpec) {
|
|
185
360
|
if (!schema) return schema;
|
|
186
361
|
if (schema.$ref) {
|
|
@@ -192,6 +367,9 @@ function resolveRef(schema, fullSpec) {
|
|
|
192
367
|
return schema;
|
|
193
368
|
}
|
|
194
369
|
|
|
370
|
+
/**
|
|
371
|
+
* 将 OpenAPI schema 转为 JSDoc 类型字符串
|
|
372
|
+
*/
|
|
195
373
|
function toJSDocType(schema, fullSpec) {
|
|
196
374
|
const resolved = resolveRef(schema, fullSpec);
|
|
197
375
|
if (!resolved || !resolved.type) return 'any';
|
|
@@ -209,6 +387,9 @@ function toJSDocType(schema, fullSpec) {
|
|
|
209
387
|
}
|
|
210
388
|
}
|
|
211
389
|
|
|
390
|
+
/**
|
|
391
|
+
* 为接口函数生成完整 JSDoc 注释
|
|
392
|
+
*/
|
|
212
393
|
function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
213
394
|
const lines = [];
|
|
214
395
|
const allParams = operation.parameters || [];
|
|
@@ -220,6 +401,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
220
401
|
: null;
|
|
221
402
|
const bodySchema = resolveRef(rawBodySchema, fullSpec);
|
|
222
403
|
|
|
404
|
+
// 响应体 schema
|
|
223
405
|
const rawRespSchema = showReturns
|
|
224
406
|
? (operation.responses?.['200']?.content?.['application/json']?.schema
|
|
225
407
|
|| operation.responses?.['201']?.content?.['application/json']?.schema
|
|
@@ -229,6 +411,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
229
411
|
|
|
230
412
|
lines.push('/**');
|
|
231
413
|
|
|
414
|
+
// 标题:优先 summary > description > funcName
|
|
232
415
|
const title = operation.summary || operation.description || funcName;
|
|
233
416
|
lines.push(` * ${title}`);
|
|
234
417
|
if (operation.summary && operation.description && operation.description !== operation.summary) {
|
|
@@ -238,6 +421,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
238
421
|
lines.push(' *');
|
|
239
422
|
lines.push(` * ${method.toUpperCase()} ${route}`);
|
|
240
423
|
|
|
424
|
+
// --- 路径参数 ---
|
|
241
425
|
const pathParamDefs = allParams.filter(p => p.in === 'path');
|
|
242
426
|
for (const p of pathParamDefs) {
|
|
243
427
|
const type = toJSDocType(p.schema, fullSpec);
|
|
@@ -245,6 +429,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
245
429
|
lines.push(` * @param {${type}} ${p.name}${desc ? ` - ${desc}` : ''}`);
|
|
246
430
|
}
|
|
247
431
|
|
|
432
|
+
// --- 查询参数 → 合并为 params 对象 ---
|
|
248
433
|
if (queryParamDefs.length > 0) {
|
|
249
434
|
const hasRequired = queryParamDefs.some(p => p.required === true);
|
|
250
435
|
lines.push(` * @param {object} ${hasRequired ? '' : '['}params${hasRequired ? '' : ']'} - 查询参数`);
|
|
@@ -256,14 +441,18 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
256
441
|
}
|
|
257
442
|
}
|
|
258
443
|
|
|
444
|
+
// --- 请求体 → data 参数 ---
|
|
259
445
|
if (hasBody) {
|
|
260
446
|
const bodyRequired = operation.requestBody.required !== false;
|
|
261
447
|
const bodyDesc = operation.requestBody.description || '请求体';
|
|
262
448
|
const type = bodySchema ? toJSDocType(bodySchema, fullSpec) : 'object';
|
|
263
449
|
lines.push(` * @param {${type}} ${bodyRequired ? '' : '['}data${bodyRequired ? '' : ']'} - ${bodyDesc}`);
|
|
450
|
+
|
|
451
|
+
// 递归展开 body 内部属性
|
|
264
452
|
expandProperties(bodySchema, 'data', bodySchema?.required || [], fullSpec, lines, 0);
|
|
265
453
|
}
|
|
266
454
|
|
|
455
|
+
// --- 返回值 ---
|
|
267
456
|
if (showReturns && respSchema) {
|
|
268
457
|
const respType = toJSDocType(respSchema, fullSpec);
|
|
269
458
|
lines.push(` * @returns {Promise<${respType}>} - 响应数据`);
|
|
@@ -276,6 +465,9 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
|
|
|
276
465
|
return lines.join('\n');
|
|
277
466
|
}
|
|
278
467
|
|
|
468
|
+
/**
|
|
469
|
+
* 递归展开 schema 属性,支持嵌套 object 和 object[] 类型
|
|
470
|
+
*/
|
|
279
471
|
function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth) {
|
|
280
472
|
if (depth > 3) return;
|
|
281
473
|
const resolved = resolveRef(schema, fullSpec);
|
|
@@ -290,9 +482,12 @@ function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth)
|
|
|
290
482
|
const fullPath = prefix ? `${prefix}.${prop}` : prop;
|
|
291
483
|
lines.push(` * @param {${propType}} ${req ? '' : '['}${fullPath}${req ? '' : ']'}${propDesc ? ` - ${propDesc}` : ''}`);
|
|
292
484
|
|
|
485
|
+
// 递归展开内嵌 object
|
|
293
486
|
if (resolvedProp?.type === 'object' && resolvedProp.properties) {
|
|
294
487
|
expandProperties(resolvedProp, fullPath, resolvedProp.required || [], fullSpec, lines, depth + 1);
|
|
295
|
-
}
|
|
488
|
+
}
|
|
489
|
+
// 递归展开数组中的对象 (T[])
|
|
490
|
+
else if (resolvedProp?.type === 'array' && resolvedProp.items) {
|
|
296
491
|
const resolvedItems = resolveRef(resolvedProp.items, fullSpec);
|
|
297
492
|
if (resolvedItems?.type === 'object' && resolvedItems.properties) {
|
|
298
493
|
expandProperties(resolvedItems, fullPath + '[]', resolvedItems.required || [], fullSpec, lines, depth + 1);
|
|
@@ -301,100 +496,6 @@ function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth)
|
|
|
301
496
|
}
|
|
302
497
|
}
|
|
303
498
|
|
|
304
|
-
function generateJS(spec, outputDir, groupName, prefix, showReturns, importPath) {
|
|
305
|
-
const apiDir = path.join(outputDir, 'api');
|
|
306
|
-
fs.mkdirSync(apiDir, { recursive: true });
|
|
307
|
-
|
|
308
|
-
const paths = spec.paths || {};
|
|
309
|
-
const modules = {};
|
|
310
|
-
const pathPrefix = prefix || '';
|
|
311
|
-
const reqImport = importPath || '@/utils/request';
|
|
312
|
-
|
|
313
|
-
for (const [route, methods] of Object.entries(paths)) {
|
|
314
|
-
for (const [method, operation] of Object.entries(methods)) {
|
|
315
|
-
if (!operation || typeof operation !== 'object') continue;
|
|
316
|
-
let tags = operation.tags?.length ? operation.tags : null;
|
|
317
|
-
if (!tags) {
|
|
318
|
-
const seg = route.split('/').filter(Boolean)[0] || 'default';
|
|
319
|
-
tags = [seg];
|
|
320
|
-
}
|
|
321
|
-
for (const tag of tags) {
|
|
322
|
-
if (!modules[tag]) modules[tag] = [];
|
|
323
|
-
modules[tag].push({ route, method: method.toUpperCase(), operation });
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const fileNames = [];
|
|
329
|
-
|
|
330
|
-
for (const [tag, apis] of Object.entries(modules)) {
|
|
331
|
-
let fileName;
|
|
332
|
-
if (groupName) {
|
|
333
|
-
fileName = groupName;
|
|
334
|
-
} else if (isAscii(tag) && tag !== 'default') {
|
|
335
|
-
fileName = tag.replace(/[^a-zA-Z0-9]/g, '_');
|
|
336
|
-
} else {
|
|
337
|
-
fileName = deriveModuleName(apis);
|
|
338
|
-
}
|
|
339
|
-
fileNames.push(fileName);
|
|
340
|
-
|
|
341
|
-
const lines = [
|
|
342
|
-
'/**',
|
|
343
|
-
` * ${tag} 模块`,
|
|
344
|
-
' * 由 pull-apifox 自动生成,请勿手动修改',
|
|
345
|
-
' */',
|
|
346
|
-
'',
|
|
347
|
-
`import request from '${reqImport}';`,
|
|
348
|
-
'',
|
|
349
|
-
];
|
|
350
|
-
|
|
351
|
-
for (const { route, method, operation } of apis) {
|
|
352
|
-
const funcName = toFuncName(operation, method, route);
|
|
353
|
-
|
|
354
|
-
const pathParams = (operation.parameters || []).filter(p => p.in === 'path').map(p => p.name);
|
|
355
|
-
const queryParams = (operation.parameters || []).filter(p => p.in === 'query').map(p => p.name);
|
|
356
|
-
const hasBody = ['POST', 'PUT', 'PATCH'].includes(method) && operation.requestBody;
|
|
357
|
-
|
|
358
|
-
const paramParts = [];
|
|
359
|
-
if (pathParams.length) paramParts.push(pathParams.join(', '));
|
|
360
|
-
if (queryParams.length) paramParts.push('params');
|
|
361
|
-
if (hasBody) paramParts.push('data');
|
|
362
|
-
|
|
363
|
-
lines.push(buildJSDoc(operation, method, route, funcName, spec, showReturns));
|
|
364
|
-
lines.push(`export function ${funcName}(${paramParts.join(', ')}) {`);
|
|
365
|
-
|
|
366
|
-
const fullPath = pathPrefix + route;
|
|
367
|
-
const urlExpr = pathParams.length
|
|
368
|
-
? '`' + fullPath.replace(/\{(\w+)\}/g, '${$1}') + '`'
|
|
369
|
-
: `'${fullPath}'`;
|
|
370
|
-
|
|
371
|
-
lines.push(' return request({');
|
|
372
|
-
lines.push(` url: ${urlExpr},`);
|
|
373
|
-
lines.push(` method: '${method}',`);
|
|
374
|
-
if (queryParams.length) lines.push(' params,');
|
|
375
|
-
if (hasBody) {
|
|
376
|
-
const ct = Object.keys(operation.requestBody?.content || { 'application/json': {} })[0];
|
|
377
|
-
lines.push(' data,');
|
|
378
|
-
if (ct !== 'application/json') lines.push(` headers: { 'Content-Type': '${ct}' },`);
|
|
379
|
-
}
|
|
380
|
-
lines.push(' });');
|
|
381
|
-
lines.push('}\n');
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
fs.writeFileSync(path.join(apiDir, `${fileName}.js`), lines.join('\n'), 'utf-8');
|
|
385
|
-
console.log(` ✅ ${fileName}.js (${apis.length} 个接口)`);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
if (fileNames.length > 0) {
|
|
389
|
-
const indexLines = fileNames
|
|
390
|
-
.map(name => `export * from './api/${name}';`)
|
|
391
|
-
.join('\n');
|
|
392
|
-
fs.writeFileSync(path.join(outputDir, 'index.js'), indexLines + '\n', 'utf-8');
|
|
393
|
-
} else {
|
|
394
|
-
console.log(' ⚠ 没有匹配到接口,请检查筛选条件(folder/tags/apis)是否正确');
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
499
|
// ======================== 主流程 ========================
|
|
399
500
|
|
|
400
501
|
async function main() {
|
|
@@ -407,7 +508,7 @@ async function main() {
|
|
|
407
508
|
console.log(' pull-apifox 使用当前目录下的 apifox.config.json');
|
|
408
509
|
console.log(' pull-apifox ./my-config.json 指定配置文件');
|
|
409
510
|
console.log('');
|
|
410
|
-
console.log('配置文档: https://github.com/
|
|
511
|
+
console.log('配置文档: https://github.com/fanwb1212/pull-apifox');
|
|
411
512
|
process.exit(0);
|
|
412
513
|
}
|
|
413
514
|
|
|
@@ -431,34 +532,63 @@ async function main() {
|
|
|
431
532
|
console.log(` 拉取组数: ${config.pull.length}`);
|
|
432
533
|
console.log('');
|
|
433
534
|
|
|
434
|
-
console.log('📥 拉取完整 OpenAPI 规范...');
|
|
435
|
-
const fullSpec = await fetchSpec(config.projectId, null, config);
|
|
436
|
-
const totalPaths = Object.keys(fullSpec.paths || {}).length;
|
|
437
|
-
console.log(` 📊 项目共有 ${totalPaths} 个接口\n`);
|
|
438
|
-
|
|
439
|
-
const allTags = new Set();
|
|
440
|
-
for (const methods of Object.values(fullSpec.paths || {})) {
|
|
441
|
-
for (const op of Object.values(methods)) {
|
|
442
|
-
(op.tags || []).forEach(t => allTags.add(t));
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
535
|
const baseOutput = path.resolve(config.output || './src/apifox');
|
|
536
|
+
const specCache = {}; // 按 folder scope 缓存
|
|
447
537
|
|
|
538
|
+
// 逐组拉取 + 筛选 + 生成
|
|
448
539
|
for (let i = 0; i < config.pull.length; i++) {
|
|
449
540
|
const group = config.pull[i];
|
|
450
|
-
const
|
|
451
|
-
|
|
541
|
+
const scope = group.folder || null;
|
|
542
|
+
const scopeLabel = group.folder || group.tags?.join(', ') || group.apis?.join(', ') || '全部接口';
|
|
543
|
+
console.log(`📦 [${i + 1}/${config.pull.length}] ${scopeLabel}`);
|
|
544
|
+
|
|
545
|
+
// 拉取(同 folder 复用缓存)
|
|
546
|
+
let spec;
|
|
547
|
+
const cacheKey = scope || '__full__';
|
|
548
|
+
if (specCache[cacheKey]) {
|
|
549
|
+
spec = specCache[cacheKey];
|
|
550
|
+
console.log(` ♻ 复用缓存`);
|
|
551
|
+
} else {
|
|
552
|
+
// 先拉全量(作为回退)
|
|
553
|
+
const fullSpec = await fetchSpec(config.projectId, null, config);
|
|
554
|
+
const fullCount = Object.keys(fullSpec.paths || {}).length;
|
|
555
|
+
|
|
556
|
+
if (scope) {
|
|
557
|
+
// 尝试 scope 拉取
|
|
558
|
+
spec = await fetchSpec(config.projectId, scope, config);
|
|
559
|
+
const scopedCount = Object.keys(spec.paths || {}).length;
|
|
560
|
+
if (scopedCount < fullCount) {
|
|
561
|
+
console.log(` 📊 限定范围共 ${scopedCount} 个接口 (全量 ${fullCount})`);
|
|
562
|
+
} else {
|
|
563
|
+
console.log(` ⚠ scope 无效,使用全量 ${fullCount} 个接口`);
|
|
564
|
+
spec = fullSpec;
|
|
565
|
+
}
|
|
566
|
+
// 缓存全量供后续无 folder 的组复用
|
|
567
|
+
specCache['__full__'] = fullSpec;
|
|
568
|
+
} else {
|
|
569
|
+
spec = fullSpec;
|
|
570
|
+
console.log(` 📊 项目共有 ${fullCount} 个接口`);
|
|
571
|
+
}
|
|
572
|
+
specCache[cacheKey] = spec;
|
|
573
|
+
}
|
|
452
574
|
|
|
453
|
-
|
|
575
|
+
// 筛选
|
|
576
|
+
const filtered = filterSpec(spec, group);
|
|
454
577
|
|
|
578
|
+
// 诊断
|
|
455
579
|
if (Object.keys(filtered.paths || {}).length === 0) {
|
|
456
|
-
const kw = group.
|
|
580
|
+
const kw = group.apis?.[0] || group.tags?.[0] || '';
|
|
457
581
|
if (kw) {
|
|
458
|
-
const matchedPaths = Object.keys(
|
|
582
|
+
const matchedPaths = Object.keys(spec.paths || {}).filter(p => p.toLowerCase().includes(kw.toLowerCase()));
|
|
459
583
|
if (matchedPaths.length > 0) {
|
|
460
584
|
console.log(` 💡 路径中包含「${kw}」的接口: ${matchedPaths.slice(0, 10).join(', ')}${matchedPaths.length > 10 ? '...' : ''}`);
|
|
461
585
|
}
|
|
586
|
+
const allTags = new Set();
|
|
587
|
+
for (const methods of Object.values(spec.paths || {})) {
|
|
588
|
+
for (const op of Object.values(methods)) {
|
|
589
|
+
(op.tags || []).forEach(t => allTags.add(t));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
462
592
|
const matchedTags = [...allTags].filter(t => t.toLowerCase().includes(kw.toLowerCase()));
|
|
463
593
|
if (matchedTags.length > 0) {
|
|
464
594
|
console.log(` 💡 标签中包含「${kw}」的: ${matchedTags.join(', ')}`);
|
package/package.json
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pull-apifox",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "从 Apifox
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "从 Apifox 拉取接口,按需筛选,自动生成带 JSDoc 的 JS 请求代码",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"apifox",
|
|
7
|
+
"openapi",
|
|
8
|
+
"codegen",
|
|
9
|
+
"api",
|
|
10
|
+
"jsdoc"
|
|
11
|
+
],
|
|
6
12
|
"license": "MIT",
|
|
7
13
|
"author": "fanwb1212",
|
|
8
14
|
"main": "lib/index.js",
|