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.
@@ -0,0 +1,590 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { FormEvent } from 'react';
3
+ import { AlertCircle, Check, ClipboardCopy, Plus, Save, Trash2 } from 'lucide-react';
4
+ import {
5
+ createManualApiCliTemplate,
6
+ formatManualApiCliConfig,
7
+ normalizeManualApiOperationConfig,
8
+ parseManualApiCliText,
9
+ } from './manualApiConfig';
10
+ import type { ManualApiFieldConfig, ManualApiOperationConfig, ManualApiResponseConfig } from './manualApiConfig';
11
+ import type { HttpMethod } from './types';
12
+
13
+ type AddApiDialogProps = {
14
+ selectedVersionId: string;
15
+ saving: boolean;
16
+ initialConfig?: ManualApiOperationConfig;
17
+ onClose: () => void;
18
+ onSave: (config: ManualApiOperationConfig) => void;
19
+ };
20
+
21
+ const HTTP_METHODS: HttpMethod[] = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
22
+ const FIELD_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object'];
23
+ const PARAM_LOCATIONS: Array<NonNullable<ManualApiFieldConfig['location']>> = ['query', 'path', 'header', 'cookie'];
24
+
25
+ export function AddApiDialog({ selectedVersionId, saving, initialConfig, onClose, onSave }: AddApiDialogProps) {
26
+ const initial = useMemo(() => initialConfig ?? parseManualApiCliText(createManualApiCliTemplate()), [initialConfig]);
27
+ const [method, setMethod] = useState<HttpMethod>(initial.method);
28
+ const [path, setPath] = useState(initial.path);
29
+ const [summary, setSummary] = useState(initial.summary);
30
+ const [operationId, setOperationId] = useState(initial.operationId ?? '');
31
+ const [tags, setTags] = useState((initial.tags ?? []).join(','));
32
+ const [description, setDescription] = useState(initial.description ?? '');
33
+ const [requestBodyRequired, setRequestBodyRequired] = useState(Boolean(initial.requestBody?.required));
34
+ const [requestContentType, setRequestContentType] = useState(initial.requestBody?.contentType || 'application/json');
35
+ const [parameters, setParameters] = useState<ManualApiFieldConfig[]>(initial.parameters?.length ? initial.parameters : [emptyManualField('query')]);
36
+ const [bodyFields, setBodyFields] = useState<ManualApiFieldConfig[]>(initial.requestBody?.fields?.length ? initial.requestBody.fields : [emptyManualField('body')]);
37
+ const [responses, setResponses] = useState<ManualApiResponseConfig[]>(initial.responses?.length ? initial.responses : [emptyManualResponse()]);
38
+ const [cliText, setCliText] = useState(() => formatManualApiCliConfig(initial));
39
+ const [cliCopied, setCliCopied] = useState(false);
40
+ const [validationErrors, setValidationErrors] = useState<string[]>([]);
41
+ const skipNextCliSync = useRef(false);
42
+ const cliEditorRef = useRef<HTMLTextAreaElement | null>(null);
43
+
44
+ const modeLabel = initialConfig ? '编辑当前接口' : selectedVersionId ? '追加到当前版本' : '保存为新版本';
45
+ const formConfig = useMemo(
46
+ () =>
47
+ buildFormConfig({
48
+ method,
49
+ path,
50
+ summary,
51
+ operationId,
52
+ tags,
53
+ description,
54
+ requestBodyRequired,
55
+ requestContentType,
56
+ parameters,
57
+ bodyFields,
58
+ responses,
59
+ }),
60
+ [bodyFields, description, method, operationId, parameters, path, requestBodyRequired, requestContentType, responses, summary, tags],
61
+ );
62
+
63
+ useEffect(() => {
64
+ if (skipNextCliSync.current) {
65
+ skipNextCliSync.current = false;
66
+ return;
67
+ }
68
+ setCliText(formatCliDraft(formConfig));
69
+ }, [formConfig]);
70
+
71
+ useEffect(() => {
72
+ const editor = cliEditorRef.current;
73
+ if (!editor) return;
74
+ const style = window.getComputedStyle(editor);
75
+ const lineHeight = Number.parseFloat(style.lineHeight) || 18;
76
+ const padding =
77
+ (Number.parseFloat(style.paddingTop) || 0) +
78
+ (Number.parseFloat(style.paddingBottom) || 0) +
79
+ (Number.parseFloat(style.borderTopWidth) || 0) +
80
+ (Number.parseFloat(style.borderBottomWidth) || 0);
81
+ const minHeight = lineHeight * 3 + padding;
82
+ const maxHeight = lineHeight * 16 + padding;
83
+
84
+ editor.style.height = 'auto';
85
+ editor.style.height = `${Math.min(maxHeight, Math.max(minHeight, editor.scrollHeight))}px`;
86
+ }, [cliText]);
87
+
88
+ function submit(event: FormEvent<HTMLFormElement>) {
89
+ event.preventDefault();
90
+ const result = parseCliConfig(cliText);
91
+ if (result.errors.length) {
92
+ setValidationErrors(result.errors);
93
+ return;
94
+ }
95
+ setValidationErrors([]);
96
+ onSave(result.config);
97
+ }
98
+
99
+ function updateCli(value: string) {
100
+ setCliText(value);
101
+ const result = parseCliConfig(value);
102
+ if (result.errors.length) return;
103
+ skipNextCliSync.current = true;
104
+ setValidationErrors([]);
105
+ hydrateForm(result.config);
106
+ }
107
+
108
+ function hydrateForm(config: ManualApiOperationConfig) {
109
+ setMethod(config.method);
110
+ setPath(config.path);
111
+ setSummary(config.summary);
112
+ setOperationId(config.operationId ?? '');
113
+ setTags((config.tags ?? []).join(','));
114
+ setDescription(config.description ?? '');
115
+ setRequestBodyRequired(Boolean(config.requestBody?.required));
116
+ setRequestContentType(config.requestBody?.contentType || 'application/json');
117
+ setParameters(config.parameters?.length ? config.parameters : [emptyManualField('query')]);
118
+ setBodyFields(config.requestBody?.fields?.length ? config.requestBody.fields : [emptyManualField('body')]);
119
+ setResponses(config.responses?.length ? config.responses : [emptyManualResponse()]);
120
+ }
121
+
122
+ function copyCliText() {
123
+ if (copyTextWithSelection(cliText, cliEditorRef.current)) {
124
+ markCliCopied();
125
+ return;
126
+ }
127
+ void navigator.clipboard.writeText(cliText).then(markCliCopied).catch(() => undefined);
128
+ }
129
+
130
+ function markCliCopied() {
131
+ setCliCopied(true);
132
+ window.setTimeout(() => setCliCopied(false), 1600);
133
+ }
134
+
135
+ return (
136
+ <div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="add-api-title">
137
+ <form className="api-config-dialog" onSubmit={submit} noValidate>
138
+ <div className="import-dialog-header">
139
+ <div>
140
+ <p className="eyebrow">Manual API Config</p>
141
+ <h2 id="add-api-title">{initialConfig ? '编辑API配置' : '新增API配置'}</h2>
142
+ </div>
143
+ <span className="import-badge running">{modeLabel}</span>
144
+ </div>
145
+
146
+ <div className="api-config-body">
147
+ {validationErrors.length ? <ValidationSummary errors={validationErrors} /> : null}
148
+
149
+ <section className="api-config-section">
150
+ <div className="section-title-row">
151
+ <div>
152
+ <h3>CLI配置</h3>
153
+ <p>支持紧凑 JSON 或 YAML。CLI 与下方表单保持联动,粘贴有效配置后会自动同步表单。</p>
154
+ </div>
155
+ <button type="button" className="secondary-button" onClick={copyCliText}>
156
+ {cliCopied ? <Check size={15} /> : <ClipboardCopy size={15} />}
157
+ {cliCopied ? '已复制' : '复制'}
158
+ </button>
159
+ </div>
160
+ <textarea
161
+ ref={cliEditorRef}
162
+ className="api-cli-editor"
163
+ value={cliText}
164
+ onChange={(event) => updateCli(event.target.value)}
165
+ rows={3}
166
+ spellCheck={false}
167
+ />
168
+ </section>
169
+
170
+ <section className="api-config-section">
171
+ <div className="section-title-row">
172
+ <h3>基础信息</h3>
173
+ <code>{selectedVersionId || 'manual-new-version'}</code>
174
+ </div>
175
+ <div className="api-config-grid">
176
+ <label>
177
+ <span>方法 *</span>
178
+ <select value={method} onChange={(event) => setMethod(event.target.value as HttpMethod)}>
179
+ {HTTP_METHODS.map((item) => (
180
+ <option key={item} value={item}>
181
+ {item.toUpperCase()}
182
+ </option>
183
+ ))}
184
+ </select>
185
+ </label>
186
+ <label className="span-2">
187
+ <span>路径 *</span>
188
+ <input value={path} onChange={(event) => setPath(event.target.value)} placeholder="/api/v1/example/{id}" />
189
+ </label>
190
+ <label>
191
+ <span>接口名称 *</span>
192
+ <input value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="查询示例详情" />
193
+ </label>
194
+ <label>
195
+ <span>Operation ID</span>
196
+ <input value={operationId} onChange={(event) => setOperationId(event.target.value)} placeholder="getExampleDetail" />
197
+ </label>
198
+ <label>
199
+ <span>分组 Tags</span>
200
+ <input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="示例,后台管理" />
201
+ </label>
202
+ <label className="span-all">
203
+ <span>说明</span>
204
+ <textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="接口用途、业务约束、注意事项" />
205
+ </label>
206
+ </div>
207
+ </section>
208
+
209
+ <FieldConfigSection
210
+ title="参数"
211
+ description="用于 path / query / header / cookie 参数。body 字段请放到请求体。"
212
+ fields={parameters}
213
+ allowLocation
214
+ onAdd={() => setParameters([...parameters, emptyManualField('query')])}
215
+ onChange={(index, field) => setParameters(updateAt(parameters, index, field))}
216
+ onRemove={(index) => setParameters(removeAt(parameters, index, emptyManualField('query')))}
217
+ />
218
+
219
+ <section className="api-config-section">
220
+ <div className="section-title-row">
221
+ <div>
222
+ <h3>请求体</h3>
223
+ <p>对象字段可以展开配置子字段,也支持用点号声明嵌套字段,例如 user.name。</p>
224
+ </div>
225
+ <label className="inline-check">
226
+ <input type="checkbox" checked={requestBodyRequired} onChange={(event) => setRequestBodyRequired(event.target.checked)} />
227
+ 必填
228
+ </label>
229
+ </div>
230
+ <div className="api-config-grid compact">
231
+ <label>
232
+ <span>Content-Type</span>
233
+ <input value={requestContentType} onChange={(event) => setRequestContentType(event.target.value)} />
234
+ </label>
235
+ </div>
236
+ <FieldRows
237
+ fields={bodyFields}
238
+ onAdd={() => setBodyFields([...bodyFields, emptyManualField('body')])}
239
+ onChange={(index, field) => setBodyFields(updateAt(bodyFields, index, { ...field, location: 'body' }))}
240
+ onRemove={(index) => setBodyFields(removeAt(bodyFields, index, emptyManualField('body')))}
241
+ />
242
+ </section>
243
+
244
+ <section className="api-config-section">
245
+ <div className="section-title-row">
246
+ <div>
247
+ <h3>响应</h3>
248
+ <p>每个状态码可配置一组响应字段,保存时会写入 OpenAPI responses。</p>
249
+ </div>
250
+ <button type="button" className="secondary-button" onClick={() => setResponses([...responses, emptyManualResponse()])}>
251
+ <Plus size={15} />
252
+ 状态码
253
+ </button>
254
+ </div>
255
+ {responses.map((response, responseIndex) => (
256
+ <div className="response-config" key={`response-${responseIndex}`}>
257
+ <div className="api-config-grid compact">
258
+ <label>
259
+ <span>状态码 *</span>
260
+ <input value={response.status} onChange={(event) => setResponses(updateAt(responses, responseIndex, { ...response, status: event.target.value }))} />
261
+ </label>
262
+ <label>
263
+ <span>描述</span>
264
+ <input
265
+ value={response.description ?? ''}
266
+ onChange={(event) => setResponses(updateAt(responses, responseIndex, { ...response, description: event.target.value }))}
267
+ />
268
+ </label>
269
+ <label>
270
+ <span>Content-Type</span>
271
+ <input
272
+ value={response.contentType ?? 'application/json'}
273
+ onChange={(event) => setResponses(updateAt(responses, responseIndex, { ...response, contentType: event.target.value }))}
274
+ />
275
+ </label>
276
+ <button type="button" className="icon-danger-button" onClick={() => setResponses(removeAt(responses, responseIndex, emptyManualResponse()))}>
277
+ <Trash2 size={15} />
278
+ </button>
279
+ </div>
280
+ <FieldRows
281
+ fields={response.fields?.length ? response.fields : [emptyManualField('body')]}
282
+ onAdd={() => setResponses(updateAt(responses, responseIndex, { ...response, fields: [...(response.fields ?? []), emptyManualField('body')] }))}
283
+ onChange={(fieldIndex, field) =>
284
+ setResponses(updateAt(responses, responseIndex, { ...response, fields: updateAt(response.fields ?? [], fieldIndex, { ...field, location: 'body' }) }))
285
+ }
286
+ onRemove={(fieldIndex) =>
287
+ setResponses(updateAt(responses, responseIndex, { ...response, fields: removeAt(response.fields ?? [], fieldIndex, emptyManualField('body')) }))
288
+ }
289
+ />
290
+ </div>
291
+ ))}
292
+ </section>
293
+ </div>
294
+
295
+ <div className="import-dialog-actions">
296
+ <button className="secondary-button" type="button" onClick={onClose} disabled={saving}>
297
+ 取消
298
+ </button>
299
+ <button className="primary-button" type="submit" disabled={saving}>
300
+ <Save size={16} />
301
+ {saving ? '保存中' : '保存'}
302
+ </button>
303
+ </div>
304
+ </form>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function ValidationSummary({ errors }: { errors: string[] }) {
310
+ return (
311
+ <div className="api-config-errors">
312
+ <AlertCircle size={17} />
313
+ <div>
314
+ <strong>请补全必填配置</strong>
315
+ {errors.map((error) => (
316
+ <p key={error}>{error}</p>
317
+ ))}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function FieldConfigSection({
324
+ title,
325
+ description,
326
+ fields,
327
+ allowLocation,
328
+ onAdd,
329
+ onChange,
330
+ onRemove,
331
+ }: {
332
+ title: string;
333
+ description: string;
334
+ fields: ManualApiFieldConfig[];
335
+ allowLocation?: boolean;
336
+ onAdd: () => void;
337
+ onChange: (index: number, field: ManualApiFieldConfig) => void;
338
+ onRemove: (index: number) => void;
339
+ }) {
340
+ return (
341
+ <section className="api-config-section">
342
+ <div className="section-title-row">
343
+ <div>
344
+ <h3>{title}</h3>
345
+ <p>{description}</p>
346
+ </div>
347
+ <button type="button" className="secondary-button" onClick={onAdd}>
348
+ <Plus size={15} />
349
+ 字段
350
+ </button>
351
+ </div>
352
+ <FieldRows fields={fields} allowLocation={allowLocation} onAdd={onAdd} onChange={onChange} onRemove={onRemove} />
353
+ </section>
354
+ );
355
+ }
356
+
357
+ function FieldRows({
358
+ fields,
359
+ allowLocation,
360
+ onAdd,
361
+ onChange,
362
+ onRemove,
363
+ }: {
364
+ fields: ManualApiFieldConfig[];
365
+ allowLocation?: boolean;
366
+ onAdd: () => void;
367
+ onChange: (index: number, field: ManualApiFieldConfig) => void;
368
+ onRemove: (index: number) => void;
369
+ }) {
370
+ return (
371
+ <div className="field-config-table">
372
+ <div className={`field-config-head ${allowLocation ? 'with-location' : ''}`}>
373
+ {allowLocation ? <span>位置</span> : null}
374
+ <span>字段名</span>
375
+ <span>类型</span>
376
+ <span>必填</span>
377
+ <span>说明</span>
378
+ <span>枚举</span>
379
+ <span />
380
+ </div>
381
+ {fields.map((field, index) => (
382
+ <div className="field-config-item" key={`field-${index}`}>
383
+ <div className={`field-config-row ${allowLocation ? 'with-location' : ''}`}>
384
+ {allowLocation ? (
385
+ <select value={field.location ?? 'query'} onChange={(event) => onChange(index, { ...field, location: event.target.value as ManualApiFieldConfig['location'] })}>
386
+ {PARAM_LOCATIONS.map((item) => (
387
+ <option key={item} value={item}>
388
+ {item}
389
+ </option>
390
+ ))}
391
+ </select>
392
+ ) : null}
393
+ <input value={field.name} onChange={(event) => onChange(index, { ...field, name: event.target.value })} placeholder="id / user.name" />
394
+ <select value={field.type || 'string'} onChange={(event) => onChange(index, normalizeFieldType(field, event.target.value))}>
395
+ {FIELD_TYPES.map((item) => (
396
+ <option key={item} value={item}>
397
+ {item}
398
+ </option>
399
+ ))}
400
+ </select>
401
+ <label className="field-check">
402
+ <input type="checkbox" checked={Boolean(field.required)} onChange={(event) => onChange(index, { ...field, required: event.target.checked })} />
403
+ </label>
404
+ <input value={field.description ?? ''} onChange={(event) => onChange(index, { ...field, description: event.target.value })} placeholder="字段说明" />
405
+ <input value={field.enumValue ?? ''} onChange={(event) => onChange(index, { ...field, enumValue: event.target.value })} placeholder="a,b,c" />
406
+ <button type="button" className="icon-danger-button" onClick={() => onRemove(index)} aria-label="删除字段">
407
+ <Trash2 size={15} />
408
+ </button>
409
+ </div>
410
+ {field.type === 'object' || field.type === 'array' ? (
411
+ <div className="child-field-config">
412
+ <div className="child-field-title">下一级结构</div>
413
+ <FieldRows
414
+ fields={field.children?.length ? field.children : [emptyManualField('body')]}
415
+ onAdd={() => onChange(index, { ...field, children: [...(field.children ?? []), emptyManualField('body')] })}
416
+ onChange={(childIndex, child) => onChange(index, { ...field, children: updateAt(field.children ?? [], childIndex, child) })}
417
+ onRemove={(childIndex) => onChange(index, { ...field, children: removeAt(field.children ?? [], childIndex, emptyManualField('body')) })}
418
+ />
419
+ </div>
420
+ ) : null}
421
+ </div>
422
+ ))}
423
+ <button type="button" className="field-add-row" onClick={onAdd}>
424
+ <Plus size={14} />
425
+ 增加字段
426
+ </button>
427
+ </div>
428
+ );
429
+ }
430
+
431
+ function buildFormConfig(input: {
432
+ method: HttpMethod;
433
+ path: string;
434
+ summary: string;
435
+ operationId: string;
436
+ tags: string;
437
+ description: string;
438
+ requestBodyRequired: boolean;
439
+ requestContentType: string;
440
+ parameters: ManualApiFieldConfig[];
441
+ bodyFields: ManualApiFieldConfig[];
442
+ responses: ManualApiResponseConfig[];
443
+ }): ManualApiOperationConfig {
444
+ return {
445
+ method: input.method,
446
+ path: input.path,
447
+ summary: input.summary,
448
+ operationId: input.operationId,
449
+ tags: input.tags
450
+ .split(',')
451
+ .map((item) => item.trim())
452
+ .filter(Boolean),
453
+ description: input.description,
454
+ parameters: input.parameters,
455
+ requestBody: {
456
+ required: input.requestBodyRequired,
457
+ contentType: input.requestContentType,
458
+ fields: input.bodyFields,
459
+ },
460
+ responses: input.responses,
461
+ };
462
+ }
463
+
464
+ function parseCliConfig(cliText: string) {
465
+ try {
466
+ return validateConfig(parseManualApiCliText(cliText));
467
+ } catch (error) {
468
+ return {
469
+ config: emptyManualConfig(),
470
+ errors: [error instanceof Error ? error.message : 'CLI 配置解析失败'],
471
+ };
472
+ }
473
+ }
474
+
475
+ function formatCliDraft(config: ManualApiOperationConfig) {
476
+ return JSON.stringify({ api: config });
477
+ }
478
+
479
+ function validateConfig(config: ManualApiOperationConfig) {
480
+ const errors: string[] = [];
481
+ if (!config.method) errors.push('请选择请求方法');
482
+ if (!config.path?.trim()) errors.push('请填写接口路径');
483
+ if (!config.summary?.trim()) errors.push('请填写接口名称');
484
+
485
+ validateFields(config.parameters ?? [], '参数', errors);
486
+ validateFields(config.requestBody?.fields ?? [], '请求体字段', errors);
487
+ (config.responses ?? []).forEach((response, responseIndex) => {
488
+ if (!response.status?.trim()) errors.push(`响应 #${responseIndex + 1} 请填写状态码`);
489
+ validateFields(response.fields ?? [], `响应 ${response.status || `#${responseIndex + 1}`} 字段`, errors);
490
+ });
491
+
492
+ if (errors.length) return { config, errors };
493
+
494
+ try {
495
+ return { config: normalizeManualApiOperationConfig(config), errors: [] };
496
+ } catch (error) {
497
+ return {
498
+ config,
499
+ errors: [error instanceof Error ? error.message : '接口配置校验失败'],
500
+ };
501
+ }
502
+ }
503
+
504
+ function validateFields(fields: ManualApiFieldConfig[], label: string, errors: string[], parent = '') {
505
+ fields.forEach((field, index) => {
506
+ const fieldLabel = `${label}${parent ? ` ${parent}` : ''} #${index + 1}`;
507
+ if (!hasMeaningfulField(field)) return;
508
+ if (!field.name?.trim()) errors.push(`${fieldLabel} 请填写字段名`);
509
+ if (field.type === 'object' || field.type === 'array') validateFields(field.children ?? [], label, errors, field.name || `#${index + 1}`);
510
+ });
511
+ }
512
+
513
+ function copyTextWithSelection(value: string, preferredTextarea?: HTMLTextAreaElement | null) {
514
+ if (preferredTextarea) {
515
+ preferredTextarea.focus();
516
+ preferredTextarea.select();
517
+ return document.execCommand('copy');
518
+ }
519
+
520
+ const textarea = document.createElement('textarea');
521
+ textarea.value = value;
522
+ textarea.setAttribute('readonly', 'true');
523
+ textarea.style.position = 'fixed';
524
+ textarea.style.left = '-9999px';
525
+ textarea.style.top = '0';
526
+ document.body.appendChild(textarea);
527
+ textarea.focus();
528
+ textarea.select();
529
+
530
+ try {
531
+ return document.execCommand('copy');
532
+ } finally {
533
+ document.body.removeChild(textarea);
534
+ }
535
+ }
536
+
537
+ function hasMeaningfulField(field: ManualApiFieldConfig) {
538
+ return Boolean(
539
+ field.name?.trim() ||
540
+ field.description?.trim() ||
541
+ field.enumValue?.trim() ||
542
+ field.defaultValue?.trim() ||
543
+ field.required ||
544
+ field.children?.some(hasMeaningfulField),
545
+ );
546
+ }
547
+
548
+ function normalizeFieldType(field: ManualApiFieldConfig, type: string): ManualApiFieldConfig {
549
+ if (type === 'object' || type === 'array') return { ...field, type, children: field.children?.length ? field.children : [emptyManualField('body')] };
550
+ return { ...field, type, children: undefined };
551
+ }
552
+
553
+ function emptyManualConfig(): ManualApiOperationConfig {
554
+ return {
555
+ method: 'get',
556
+ path: '',
557
+ summary: '',
558
+ responses: [emptyManualResponse()],
559
+ };
560
+ }
561
+
562
+ function emptyManualField(location: ManualApiFieldConfig['location'] = 'query'): ManualApiFieldConfig {
563
+ return {
564
+ name: '',
565
+ location,
566
+ required: false,
567
+ type: 'string',
568
+ description: '',
569
+ enumValue: '',
570
+ };
571
+ }
572
+
573
+ function emptyManualResponse(): ManualApiResponseConfig {
574
+ return {
575
+ status: '200',
576
+ description: 'Success',
577
+ contentType: 'application/json',
578
+ fields: [emptyManualField('body')],
579
+ };
580
+ }
581
+
582
+ function updateAt<T>(items: T[], index: number, value: T) {
583
+ if (index >= items.length) return [...items, value];
584
+ return items.map((item, currentIndex) => (currentIndex === index ? value : item));
585
+ }
586
+
587
+ function removeAt<T>(items: T[], index: number, fallback: T) {
588
+ const next = items.filter((_, currentIndex) => currentIndex !== index);
589
+ return next.length ? next : [fallback];
590
+ }