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
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
|
+
|
|
7
|
+
export const METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
|
|
8
|
+
|
|
9
|
+
const rootDir = process.env.APISKILL_ROOT || resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
10
|
+
const cacheDir = process.env.APISKILL_CACHE_DIR || resolve(rootDir, 'cache');
|
|
11
|
+
const versionsDir = resolve(cacheDir, 'versions');
|
|
12
|
+
const latestMetaPath = resolve(cacheDir, 'latest-import.json');
|
|
13
|
+
const legacyCachePath = resolve(cacheDir, 'openapi-cache.json');
|
|
14
|
+
const legacyCacheMetaPath = resolve(cacheDir, 'import-meta.json');
|
|
15
|
+
|
|
16
|
+
export function parseOpenApiText(text) {
|
|
17
|
+
const cleaned = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(cleaned);
|
|
20
|
+
} catch {
|
|
21
|
+
return parseYaml(cleaned);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isOpenApiDocument(value) {
|
|
26
|
+
return Boolean(value && typeof value === 'object' && (value.openapi || value.swagger) && value.paths && typeof value.paths === 'object');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readCachedDocument(versionId) {
|
|
30
|
+
if (versionId) {
|
|
31
|
+
assertSafeVersionId(versionId);
|
|
32
|
+
const metaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
33
|
+
const documentPath = resolve(versionsDir, `${versionId}.json`);
|
|
34
|
+
if (!existsSync(metaPath) && !existsSync(documentPath)) throw new Error(`缓存版本不存在: ${versionId}`);
|
|
35
|
+
const meta = existsSync(metaPath) ? JSON.parse(await readFile(metaPath, 'utf8')) : { versionId, savedPath: documentPath };
|
|
36
|
+
const savedPath = typeof meta.savedPath === 'string' ? meta.savedPath : documentPath;
|
|
37
|
+
return { document: JSON.parse(await readFile(savedPath, 'utf8')), meta: { ...meta, savedPath } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!existsSync(latestMetaPath) && !existsSync(legacyCachePath)) throw new Error('没有本地缓存文档');
|
|
41
|
+
const meta = existsSync(latestMetaPath)
|
|
42
|
+
? JSON.parse(await readFile(latestMetaPath, 'utf8'))
|
|
43
|
+
: existsSync(legacyCacheMetaPath)
|
|
44
|
+
? JSON.parse(await readFile(legacyCacheMetaPath, 'utf8'))
|
|
45
|
+
: {};
|
|
46
|
+
const documentPath = typeof meta.savedPath === 'string' ? meta.savedPath : legacyCachePath;
|
|
47
|
+
return { document: JSON.parse(await readFile(documentPath, 'utf8')), meta };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function listCachedVersions() {
|
|
51
|
+
const latestMeta = existsSync(latestMetaPath) ? JSON.parse(await readFile(latestMetaPath, 'utf8')) : undefined;
|
|
52
|
+
if (!existsSync(versionsDir)) {
|
|
53
|
+
if (!existsSync(legacyCachePath)) return [];
|
|
54
|
+
const fileStat = await stat(legacyCachePath);
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
versionId: 'legacy-openapi-cache',
|
|
58
|
+
mode: 'legacy',
|
|
59
|
+
savedAt: fileStat.mtime.toISOString(),
|
|
60
|
+
savedPath: legacyCachePath,
|
|
61
|
+
latest: true,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
const files = await readdir(versionsDir);
|
|
66
|
+
const versionIds = new Set(files.filter((file) => file.endsWith('.json') && !file.endsWith('.meta.json')).map((file) => file.replace(/\.json$/, '')));
|
|
67
|
+
files.filter((file) => file.endsWith('.meta.json')).forEach((file) => versionIds.add(file.replace(/\.meta\.json$/, '')));
|
|
68
|
+
const metas = await Promise.all(
|
|
69
|
+
[...versionIds].map(async (versionId) => {
|
|
70
|
+
const documentPath = resolve(versionsDir, `${versionId}.json`);
|
|
71
|
+
const metaPath = resolve(versionsDir, `${versionId}.meta.json`);
|
|
72
|
+
try {
|
|
73
|
+
const fileStat = existsSync(documentPath) ? await stat(documentPath) : undefined;
|
|
74
|
+
const meta = existsSync(metaPath) ? JSON.parse(await readFile(metaPath, 'utf8')) : {};
|
|
75
|
+
return {
|
|
76
|
+
versionId,
|
|
77
|
+
mode: meta.mode || '',
|
|
78
|
+
inputUrl: meta.inputUrl || '',
|
|
79
|
+
resolvedUrl: meta.resolvedUrl || '',
|
|
80
|
+
title: meta.title || '',
|
|
81
|
+
version: meta.version || '',
|
|
82
|
+
savedAt: meta.savedAt || fileStat?.mtime.toISOString() || '',
|
|
83
|
+
paths: meta.paths,
|
|
84
|
+
schemas: meta.schemas,
|
|
85
|
+
savedFile: meta.savedFile || `${versionId}.json`,
|
|
86
|
+
savedPath: meta.savedPath || documentPath,
|
|
87
|
+
latest: latestMeta?.versionId === versionId,
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
return metas.filter(Boolean).sort((a, b) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function saveImportedDocument({ document, mode, inputUrl, resolvedUrl, versionId }) {
|
|
98
|
+
if (!isOpenApiDocument(document)) throw new Error('不是有效的 OpenAPI/Swagger 文档');
|
|
99
|
+
const existing = versionId ? await readCachedDocument(versionId) : undefined;
|
|
100
|
+
const savedAt = new Date();
|
|
101
|
+
const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-${mode}-${slugify(sourceName(resolvedUrl || inputUrl))}`;
|
|
102
|
+
return writeVersion(document, {
|
|
103
|
+
...(existing?.meta ?? {}),
|
|
104
|
+
savedPath: existing?.meta?.savedPath,
|
|
105
|
+
versionId: nextVersionId,
|
|
106
|
+
mode,
|
|
107
|
+
inputUrl,
|
|
108
|
+
resolvedUrl: resolvedUrl || inputUrl,
|
|
109
|
+
title: document.info?.title || '',
|
|
110
|
+
version: document.info?.version || '',
|
|
111
|
+
savedAt: savedAt.toISOString(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function createBlankDocument({
|
|
116
|
+
title = 'API Skill Document',
|
|
117
|
+
version = '1.0.0',
|
|
118
|
+
description = 'Created from API Skill blank document.',
|
|
119
|
+
environmentName = '',
|
|
120
|
+
environmentBaseUrl = '',
|
|
121
|
+
} = {}) {
|
|
122
|
+
const document = createManualDocument(text(title) || 'API Skill Document', text(version) || '1.0.0', text(description));
|
|
123
|
+
const savedAt = new Date();
|
|
124
|
+
const versionId = `${formatVersionDate(savedAt)}-document-${slugify(document.info?.title || 'api-skill-document')}`;
|
|
125
|
+
return writeVersion(document, {
|
|
126
|
+
versionId,
|
|
127
|
+
mode: 'document',
|
|
128
|
+
inputUrl: 'manual-document',
|
|
129
|
+
resolvedUrl: 'manual-document',
|
|
130
|
+
title: document.info?.title || '',
|
|
131
|
+
version: document.info?.version || '',
|
|
132
|
+
environmentName: text(environmentName).slice(0, 80),
|
|
133
|
+
environmentBaseUrl: text(environmentBaseUrl),
|
|
134
|
+
savedAt: savedAt.toISOString(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function saveManualOperation({ versionId, config, replaceTarget }) {
|
|
139
|
+
const normalized = normalizeManualConfig(config);
|
|
140
|
+
const existing = versionId ? await readCachedDocument(versionId) : undefined;
|
|
141
|
+
let document = existing?.document ?? createManualDocument();
|
|
142
|
+
if (replaceTarget?.method && replaceTarget?.path) {
|
|
143
|
+
document = deleteOperation(document, replaceTarget.method, replaceTarget.path);
|
|
144
|
+
}
|
|
145
|
+
document = applyOperation(document, normalized);
|
|
146
|
+
const savedAt = new Date();
|
|
147
|
+
const nextVersionId = existing?.meta?.versionId || `${formatVersionDate(savedAt)}-manual-${slugify(normalized.operationId || normalized.summary || normalized.path)}`;
|
|
148
|
+
return writeVersion(document, {
|
|
149
|
+
...(existing?.meta ?? {}),
|
|
150
|
+
versionId: nextVersionId,
|
|
151
|
+
mode: existing?.meta?.mode || 'manual',
|
|
152
|
+
inputUrl: existing?.meta?.inputUrl || 'manual-api-config',
|
|
153
|
+
resolvedUrl: existing?.meta?.resolvedUrl || 'manual-api-config',
|
|
154
|
+
title: document.info?.title || 'Manual API Config',
|
|
155
|
+
version: document.info?.version || 'manual',
|
|
156
|
+
savedAt: savedAt.toISOString(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function deleteManualOperation({ versionId, method, path }) {
|
|
161
|
+
const existing = await readCachedDocument(required(versionId, '必须指定 --version'));
|
|
162
|
+
const document = deleteOperation(existing.document, method, path);
|
|
163
|
+
return writeVersion(document, {
|
|
164
|
+
...existing.meta,
|
|
165
|
+
savedAt: new Date().toISOString(),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseManualCliText(text) {
|
|
170
|
+
const cleaned = stripFence(required(text, 'API 配置不能为空'));
|
|
171
|
+
let parsed;
|
|
172
|
+
try {
|
|
173
|
+
parsed = JSON.parse(cleaned);
|
|
174
|
+
} catch {
|
|
175
|
+
parsed = parseYaml(cleaned);
|
|
176
|
+
}
|
|
177
|
+
const record = parsed && typeof parsed === 'object' ? parsed : {};
|
|
178
|
+
return normalizeManualConfig(record.api ?? record.config ?? record.operation ?? parsed);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatManualCli(config) {
|
|
182
|
+
return JSON.stringify({ api: normalizeManualConfig(config) });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function listEndpoints(document) {
|
|
186
|
+
return Object.entries(document.paths ?? {})
|
|
187
|
+
.flatMap(([path, item]) =>
|
|
188
|
+
METHODS.flatMap((method) => {
|
|
189
|
+
const operation = item?.[method];
|
|
190
|
+
if (!operation) return [];
|
|
191
|
+
const tags = operation.tags?.length ? operation.tags : ['未分组'];
|
|
192
|
+
const summary = operation.summary || operation.description || '未命名接口';
|
|
193
|
+
const params = operation.parameters ?? [];
|
|
194
|
+
const searchable = [
|
|
195
|
+
method,
|
|
196
|
+
path,
|
|
197
|
+
summary,
|
|
198
|
+
operation.description,
|
|
199
|
+
operation.operationId,
|
|
200
|
+
tags.join(' '),
|
|
201
|
+
params.map((parameter) => `${parameter.name} ${parameter.description ?? ''} ${parameter.schema?.description ?? ''}`).join(' '),
|
|
202
|
+
]
|
|
203
|
+
.filter(Boolean)
|
|
204
|
+
.join(' ')
|
|
205
|
+
.toLowerCase();
|
|
206
|
+
return [{ method, path, operation, tags, summary, searchable, hasBody: Boolean(operation.requestBody) }];
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function findEndpoint(document, method, path) {
|
|
213
|
+
return listEndpoints(document).find((endpoint) => endpoint.method === String(method).toLowerCase() && endpoint.path === path);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function endpointToManualConfig(endpoint, document = {}) {
|
|
217
|
+
const operation = endpoint.operation;
|
|
218
|
+
return normalizeManualConfig({
|
|
219
|
+
method: endpoint.method,
|
|
220
|
+
path: endpoint.path,
|
|
221
|
+
summary: endpoint.summary,
|
|
222
|
+
description: operation.description || '',
|
|
223
|
+
operationId: operation.operationId || '',
|
|
224
|
+
tags: operation.tags || [],
|
|
225
|
+
parameters: (operation.parameters ?? [])
|
|
226
|
+
.filter((parameter) => parameter.in !== 'body' && parameter.in !== 'formData')
|
|
227
|
+
.map((parameter) => ({
|
|
228
|
+
name: parameter.name,
|
|
229
|
+
location: parameter.in,
|
|
230
|
+
required: Boolean(parameter.required),
|
|
231
|
+
type: schemaType(parameter.schema ?? parameter),
|
|
232
|
+
format: schemaFormat(parameter.schema ?? parameter),
|
|
233
|
+
description: parameter.description || parameter.schema?.description || '',
|
|
234
|
+
enumValue: parameter.enum?.join(',') || parameter.schema?.enum?.join(',') || '',
|
|
235
|
+
}))
|
|
236
|
+
.sort(compareFields),
|
|
237
|
+
requestBody: requestBodyConfig(operation, document),
|
|
238
|
+
responses: Object.entries(operation.responses ?? {}).map(([status, response]) => responseConfig(status, response, document)),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function requestBodyConfig(operation, document) {
|
|
243
|
+
const entry = Object.entries(operation.requestBody?.content ?? {})[0];
|
|
244
|
+
const bodyParameter = operation.parameters?.find((parameter) => parameter.in === 'body');
|
|
245
|
+
if (entry) {
|
|
246
|
+
return {
|
|
247
|
+
required: Boolean(operation.requestBody?.required),
|
|
248
|
+
contentType: entry[0],
|
|
249
|
+
fields: schemaToFields(entry[1].schema, document),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (bodyParameter?.schema) {
|
|
253
|
+
return { required: Boolean(bodyParameter.required), contentType: operation.consumes?.[0] || 'application/json', fields: schemaToFields(bodyParameter.schema, document) };
|
|
254
|
+
}
|
|
255
|
+
return { required: false, contentType: 'application/json', fields: [] };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function responseConfig(status, response, document) {
|
|
259
|
+
const entry = Object.entries(response.content ?? {})[0];
|
|
260
|
+
const schema = entry?.[1]?.schema ?? response.schema ?? response.responseSchema;
|
|
261
|
+
return {
|
|
262
|
+
status,
|
|
263
|
+
description: response.description || '',
|
|
264
|
+
contentType: entry?.[0] || 'application/json',
|
|
265
|
+
fields: schemaToFields(schema, document),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function schemaToFields(schema, document = {}, prefix = '', seen = new Set()) {
|
|
270
|
+
if (!schema) return [];
|
|
271
|
+
const resolved = resolveSchema(document, schema, seen) ?? schema;
|
|
272
|
+
if (resolved.type === 'array') return schemaToFields(resolved.items, document, prefix, seen);
|
|
273
|
+
const properties = resolved.properties ?? {};
|
|
274
|
+
return Object.entries(properties)
|
|
275
|
+
.sort(([left], [right]) => left.localeCompare(right, undefined, { sensitivity: 'base' }))
|
|
276
|
+
.map(([name, property]) => ({
|
|
277
|
+
name: prefix ? `${prefix}.${name}` : name,
|
|
278
|
+
location: 'body',
|
|
279
|
+
required: resolved.required?.includes(name) ?? false,
|
|
280
|
+
type: schemaType(resolveSchema(document, property) ?? property),
|
|
281
|
+
format: schemaFormat(resolveSchema(document, property) ?? property),
|
|
282
|
+
description: property.description || (resolveSchema(document, property) ?? property).description || '',
|
|
283
|
+
defaultValue: formatValue((resolveSchema(document, property) ?? property).default),
|
|
284
|
+
enumValue: ((resolveSchema(document, property) ?? property).enum ?? property.enum)?.join(',') || '',
|
|
285
|
+
children: hasChildren(document, property) ? schemaToFields(childSchema(document, property), document, '', new Set(seen)).sort(compareFields) : [],
|
|
286
|
+
}))
|
|
287
|
+
.sort(compareFields);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function schemaType(schema = {}) {
|
|
291
|
+
if (schema.$ref) return 'object';
|
|
292
|
+
if (schema.type) return schema.type;
|
|
293
|
+
if (schema.properties) return 'object';
|
|
294
|
+
return 'string';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function schemaFormat(schema = {}) {
|
|
298
|
+
if (schema.type === 'array' && schema.items?.$ref) return `[]*${schema.items.$ref.split('/').pop()}`;
|
|
299
|
+
return schema.format || '';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function hasChildren(document, schema) {
|
|
303
|
+
const resolved = resolveSchema(document, schema) ?? schema;
|
|
304
|
+
if (resolved.type === 'array') return Boolean(resolveSchema(document, resolved.items)?.properties || resolved.items?.properties);
|
|
305
|
+
return Boolean(resolved.properties);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function childSchema(document, schema) {
|
|
309
|
+
const resolved = resolveSchema(document, schema) ?? schema;
|
|
310
|
+
if (resolved.type === 'array') return resolved.items;
|
|
311
|
+
return resolved;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveSchema(document, schema, seen = new Set()) {
|
|
315
|
+
if (!schema || typeof schema !== 'object') return undefined;
|
|
316
|
+
if (schema.$ref) {
|
|
317
|
+
if (seen.has(schema.$ref)) return schema;
|
|
318
|
+
seen.add(schema.$ref);
|
|
319
|
+
const resolved = schema.$ref
|
|
320
|
+
.slice(2)
|
|
321
|
+
.split('/')
|
|
322
|
+
.reduce((current, key) => (current && typeof current === 'object' ? current[decodeURIComponent(key)] : undefined), document);
|
|
323
|
+
const next = resolveSchema(document, resolved, seen) ?? resolved;
|
|
324
|
+
return { ...next, description: schema.description || next?.description };
|
|
325
|
+
}
|
|
326
|
+
if (Array.isArray(schema.allOf)) {
|
|
327
|
+
return schema.allOf.reduce(
|
|
328
|
+
(merged, item) => {
|
|
329
|
+
const next = resolveSchema(document, item, seen) ?? item;
|
|
330
|
+
return {
|
|
331
|
+
...merged,
|
|
332
|
+
...next,
|
|
333
|
+
required: [...new Set([...(merged.required ?? []), ...(next.required ?? [])])],
|
|
334
|
+
properties: { ...(merged.properties ?? {}), ...(next.properties ?? {}) },
|
|
335
|
+
description: [merged.description, next.description].filter(Boolean).join(';') || undefined,
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
{ ...schema, allOf: undefined },
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return schema;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function normalizeManualConfig(input) {
|
|
345
|
+
if (!input || typeof input !== 'object') throw new Error('接口配置不能为空');
|
|
346
|
+
const method = String(input.method || '').toLowerCase();
|
|
347
|
+
if (!METHODS.includes(method)) throw new Error('method 必须是有效 HTTP 方法');
|
|
348
|
+
const path = normalizePath(required(input.path, 'path 不能为空'));
|
|
349
|
+
const summary = required(input.summary, 'summary 不能为空');
|
|
350
|
+
const requestBody = input.requestBody && typeof input.requestBody === 'object' ? input.requestBody : {};
|
|
351
|
+
const responses = Array.isArray(input.responses) ? input.responses.map(normalizeResponse).filter((item) => item.status) : [];
|
|
352
|
+
return {
|
|
353
|
+
method,
|
|
354
|
+
path,
|
|
355
|
+
summary,
|
|
356
|
+
description: text(input.description),
|
|
357
|
+
operationId: text(input.operationId),
|
|
358
|
+
tags: Array.isArray(input.tags) ? input.tags.map(text).filter(Boolean) : text(input.tags).split(',').map((item) => item.trim()).filter(Boolean),
|
|
359
|
+
parameters: Array.isArray(input.parameters) ? input.parameters.map(normalizeField).filter((item) => item.name) : [],
|
|
360
|
+
requestBody: {
|
|
361
|
+
required: Boolean(requestBody.required),
|
|
362
|
+
contentType: text(requestBody.contentType) || 'application/json',
|
|
363
|
+
fields: Array.isArray(requestBody.fields) ? requestBody.fields.map(normalizeField).filter((item) => item.name).sort(compareFields) : [],
|
|
364
|
+
},
|
|
365
|
+
responses: (responses.length ? responses : [{ status: '200', description: 'Success', contentType: 'application/json', fields: [] }]).map((response) => ({
|
|
366
|
+
...response,
|
|
367
|
+
fields: [...(response.fields ?? [])].sort(compareFields),
|
|
368
|
+
})),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function applyOperation(document, config) {
|
|
373
|
+
const next = clone(document);
|
|
374
|
+
next.paths ??= {};
|
|
375
|
+
next.paths[config.path] ??= {};
|
|
376
|
+
next.paths[config.path][config.method] = {
|
|
377
|
+
tags: config.tags?.length ? config.tags : ['手动配置'],
|
|
378
|
+
summary: config.summary,
|
|
379
|
+
description: config.description || undefined,
|
|
380
|
+
operationId: config.operationId || undefined,
|
|
381
|
+
parameters: (config.parameters ?? []).map(fieldToParameter),
|
|
382
|
+
responses: Object.fromEntries((config.responses ?? []).map((response) => [response.status, responseToOpenApi(response)])),
|
|
383
|
+
};
|
|
384
|
+
if (config.requestBody?.fields?.length) {
|
|
385
|
+
next.paths[config.path][config.method].requestBody = {
|
|
386
|
+
required: Boolean(config.requestBody.required),
|
|
387
|
+
content: {
|
|
388
|
+
[config.requestBody.contentType || 'application/json']: { schema: fieldsToSchema(config.requestBody.fields) },
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function deleteOperation(document, method, path) {
|
|
396
|
+
const next = clone(document);
|
|
397
|
+
const normalizedPath = normalizePath(path);
|
|
398
|
+
const normalizedMethod = String(method || '').toLowerCase();
|
|
399
|
+
delete next.paths?.[normalizedPath]?.[normalizedMethod];
|
|
400
|
+
if (next.paths?.[normalizedPath] && !Object.keys(next.paths[normalizedPath]).length) delete next.paths[normalizedPath];
|
|
401
|
+
return next;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function createManualDocument(title = 'Manual API Config', version = 'manual', description = '') {
|
|
405
|
+
return {
|
|
406
|
+
openapi: '3.0.3',
|
|
407
|
+
info: {
|
|
408
|
+
title,
|
|
409
|
+
version,
|
|
410
|
+
...(description ? { description } : {}),
|
|
411
|
+
},
|
|
412
|
+
paths: {},
|
|
413
|
+
components: { schemas: {} },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function fieldToParameter(field) {
|
|
418
|
+
return { name: field.name, in: field.location || 'query', required: Boolean(field.required), description: field.description || undefined, schema: fieldToSchema(field) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function responseToOpenApi(response) {
|
|
422
|
+
const value = { description: response.description || 'Success' };
|
|
423
|
+
if (response.fields?.length) value.content = { [response.contentType || 'application/json']: { schema: fieldsToSchema(response.fields) } };
|
|
424
|
+
return value;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function fieldsToSchema(fields) {
|
|
428
|
+
const schema = { type: 'object', properties: {}, required: [] };
|
|
429
|
+
for (const field of fields ?? []) {
|
|
430
|
+
setSchemaProperty(schema, field.name, fieldToSchema(field), Boolean(field.required));
|
|
431
|
+
}
|
|
432
|
+
if (!schema.required.length) delete schema.required;
|
|
433
|
+
return schema;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setSchemaProperty(root, path, schema, required) {
|
|
437
|
+
const parts = String(path || '').split('.').map((part) => part.trim()).filter(Boolean);
|
|
438
|
+
if (!parts.length) return;
|
|
439
|
+
let current = root;
|
|
440
|
+
parts.forEach((part, index) => {
|
|
441
|
+
current.properties ??= {};
|
|
442
|
+
if (index === parts.length - 1) {
|
|
443
|
+
current.properties[part] = schema;
|
|
444
|
+
if (required) current.required = [...new Set([...(current.required ?? []), part])];
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
current.properties[part] ??= { type: 'object', properties: {}, required: [] };
|
|
448
|
+
current = current.properties[part];
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function fieldToSchema(field) {
|
|
453
|
+
const schema = { type: field.type || 'string', format: field.format || undefined, description: field.description || undefined };
|
|
454
|
+
if (field.enumValue) schema.enum = field.enumValue.split(',').map((item) => item.trim()).filter(Boolean);
|
|
455
|
+
if (field.type === 'object') {
|
|
456
|
+
const child = fieldsToSchema(field.children ?? []);
|
|
457
|
+
schema.properties = child.properties;
|
|
458
|
+
schema.required = child.required;
|
|
459
|
+
}
|
|
460
|
+
if (field.type === 'array') schema.items = field.children?.length ? fieldsToSchema(field.children) : { type: 'string' };
|
|
461
|
+
return schema;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function normalizeResponse(input) {
|
|
465
|
+
return { status: text(input.status) || '200', description: text(input.description), contentType: text(input.contentType) || 'application/json', fields: Array.isArray(input.fields) ? input.fields.map(normalizeField).filter((item) => item.name) : [] };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function normalizeField(input) {
|
|
469
|
+
const location = text(input.location);
|
|
470
|
+
return {
|
|
471
|
+
name: text(input.name),
|
|
472
|
+
location: ['query', 'path', 'header', 'cookie', 'body'].includes(location) ? location : 'query',
|
|
473
|
+
required: Boolean(input.required),
|
|
474
|
+
type: text(input.type) || 'string',
|
|
475
|
+
format: text(input.format),
|
|
476
|
+
description: text(input.description),
|
|
477
|
+
defaultValue: text(input.defaultValue),
|
|
478
|
+
enumValue: text(input.enumValue),
|
|
479
|
+
children: Array.isArray(input.children) ? input.children.map(normalizeField).filter((item) => item.name).sort(compareFields) : [],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function compareFields(left, right) {
|
|
484
|
+
return String(left.name || '').localeCompare(String(right.name || ''), undefined, { sensitivity: 'base' });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function writeVersion(document, metaInput) {
|
|
488
|
+
const stats = { paths: Object.keys(document.paths ?? {}).length, schemas: Object.keys(document.components?.schemas ?? document.definitions ?? {}).length };
|
|
489
|
+
const versionPath = metaInput.savedPath || resolve(versionsDir, `${metaInput.versionId}.json`);
|
|
490
|
+
const meta = { ...metaInput, ...stats, savedFile: basename(versionPath), savedPath: versionPath };
|
|
491
|
+
await mkdir(dirname(versionPath), { recursive: true });
|
|
492
|
+
await writeFile(versionPath, JSON.stringify(document, null, 2));
|
|
493
|
+
await writeFile(resolve(versionsDir, `${meta.versionId}.meta.json`), JSON.stringify(meta, null, 2));
|
|
494
|
+
await writeFile(latestMetaPath, JSON.stringify(meta, null, 2));
|
|
495
|
+
return { document, meta };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function assertSafeVersionId(versionId) {
|
|
499
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(versionId)) throw new Error('versionId 不合法');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function formatVersionDate(value) {
|
|
503
|
+
return value.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function sourceName(value) {
|
|
507
|
+
try {
|
|
508
|
+
return new URL(value).hostname || value;
|
|
509
|
+
} catch {
|
|
510
|
+
return value;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function slugify(value) {
|
|
515
|
+
return String(value || 'openapi').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'openapi';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function normalizePath(value) {
|
|
519
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function required(value, message) {
|
|
523
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(message);
|
|
524
|
+
return value.trim();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function text(value) {
|
|
528
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function formatValue(value) {
|
|
532
|
+
if (value === undefined || value === null) return '';
|
|
533
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function stripFence(value) {
|
|
537
|
+
return value.trim().replace(/^```(?:json|ya?ml|yaml)?\s*/i, '').replace(/```$/i, '').trim();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function clone(value) {
|
|
541
|
+
return JSON.parse(JSON.stringify(value || createManualDocument()));
|
|
542
|
+
}
|