apiskill 0.1.0
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/MCP.md +12 -0
- package/README.ja.md +108 -0
- package/README.ko.md +108 -0
- package/README.md +119 -0
- package/README.zh.md +119 -0
- package/dist/assets/index-DH0wsJCI.js +299 -0
- package/dist/assets/index-vocUDpcf.css +1 -0
- package/dist/index.html +13 -0
- package/docs/cli.ja.md +90 -0
- package/docs/cli.ko.md +90 -0
- package/docs/cli.md +117 -0
- package/docs/cli.zh.md +117 -0
- package/docs/mcp.ja.md +79 -0
- package/docs/mcp.ko.md +79 -0
- package/docs/mcp.md +79 -0
- package/docs/mcp.zh.md +79 -0
- package/docs/web.ja.md +44 -0
- package/docs/web.ko.md +44 -0
- package/docs/web.md +57 -0
- package/docs/web.zh.md +57 -0
- package/index.html +12 -0
- package/mcp-config.example.json +13 -0
- package/package.json +44 -0
- package/scripts/apiskill-cli.mjs +372 -0
- package/scripts/lib/apiskill-core.mjs +520 -0
- package/scripts/lib/mock-server.mjs +262 -0
- package/scripts/lib/openapi-importer.mjs +169 -0
- package/scripts/lib/openapi-store.mjs +542 -0
- package/scripts/mcp-server.mjs +408 -0
- package/skills/apiskill/SKILL.md +71 -0
- package/skills/apiskill/agents/openai.yaml +4 -0
- package/src/AddApiDialog.tsx +590 -0
- package/src/App.tsx +2570 -0
- package/src/DocumentVersionManager.tsx +264 -0
- package/src/main.tsx +10 -0
- package/src/manualApiConfig.ts +401 -0
- package/src/styles.css +2101 -0
- package/src/swagger.ts +664 -0
- package/src/types.ts +115 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +1380 -0
package/vite.config.ts
ADDED
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { request as httpRequest } from 'node:http';
|
|
8
|
+
import { request as httpsRequest } from 'node:https';
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
import {
|
|
11
|
+
applyManualApiOperationConfig,
|
|
12
|
+
createManualApiDocument,
|
|
13
|
+
deleteManualApiOperation,
|
|
14
|
+
normalizeManualApiOperationConfig,
|
|
15
|
+
openApiStats,
|
|
16
|
+
} from './src/manualApiConfig';
|
|
17
|
+
import { startMockServer } from './scripts/lib/mock-server.mjs';
|
|
18
|
+
|
|
19
|
+
const appRootDir = process.env.APISKILL_ROOT || process.cwd();
|
|
20
|
+
const cacheDir = process.env.APISKILL_CACHE_DIR || resolve(appRootDir, 'cache');
|
|
21
|
+
const versionsDir = resolve(cacheDir, 'versions');
|
|
22
|
+
const latestMetaPath = resolve(cacheDir, 'latest-import.json');
|
|
23
|
+
const legacyCachePath = resolve(cacheDir, 'openapi-cache.json');
|
|
24
|
+
const legacyCacheMetaPath = resolve(cacheDir, 'import-meta.json');
|
|
25
|
+
|
|
26
|
+
type ImportAuth = {
|
|
27
|
+
type: 'none' | 'basic';
|
|
28
|
+
username?: string;
|
|
29
|
+
password?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type SwaggerResource = {
|
|
33
|
+
name?: string;
|
|
34
|
+
location?: string;
|
|
35
|
+
url?: string;
|
|
36
|
+
swaggerVersion?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default defineConfig({
|
|
40
|
+
plugins: [openapiImportPlugin(), react()],
|
|
41
|
+
server: {
|
|
42
|
+
port: 8888,
|
|
43
|
+
strictPort: false, // 如果端口被占用,会自动尝试下一个可用端口
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function openapiImportPlugin() {
|
|
48
|
+
let mockServerState: Awaited<ReturnType<typeof startMockServer>> | undefined;
|
|
49
|
+
|
|
50
|
+
async function stopMockServer() {
|
|
51
|
+
if (!mockServerState) return;
|
|
52
|
+
const current = mockServerState;
|
|
53
|
+
mockServerState = undefined;
|
|
54
|
+
await new Promise<void>((resolveStop) => current.server.close(() => resolveStop()));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: 'apiskill-openapi-importer',
|
|
59
|
+
configureServer(server) {
|
|
60
|
+
server.httpServer?.once('close', () => {
|
|
61
|
+
void stopMockServer();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
server.middlewares.use('/api/openapi/cache', async (req, res) => {
|
|
65
|
+
if (req.method !== 'GET') {
|
|
66
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const versionId = new URL(req.url || '', 'http://localhost').searchParams.get('versionId') || undefined;
|
|
72
|
+
const payload = await readCachedDocument(versionId);
|
|
73
|
+
sendJson(res, 200, payload);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message = error instanceof Error ? error.message : 'Read cache failed';
|
|
76
|
+
sendJson(res, message.includes('No cached') ? 404 : 500, { error: message });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
server.middlewares.use('/api/openapi/versions', async (req, res) => {
|
|
81
|
+
if (req.method !== 'GET') {
|
|
82
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
sendJson(res, 200, { versions: await listCachedVersions() });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : 'Read versions failed' });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
server.middlewares.use('/api/openapi/version-select', async (req, res) => {
|
|
94
|
+
if (req.method !== 'POST') {
|
|
95
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const body = (await readJsonBody(req)) as { versionId?: string };
|
|
101
|
+
const versionId = requiredText(body.versionId, '请选择要切换的文档版本');
|
|
102
|
+
const payload = await readCachedDocument(versionId);
|
|
103
|
+
await writeFile(latestMetaPath, JSON.stringify(payload.meta, null, 2));
|
|
104
|
+
sendJson(res, 200, { ...payload, versions: await listCachedVersions() });
|
|
105
|
+
} catch (error) {
|
|
106
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Select version failed' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
server.middlewares.use('/api/openapi/version-meta', async (req, res) => {
|
|
111
|
+
if (req.method !== 'POST') {
|
|
112
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const body = (await readJsonBody(req)) as { versionId?: string; environmentName?: unknown; environmentBaseUrl?: unknown };
|
|
118
|
+
const versionId = requiredText(body.versionId, '请选择要更新的文档版本');
|
|
119
|
+
assertSafeVersionId(versionId);
|
|
120
|
+
const metaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
121
|
+
if (!existsSync(metaPath)) throw new Error('缓存版本不存在');
|
|
122
|
+
|
|
123
|
+
const currentMeta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
124
|
+
const meta = {
|
|
125
|
+
...currentMeta,
|
|
126
|
+
environmentName: optionalText(body.environmentName).slice(0, 80),
|
|
127
|
+
environmentBaseUrl: normalizeOptionalBaseUrl(body.environmentBaseUrl),
|
|
128
|
+
environmentUpdatedAt: new Date().toISOString(),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2));
|
|
132
|
+
const latestMeta = await readLatestMeta();
|
|
133
|
+
if (latestMeta?.versionId === versionId) {
|
|
134
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
135
|
+
}
|
|
136
|
+
sendJson(res, 200, { meta, versions: await listCachedVersions() });
|
|
137
|
+
} catch (error) {
|
|
138
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Update version meta failed' });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.middlewares.use('/api/openapi/version-delete', async (req, res) => {
|
|
143
|
+
if (req.method !== 'POST') {
|
|
144
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const body = (await readJsonBody(req)) as { versionId?: string };
|
|
150
|
+
const versionId = requiredText(body.versionId, '请选择要删除的文档版本');
|
|
151
|
+
assertSafeVersionId(versionId);
|
|
152
|
+
const metaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
153
|
+
const versionPath = resolve(versionsDir, `${versionId}.json`);
|
|
154
|
+
if (!existsSync(metaPath) && !existsSync(versionPath)) throw new Error('缓存版本不存在');
|
|
155
|
+
|
|
156
|
+
await rm(metaPath, { force: true });
|
|
157
|
+
await rm(versionPath, { force: true });
|
|
158
|
+
|
|
159
|
+
const latestMeta = await readLatestMeta();
|
|
160
|
+
const versions = await listCachedVersions();
|
|
161
|
+
let nextVersionId = '';
|
|
162
|
+
if (latestMeta?.versionId === versionId || !latestMeta?.versionId) {
|
|
163
|
+
const nextLatest = versions[0];
|
|
164
|
+
if (nextLatest) {
|
|
165
|
+
await writeFile(latestMetaPath, JSON.stringify(nextLatest, null, 2));
|
|
166
|
+
nextVersionId = nextLatest.versionId || '';
|
|
167
|
+
} else {
|
|
168
|
+
await rm(latestMetaPath, { force: true });
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
nextVersionId = latestMeta.versionId || versions[0]?.versionId || '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
sendJson(res, 200, { versions, nextVersionId });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Delete version failed' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
server.middlewares.use('/api/openapi/version-export', async (req, res) => {
|
|
181
|
+
if (req.method !== 'GET') {
|
|
182
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const versionId = new URL(req.url || '', 'http://localhost').searchParams.get('versionId') || '';
|
|
188
|
+
const payload = await readCachedDocument(requiredText(versionId, '请选择要导出的文档版本'));
|
|
189
|
+
const safeVersionId = payload.meta?.versionId || versionId;
|
|
190
|
+
res.statusCode = 200;
|
|
191
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
192
|
+
res.setHeader('content-disposition', `attachment; filename="${safeVersionId}.json"`);
|
|
193
|
+
res.end(JSON.stringify(payload.document, null, 2));
|
|
194
|
+
} catch (error) {
|
|
195
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Export version failed' });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
server.middlewares.use('/api/openapi/auth-check', async (req, res) => {
|
|
200
|
+
if (req.method !== 'POST') {
|
|
201
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const body = (await readJsonBody(req)) as { url?: string; mode?: 'file' | 'crawl' | 'curl' };
|
|
207
|
+
const sourceUrl = normalizeUrl(body.url);
|
|
208
|
+
const mode = body.mode === 'crawl' ? 'crawl' : 'file';
|
|
209
|
+
const result = await checkDocumentAuth(sourceUrl, mode);
|
|
210
|
+
sendJson(res, 200, result);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Auth check failed' });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
server.middlewares.use('/api/mock/status', async (req, res) => {
|
|
217
|
+
if (req.method !== 'GET') {
|
|
218
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
sendJson(res, 200, {
|
|
223
|
+
running: Boolean(mockServerState),
|
|
224
|
+
mock: mockServerState ? mockInfo(mockServerState) : undefined,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
server.middlewares.use('/api/mock/start', async (req, res) => {
|
|
229
|
+
if (req.method !== 'POST') {
|
|
230
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const body = (await readJsonBody(req)) as { versionId?: string; port?: unknown; host?: unknown };
|
|
236
|
+
const payload = await readCachedDocument(optionalText(body.versionId) || undefined);
|
|
237
|
+
await stopMockServer();
|
|
238
|
+
mockServerState = await startMockServer({
|
|
239
|
+
document: payload.document,
|
|
240
|
+
meta: payload.meta,
|
|
241
|
+
host: optionalText(body.host) || '127.0.0.1',
|
|
242
|
+
port: Number(body.port) || 4010,
|
|
243
|
+
});
|
|
244
|
+
sendJson(res, 200, { running: true, mock: mockInfo(mockServerState) });
|
|
245
|
+
} catch (error) {
|
|
246
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Start mock server failed' });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
server.middlewares.use('/api/mock/stop', async (req, res) => {
|
|
251
|
+
if (req.method !== 'POST') {
|
|
252
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await stopMockServer();
|
|
257
|
+
sendJson(res, 200, { running: false });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
server.middlewares.use('/api/openapi/import', async (req, res) => {
|
|
261
|
+
if (req.method !== 'POST') {
|
|
262
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const body = (await readJsonBody(req)) as {
|
|
268
|
+
url?: string;
|
|
269
|
+
mode?: 'file' | 'crawl' | 'curl' | 'upload';
|
|
270
|
+
auth?: unknown;
|
|
271
|
+
content?: unknown;
|
|
272
|
+
versionId?: string;
|
|
273
|
+
};
|
|
274
|
+
const mode = body.mode === 'crawl' ? 'crawl' : body.mode === 'curl' ? 'curl' : body.mode === 'upload' ? 'upload' : 'file';
|
|
275
|
+
const sourceInput = requiredText(
|
|
276
|
+
body.url,
|
|
277
|
+
mode === 'curl' ? '请输入 CURL 命令' : mode === 'upload' ? '请选择 JSON 或 YAML 文件' : '请输入文档地址',
|
|
278
|
+
);
|
|
279
|
+
const existing = body.versionId ? await readCachedDocument(body.versionId) : undefined;
|
|
280
|
+
const sourceUrl = mode === 'curl' || mode === 'upload' ? sourceInput : normalizeUrl(sourceInput);
|
|
281
|
+
const auth = normalizeImportAuth(body.auth);
|
|
282
|
+
const result =
|
|
283
|
+
mode === 'crawl'
|
|
284
|
+
? await crawlOpenApi(sourceUrl, auth)
|
|
285
|
+
: mode === 'curl'
|
|
286
|
+
? await fetchOpenApiFromCurl(sourceInput)
|
|
287
|
+
: mode === 'upload'
|
|
288
|
+
? parseOpenApiFromUpload(requiredText(body.content, '上传文件内容为空'), sourceInput)
|
|
289
|
+
: await fetchOpenApiJson(sourceUrl, auth);
|
|
290
|
+
const savedAt = new Date();
|
|
291
|
+
const versionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-${mode}-${slugify(resultSourceName(result.url, sourceInput))}`;
|
|
292
|
+
const versionPath = existing?.meta?.savedPath || resolve(versionsDir, `${versionId}.json`);
|
|
293
|
+
const versionMetaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
294
|
+
const meta = {
|
|
295
|
+
...(existing?.meta ?? {}),
|
|
296
|
+
versionId,
|
|
297
|
+
mode,
|
|
298
|
+
inputUrl: sourceInput,
|
|
299
|
+
resolvedUrl: result.url,
|
|
300
|
+
title: result.document.info?.title || '',
|
|
301
|
+
version: result.document.info?.version || '',
|
|
302
|
+
paths: Object.keys(result.document.paths ?? {}).length,
|
|
303
|
+
schemas: Object.keys(result.document.components?.schemas ?? result.document.definitions ?? {}).length,
|
|
304
|
+
savedFile: basename(versionPath),
|
|
305
|
+
savedPath: versionPath,
|
|
306
|
+
savedAt: savedAt.toISOString(),
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
310
|
+
await writeFile(versionPath, JSON.stringify(result.document, null, 2));
|
|
311
|
+
await writeFile(versionMetaPath, JSON.stringify(meta, null, 2));
|
|
312
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
313
|
+
sendJson(res, 200, { document: result.document, meta, versions: await listCachedVersions() });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Import failed' });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
server.middlewares.use('/api/openapi/document', async (req, res) => {
|
|
320
|
+
if (req.method !== 'POST') {
|
|
321
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const body = (await readJsonBody(req)) as {
|
|
327
|
+
title?: unknown;
|
|
328
|
+
version?: unknown;
|
|
329
|
+
description?: unknown;
|
|
330
|
+
environmentName?: unknown;
|
|
331
|
+
environmentBaseUrl?: unknown;
|
|
332
|
+
versionId?: string;
|
|
333
|
+
};
|
|
334
|
+
const title = requiredText(body.title, '请输入文档名称');
|
|
335
|
+
const existing = body.versionId ? await readCachedDocument(body.versionId) : undefined;
|
|
336
|
+
const document = createManualApiDocument(title);
|
|
337
|
+
document.info.version = optionalText(body.version) || '1.0.0';
|
|
338
|
+
document.info.description = optionalText(body.description) || 'Created from API Skill blank document.';
|
|
339
|
+
const savedAt = new Date();
|
|
340
|
+
const versionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-document-${slugify(title)}`;
|
|
341
|
+
const versionPath = existing?.meta?.savedPath || resolve(versionsDir, `${versionId}.json`);
|
|
342
|
+
const versionMetaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
343
|
+
const stats = openApiStats(document);
|
|
344
|
+
const meta = {
|
|
345
|
+
...(existing?.meta ?? {}),
|
|
346
|
+
versionId,
|
|
347
|
+
mode: 'document',
|
|
348
|
+
inputUrl: 'manual-document',
|
|
349
|
+
resolvedUrl: 'manual-document',
|
|
350
|
+
title: document.info?.title || '',
|
|
351
|
+
version: document.info?.version || '',
|
|
352
|
+
paths: stats.paths,
|
|
353
|
+
schemas: stats.schemas,
|
|
354
|
+
environmentName:
|
|
355
|
+
body.environmentName === undefined ? existing?.meta?.environmentName || '' : optionalText(body.environmentName).slice(0, 80),
|
|
356
|
+
environmentBaseUrl:
|
|
357
|
+
body.environmentBaseUrl === undefined ? existing?.meta?.environmentBaseUrl || '' : normalizeOptionalBaseUrl(body.environmentBaseUrl),
|
|
358
|
+
savedFile: basename(versionPath),
|
|
359
|
+
savedPath: versionPath,
|
|
360
|
+
savedAt: savedAt.toISOString(),
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
364
|
+
await writeFile(versionPath, JSON.stringify(document, null, 2));
|
|
365
|
+
await writeFile(versionMetaPath, JSON.stringify(meta, null, 2));
|
|
366
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
367
|
+
sendJson(res, 200, { document, meta, versions: await listCachedVersions() });
|
|
368
|
+
} catch (error) {
|
|
369
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Create document failed' });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
server.middlewares.use('/api/openapi/custom-operation', async (req, res, next) => {
|
|
374
|
+
if (new URL(req.url || '/', 'http://localhost').pathname !== '/') {
|
|
375
|
+
next();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (req.method !== 'POST') {
|
|
380
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const body = (await readJsonBody(req)) as { versionId?: string; config?: unknown; replaceTarget?: { method?: string; path?: string } };
|
|
386
|
+
const config = normalizeManualApiOperationConfig(body.config);
|
|
387
|
+
const existing = body.versionId ? await readCachedDocument(body.versionId) : undefined;
|
|
388
|
+
let baseDocument = existing?.document ?? createManualApiDocument();
|
|
389
|
+
if (body.replaceTarget?.method && body.replaceTarget?.path) {
|
|
390
|
+
const method = body.replaceTarget.method.toLowerCase();
|
|
391
|
+
if (['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].includes(method)) {
|
|
392
|
+
baseDocument = deleteManualApiOperation(baseDocument, method as 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head', body.replaceTarget.path);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const document = applyManualApiOperationConfig(baseDocument, config);
|
|
396
|
+
const savedAt = new Date();
|
|
397
|
+
const versionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-manual-${slugify(config.operationId || config.summary || config.path)}`;
|
|
398
|
+
const versionPath = existing?.meta?.savedPath || resolve(versionsDir, `${versionId}.json`);
|
|
399
|
+
const versionMetaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
400
|
+
const stats = openApiStats(document);
|
|
401
|
+
const meta = {
|
|
402
|
+
...(existing?.meta ?? {}),
|
|
403
|
+
versionId,
|
|
404
|
+
mode: existing?.meta?.mode || 'manual',
|
|
405
|
+
inputUrl: existing?.meta?.inputUrl || 'manual-api-config',
|
|
406
|
+
resolvedUrl: existing?.meta?.resolvedUrl || 'manual-api-config',
|
|
407
|
+
title: document.info?.title || 'Manual API Config',
|
|
408
|
+
version: document.info?.version || 'manual',
|
|
409
|
+
paths: stats.paths,
|
|
410
|
+
schemas: stats.schemas,
|
|
411
|
+
savedFile: basename(versionPath),
|
|
412
|
+
savedPath: versionPath,
|
|
413
|
+
savedAt: savedAt.toISOString(),
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
417
|
+
await writeFile(versionPath, JSON.stringify(document, null, 2));
|
|
418
|
+
await writeFile(versionMetaPath, JSON.stringify(meta, null, 2));
|
|
419
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
420
|
+
sendJson(res, 200, { document, meta });
|
|
421
|
+
} catch (error) {
|
|
422
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Save custom operation failed' });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
server.middlewares.use('/api/openapi/operation-links', async (req, res) => {
|
|
427
|
+
if (req.method !== 'POST') {
|
|
428
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const body = (await readJsonBody(req)) as { versionId?: string; method?: string; path?: string; links?: unknown };
|
|
434
|
+
const versionId = requiredText(body.versionId, '请选择要更新的文档版本');
|
|
435
|
+
const method = requiredText(body.method, '请选择请求方法').toLowerCase();
|
|
436
|
+
if (!['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].includes(method)) {
|
|
437
|
+
throw new Error('请求方法无效');
|
|
438
|
+
}
|
|
439
|
+
const path = requiredText(body.path, '接口路径不能为空');
|
|
440
|
+
const existing = await readCachedDocument(versionId);
|
|
441
|
+
const document = JSON.parse(JSON.stringify(existing.document));
|
|
442
|
+
const operation = document.paths?.[path]?.[method];
|
|
443
|
+
if (!operation) throw new Error('接口不存在,无法保存关联配置');
|
|
444
|
+
|
|
445
|
+
operation['x-apiskill-links'] = normalizeOperationLinks(body.links);
|
|
446
|
+
|
|
447
|
+
const savedAt = new Date();
|
|
448
|
+
const versionPath = existing.meta?.savedPath || resolve(versionsDir, `${versionId}.json`);
|
|
449
|
+
const versionMetaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
450
|
+
const stats = openApiStats(document);
|
|
451
|
+
const meta = {
|
|
452
|
+
...(existing.meta ?? {}),
|
|
453
|
+
versionId,
|
|
454
|
+
title: document.info?.title || existing.meta?.title || '',
|
|
455
|
+
version: document.info?.version || existing.meta?.version || '',
|
|
456
|
+
paths: stats.paths,
|
|
457
|
+
schemas: stats.schemas,
|
|
458
|
+
savedFile: basename(versionPath),
|
|
459
|
+
savedPath: versionPath,
|
|
460
|
+
savedAt: savedAt.toISOString(),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
464
|
+
await writeFile(versionPath, JSON.stringify(document, null, 2));
|
|
465
|
+
await writeFile(versionMetaPath, JSON.stringify(meta, null, 2));
|
|
466
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
467
|
+
sendJson(res, 200, { document, meta, versions: await listCachedVersions() });
|
|
468
|
+
} catch (error) {
|
|
469
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Save operation links failed' });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
server.middlewares.use('/api/openapi/custom-operation/delete', async (req, res) => {
|
|
474
|
+
if (req.method !== 'POST') {
|
|
475
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const body = (await readJsonBody(req)) as { versionId?: string; method?: string; path?: string };
|
|
481
|
+
const versionId = requiredText(body.versionId, '请选择要修改的本地版本');
|
|
482
|
+
const method = requiredText(body.method, '请选择请求方法').toLowerCase();
|
|
483
|
+
if (!['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].includes(method)) {
|
|
484
|
+
throw new Error('请求方法无效');
|
|
485
|
+
}
|
|
486
|
+
const path = requiredText(body.path, '接口路径不能为空');
|
|
487
|
+
const existing = await readCachedDocument(versionId);
|
|
488
|
+
const document = deleteManualApiOperation(existing.document, method as 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options' | 'head', path);
|
|
489
|
+
const savedAt = new Date();
|
|
490
|
+
const versionPath = existing.meta?.savedPath || resolve(versionsDir, `${versionId}.json`);
|
|
491
|
+
const versionMetaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
492
|
+
const stats = openApiStats(document);
|
|
493
|
+
const meta = {
|
|
494
|
+
...(existing.meta ?? {}),
|
|
495
|
+
versionId,
|
|
496
|
+
title: document.info?.title || existing.meta?.title || '',
|
|
497
|
+
version: document.info?.version || existing.meta?.version || '',
|
|
498
|
+
paths: stats.paths,
|
|
499
|
+
schemas: stats.schemas,
|
|
500
|
+
savedFile: basename(versionPath),
|
|
501
|
+
savedPath: versionPath,
|
|
502
|
+
savedAt: savedAt.toISOString(),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
506
|
+
await writeFile(versionPath, JSON.stringify(document, null, 2));
|
|
507
|
+
await writeFile(versionMetaPath, JSON.stringify(meta, null, 2));
|
|
508
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
509
|
+
sendJson(res, 200, { document, meta });
|
|
510
|
+
} catch (error) {
|
|
511
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Delete custom operation failed' });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
server.middlewares.use('/api/request/send', async (req, res) => {
|
|
516
|
+
if (req.method !== 'POST') {
|
|
517
|
+
sendJson(res, 405, { error: 'Method Not Allowed' });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const body = (await readJsonBody(req)) as {
|
|
523
|
+
url?: string;
|
|
524
|
+
method?: string;
|
|
525
|
+
headers?: Record<string, string>;
|
|
526
|
+
body?: string;
|
|
527
|
+
};
|
|
528
|
+
const result = await sendApiRequest(body);
|
|
529
|
+
sendJson(res, 200, result);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : 'Request failed' });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function readCachedDocument(versionId?: string) {
|
|
539
|
+
if (versionId) {
|
|
540
|
+
assertSafeVersionId(versionId);
|
|
541
|
+
const metaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
542
|
+
if (!existsSync(metaPath)) throw new Error('No cached OpenAPI document');
|
|
543
|
+
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
544
|
+
const documentPath = typeof meta.savedPath === 'string' ? meta.savedPath : resolve(versionsDir, `${versionId}.json`);
|
|
545
|
+
const documentText = await readFile(documentPath, 'utf8');
|
|
546
|
+
return {
|
|
547
|
+
document: JSON.parse(documentText),
|
|
548
|
+
meta,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!existsSync(latestMetaPath) && !existsSync(legacyCachePath)) {
|
|
553
|
+
throw new Error('No cached OpenAPI document');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const meta = existsSync(latestMetaPath)
|
|
557
|
+
? JSON.parse(await readFile(latestMetaPath, 'utf8'))
|
|
558
|
+
: existsSync(legacyCacheMetaPath)
|
|
559
|
+
? JSON.parse(await readFile(legacyCacheMetaPath, 'utf8'))
|
|
560
|
+
: {};
|
|
561
|
+
const documentPath = typeof meta.savedPath === 'string' ? meta.savedPath : legacyCachePath;
|
|
562
|
+
const documentText = await readFile(documentPath, 'utf8');
|
|
563
|
+
return {
|
|
564
|
+
document: JSON.parse(documentText),
|
|
565
|
+
meta,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function listCachedVersions() {
|
|
570
|
+
if (!existsSync(versionsDir)) return [];
|
|
571
|
+
const files = await readdir(versionsDir);
|
|
572
|
+
const metas = await Promise.all(
|
|
573
|
+
files
|
|
574
|
+
.filter((file) => file.endsWith('.meta.json'))
|
|
575
|
+
.map(async (file) => {
|
|
576
|
+
try {
|
|
577
|
+
return JSON.parse(await readFile(resolve(versionsDir, file), 'utf8'));
|
|
578
|
+
} catch {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
}),
|
|
582
|
+
);
|
|
583
|
+
return metas.filter(Boolean).sort((a, b) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function readLatestMeta() {
|
|
587
|
+
if (!existsSync(latestMetaPath)) return undefined;
|
|
588
|
+
try {
|
|
589
|
+
return JSON.parse(await readFile(latestMetaPath, 'utf8'));
|
|
590
|
+
} catch {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function assertSafeVersionId(versionId: string) {
|
|
596
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(versionId)) throw new Error('Invalid version id');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function checkDocumentAuth(sourceUrl: string, mode: 'file' | 'crawl') {
|
|
600
|
+
const direct = await inspectDocumentUrl(sourceUrl, {
|
|
601
|
+
accept: mode === 'crawl' ? 'text/html,application/xhtml+xml,application/json,*/*' : 'application/json,text/plain,*/*',
|
|
602
|
+
userAgent: 'apiskill-openapi-auth-check/1.0',
|
|
603
|
+
timeoutMs: 5000,
|
|
604
|
+
});
|
|
605
|
+
const directAuth = authRequirementFromResponse(direct);
|
|
606
|
+
if (directAuth) return directAuth;
|
|
607
|
+
|
|
608
|
+
if (mode !== 'crawl' || direct.status < 200 || direct.status >= 300) {
|
|
609
|
+
return { requiresAuth: false, status: direct.status };
|
|
610
|
+
}
|
|
611
|
+
if (isOpenApiText(direct.text)) return { requiresAuth: false, status: direct.status };
|
|
612
|
+
|
|
613
|
+
const resourceProbe = await fetchKnife4jOpenApiUrls(direct.url || sourceUrl, { type: 'none' }, true);
|
|
614
|
+
if (resourceProbe.requiresAuth) {
|
|
615
|
+
return {
|
|
616
|
+
requiresAuth: true,
|
|
617
|
+
status: resourceProbe.status,
|
|
618
|
+
message: `Knife4j 分组接口返回 ${resourceProbe.status},需要输入用户名和密码后继续`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const candidates = discoverOpenApiUrls(direct.url || sourceUrl, direct.text);
|
|
623
|
+
for (const candidate of candidates.slice(0, 12)) {
|
|
624
|
+
const candidateResponse = await inspectDocumentUrl(candidate, {
|
|
625
|
+
accept: 'application/json,text/plain,*/*',
|
|
626
|
+
userAgent: 'apiskill-openapi-auth-check/1.0',
|
|
627
|
+
referer: direct.url || sourceUrl,
|
|
628
|
+
timeoutMs: 3000,
|
|
629
|
+
});
|
|
630
|
+
const candidateAuth = authRequirementFromResponse(candidateResponse);
|
|
631
|
+
if (candidateAuth) return candidateAuth;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { requiresAuth: false, status: direct.status };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function inspectDocumentUrl(
|
|
638
|
+
sourceUrl: string,
|
|
639
|
+
options: { accept: string; userAgent: string; referer?: string; timeoutMs?: number },
|
|
640
|
+
) {
|
|
641
|
+
try {
|
|
642
|
+
return await readUrlText(sourceUrl, options);
|
|
643
|
+
} catch {
|
|
644
|
+
return { status: 0, url: sourceUrl, text: '', contentType: '' };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function authRequirementFromResponse(response: { status: number; text: string; contentType: string }) {
|
|
649
|
+
if (response.status === 401 || response.status === 403) {
|
|
650
|
+
return {
|
|
651
|
+
requiresAuth: true,
|
|
652
|
+
status: response.status,
|
|
653
|
+
message: `该文档地址返回 ${response.status},需要输入用户名和密码后继续`,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (response.status >= 200 && response.status < 300 && looksLikeLoginPage(response.text, response.contentType)) {
|
|
658
|
+
return {
|
|
659
|
+
requiresAuth: true,
|
|
660
|
+
status: response.status,
|
|
661
|
+
message: '该文档地址打开后显示登录页,需要输入用户名和密码后继续',
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function looksLikeLoginPage(text: string, contentType: string) {
|
|
669
|
+
const normalizedContentType = contentType.toLowerCase();
|
|
670
|
+
const sample = text.slice(0, 20000).toLowerCase();
|
|
671
|
+
return (
|
|
672
|
+
normalizedContentType.includes('text/html') &&
|
|
673
|
+
/<input\b[^>]+type=["']?password/i.test(text) &&
|
|
674
|
+
(sample.includes('login') || sample.includes('username') || sample.includes('password') || sample.includes('登录') || sample.includes('密码'))
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function isOpenApiText(text: string) {
|
|
679
|
+
try {
|
|
680
|
+
return isOpenApiDocument(parseOpenApiText(text));
|
|
681
|
+
} catch {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function sendApiRequest(input: {
|
|
687
|
+
url?: string;
|
|
688
|
+
method?: string;
|
|
689
|
+
headers?: Record<string, string>;
|
|
690
|
+
body?: string;
|
|
691
|
+
}): Promise<{ status: number; statusText: string; headers: Record<string, string | string[]>; body: string; elapsedMs: number; url: string }> {
|
|
692
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
693
|
+
const targetUrl = normalizeUrl(input.url);
|
|
694
|
+
const url = new URL(targetUrl);
|
|
695
|
+
const method = (input.method || 'GET').toUpperCase();
|
|
696
|
+
const requestImpl = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
697
|
+
const start = Date.now();
|
|
698
|
+
const headers = Object.fromEntries(
|
|
699
|
+
Object.entries(input.headers ?? {}).filter(([key, value]) => key.trim() && String(value).trim()),
|
|
700
|
+
);
|
|
701
|
+
const requestBody = ['GET', 'HEAD'].includes(method) ? undefined : input.body;
|
|
702
|
+
|
|
703
|
+
const req = requestImpl(
|
|
704
|
+
url,
|
|
705
|
+
{
|
|
706
|
+
method,
|
|
707
|
+
rejectUnauthorized: false,
|
|
708
|
+
headers: requestBody && !headers['content-length']
|
|
709
|
+
? {
|
|
710
|
+
...headers,
|
|
711
|
+
'content-length': Buffer.byteLength(requestBody),
|
|
712
|
+
}
|
|
713
|
+
: headers,
|
|
714
|
+
},
|
|
715
|
+
(res) => {
|
|
716
|
+
const chunks: Buffer[] = [];
|
|
717
|
+
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
718
|
+
res.on('end', () => {
|
|
719
|
+
resolveRequest({
|
|
720
|
+
status: res.statusCode ?? 0,
|
|
721
|
+
statusText: res.statusMessage || '',
|
|
722
|
+
headers: normalizeHeaders(res.headers),
|
|
723
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
724
|
+
elapsedMs: Date.now() - start,
|
|
725
|
+
url: targetUrl,
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
},
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
req.setTimeout(30000, () => req.destroy(new Error('请求超时')));
|
|
732
|
+
req.on('error', rejectRequest);
|
|
733
|
+
if (requestBody) req.write(requestBody);
|
|
734
|
+
req.end();
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function mockInfo(mock: Awaited<ReturnType<typeof startMockServer>>) {
|
|
739
|
+
return {
|
|
740
|
+
url: mock.url,
|
|
741
|
+
host: mock.host,
|
|
742
|
+
port: mock.port,
|
|
743
|
+
versionId: mock.versionId,
|
|
744
|
+
title: mock.title,
|
|
745
|
+
routesCount: mock.routesCount,
|
|
746
|
+
routes: mock.routes.slice(0, 20),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function normalizeOperationLinks(input: unknown) {
|
|
751
|
+
const record = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
752
|
+
return {
|
|
753
|
+
prototype: normalizeAssociatedLink(record.prototype),
|
|
754
|
+
ui: normalizeAssociatedLink(record.ui),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function normalizeAssociatedLink(input: unknown) {
|
|
759
|
+
const record = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
760
|
+
const sourceType = optionalText(record.sourceType);
|
|
761
|
+
const authType = optionalText(record.authType);
|
|
762
|
+
return {
|
|
763
|
+
sourceType: sourceType === 'online' || sourceType === 'curl' ? sourceType : 'local',
|
|
764
|
+
title: optionalText(record.title).slice(0, 120),
|
|
765
|
+
url: optionalText(record.url),
|
|
766
|
+
curl: optionalText(record.curl),
|
|
767
|
+
authType: ['browser', 'bearer', 'header'].includes(authType) ? authType : 'none',
|
|
768
|
+
token: optionalText(record.token),
|
|
769
|
+
headerName: optionalText(record.headerName).slice(0, 80),
|
|
770
|
+
note: optionalText(record.note).slice(0, 1000),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function crawlOpenApi(sourceUrl: string, auth: ImportAuth) {
|
|
775
|
+
const direct = await tryFetchOpenApi(sourceUrl, auth, sourceUrl);
|
|
776
|
+
if (direct) return direct;
|
|
777
|
+
|
|
778
|
+
const response = await readUrlText(sourceUrl, {
|
|
779
|
+
accept: 'text/html,application/xhtml+xml,application/json,*/*',
|
|
780
|
+
userAgent: 'apiskill-openapi-crawler/1.0',
|
|
781
|
+
auth,
|
|
782
|
+
authOrigin: new URL(sourceUrl).origin,
|
|
783
|
+
});
|
|
784
|
+
if (response.status < 200 || response.status >= 300) throw new Error(`爬取页面失败: ${response.status}`);
|
|
785
|
+
|
|
786
|
+
const html = response.text;
|
|
787
|
+
const pageUrl = response.url || sourceUrl;
|
|
788
|
+
const knife4jCandidates = await fetchKnife4jOpenApiUrls(pageUrl, auth);
|
|
789
|
+
const scriptCandidates = await fetchScriptOpenApiUrls(pageUrl, html, auth);
|
|
790
|
+
const candidates = uniqueUrls([...knife4jCandidates.urls, ...scriptCandidates, ...discoverOpenApiUrls(pageUrl, html)]);
|
|
791
|
+
for (const url of candidates) {
|
|
792
|
+
const result = await tryFetchOpenApi(url, auth, pageUrl);
|
|
793
|
+
if (result) return result;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
throw new Error('未在页面中识别到可用的 OpenAPI/Swagger JSON 地址');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function fetchOpenApiJson(sourceUrl: string, auth: ImportAuth) {
|
|
800
|
+
const result = await tryFetchOpenApi(sourceUrl, auth, sourceUrl);
|
|
801
|
+
if (!result) throw new Error('该地址未返回有效的 OpenAPI/Swagger JSON 文档');
|
|
802
|
+
return result;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function fetchOpenApiFromCurl(curlText: string) {
|
|
806
|
+
const parsed = parseCurlCommand(curlText);
|
|
807
|
+
const response = await readTextRequest({
|
|
808
|
+
url: parsed.url,
|
|
809
|
+
method: parsed.method,
|
|
810
|
+
headers: parsed.headers,
|
|
811
|
+
body: parsed.body,
|
|
812
|
+
timeoutMs: 30000,
|
|
813
|
+
});
|
|
814
|
+
if (response.status < 200 || response.status >= 300) {
|
|
815
|
+
throw new Error(`CURL 请求返回 ${response.status}`);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const document = parseOpenApiText(response.text);
|
|
819
|
+
if (!isOpenApiDocument(document)) {
|
|
820
|
+
throw new Error('CURL 响应不是有效的 OpenAPI/Swagger JSON 或 YAML 文档');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return { url: response.url || parsed.url, document };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function parseOpenApiFromUpload(content: string, fileName: string) {
|
|
827
|
+
if (!/\.(json|ya?ml)$/i.test(fileName)) {
|
|
828
|
+
throw new Error('仅支持 JSON、YAML 或 YML 文件');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const document = parseOpenApiText(content);
|
|
832
|
+
if (!isOpenApiDocument(document)) {
|
|
833
|
+
throw new Error('上传文件不是有效的 OpenAPI/Swagger JSON 或 YAML 文档');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return { url: fileName, document };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function tryFetchOpenApi(sourceUrl: string, auth: ImportAuth, authSourceUrl: string) {
|
|
840
|
+
try {
|
|
841
|
+
const response = await readUrlText(sourceUrl, {
|
|
842
|
+
accept: 'application/json,text/plain,*/*',
|
|
843
|
+
userAgent: 'apiskill-openapi-importer/1.0',
|
|
844
|
+
referer: sourceUrl,
|
|
845
|
+
auth,
|
|
846
|
+
authOrigin: new URL(authSourceUrl).origin,
|
|
847
|
+
});
|
|
848
|
+
if (response.status < 200 || response.status >= 300) return undefined;
|
|
849
|
+
|
|
850
|
+
const document = parseOpenApiText(response.text);
|
|
851
|
+
if (!isOpenApiDocument(document)) return undefined;
|
|
852
|
+
return { url: response.url || sourceUrl, document };
|
|
853
|
+
} catch {
|
|
854
|
+
return undefined;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function readTextRequest(input: {
|
|
859
|
+
url: string;
|
|
860
|
+
method: string;
|
|
861
|
+
headers: Record<string, string>;
|
|
862
|
+
body?: string;
|
|
863
|
+
timeoutMs?: number;
|
|
864
|
+
}): Promise<{ status: number; url: string; text: string; contentType: string }> {
|
|
865
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
866
|
+
const url = new URL(input.url);
|
|
867
|
+
const requestImpl = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
868
|
+
const headers = { ...input.headers };
|
|
869
|
+
const requestBody = ['GET', 'HEAD'].includes(input.method) ? undefined : input.body;
|
|
870
|
+
if (requestBody && !headerExists(headers, 'content-length')) {
|
|
871
|
+
headers['content-length'] = String(Buffer.byteLength(requestBody));
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const req = requestImpl(
|
|
875
|
+
url,
|
|
876
|
+
{
|
|
877
|
+
method: input.method,
|
|
878
|
+
rejectUnauthorized: false,
|
|
879
|
+
headers,
|
|
880
|
+
},
|
|
881
|
+
(res) => {
|
|
882
|
+
const chunks: Buffer[] = [];
|
|
883
|
+
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
884
|
+
res.on('end', () => {
|
|
885
|
+
resolveRequest({
|
|
886
|
+
status: res.statusCode ?? 0,
|
|
887
|
+
url: url.toString(),
|
|
888
|
+
text: Buffer.concat(chunks).toString('utf8'),
|
|
889
|
+
contentType: String(res.headers['content-type'] ?? ''),
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
},
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
req.setTimeout(input.timeoutMs ?? 30000, () => req.destroy(new Error('请求超时')));
|
|
896
|
+
req.on('error', rejectRequest);
|
|
897
|
+
if (requestBody) req.write(requestBody);
|
|
898
|
+
req.end();
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function parseCurlCommand(curlText: string) {
|
|
903
|
+
const tokens = tokenizeShell(curlText.replace(/\\\r?\n/g, ' '));
|
|
904
|
+
if (!tokens.length || tokens[0] !== 'curl') {
|
|
905
|
+
throw new Error('请输入以 curl 开头的命令');
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const headers: Record<string, string> = {};
|
|
909
|
+
let method = 'GET';
|
|
910
|
+
let body: string | undefined;
|
|
911
|
+
let url = '';
|
|
912
|
+
|
|
913
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
914
|
+
const token = tokens[index];
|
|
915
|
+
const next = () => {
|
|
916
|
+
index += 1;
|
|
917
|
+
if (index >= tokens.length) throw new Error(`CURL 参数 ${token} 缺少值`);
|
|
918
|
+
return tokens[index];
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
if (token === '-H' || token === '--header') {
|
|
922
|
+
applyHeader(headers, next());
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (token.startsWith('-H') && token.length > 2) {
|
|
926
|
+
applyHeader(headers, token.slice(2));
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (token === '-X' || token === '--request') {
|
|
930
|
+
method = next().toUpperCase();
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (token === '--url') {
|
|
934
|
+
url = next();
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (['-d', '--data', '--data-raw', '--data-binary', '--data-urlencode'].includes(token)) {
|
|
938
|
+
body = next();
|
|
939
|
+
if (method === 'GET') method = 'POST';
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (token === '-A' || token === '--user-agent') {
|
|
943
|
+
headers['user-agent'] = next();
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (token === '-e' || token === '--referer') {
|
|
947
|
+
headers.referer = next();
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (token === '-u' || token === '--user') {
|
|
951
|
+
headers.authorization = `Basic ${Buffer.from(next(), 'utf8').toString('base64')}`;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (token === '-b' || token === '--cookie') {
|
|
955
|
+
headers.cookie = next();
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (token === '-I' || token === '--head') {
|
|
959
|
+
method = 'HEAD';
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (['-o', '--output', '--connect-timeout', '--max-time'].includes(token)) {
|
|
963
|
+
next();
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (token.startsWith('-')) {
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (!url) url = token;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (!url) throw new Error('CURL 命令中没有找到请求 URL');
|
|
973
|
+
const normalizedUrl = normalizeUrl(url);
|
|
974
|
+
return { url: normalizedUrl, method, headers, body };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function tokenizeShell(value: string) {
|
|
978
|
+
const tokens: string[] = [];
|
|
979
|
+
let current = '';
|
|
980
|
+
let quote: '"' | "'" | '' = '';
|
|
981
|
+
let escaping = false;
|
|
982
|
+
|
|
983
|
+
for (const char of value.trim()) {
|
|
984
|
+
if (escaping) {
|
|
985
|
+
current += char;
|
|
986
|
+
escaping = false;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (char === '\\' && quote !== "'") {
|
|
990
|
+
escaping = true;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if ((char === '"' || char === "'") && !quote) {
|
|
994
|
+
quote = char;
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
if (char === quote) {
|
|
998
|
+
quote = '';
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (!quote && /\s/.test(char)) {
|
|
1002
|
+
if (current) {
|
|
1003
|
+
tokens.push(current);
|
|
1004
|
+
current = '';
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
current += char;
|
|
1009
|
+
}
|
|
1010
|
+
if (quote) throw new Error('CURL 命令存在未闭合的引号');
|
|
1011
|
+
if (current) tokens.push(current);
|
|
1012
|
+
return tokens;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function applyHeader(headers: Record<string, string>, rawHeader: string) {
|
|
1016
|
+
const index = rawHeader.indexOf(':');
|
|
1017
|
+
if (index <= 0) return;
|
|
1018
|
+
const key = rawHeader.slice(0, index).trim();
|
|
1019
|
+
const value = rawHeader.slice(index + 1).trim();
|
|
1020
|
+
if (key) headers[key] = value;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function headerExists(headers: Record<string, string>, target: string) {
|
|
1024
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === target.toLowerCase());
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function readUrlText(
|
|
1028
|
+
sourceUrl: string,
|
|
1029
|
+
options: { accept: string; userAgent: string; referer?: string; auth?: ImportAuth; authOrigin?: string; timeoutMs?: number },
|
|
1030
|
+
redirectCount = 0,
|
|
1031
|
+
): Promise<{ status: number; url: string; text: string; contentType: string }> {
|
|
1032
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
1033
|
+
const url = new URL(sourceUrl);
|
|
1034
|
+
const requestImpl = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
1035
|
+
const normalizedSourceUrl = url.toString();
|
|
1036
|
+
const headers: Record<string, string> = {
|
|
1037
|
+
accept: options.accept,
|
|
1038
|
+
referer: normalizeHeaderUrl(options.referer || normalizedSourceUrl),
|
|
1039
|
+
'user-agent': options.userAgent,
|
|
1040
|
+
};
|
|
1041
|
+
const authorization = basicAuthorizationHeader(normalizedSourceUrl, options.auth, options.authOrigin);
|
|
1042
|
+
if (authorization) headers.authorization = authorization;
|
|
1043
|
+
const req = requestImpl(
|
|
1044
|
+
url,
|
|
1045
|
+
{
|
|
1046
|
+
method: 'GET',
|
|
1047
|
+
rejectUnauthorized: false,
|
|
1048
|
+
headers,
|
|
1049
|
+
},
|
|
1050
|
+
(res) => {
|
|
1051
|
+
const status = res.statusCode ?? 0;
|
|
1052
|
+
const location = res.headers.location;
|
|
1053
|
+
if (location && [301, 302, 303, 307, 308].includes(status)) {
|
|
1054
|
+
res.resume();
|
|
1055
|
+
if (redirectCount >= 5) {
|
|
1056
|
+
rejectRequest(new Error('重定向次数过多'));
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
resolveRequest(readUrlText(new URL(location, sourceUrl).toString(), options, redirectCount + 1));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const chunks: Buffer[] = [];
|
|
1064
|
+
res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
1065
|
+
res.on('end', () => {
|
|
1066
|
+
resolveRequest({
|
|
1067
|
+
status,
|
|
1068
|
+
url: normalizedSourceUrl,
|
|
1069
|
+
text: Buffer.concat(chunks).toString('utf8'),
|
|
1070
|
+
contentType: String(res.headers['content-type'] ?? ''),
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
},
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
req.setTimeout(options.timeoutMs ?? 20000, () => req.destroy(new Error('请求超时')));
|
|
1077
|
+
req.on('error', rejectRequest);
|
|
1078
|
+
req.end();
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function discoverOpenApiUrls(pageUrl: string, html: string) {
|
|
1083
|
+
const urls = new Set<string>();
|
|
1084
|
+
const urlPatterns = [
|
|
1085
|
+
/spec-url\s*=\s*["'`]([^"'`]+)["'`]/gi,
|
|
1086
|
+
/specUrl\s*[:=]\s*["'`]([^"'`]+)["'`]/g,
|
|
1087
|
+
/spec-url\s*:\s*["'`]([^"'`]+)["'`]/gi,
|
|
1088
|
+
/spec\s*[:=]\s*["'`]([^"'`]+\.(?:ya?ml|json)(?:\?[^"'`]*)?)["'`]/gi,
|
|
1089
|
+
/url\s*:\s*["'`]([^"'`]+)["'`]/g,
|
|
1090
|
+
/urls\s*:\s*\[[\s\S]*?url\s*:\s*["'`]([^"'`]+)["'`]/g,
|
|
1091
|
+
/href=["']([^"']+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml|\.json)[^"']*)["']/gi,
|
|
1092
|
+
/src=["']([^"']+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml|\.json)[^"']*)["']/gi,
|
|
1093
|
+
/["'`]([^"'`]+(?:api-docs|swagger|openapi|api\.json|swagger\.json|\.ya?ml)[^"'`]*)["'`]/gi,
|
|
1094
|
+
];
|
|
1095
|
+
|
|
1096
|
+
urlPatterns.forEach((pattern) => {
|
|
1097
|
+
for (const match of html.matchAll(pattern)) {
|
|
1098
|
+
addCandidate(urls, pageUrl, match[1]);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
[
|
|
1103
|
+
'swagger-resources',
|
|
1104
|
+
'/api.json',
|
|
1105
|
+
'/swagger.json',
|
|
1106
|
+
'/openapi.json',
|
|
1107
|
+
'/openapi.yaml',
|
|
1108
|
+
'/swagger.yaml',
|
|
1109
|
+
'/v2/api-docs',
|
|
1110
|
+
'/v3/api-docs',
|
|
1111
|
+
'api.json',
|
|
1112
|
+
'swagger.json',
|
|
1113
|
+
'openapi.json',
|
|
1114
|
+
'openapi.yaml',
|
|
1115
|
+
'swagger.yaml',
|
|
1116
|
+
'../api.json',
|
|
1117
|
+
'../swagger.json',
|
|
1118
|
+
'../openapi.json',
|
|
1119
|
+
'../openapi.yaml',
|
|
1120
|
+
'../swagger.yaml',
|
|
1121
|
+
].forEach((candidate) => addCandidate(urls, pageUrl, candidate));
|
|
1122
|
+
|
|
1123
|
+
return [...urls];
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function fetchScriptOpenApiUrls(pageUrl: string, html: string, auth: ImportAuth) {
|
|
1127
|
+
const urls = new Set<string>();
|
|
1128
|
+
for (const scriptUrl of discoverScriptUrls(pageUrl, html)) {
|
|
1129
|
+
try {
|
|
1130
|
+
const response = await readUrlText(scriptUrl, {
|
|
1131
|
+
accept: 'application/javascript,text/javascript,text/plain,*/*',
|
|
1132
|
+
userAgent: 'apiskill-openapi-crawler/1.0',
|
|
1133
|
+
referer: pageUrl,
|
|
1134
|
+
auth,
|
|
1135
|
+
authOrigin: new URL(pageUrl).origin,
|
|
1136
|
+
timeoutMs: 8000,
|
|
1137
|
+
});
|
|
1138
|
+
if (response.status < 200 || response.status >= 300) continue;
|
|
1139
|
+
discoverOpenApiUrls(response.url || scriptUrl, response.text).forEach((url) => urls.add(url));
|
|
1140
|
+
discoverBundledSpecUrls(response.url || scriptUrl, response.text).forEach((url) => urls.add(url));
|
|
1141
|
+
} catch {
|
|
1142
|
+
// External scripts are optional discovery hints.
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return [...urls];
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function discoverScriptUrls(pageUrl: string, html: string) {
|
|
1149
|
+
const urls = new Set<string>();
|
|
1150
|
+
const pattern = /<script\b[^>]*\bsrc=["']([^"']+\.js(?:\?[^"']*)?)["'][^>]*>/gi;
|
|
1151
|
+
for (const match of html.matchAll(pattern)) {
|
|
1152
|
+
addCandidate(urls, pageUrl, match[1]);
|
|
1153
|
+
}
|
|
1154
|
+
return [...urls].slice(0, 8);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function discoverBundledSpecUrls(scriptUrl: string, scriptText: string) {
|
|
1158
|
+
const urls = new Set<string>();
|
|
1159
|
+
const patterns = [
|
|
1160
|
+
/value\s*:\s*["'`]([^"'`]+\.(?:ya?ml|json)(?:\?[^"'`]*)?)["'`]/gi,
|
|
1161
|
+
/["'`]([^"'`]+\.(?:ya?ml|json)(?:\?[^"'`]*)?)["'`]/gi,
|
|
1162
|
+
];
|
|
1163
|
+
patterns.forEach((pattern) => {
|
|
1164
|
+
for (const match of scriptText.matchAll(pattern)) {
|
|
1165
|
+
addCandidate(urls, scriptUrl, match[1]);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
return [...urls];
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function fetchKnife4jOpenApiUrls(pageUrl: string, auth: ImportAuth, probeOnly = false) {
|
|
1172
|
+
const urls = new Set<string>();
|
|
1173
|
+
const resourceUrls = discoverSwaggerResourceUrls(pageUrl);
|
|
1174
|
+
let requiresAuth = false;
|
|
1175
|
+
let status = 0;
|
|
1176
|
+
|
|
1177
|
+
for (const resourceUrl of resourceUrls) {
|
|
1178
|
+
try {
|
|
1179
|
+
const response = await readUrlText(resourceUrl, {
|
|
1180
|
+
accept: 'application/json,text/plain,*/*',
|
|
1181
|
+
userAgent: probeOnly ? 'apiskill-openapi-auth-check/1.0' : 'apiskill-openapi-crawler/1.0',
|
|
1182
|
+
referer: pageUrl,
|
|
1183
|
+
auth,
|
|
1184
|
+
authOrigin: new URL(pageUrl).origin,
|
|
1185
|
+
timeoutMs: probeOnly ? 3000 : 20000,
|
|
1186
|
+
});
|
|
1187
|
+
status = response.status;
|
|
1188
|
+
if (response.status === 401 || response.status === 403) {
|
|
1189
|
+
requiresAuth = true;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (response.status < 200 || response.status >= 300) continue;
|
|
1193
|
+
|
|
1194
|
+
const resources = JSON.parse(stripBom(response.text));
|
|
1195
|
+
if (!Array.isArray(resources)) continue;
|
|
1196
|
+
|
|
1197
|
+
resources.forEach((resource) => {
|
|
1198
|
+
const location = resourceLocation(resource);
|
|
1199
|
+
if (!location) return;
|
|
1200
|
+
urls.add(resolveKnife4jResourceLocation(response.url || resourceUrl, pageUrl, location));
|
|
1201
|
+
});
|
|
1202
|
+
if (urls.size) break;
|
|
1203
|
+
} catch {
|
|
1204
|
+
// Keep trying the other common Knife4j resource locations.
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
return { urls: [...urls], requiresAuth, status };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function discoverSwaggerResourceUrls(pageUrl: string) {
|
|
1212
|
+
const urls = new Set<string>();
|
|
1213
|
+
addCandidate(urls, pageUrl, 'swagger-resources');
|
|
1214
|
+
addCandidate(urls, pageUrl, './swagger-resources');
|
|
1215
|
+
addCandidate(urls, pageUrl, '/swagger-resources');
|
|
1216
|
+
return [...urls];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function resourceLocation(resource: unknown) {
|
|
1220
|
+
if (!resource || typeof resource !== 'object') return '';
|
|
1221
|
+
const record = resource as SwaggerResource;
|
|
1222
|
+
return typeof record.location === 'string' ? record.location : typeof record.url === 'string' ? record.url : '';
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function resolveKnife4jResourceLocation(resourceUrl: string, pageUrl: string, location: string) {
|
|
1226
|
+
if (/^https?:\/\//i.test(location)) return location;
|
|
1227
|
+
if (!location.startsWith('/')) return new URL(location, resourceUrl).toString();
|
|
1228
|
+
|
|
1229
|
+
const contextPath = knife4jContextPath(resourceUrl) || knife4jContextPath(pageUrl);
|
|
1230
|
+
const origin = new URL(resourceUrl).origin;
|
|
1231
|
+
return `${origin}${contextPath}${location}`.replace(/([^:]\/)\/+/g, '$1');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function knife4jContextPath(value: string) {
|
|
1235
|
+
const pathname = new URL(value).pathname;
|
|
1236
|
+
const marker = pathname.indexOf('/swagger-resources');
|
|
1237
|
+
if (marker > 0) return pathname.slice(0, marker);
|
|
1238
|
+
if (pathname.endsWith('/doc.html')) return pathname.slice(0, -'/doc.html'.length);
|
|
1239
|
+
return '';
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function addCandidate(urls: Set<string>, pageUrl: string, candidate?: string) {
|
|
1243
|
+
if (!candidate) return;
|
|
1244
|
+
const cleaned = candidate.replace(/\\u002F/g, '/').replace(/&/g, '&').trim();
|
|
1245
|
+
if (!cleaned || cleaned.startsWith('data:') || cleaned.startsWith('javascript:')) return;
|
|
1246
|
+
|
|
1247
|
+
try {
|
|
1248
|
+
urls.add(new URL(cleaned, pageUrl).toString());
|
|
1249
|
+
} catch {
|
|
1250
|
+
// Ignore malformed URLs found in page scripts.
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function isOpenApiDocument(value: unknown): value is { openapi?: string; swagger?: string; info?: { title?: string; version?: string }; paths?: object; components?: { schemas?: object }; definitions?: object } {
|
|
1255
|
+
if (!value || typeof value !== 'object') return false;
|
|
1256
|
+
const record = value as Record<string, unknown>;
|
|
1257
|
+
return Boolean((record.openapi || record.swagger) && record.paths && typeof record.paths === 'object');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function normalizeUrl(value?: string) {
|
|
1261
|
+
if (!value?.trim()) throw new Error('请输入文档地址');
|
|
1262
|
+
const url = new URL(value.trim());
|
|
1263
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
1264
|
+
throw new Error('仅支持 http 或 https 地址');
|
|
1265
|
+
}
|
|
1266
|
+
return url.toString();
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function requiredText(value: unknown, message: string) {
|
|
1270
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(message);
|
|
1271
|
+
return value.trim();
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function optionalText(value: unknown) {
|
|
1275
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function normalizeOptionalBaseUrl(value: unknown) {
|
|
1279
|
+
const text = optionalText(value);
|
|
1280
|
+
if (!text) return '';
|
|
1281
|
+
const url = new URL(text);
|
|
1282
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
1283
|
+
throw new Error('环境 Base URL 仅支持 http 或 https 地址');
|
|
1284
|
+
}
|
|
1285
|
+
return url.toString().replace(/\/$/, '');
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function normalizeHeaderUrl(value: string) {
|
|
1289
|
+
try {
|
|
1290
|
+
return new URL(value).toString();
|
|
1291
|
+
} catch {
|
|
1292
|
+
return value;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function normalizeImportAuth(value: unknown): ImportAuth {
|
|
1297
|
+
if (!value || typeof value !== 'object') return { type: 'none' };
|
|
1298
|
+
const record = value as Record<string, unknown>;
|
|
1299
|
+
if (record.type !== 'basic') return { type: 'none' };
|
|
1300
|
+
return {
|
|
1301
|
+
type: 'basic',
|
|
1302
|
+
username: typeof record.username === 'string' ? record.username : '',
|
|
1303
|
+
password: typeof record.password === 'string' ? record.password : '',
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function basicAuthorizationHeader(sourceUrl: string, auth?: ImportAuth, authOrigin?: string) {
|
|
1308
|
+
if (auth?.type !== 'basic') return undefined;
|
|
1309
|
+
if (!auth.username && !auth.password) return undefined;
|
|
1310
|
+
|
|
1311
|
+
try {
|
|
1312
|
+
if (authOrigin && new URL(sourceUrl).origin !== authOrigin) return undefined;
|
|
1313
|
+
} catch {
|
|
1314
|
+
return undefined;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
return `Basic ${Buffer.from(`${auth.username ?? ''}:${auth.password ?? ''}`, 'utf8').toString('base64')}`;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function stripBom(value: string) {
|
|
1321
|
+
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function parseOpenApiText(text: string) {
|
|
1325
|
+
const cleaned = stripBom(text);
|
|
1326
|
+
try {
|
|
1327
|
+
return JSON.parse(cleaned);
|
|
1328
|
+
} catch {
|
|
1329
|
+
return parseYaml(cleaned);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function uniqueUrls(urls: string[]) {
|
|
1334
|
+
return [...new Set(urls)];
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function formatVersionDate(value: Date) {
|
|
1338
|
+
return value.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function resultSourceName(value: string, fallback: string) {
|
|
1342
|
+
try {
|
|
1343
|
+
return new URL(value).hostname || fallback;
|
|
1344
|
+
} catch {
|
|
1345
|
+
return fallback;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function slugify(value: string) {
|
|
1350
|
+
return value
|
|
1351
|
+
.toLowerCase()
|
|
1352
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1353
|
+
.replace(/^-+|-+$/g, '')
|
|
1354
|
+
.slice(0, 48) || 'openapi';
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function normalizeHeaders(headers: IncomingMessage['headers']) {
|
|
1358
|
+
const normalized: Record<string, string | string[]> = {};
|
|
1359
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
1360
|
+
if (Array.isArray(value)) normalized[key] = value;
|
|
1361
|
+
else if (value !== undefined) normalized[key] = String(value);
|
|
1362
|
+
});
|
|
1363
|
+
return normalized;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function readJsonBody(req: IncomingMessage) {
|
|
1367
|
+
const chunks: Buffer[] = [];
|
|
1368
|
+
for await (const chunk of req) {
|
|
1369
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
1373
|
+
return text ? JSON.parse(text) : {};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
1377
|
+
res.statusCode = status;
|
|
1378
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
1379
|
+
res.end(JSON.stringify(body));
|
|
1380
|
+
}
|