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,264 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Check, Download, Pencil, RefreshCcw, Server, Trash2, X } from 'lucide-react';
|
|
3
|
+
import { VERSION_EXPORT_API_URL } from './swagger';
|
|
4
|
+
|
|
5
|
+
export type DocumentVersionMeta = {
|
|
6
|
+
versionId?: string;
|
|
7
|
+
mode?: string;
|
|
8
|
+
inputUrl?: string;
|
|
9
|
+
resolvedUrl?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
savedAt?: string;
|
|
13
|
+
paths?: number;
|
|
14
|
+
schemas?: number;
|
|
15
|
+
environmentName?: string;
|
|
16
|
+
environmentBaseUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function DocumentVersionManager({
|
|
20
|
+
versions,
|
|
21
|
+
selectedVersionId,
|
|
22
|
+
currentVersion,
|
|
23
|
+
disabled,
|
|
24
|
+
onSelect,
|
|
25
|
+
onPrepareUpdate,
|
|
26
|
+
onUpdateEnvironment,
|
|
27
|
+
onDelete,
|
|
28
|
+
}: {
|
|
29
|
+
versions: DocumentVersionMeta[];
|
|
30
|
+
selectedVersionId: string;
|
|
31
|
+
currentVersion?: DocumentVersionMeta;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
onSelect: (versionId: string) => void;
|
|
34
|
+
onPrepareUpdate: (version: DocumentVersionMeta) => Promise<void> | void;
|
|
35
|
+
onUpdateEnvironment: (versionId: string, environmentName: string, environmentBaseUrl: string) => Promise<void>;
|
|
36
|
+
onDelete: (versionId: string) => Promise<void>;
|
|
37
|
+
}) {
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [editingVersionId, setEditingVersionId] = useState('');
|
|
40
|
+
const [environmentName, setEnvironmentName] = useState('');
|
|
41
|
+
const [environmentBaseUrl, setEnvironmentBaseUrl] = useState('');
|
|
42
|
+
const [busyVersionId, setBusyVersionId] = useState('');
|
|
43
|
+
const [message, setMessage] = useState('');
|
|
44
|
+
|
|
45
|
+
const selectedVersion = useMemo(
|
|
46
|
+
() => currentVersion ?? versions.find((version) => version.versionId === selectedVersionId),
|
|
47
|
+
[currentVersion, selectedVersionId, versions],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!open) {
|
|
52
|
+
setEditingVersionId('');
|
|
53
|
+
setMessage('');
|
|
54
|
+
}
|
|
55
|
+
}, [open]);
|
|
56
|
+
|
|
57
|
+
function beginEdit(version: DocumentVersionMeta) {
|
|
58
|
+
setMessage('');
|
|
59
|
+
setEditingVersionId(version.versionId || '');
|
|
60
|
+
setEnvironmentName(version.environmentName || '');
|
|
61
|
+
setEnvironmentBaseUrl(version.environmentBaseUrl || '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function saveEnvironment(versionId: string) {
|
|
65
|
+
setBusyVersionId(versionId);
|
|
66
|
+
setMessage('');
|
|
67
|
+
try {
|
|
68
|
+
await onUpdateEnvironment(versionId, environmentName, environmentBaseUrl);
|
|
69
|
+
setEditingVersionId('');
|
|
70
|
+
setMessage('环境已保存');
|
|
71
|
+
} catch (error) {
|
|
72
|
+
setMessage(error instanceof Error ? error.message : '环境保存失败');
|
|
73
|
+
} finally {
|
|
74
|
+
setBusyVersionId('');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function deleteVersion(version: DocumentVersionMeta) {
|
|
79
|
+
const versionId = version.versionId;
|
|
80
|
+
if (!versionId) return;
|
|
81
|
+
if (!window.confirm(`确认删除文档版本 ${formatVersionTitle(version)}?`)) return;
|
|
82
|
+
|
|
83
|
+
setBusyVersionId(versionId);
|
|
84
|
+
setMessage('');
|
|
85
|
+
try {
|
|
86
|
+
await onDelete(versionId);
|
|
87
|
+
setMessage('版本已删除');
|
|
88
|
+
} catch (error) {
|
|
89
|
+
setMessage(error instanceof Error ? error.message : '版本删除失败');
|
|
90
|
+
} finally {
|
|
91
|
+
setBusyVersionId('');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function prepareUpdate(version: DocumentVersionMeta) {
|
|
96
|
+
const versionId = version.versionId;
|
|
97
|
+
if (!versionId) return;
|
|
98
|
+
|
|
99
|
+
setBusyVersionId(versionId);
|
|
100
|
+
setMessage('');
|
|
101
|
+
try {
|
|
102
|
+
await onPrepareUpdate(version);
|
|
103
|
+
setOpen(false);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
setMessage(error instanceof Error ? error.message : '准备更新失败');
|
|
106
|
+
} finally {
|
|
107
|
+
setBusyVersionId('');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function exportVersion(versionId?: string) {
|
|
112
|
+
if (!versionId) return;
|
|
113
|
+
const anchor = document.createElement('a');
|
|
114
|
+
anchor.href = `${VERSION_EXPORT_API_URL}?versionId=${encodeURIComponent(versionId)}`;
|
|
115
|
+
anchor.download = `${versionId}.json`;
|
|
116
|
+
document.body.appendChild(anchor);
|
|
117
|
+
anchor.click();
|
|
118
|
+
document.body.removeChild(anchor);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="version-manager">
|
|
123
|
+
<button className="version-manager-trigger" onClick={() => setOpen(true)} disabled={disabled || !versions.length}>
|
|
124
|
+
<Server size={16} />
|
|
125
|
+
<span>
|
|
126
|
+
<strong>{selectedVersion ? formatVersionTitle(selectedVersion) : '无缓存版本'}</strong>
|
|
127
|
+
<small>
|
|
128
|
+
{selectedVersion
|
|
129
|
+
? [
|
|
130
|
+
selectedVersion.environmentName || '未设置环境',
|
|
131
|
+
selectedVersion.paths === undefined ? '' : `${selectedVersion.paths} paths`,
|
|
132
|
+
selectedVersion.savedAt ? formatVersionDate(selectedVersion.savedAt) : '',
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(' · ')
|
|
136
|
+
: '导入或爬取文档后可管理版本'}
|
|
137
|
+
</small>
|
|
138
|
+
</span>
|
|
139
|
+
</button>
|
|
140
|
+
|
|
141
|
+
{open ? (
|
|
142
|
+
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="version-manager-title">
|
|
143
|
+
<div className="version-manager-dialog">
|
|
144
|
+
<div className="import-dialog-header">
|
|
145
|
+
<div>
|
|
146
|
+
<p className="eyebrow">Document Versions</p>
|
|
147
|
+
<h2 id="version-manager-title">文档版本管理</h2>
|
|
148
|
+
</div>
|
|
149
|
+
<button className="icon-secondary-button" onClick={() => setOpen(false)} aria-label="关闭">
|
|
150
|
+
<X size={17} />
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="version-manager-body">
|
|
155
|
+
{message ? <div className={message.includes('失败') || message.includes('不支持') ? 'version-message error' : 'version-message'}>{message}</div> : null}
|
|
156
|
+
{!versions.length ? (
|
|
157
|
+
<p className="muted">暂无缓存版本。</p>
|
|
158
|
+
) : (
|
|
159
|
+
versions.map((version) => {
|
|
160
|
+
const versionId = version.versionId || '';
|
|
161
|
+
const editing = editingVersionId === versionId;
|
|
162
|
+
const busy = busyVersionId === versionId;
|
|
163
|
+
const selected = versionId === selectedVersionId;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<section key={versionId} className={`version-item ${selected ? 'active' : ''}`}>
|
|
167
|
+
<div className="version-item-main">
|
|
168
|
+
<div>
|
|
169
|
+
<div className="version-item-title">
|
|
170
|
+
<strong>{formatVersionTitle(version)}</strong>
|
|
171
|
+
{selected ? <span>当前</span> : null}
|
|
172
|
+
</div>
|
|
173
|
+
<p>{versionId}</p>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="version-item-stats">
|
|
176
|
+
<span>{version.mode || 'unknown'}</span>
|
|
177
|
+
<span>{version.paths ?? 0} paths</span>
|
|
178
|
+
<span>{version.schemas ?? 0} schemas</span>
|
|
179
|
+
<span>{version.savedAt ? formatVersionDate(version.savedAt) : '-'}</span>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{editing ? (
|
|
184
|
+
<div className="version-env-editor">
|
|
185
|
+
<label>
|
|
186
|
+
环境名称
|
|
187
|
+
<input value={environmentName} onChange={(event) => setEnvironmentName(event.target.value)} placeholder="例如 dev / test / prod" />
|
|
188
|
+
</label>
|
|
189
|
+
<label>
|
|
190
|
+
Base URL
|
|
191
|
+
<input
|
|
192
|
+
value={environmentBaseUrl}
|
|
193
|
+
onChange={(event) => setEnvironmentBaseUrl(event.target.value)}
|
|
194
|
+
placeholder="https://api.example.com"
|
|
195
|
+
/>
|
|
196
|
+
</label>
|
|
197
|
+
</div>
|
|
198
|
+
) : (
|
|
199
|
+
<div className="version-env-summary">
|
|
200
|
+
<span>{version.environmentName || '未设置环境'}</span>
|
|
201
|
+
<code>{version.environmentBaseUrl || '未设置 Base URL'}</code>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
<div className="version-item-actions">
|
|
206
|
+
<button className="secondary-button" onClick={() => onSelect(versionId)} disabled={selected || busy || !versionId}>
|
|
207
|
+
<RefreshCcw size={15} />
|
|
208
|
+
切换
|
|
209
|
+
</button>
|
|
210
|
+
<button className="primary-button" onClick={() => prepareUpdate(version)} disabled={busy || !versionId}>
|
|
211
|
+
<RefreshCcw size={15} />
|
|
212
|
+
更新
|
|
213
|
+
</button>
|
|
214
|
+
{editing ? (
|
|
215
|
+
<>
|
|
216
|
+
<button className="primary-button" onClick={() => saveEnvironment(versionId)} disabled={busy || !versionId}>
|
|
217
|
+
<Check size={15} />
|
|
218
|
+
保存环境
|
|
219
|
+
</button>
|
|
220
|
+
<button className="secondary-button" onClick={() => setEditingVersionId('')} disabled={busy}>
|
|
221
|
+
取消
|
|
222
|
+
</button>
|
|
223
|
+
</>
|
|
224
|
+
) : (
|
|
225
|
+
<button className="secondary-button" onClick={() => beginEdit(version)} disabled={busy || !versionId}>
|
|
226
|
+
<Pencil size={15} />
|
|
227
|
+
环境
|
|
228
|
+
</button>
|
|
229
|
+
)}
|
|
230
|
+
<button className="secondary-button" onClick={() => exportVersion(versionId)} disabled={!versionId}>
|
|
231
|
+
<Download size={15} />
|
|
232
|
+
导出
|
|
233
|
+
</button>
|
|
234
|
+
<button className="danger-button" onClick={() => deleteVersion(version)} disabled={busy || !versionId}>
|
|
235
|
+
<Trash2 size={15} />
|
|
236
|
+
删除
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
</section>
|
|
240
|
+
);
|
|
241
|
+
})
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
) : null}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function formatVersionTitle(version: DocumentVersionMeta) {
|
|
252
|
+
return version.title || version.inputUrl || version.versionId || '未命名版本';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatVersionDate(value: string) {
|
|
256
|
+
const date = new Date(value);
|
|
257
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
258
|
+
return date.toLocaleString('zh-CN', {
|
|
259
|
+
month: '2-digit',
|
|
260
|
+
day: '2-digit',
|
|
261
|
+
hour: '2-digit',
|
|
262
|
+
minute: '2-digit',
|
|
263
|
+
});
|
|
264
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { HttpMethod, SwaggerDocument, SwaggerOperation, SwaggerParameter, SwaggerSchema } from './types';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
|
|
4
|
+
export type ManualApiFieldLocation = 'query' | 'path' | 'header' | 'cookie' | 'body';
|
|
5
|
+
|
|
6
|
+
export type ManualApiFieldConfig = {
|
|
7
|
+
name: string;
|
|
8
|
+
location?: ManualApiFieldLocation;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
type?: string;
|
|
11
|
+
format?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
enumValue?: string;
|
|
15
|
+
children?: ManualApiFieldConfig[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ManualApiResponseConfig = {
|
|
19
|
+
status: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
contentType?: string;
|
|
22
|
+
fields?: ManualApiFieldConfig[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ManualApiOperationConfig = {
|
|
26
|
+
method: HttpMethod;
|
|
27
|
+
path: string;
|
|
28
|
+
summary: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
operationId?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
parameters?: ManualApiFieldConfig[];
|
|
33
|
+
requestBody?: {
|
|
34
|
+
required?: boolean;
|
|
35
|
+
contentType?: string;
|
|
36
|
+
fields?: ManualApiFieldConfig[];
|
|
37
|
+
};
|
|
38
|
+
responses?: ManualApiResponseConfig[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const HTTP_METHODS = new Set<HttpMethod>(['get', 'post', 'put', 'delete', 'patch', 'options', 'head']);
|
|
42
|
+
|
|
43
|
+
export function parseManualApiCliText(value: string): ManualApiOperationConfig {
|
|
44
|
+
const cleaned = stripCliText(value);
|
|
45
|
+
if (!cleaned) throw new Error('请输入 API CLI 配置文本');
|
|
46
|
+
|
|
47
|
+
let parsed: unknown;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(cleaned);
|
|
50
|
+
} catch {
|
|
51
|
+
parsed = parseYaml(cleaned);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const record = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
|
|
55
|
+
return normalizeManualApiOperationConfig(record.api ?? record.config ?? record.operation ?? parsed);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createManualApiCliTemplate() {
|
|
59
|
+
return formatManualApiCliConfig({
|
|
60
|
+
method: 'post',
|
|
61
|
+
path: '/api/v1/example/{id}',
|
|
62
|
+
summary: '创建示例',
|
|
63
|
+
operationId: 'createExample',
|
|
64
|
+
tags: ['示例', '后台管理'],
|
|
65
|
+
description: '接口用途、业务约束、鉴权说明等。',
|
|
66
|
+
parameters: [
|
|
67
|
+
{
|
|
68
|
+
name: 'id',
|
|
69
|
+
location: 'path',
|
|
70
|
+
required: true,
|
|
71
|
+
type: 'string',
|
|
72
|
+
description: '示例 ID',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
requestBody: {
|
|
76
|
+
required: true,
|
|
77
|
+
contentType: 'application/json',
|
|
78
|
+
fields: [
|
|
79
|
+
{
|
|
80
|
+
name: 'name',
|
|
81
|
+
type: 'string',
|
|
82
|
+
required: true,
|
|
83
|
+
description: '名称',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'profile',
|
|
87
|
+
type: 'object',
|
|
88
|
+
description: '扩展信息',
|
|
89
|
+
children: [
|
|
90
|
+
{
|
|
91
|
+
name: 'email',
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: '邮箱',
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
responses: [
|
|
100
|
+
{
|
|
101
|
+
status: '200',
|
|
102
|
+
description: 'OK',
|
|
103
|
+
contentType: 'application/json',
|
|
104
|
+
fields: [
|
|
105
|
+
{
|
|
106
|
+
name: 'data',
|
|
107
|
+
type: 'object',
|
|
108
|
+
description: '响应数据',
|
|
109
|
+
children: [
|
|
110
|
+
{
|
|
111
|
+
name: 'id',
|
|
112
|
+
type: 'string',
|
|
113
|
+
description: '记录 ID',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function formatManualApiCliConfig(config: ManualApiOperationConfig) {
|
|
124
|
+
return JSON.stringify({ api: normalizeManualApiOperationConfig(config) });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function normalizeManualApiOperationConfig(input: unknown): ManualApiOperationConfig {
|
|
128
|
+
if (!input || typeof input !== 'object') throw new Error('接口配置不能为空');
|
|
129
|
+
const record = input as Record<string, unknown>;
|
|
130
|
+
const method = text(record.method).toLowerCase() as HttpMethod;
|
|
131
|
+
if (!HTTP_METHODS.has(method)) throw new Error('请选择有效的请求方法');
|
|
132
|
+
|
|
133
|
+
const path = normalizePath(text(record.path));
|
|
134
|
+
const summary = text(record.summary);
|
|
135
|
+
if (!path) throw new Error('接口路径不能为空');
|
|
136
|
+
if (!summary) throw new Error('接口名称不能为空');
|
|
137
|
+
|
|
138
|
+
const parameters = array(record.parameters).map(normalizeField).filter((field) => field.name);
|
|
139
|
+
const requestBodyRecord = record.requestBody && typeof record.requestBody === 'object' ? (record.requestBody as Record<string, unknown>) : {};
|
|
140
|
+
const requestFields = array(requestBodyRecord.fields).map(normalizeField).filter((field) => field.name);
|
|
141
|
+
const responses = array(record.responses).map(normalizeResponse).filter((response) => response.status);
|
|
142
|
+
if (!responses.length) responses.push({ status: '200', description: 'Success', contentType: 'application/json', fields: [] });
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
method,
|
|
146
|
+
path,
|
|
147
|
+
summary,
|
|
148
|
+
description: text(record.description),
|
|
149
|
+
operationId: text(record.operationId),
|
|
150
|
+
tags: splitTags(record.tags),
|
|
151
|
+
parameters,
|
|
152
|
+
requestBody: {
|
|
153
|
+
required: Boolean(requestBodyRecord.required),
|
|
154
|
+
contentType: text(requestBodyRecord.contentType) || 'application/json',
|
|
155
|
+
fields: requestFields,
|
|
156
|
+
},
|
|
157
|
+
responses,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createManualApiDocument(title = 'Manual API Config'): SwaggerDocument {
|
|
162
|
+
return {
|
|
163
|
+
openapi: '3.0.3',
|
|
164
|
+
info: {
|
|
165
|
+
title,
|
|
166
|
+
version: 'manual',
|
|
167
|
+
description: 'Created from API Skill Console manual configuration.',
|
|
168
|
+
},
|
|
169
|
+
paths: {},
|
|
170
|
+
components: {
|
|
171
|
+
schemas: {},
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function applyManualApiOperationConfig(document: SwaggerDocument, config: ManualApiOperationConfig): SwaggerDocument {
|
|
177
|
+
const nextDoc = cloneDocument(document);
|
|
178
|
+
const normalized = normalizeManualApiOperationConfig(config);
|
|
179
|
+
const pathItem = nextDoc.paths?.[normalized.path] ?? {};
|
|
180
|
+
pathItem[normalized.method] = buildOperation(normalized);
|
|
181
|
+
nextDoc.paths = {
|
|
182
|
+
...(nextDoc.paths ?? {}),
|
|
183
|
+
[normalized.path]: pathItem,
|
|
184
|
+
};
|
|
185
|
+
return nextDoc;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function deleteManualApiOperation(document: SwaggerDocument, method: HttpMethod, path: string): SwaggerDocument {
|
|
189
|
+
const nextDoc = cloneDocument(document);
|
|
190
|
+
const normalizedPath = normalizePath(path);
|
|
191
|
+
const pathItem = nextDoc.paths?.[normalizedPath];
|
|
192
|
+
if (!pathItem) return nextDoc;
|
|
193
|
+
|
|
194
|
+
delete pathItem[method];
|
|
195
|
+
if (!Object.keys(pathItem).length) {
|
|
196
|
+
delete nextDoc.paths?.[normalizedPath];
|
|
197
|
+
} else {
|
|
198
|
+
nextDoc.paths = {
|
|
199
|
+
...(nextDoc.paths ?? {}),
|
|
200
|
+
[normalizedPath]: pathItem,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return nextDoc;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function openApiStats(document: SwaggerDocument) {
|
|
207
|
+
return {
|
|
208
|
+
paths: Object.keys(document.paths ?? {}).length,
|
|
209
|
+
schemas: Object.keys(document.components?.schemas ?? document.definitions ?? {}).length,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildOperation(config: ManualApiOperationConfig): SwaggerOperation {
|
|
214
|
+
const bodyFields = config.requestBody?.fields?.filter((field) => field.name) ?? [];
|
|
215
|
+
const operation: SwaggerOperation = {
|
|
216
|
+
tags: config.tags?.length ? config.tags : ['手动配置'],
|
|
217
|
+
summary: config.summary,
|
|
218
|
+
description: config.description || undefined,
|
|
219
|
+
operationId: config.operationId || undefined,
|
|
220
|
+
parameters: (config.parameters ?? []).filter((field) => field.name).map(fieldToParameter),
|
|
221
|
+
responses: Object.fromEntries((config.responses ?? []).map((response) => [response.status, responseToOpenApi(response)])),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (bodyFields.length) {
|
|
225
|
+
operation.requestBody = {
|
|
226
|
+
required: Boolean(config.requestBody?.required),
|
|
227
|
+
content: {
|
|
228
|
+
[config.requestBody?.contentType || 'application/json']: {
|
|
229
|
+
schema: fieldsToObjectSchema(bodyFields),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return operation;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fieldToParameter(field: ManualApiFieldConfig): SwaggerParameter {
|
|
239
|
+
return {
|
|
240
|
+
name: field.name,
|
|
241
|
+
in: field.location === 'body' || !field.location ? 'query' : field.location,
|
|
242
|
+
required: Boolean(field.required),
|
|
243
|
+
description: field.description || undefined,
|
|
244
|
+
schema: fieldToSchema(field),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function responseToOpenApi(response: ManualApiResponseConfig) {
|
|
249
|
+
const fields = response.fields?.filter((field) => field.name) ?? [];
|
|
250
|
+
const value: NonNullable<SwaggerOperation['responses']>[string] = {
|
|
251
|
+
description: response.description || 'Success',
|
|
252
|
+
};
|
|
253
|
+
if (fields.length) {
|
|
254
|
+
value.content = {
|
|
255
|
+
[response.contentType || 'application/json']: {
|
|
256
|
+
schema: fieldsToObjectSchema(fields),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function fieldsToObjectSchema(fields: ManualApiFieldConfig[]): SwaggerSchema {
|
|
264
|
+
const schema: SwaggerSchema = {
|
|
265
|
+
type: 'object',
|
|
266
|
+
properties: {},
|
|
267
|
+
required: [],
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
fields.forEach((field) => {
|
|
271
|
+
if (!field.name) return;
|
|
272
|
+
setSchemaProperty(schema, field.name, fieldToSchema(field), Boolean(field.required));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (!schema.required?.length) delete schema.required;
|
|
276
|
+
return schema;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function setSchemaProperty(root: SwaggerSchema, path: string, schema: SwaggerSchema, required: boolean) {
|
|
280
|
+
const parts = path.split('.').map((part) => part.trim()).filter(Boolean);
|
|
281
|
+
if (!parts.length) return;
|
|
282
|
+
|
|
283
|
+
let current = root;
|
|
284
|
+
parts.forEach((part, index) => {
|
|
285
|
+
current.properties ??= {};
|
|
286
|
+
if (index === parts.length - 1) {
|
|
287
|
+
current.properties[part] = schema;
|
|
288
|
+
if (required) current.required = [...new Set([...(current.required ?? []), part])];
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const next = current.properties[part] ?? { type: 'object', properties: {}, required: [] };
|
|
293
|
+
next.type = next.type || 'object';
|
|
294
|
+
next.properties ??= {};
|
|
295
|
+
current.properties[part] = next;
|
|
296
|
+
current = next;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function fieldToSchema(field: ManualApiFieldConfig): SwaggerSchema {
|
|
301
|
+
const schema: SwaggerSchema = {
|
|
302
|
+
type: field.type || 'string',
|
|
303
|
+
format: field.format || undefined,
|
|
304
|
+
description: field.description || undefined,
|
|
305
|
+
};
|
|
306
|
+
const enumValue = parseEnum(field.enumValue);
|
|
307
|
+
if (enumValue.length) schema.enum = enumValue;
|
|
308
|
+
if (field.defaultValue) schema.default = coerceValue(field.defaultValue, schema.type);
|
|
309
|
+
if (schema.type === 'object') {
|
|
310
|
+
const children = field.children?.filter((child) => child.name) ?? [];
|
|
311
|
+
if (children.length) {
|
|
312
|
+
const childSchema = fieldsToObjectSchema(children);
|
|
313
|
+
schema.properties = childSchema.properties;
|
|
314
|
+
schema.required = childSchema.required;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (schema.type === 'array') {
|
|
318
|
+
const children = field.children?.filter((child) => child.name) ?? [];
|
|
319
|
+
schema.items = children.length ? fieldsToObjectSchema(children) : { type: 'string' };
|
|
320
|
+
}
|
|
321
|
+
return schema;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function normalizeResponse(input: unknown): ManualApiResponseConfig {
|
|
325
|
+
const record = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
326
|
+
return {
|
|
327
|
+
status: text(record.status) || '200',
|
|
328
|
+
description: text(record.description) || 'Success',
|
|
329
|
+
contentType: text(record.contentType) || 'application/json',
|
|
330
|
+
fields: array(record.fields).map(normalizeField).filter((field) => field.name),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeField(input: unknown): ManualApiFieldConfig {
|
|
335
|
+
const record = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
|
336
|
+
const location = text(record.location) as ManualApiFieldLocation;
|
|
337
|
+
return {
|
|
338
|
+
name: text(record.name),
|
|
339
|
+
location: ['query', 'path', 'header', 'cookie', 'body'].includes(location) ? location : 'query',
|
|
340
|
+
required: Boolean(record.required),
|
|
341
|
+
type: text(record.type) || 'string',
|
|
342
|
+
format: text(record.format),
|
|
343
|
+
description: text(record.description),
|
|
344
|
+
defaultValue: text(record.defaultValue),
|
|
345
|
+
enumValue: text(record.enumValue),
|
|
346
|
+
children: array(record.children).map(normalizeField).filter((field) => field.name),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stripCliText(value: string) {
|
|
351
|
+
return value
|
|
352
|
+
.trim()
|
|
353
|
+
.replace(/^```(?:json|ya?ml|yaml)?\s*/i, '')
|
|
354
|
+
.replace(/```$/i, '')
|
|
355
|
+
.trim();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function splitTags(value: unknown) {
|
|
359
|
+
if (Array.isArray(value)) return value.map(text).filter(Boolean);
|
|
360
|
+
return text(value)
|
|
361
|
+
.split(',')
|
|
362
|
+
.map((item) => item.trim())
|
|
363
|
+
.filter(Boolean);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function parseEnum(value?: string) {
|
|
367
|
+
return (value || '')
|
|
368
|
+
.split(',')
|
|
369
|
+
.map((item) => item.trim())
|
|
370
|
+
.filter(Boolean)
|
|
371
|
+
.map((item) => coerceValue(item));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function coerceValue(value: string, type?: string): unknown {
|
|
375
|
+
if (type === 'number' || type === 'integer') {
|
|
376
|
+
const numeric = Number(value);
|
|
377
|
+
return Number.isFinite(numeric) ? numeric : value;
|
|
378
|
+
}
|
|
379
|
+
if (type === 'boolean') {
|
|
380
|
+
if (value === 'true') return true;
|
|
381
|
+
if (value === 'false') return false;
|
|
382
|
+
}
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizePath(value: string) {
|
|
387
|
+
if (!value) return '';
|
|
388
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function text(value: unknown) {
|
|
392
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function array(value: unknown) {
|
|
396
|
+
return Array.isArray(value) ? value : [];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function cloneDocument(document: SwaggerDocument): SwaggerDocument {
|
|
400
|
+
return JSON.parse(JSON.stringify(document || createManualApiDocument())) as SwaggerDocument;
|
|
401
|
+
}
|