@milian/linker-cli 0.0.0-stage → 0.17.3
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 +117 -2
- package/bin/linker-cli.js +24 -0
- package/package.json +19 -4
- package/src/api.js +154 -0
- package/src/cli.js +222 -0
- package/src/commands/api-create.js +478 -0
- package/src/commands/api-list.js +247 -0
- package/src/commands/api-publish.js +288 -0
- package/src/commands/auth.js +242 -0
- package/src/commands/groups.js +109 -0
- package/src/commands/mcp.js +485 -0
- package/src/commands/platform-create.js +279 -0
- package/src/commands/platforms.js +206 -0
- package/src/commands/request.js +55 -0
- package/src/commands/session.js +119 -0
- package/src/config.js +227 -0
- package/src/crypto.js +175 -0
- package/src/login-page.js +175 -0
- package/src/schema.js +582 -0
- package/src/ui.js +57 -0
package/src/schema.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 入参/出参结构转换(纯函数,不做任何 IO)。
|
|
5
|
+
*
|
|
6
|
+
* 平台要求 outboundApiParamList / outboundApiResultList 是「字段树」,
|
|
7
|
+
* 而人给的是 curl 或 JSON 示例。这里负责把示例推导成字段树。
|
|
8
|
+
*
|
|
9
|
+
* 平台权威规则(业务方给定):
|
|
10
|
+
* - requestParam / requestBody / requestHeader 三个根节点必须都在
|
|
11
|
+
* - responseBody 根节点必须都在
|
|
12
|
+
* - collective: 是否数组
|
|
13
|
+
* - fieldType: object | number | string | boolean
|
|
14
|
+
* - fieldLength: 仅 string 需要,不清楚填 255,其他 null
|
|
15
|
+
* - basic: fieldType 是 object 则 false,否则 true
|
|
16
|
+
* - paramType: header -> HEADER,body -> BODY,路径参数/query -> PARAM
|
|
17
|
+
* - requestHeader / requestParam 的子级只能是 string
|
|
18
|
+
* - requestBody 的子级可以是 object,需继续向下挂 children
|
|
19
|
+
*
|
|
20
|
+
* labelName(字段描述)**不由类型推导**——它应该是接口文档里的真实注释。
|
|
21
|
+
* 平台样例里那些「对象」「字符串数组」只是人工随手填的,不能当作规则。
|
|
22
|
+
* 没给注释就留空;给了就原样用,超过 MAX_LABEL_LENGTH 会截断并提示调用方去总结。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const STRING_LENGTH = 255;
|
|
26
|
+
|
|
27
|
+
const ROOT_LABEL = '根节点';
|
|
28
|
+
const ROOT_PARAM = 'requestParam';
|
|
29
|
+
const ROOT_BODY = 'requestBody';
|
|
30
|
+
const ROOT_HEADER = 'requestHeader';
|
|
31
|
+
const ROOT_RESULT = 'responseBody';
|
|
32
|
+
|
|
33
|
+
const PARAM_TYPE_PARAM = 'PARAM';
|
|
34
|
+
const PARAM_TYPE_BODY = 'BODY';
|
|
35
|
+
const PARAM_TYPE_HEADER = 'HEADER';
|
|
36
|
+
|
|
37
|
+
const FIELD_OBJECT = 'object';
|
|
38
|
+
const FIELD_NUMBER = 'number';
|
|
39
|
+
const FIELD_STRING = 'string';
|
|
40
|
+
const FIELD_BOOLEAN = 'boolean';
|
|
41
|
+
|
|
42
|
+
/** Content-Type 由 contentType 数字字段表达,不进 requestHeader(与平台前端一致) */
|
|
43
|
+
const IGNORED_HEADERS = ['content-type'];
|
|
44
|
+
|
|
45
|
+
/** 字段描述(labelName)长度上限。超了必须由 AI 总结后再提交 */
|
|
46
|
+
const MAX_LABEL_LENGTH = 200;
|
|
47
|
+
|
|
48
|
+
/** 注释的作用域 */
|
|
49
|
+
const LABEL_SCOPES = ['request', 'response', 'param', 'header'];
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------- JSON 解析
|
|
52
|
+
|
|
53
|
+
function parseJsonText(text, label) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
throw new Error(`${label} 不是合法 JSON:${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------- 类型推导
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 从示例值推导字段类型。只推类型/是否数组/长度,**不猜描述**。
|
|
65
|
+
* @returns {{fieldType:string, collective:boolean, fieldLength:number|null}}
|
|
66
|
+
*/
|
|
67
|
+
function classifyValue(value) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
const inner = classifyValue(value.length ? value[0] : '');
|
|
70
|
+
return {
|
|
71
|
+
fieldType: inner.fieldType,
|
|
72
|
+
collective: true,
|
|
73
|
+
fieldLength: inner.fieldType === FIELD_STRING ? STRING_LENGTH : null
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (value !== null && typeof value === 'object') {
|
|
77
|
+
return { fieldType: FIELD_OBJECT, collective: false, fieldLength: null };
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === 'number') {
|
|
80
|
+
return { fieldType: FIELD_NUMBER, collective: false, fieldLength: null };
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'boolean') {
|
|
83
|
+
return { fieldType: FIELD_BOOLEAN, collective: false, fieldLength: null };
|
|
84
|
+
}
|
|
85
|
+
return { fieldType: FIELD_STRING, collective: false, fieldLength: STRING_LENGTH };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------- 上下文
|
|
89
|
+
|
|
90
|
+
function toList(raw) {
|
|
91
|
+
if (raw instanceof Set) return Array.from(raw);
|
|
92
|
+
if (Array.isArray(raw)) return raw.map((s) => String(s));
|
|
93
|
+
return String(raw == null ? '' : raw)
|
|
94
|
+
.split(',')
|
|
95
|
+
.map((s) => s.trim())
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 按字符数截断(平台对 labelName 有长度限制) */
|
|
100
|
+
function clipLabel(text, max) {
|
|
101
|
+
const s = String(text == null ? '' : text);
|
|
102
|
+
const limit = Number.isFinite(max) && max > 0 ? max : MAX_LABEL_LENGTH;
|
|
103
|
+
return s.length > limit ? s.slice(0, limit) : s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 'a=1' / ['a=1'] / {a:1} / Map → [[k,v]] */
|
|
107
|
+
function toEntries(raw) {
|
|
108
|
+
if (!raw) return [];
|
|
109
|
+
if (raw instanceof Map) return Array.from(raw.entries());
|
|
110
|
+
if (Array.isArray(raw)) {
|
|
111
|
+
return raw
|
|
112
|
+
.map((item) => {
|
|
113
|
+
const s = String(item == null ? '' : item);
|
|
114
|
+
const i = s.indexOf('=');
|
|
115
|
+
return i > 0 ? [s.slice(0, i).trim(), s.slice(i + 1).trim()] : null;
|
|
116
|
+
})
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
if (typeof raw === 'object') return Object.entries(raw);
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isNormalizedLabels(x) {
|
|
124
|
+
return Boolean(x) && LABEL_SCOPES.every((s) => x[s] instanceof Map);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 归一化字段注释(labelName)。
|
|
129
|
+
*
|
|
130
|
+
* 支持三种来源:
|
|
131
|
+
* 1. 分域对象 { request:{path:注释}, response:{...}, param:{...}, header:{...} }
|
|
132
|
+
* 2. 扁平对象 { 'b.d': '订单号' } → 同时作用于 request / response / param / header
|
|
133
|
+
* 3. 'path=注释' 数组(--label 的原始形态)
|
|
134
|
+
* 4. 已归一化过的结果(幂等)
|
|
135
|
+
*
|
|
136
|
+
* 超过 MAX_LABEL_LENGTH 的会被截断,并记入 clipped 供调用方提示"应当总结"。
|
|
137
|
+
* @returns {{labels:{request:Map,response:Map,param:Map,header:Map}, clipped:object[]}}
|
|
138
|
+
*/
|
|
139
|
+
function normalizeLabels(raw) {
|
|
140
|
+
if (isNormalizedLabels(raw)) return { labels: raw, clipped: [] };
|
|
141
|
+
|
|
142
|
+
const labels = { request: new Map(), response: new Map(), param: new Map(), header: new Map() };
|
|
143
|
+
const clipped = [];
|
|
144
|
+
const entries = toEntries(raw);
|
|
145
|
+
const scoped = entries.some(([k]) => LABEL_SCOPES.includes(k));
|
|
146
|
+
|
|
147
|
+
const put = (scope, path, text) => {
|
|
148
|
+
const key = String(path == null ? '' : path).trim();
|
|
149
|
+
if (!key) return;
|
|
150
|
+
const value = String(text == null ? '' : text);
|
|
151
|
+
const value2 = clipLabel(value);
|
|
152
|
+
if (value2.length !== value.length) clipped.push({ scope, path: key, length: value.length });
|
|
153
|
+
labels[scope].set(key, value2);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
for (const [k, v] of entries) {
|
|
157
|
+
if (scoped) {
|
|
158
|
+
if (LABEL_SCOPES.includes(k)) {
|
|
159
|
+
for (const [p, t] of toEntries(v)) put(k, p, t);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// 扁平模式:同名路径在四个作用域都生效(键名通常不冲突,方便一次性给全)
|
|
164
|
+
for (const scope of LABEL_SCOPES) put(scope, k, v);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { labels, clipped };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function labelOf(map, key, fallback) {
|
|
171
|
+
if (map && map.has(key)) return map.get(key);
|
|
172
|
+
return fallback == null ? '' : String(fallback);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------- 入参树
|
|
176
|
+
|
|
177
|
+
function buildBodyNode(key, value, path, ctx) {
|
|
178
|
+
const c = classifyValue(value);
|
|
179
|
+
const node = {
|
|
180
|
+
fieldCode: key,
|
|
181
|
+
mandatory: ctx.required.has(path),
|
|
182
|
+
collective: c.collective,
|
|
183
|
+
fieldType: c.fieldType,
|
|
184
|
+
fieldLength: c.fieldLength,
|
|
185
|
+
labelName: labelOf(ctx.labels, path),
|
|
186
|
+
basic: c.fieldType !== FIELD_OBJECT,
|
|
187
|
+
paramType: PARAM_TYPE_BODY
|
|
188
|
+
};
|
|
189
|
+
if (c.fieldType === FIELD_OBJECT) {
|
|
190
|
+
const src = c.collective ? (Array.isArray(value) && value.length ? value[0] : {}) : value;
|
|
191
|
+
node.children = buildBodyChildren(src, path, ctx);
|
|
192
|
+
}
|
|
193
|
+
return node;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildBodyChildren(obj, parentPath, ctx) {
|
|
197
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];
|
|
198
|
+
return Object.keys(obj).map((k) => buildBodyNode(k, obj[k], parentPath ? parentPath + '.' + k : k, ctx));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** requestParam / requestHeader 的子级只能是 string */
|
|
202
|
+
function buildLeafParam(key, paramType, fallbackLabel, ctx) {
|
|
203
|
+
return {
|
|
204
|
+
fieldCode: key,
|
|
205
|
+
mandatory: ctx.required.has(key),
|
|
206
|
+
collective: false,
|
|
207
|
+
fieldType: FIELD_STRING,
|
|
208
|
+
fieldLength: STRING_LENGTH,
|
|
209
|
+
labelName: labelOf(ctx.labels, key, fallbackLabel),
|
|
210
|
+
basic: true,
|
|
211
|
+
paramType
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 三个根节点的公共骨架。
|
|
217
|
+
* 注意:官方样例里 requestHeader 根节点没有 fieldLength 字段(requestParam / requestBody 有),
|
|
218
|
+
* 这里逐字对齐,避免提交体与平台前端生成的形态出现差异。
|
|
219
|
+
*/
|
|
220
|
+
function buildRoot(fieldCode, paramType) {
|
|
221
|
+
const root = {
|
|
222
|
+
fieldCode,
|
|
223
|
+
mandatory: false,
|
|
224
|
+
collective: false,
|
|
225
|
+
fieldType: FIELD_OBJECT,
|
|
226
|
+
labelName: ROOT_LABEL
|
|
227
|
+
};
|
|
228
|
+
if (paramType !== PARAM_TYPE_HEADER) root.fieldLength = null;
|
|
229
|
+
root.paramType = paramType;
|
|
230
|
+
root.children = [];
|
|
231
|
+
root.basic = false;
|
|
232
|
+
return root;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function unique(list) {
|
|
236
|
+
const out = [];
|
|
237
|
+
for (const item of list || []) {
|
|
238
|
+
const s = String(item == null ? '' : item).trim();
|
|
239
|
+
if (s && !out.includes(s)) out.push(s);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 生成 outboundApiParamList(顺序固定:requestParam → requestBody → requestHeader)
|
|
246
|
+
* @param {object} options
|
|
247
|
+
* @param {string[]|string} [options.queryParams] query 参数名列表
|
|
248
|
+
* @param {string[]|string} [options.pathParams] 路径参数名列表(url 里 {x} 抽出来的)
|
|
249
|
+
* @param {string[]|string} [options.headerParams] 请求头名列表
|
|
250
|
+
* @param {any} [options.bodyJson] 请求体 JSON 示例
|
|
251
|
+
* @param {string[]|string} [options.required] 必填字段路径(如 b.d)
|
|
252
|
+
* @param {object|Map|string[]} [options.labels] 字段描述(来自接口文档注释),见 normalizeLabels
|
|
253
|
+
*/
|
|
254
|
+
function buildParamTree(options) {
|
|
255
|
+
const o = options || {};
|
|
256
|
+
const required = new Set(toList(o.required));
|
|
257
|
+
const { labels } = normalizeLabels(o.labels);
|
|
258
|
+
|
|
259
|
+
const pathKeys = unique(o.pathParams);
|
|
260
|
+
const queryKeys = unique(o.queryParams).filter((k) => !pathKeys.includes(k));
|
|
261
|
+
const headerKeys = unique(o.headerParams);
|
|
262
|
+
|
|
263
|
+
const paramCtx = { required, labels: labels.param };
|
|
264
|
+
const paramRoot = buildRoot(ROOT_PARAM, PARAM_TYPE_PARAM);
|
|
265
|
+
paramRoot.children = [
|
|
266
|
+
...pathKeys.map((k) => buildLeafParam(k, PARAM_TYPE_PARAM, '路径参数', paramCtx)),
|
|
267
|
+
...queryKeys.map((k) => buildLeafParam(k, PARAM_TYPE_PARAM, '', paramCtx))
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const bodyCtx = { required, labels: labels.request };
|
|
271
|
+
const bodyRoot = buildRoot(ROOT_BODY, PARAM_TYPE_BODY);
|
|
272
|
+
const body = o.bodyJson;
|
|
273
|
+
if (Array.isArray(body)) {
|
|
274
|
+
bodyRoot.collective = true;
|
|
275
|
+
bodyRoot.children = buildBodyChildren(body.length ? body[0] : {}, '', bodyCtx);
|
|
276
|
+
} else if (body !== null && typeof body === 'object') {
|
|
277
|
+
bodyRoot.children = buildBodyChildren(body, '', bodyCtx);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const headerCtx = { required, labels: labels.header };
|
|
281
|
+
const headerRoot = buildRoot(ROOT_HEADER, PARAM_TYPE_HEADER);
|
|
282
|
+
headerRoot.children = headerKeys.map((k) => buildLeafParam(k, PARAM_TYPE_HEADER, '', headerCtx));
|
|
283
|
+
|
|
284
|
+
return [paramRoot, bodyRoot, headerRoot];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------- 出参树
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 出参节点。
|
|
291
|
+
* 出参没有 mandatory / paramType / fieldLength,labelName 同样只来自注释。
|
|
292
|
+
*/
|
|
293
|
+
function buildResultNode(key, value, path, ctx) {
|
|
294
|
+
const c = classifyValue(value);
|
|
295
|
+
const node = {
|
|
296
|
+
fieldCode: key,
|
|
297
|
+
collective: c.collective,
|
|
298
|
+
fieldType: c.fieldType,
|
|
299
|
+
sensitiveFieldIds: null,
|
|
300
|
+
filterFlag: 0,
|
|
301
|
+
labelName: labelOf(ctx.labels, path),
|
|
302
|
+
basic: c.fieldType !== FIELD_OBJECT
|
|
303
|
+
};
|
|
304
|
+
if (c.fieldType === FIELD_OBJECT) {
|
|
305
|
+
const src = c.collective ? (Array.isArray(value) && value.length ? value[0] : {}) : value;
|
|
306
|
+
node.children = buildResultChildren(src, path, ctx);
|
|
307
|
+
}
|
|
308
|
+
return node;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildResultChildren(obj, parentPath, ctx) {
|
|
312
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];
|
|
313
|
+
return Object.keys(obj).map((k) => buildResultNode(k, obj[k], parentPath ? parentPath + '.' + k : k, ctx));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 生成 outboundApiResultList(固定只有一个 responseBody 根节点)
|
|
318
|
+
* 注意:出参节点没有 mandatory / paramType / fieldLength(平台样例如此)
|
|
319
|
+
* @param {any} resultJson 出参示例
|
|
320
|
+
* @param {object} [opts] { labels } 字段描述(来自接口文档注释)
|
|
321
|
+
*/
|
|
322
|
+
function buildResultTree(resultJson, opts) {
|
|
323
|
+
const o = opts || {};
|
|
324
|
+
const { labels } = normalizeLabels(o.labels);
|
|
325
|
+
const ctx = { required: new Set(toList(o.required)), labels: labels.response };
|
|
326
|
+
const root = {
|
|
327
|
+
fieldCode: ROOT_RESULT,
|
|
328
|
+
collective: false,
|
|
329
|
+
fieldType: FIELD_OBJECT,
|
|
330
|
+
labelName: ROOT_LABEL,
|
|
331
|
+
sensitiveFieldIds: null,
|
|
332
|
+
filterFlag: 0,
|
|
333
|
+
children: [],
|
|
334
|
+
basic: false
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (resultJson === undefined || resultJson === null) return [root];
|
|
338
|
+
|
|
339
|
+
if (Array.isArray(resultJson)) {
|
|
340
|
+
root.collective = true;
|
|
341
|
+
root.children = buildResultChildren(resultJson.length ? resultJson[0] : {}, '', ctx);
|
|
342
|
+
return [root];
|
|
343
|
+
}
|
|
344
|
+
if (typeof resultJson === 'object') {
|
|
345
|
+
root.children = buildResultChildren(resultJson, '', ctx);
|
|
346
|
+
}
|
|
347
|
+
return [root];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------- url 处理
|
|
351
|
+
|
|
352
|
+
const PATH_PARAM_RE = /\{\s*([A-Za-z0-9_.\-]+)\s*\}/g;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 规范化 api 路径:
|
|
356
|
+
* - 必须以 / 开头
|
|
357
|
+
* - 去掉 host(只保留 path)
|
|
358
|
+
* - 路径参数 /asd/{orderId}/asd → /asd/orderId/asd,并抽出 orderId
|
|
359
|
+
*/
|
|
360
|
+
function normalizeUrl(rawUrl) {
|
|
361
|
+
let u = String(rawUrl == null ? '' : rawUrl).trim();
|
|
362
|
+
if (!u) throw new Error('api 路径不能为空');
|
|
363
|
+
|
|
364
|
+
const hostMatch = /^[a-z][a-z0-9+.\-]*:\/\/[^/]+(\/.*)?$/i.exec(u);
|
|
365
|
+
if (hostMatch) u = hostMatch[1] || '/';
|
|
366
|
+
if (!u.startsWith('/')) u = '/' + u;
|
|
367
|
+
|
|
368
|
+
const pathParams = [];
|
|
369
|
+
u = u.replace(PATH_PARAM_RE, (_, key) => {
|
|
370
|
+
if (!pathParams.includes(key)) pathParams.push(key);
|
|
371
|
+
return key;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (/\s/.test(u)) throw new Error(`api 路径 "${u}" 含空白字符,不符合路径规范`);
|
|
375
|
+
|
|
376
|
+
return { url: u, pathParams };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 只保留路径段允许的字符 */
|
|
380
|
+
function sanitizeSegment(s) {
|
|
381
|
+
return String(s == null ? '' : s).replace(/[^A-Za-z0-9\-_.~]/g, '');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function applyUrlSuffix(url, suffix) {
|
|
385
|
+
const base = String(url == null ? '' : url).replace(/\/+$/, '');
|
|
386
|
+
const seg = sanitizeSegment(suffix);
|
|
387
|
+
return seg ? base + '/' + seg : base;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* api 名称里的英文片段 → 小驼峰
|
|
392
|
+
* - get_user_info → getUserInfo
|
|
393
|
+
* - getUserInfo → getUserInfo(已是驼峰就原样保留,不要压平)
|
|
394
|
+
* - 纯中文 → ''(无法提取)
|
|
395
|
+
*/
|
|
396
|
+
function asciiSlug(name) {
|
|
397
|
+
const parts = String(name == null ? '' : name)
|
|
398
|
+
.split(/[^A-Za-z0-9]+/)
|
|
399
|
+
.filter(Boolean);
|
|
400
|
+
if (!parts.length) return '';
|
|
401
|
+
|
|
402
|
+
// 只有一段且本身带大写,说明已经是驼峰,原样保留
|
|
403
|
+
if (parts.length === 1) {
|
|
404
|
+
const only = parts[0];
|
|
405
|
+
if (only.replace(/[^A-Za-z]/g, '').length < 2) return '';
|
|
406
|
+
return /[A-Z]/.test(only) ? only : only.toLowerCase();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const words = parts.map((w) => w.toLowerCase());
|
|
410
|
+
if (words.join('').replace(/[^A-Za-z]/g, '').length < 2) return '';
|
|
411
|
+
return words[0] + words.slice(1).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** 路径冲突时的候选后缀:优先用 api 名称里的英文,其次语义化单词池 */
|
|
415
|
+
const FALLBACK_WORDS = ['query', 'list', 'data', 'detail', 'search', 'sync', 'info', 'fetch', 'exec', 'invoke'];
|
|
416
|
+
|
|
417
|
+
function buildRetrySuffix(apiName, attempt) {
|
|
418
|
+
const n = Math.max(1, Number(attempt) || 1);
|
|
419
|
+
const slug = asciiSlug(apiName);
|
|
420
|
+
if (n === 1 && slug) return slug;
|
|
421
|
+
const idx = slug ? n - 2 : n - 1;
|
|
422
|
+
if (idx >= 0 && idx < FALLBACK_WORDS.length) return FALLBACK_WORDS[idx];
|
|
423
|
+
return 'v' + n;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 第 attempt 次发布使用的 url。
|
|
428
|
+
* attempt=1 用发布准备返回的 url;之后在「原始 url」后追加后缀(不是层层叠加)。
|
|
429
|
+
*/
|
|
430
|
+
function urlForAttempt(baseUrl, apiName, attempt, explicitSuffix) {
|
|
431
|
+
const n = Math.max(1, Number(attempt) || 1);
|
|
432
|
+
if (n <= 1) return String(baseUrl == null ? '' : baseUrl);
|
|
433
|
+
const suffix = n === 2 && explicitSuffix ? explicitSuffix : buildRetrySuffix(apiName, n - 1);
|
|
434
|
+
return applyUrlSuffix(baseUrl, suffix);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------- 命令行入参解析
|
|
438
|
+
|
|
439
|
+
/** 'a=1&b=2' / ['a=1','b=2'] / '?a=1' → ['a','b'] */
|
|
440
|
+
function parseQueryKeys(raw) {
|
|
441
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
442
|
+
const keys = [];
|
|
443
|
+
for (const item of items) {
|
|
444
|
+
const s = String(item == null ? '' : item).trim().replace(/^\?/, '');
|
|
445
|
+
if (!s) continue;
|
|
446
|
+
for (const part of s.split('&')) {
|
|
447
|
+
const k = part.split('=')[0].trim();
|
|
448
|
+
if (k && !keys.includes(k)) keys.push(k);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return keys;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 'e: 2' / ['e: 2','x=1'] → ['e','x'];Content-Type 自动忽略 */
|
|
455
|
+
function parseHeaderKeys(raw) {
|
|
456
|
+
const items = Array.isArray(raw) ? raw : [raw];
|
|
457
|
+
const keys = [];
|
|
458
|
+
for (const item of items) {
|
|
459
|
+
const s = String(item == null ? '' : item).trim();
|
|
460
|
+
if (!s) continue;
|
|
461
|
+
const sep = s.search(/[:=]/);
|
|
462
|
+
const k = (sep > 0 ? s.slice(0, sep) : s).trim();
|
|
463
|
+
if (!k) continue;
|
|
464
|
+
if (IGNORED_HEADERS.includes(k.toLowerCase())) continue;
|
|
465
|
+
if (!keys.includes(k)) keys.push(k);
|
|
466
|
+
}
|
|
467
|
+
return keys;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 'number' / '1' / '' → contentType;GET 默认 0,其余默认 1 */
|
|
471
|
+
function parseContentType(raw, method) {
|
|
472
|
+
const v = String(raw == null ? '' : raw).trim();
|
|
473
|
+
if (!v) return String(method || '').toUpperCase() === 'GET' ? 0 : 1;
|
|
474
|
+
const n = Number(v);
|
|
475
|
+
if (!Number.isInteger(n) || n < 0 || n > 6) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
`contentType "${v}" 不合法:0=none 1=json 2=soap+xml 3=xml 4=form 5=multipart 6=octet-stream`
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return n;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 示例转字符串(paramExample / resultExample 平台存的是文本) */
|
|
484
|
+
function prettyExample(value) {
|
|
485
|
+
if (value === undefined || value === null) return '';
|
|
486
|
+
if (typeof value === 'string') return value;
|
|
487
|
+
return JSON.stringify(value, null, 4);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function pickOutboundApiId(data) {
|
|
491
|
+
if (data === null || data === undefined || data === '') return '';
|
|
492
|
+
if (typeof data === 'string' || typeof data === 'number') return String(data);
|
|
493
|
+
if (typeof data === 'object') {
|
|
494
|
+
return String(data.outboundApiId || data.apiId || data.id || '');
|
|
495
|
+
}
|
|
496
|
+
return '';
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------- 组装
|
|
500
|
+
|
|
501
|
+
function buildSaveBody(v) {
|
|
502
|
+
const head = v.outboundApiId
|
|
503
|
+
? { platformId: v.platformId, outboundApiId: v.outboundApiId, id: v.outboundApiId }
|
|
504
|
+
: { platformId: v.platformId };
|
|
505
|
+
|
|
506
|
+
return Object.assign(head, {
|
|
507
|
+
dynamicApi: true,
|
|
508
|
+
apiName: v.apiName,
|
|
509
|
+
serviceName: v.serviceName || '',
|
|
510
|
+
url: v.url,
|
|
511
|
+
httpMethod: v.httpMethod,
|
|
512
|
+
platformHostId: v.platformHostId,
|
|
513
|
+
remark: v.remark,
|
|
514
|
+
apiType: 0,
|
|
515
|
+
devStatus: 0,
|
|
516
|
+
execType: 0,
|
|
517
|
+
contentType: v.contentType,
|
|
518
|
+
outboundApiParamList: v.outboundApiParamList,
|
|
519
|
+
deleteParamList: [],
|
|
520
|
+
paramExample: v.paramExample || '',
|
|
521
|
+
outboundApiResultList: v.outboundApiResultList,
|
|
522
|
+
deleteResultList: [],
|
|
523
|
+
resultExample: v.resultExample || '',
|
|
524
|
+
httpCodeList: []
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------- 展示
|
|
529
|
+
|
|
530
|
+
function renderTree(nodes, indent) {
|
|
531
|
+
const pad = ' '.repeat(indent || 0);
|
|
532
|
+
const lines = [];
|
|
533
|
+
for (const n of nodes || []) {
|
|
534
|
+
const meta = [n.fieldType];
|
|
535
|
+
if (n.collective) meta.push('数组');
|
|
536
|
+
if (n.fieldLength) meta.push(`长度${n.fieldLength}`);
|
|
537
|
+
// labelName 为「必填 xxx」时不再重复标必填,避免出现「必填 必填 数字」
|
|
538
|
+
if (n.mandatory && !/^必填/.test(String(n.labelName || ''))) meta.push('必填');
|
|
539
|
+
if (n.labelName) meta.push(n.labelName);
|
|
540
|
+
lines.push(`${pad}${n.fieldCode} (${meta.join(' ')})`);
|
|
541
|
+
if (n.children && n.children.length) lines.push(...renderTree(n.children, (indent || 0) + 1));
|
|
542
|
+
}
|
|
543
|
+
return lines;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
module.exports = {
|
|
547
|
+
STRING_LENGTH,
|
|
548
|
+
ROOT_LABEL,
|
|
549
|
+
ROOT_PARAM,
|
|
550
|
+
ROOT_BODY,
|
|
551
|
+
ROOT_HEADER,
|
|
552
|
+
ROOT_RESULT,
|
|
553
|
+
PARAM_TYPE_PARAM,
|
|
554
|
+
PARAM_TYPE_BODY,
|
|
555
|
+
PARAM_TYPE_HEADER,
|
|
556
|
+
IGNORED_HEADERS,
|
|
557
|
+
FALLBACK_WORDS,
|
|
558
|
+
MAX_LABEL_LENGTH,
|
|
559
|
+
LABEL_SCOPES,
|
|
560
|
+
parseJsonText,
|
|
561
|
+
classifyValue,
|
|
562
|
+
clipLabel,
|
|
563
|
+
normalizeLabels,
|
|
564
|
+
isNormalizedLabels,
|
|
565
|
+
toEntries,
|
|
566
|
+
buildParamTree,
|
|
567
|
+
buildResultTree,
|
|
568
|
+
normalizeUrl,
|
|
569
|
+
sanitizeSegment,
|
|
570
|
+
applyUrlSuffix,
|
|
571
|
+
asciiSlug,
|
|
572
|
+
buildRetrySuffix,
|
|
573
|
+
urlForAttempt,
|
|
574
|
+
parseQueryKeys,
|
|
575
|
+
parseHeaderKeys,
|
|
576
|
+
parseContentType,
|
|
577
|
+
prettyExample,
|
|
578
|
+
pickOutboundApiId,
|
|
579
|
+
buildSaveBody,
|
|
580
|
+
renderTree,
|
|
581
|
+
toList
|
|
582
|
+
};
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** 终端显示宽度:CJK 字符占 2 列,否则中文表格会错行 */
|
|
4
|
+
function dispWidth(s) {
|
|
5
|
+
let w = 0;
|
|
6
|
+
for (const ch of String(s == null ? '' : s)) {
|
|
7
|
+
w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch)
|
|
8
|
+
? 2
|
|
9
|
+
: 1;
|
|
10
|
+
}
|
|
11
|
+
return w;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pad(s, n) {
|
|
15
|
+
const str = String(s == null ? '' : s);
|
|
16
|
+
const w = dispWidth(str);
|
|
17
|
+
return w >= n ? str : str + ' '.repeat(n - w);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 渲染对齐表格。
|
|
22
|
+
* @param {string[]} header 表头
|
|
23
|
+
* @param {string[][]} rows 数据行(已转字符串)
|
|
24
|
+
* @returns {string} 完整表格文本(含表头、分隔线)
|
|
25
|
+
*/
|
|
26
|
+
function renderTable(header, rows) {
|
|
27
|
+
if (!rows.length) return '';
|
|
28
|
+
const widths = header.map((h, ci) => Math.max(dispWidth(h), ...rows.map((row) => dispWidth(row[ci]))));
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(header.map((h, i) => pad(h, widths[i])).join(' '));
|
|
31
|
+
lines.push(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
lines.push(row.map((c, i) => pad(c, widths[i])).join(' '));
|
|
34
|
+
}
|
|
35
|
+
return lines.join('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 按显示宽度截断,超出用省略号收尾。
|
|
40
|
+
* 用于列宽失控的场景(如工具名可能长达上百列),避免整表错行、刷屏。
|
|
41
|
+
*/
|
|
42
|
+
function truncate(s, max) {
|
|
43
|
+
const str = String(s == null ? '' : s);
|
|
44
|
+
const limit = Math.max(1, Number(max) || 1);
|
|
45
|
+
if (dispWidth(str) <= limit) return str;
|
|
46
|
+
let out = '';
|
|
47
|
+
let w = 0;
|
|
48
|
+
for (const ch of str) {
|
|
49
|
+
const cw = dispWidth(ch);
|
|
50
|
+
if (w + cw > limit - 1) break;
|
|
51
|
+
out += ch;
|
|
52
|
+
w += cw;
|
|
53
|
+
}
|
|
54
|
+
return out + '…';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { dispWidth, pad, truncate, renderTable };
|