@aion0/forge 0.5.48 → 0.5.49
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/CLAUDE.md +0 -1
- package/RELEASE_NOTES.md +8 -3
- package/app/api/tasks/[id]/log/entry/route.ts +13 -0
- package/app/api/tasks/[id]/log/route.ts +23 -0
- package/app/api/tasks/route.ts +2 -2
- package/components/ProjectDetail.tsx +1 -16
- package/components/TaskDetail.tsx +201 -51
- package/lib/help-docs/CLAUDE.md +0 -2
- package/lib/task-manager.ts +110 -0
- package/package.json +1 -1
- package/src/types/index.ts +7 -0
- package/app/api/migration/config/route.ts +0 -19
- package/app/api/migration/discover/route.ts +0 -26
- package/app/api/migration/failures/route.ts +0 -35
- package/app/api/migration/fix/route.ts +0 -82
- package/app/api/migration/run/route.ts +0 -22
- package/app/api/migration/run-batch/route.ts +0 -86
- package/components/MigrationCockpit.tsx +0 -541
- package/lib/help-docs/14-migration.md +0 -154
- package/lib/migration/differ.ts +0 -193
- package/lib/migration/discoverer.ts +0 -363
- package/lib/migration/openapi.ts +0 -137
- package/lib/migration/runner.ts +0 -219
- package/lib/migration/store.ts +0 -89
- package/lib/migration/types.ts +0 -115
|
@@ -1,363 +0,0 @@
|
|
|
1
|
-
// Discover endpoints. Strategy:
|
|
2
|
-
// - If openApiSpec is configured → OpenAPI is the PRIMARY source (full surface).
|
|
3
|
-
// Per-controller docs + history file are then used to ANNOTATE migration status.
|
|
4
|
-
// - Otherwise → fall back to per-controller doc parsing (legacy behavior).
|
|
5
|
-
|
|
6
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
-
import { createHash } from 'node:crypto';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import type { Endpoint, EndpointStatus, HttpMethod, MigrationConfig } from './types';
|
|
10
|
-
import { loadOpenApi, getResponseSchema, type OpenApiDoc } from './openapi';
|
|
11
|
-
|
|
12
|
-
function endpointId(method: string, path: string): string {
|
|
13
|
-
return createHash('sha1').update(`${method.toUpperCase()} ${path}`).digest('hex').slice(0, 12);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const METHOD_PATH_RE = /\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b\s+(\/[^\s`|<>]+)/i;
|
|
17
|
-
const PATH_ANNOTATION_RE = /@Path\(\s*"([^"]+)"\s*\)/;
|
|
18
|
-
|
|
19
|
-
type SectionKind = 'migrated' | 'stubbed' | 'parity-only' | 'unknown';
|
|
20
|
-
|
|
21
|
-
function classifyHeading(line: string): SectionKind | null {
|
|
22
|
-
const lower = line.toLowerCase();
|
|
23
|
-
if (lower.includes('url parity') || lower.includes('url-parity')) return 'parity-only';
|
|
24
|
-
if (lower.includes('stub') || lower.includes('🚫') || lower.includes('501') || lower.includes('not implemented')) return 'stubbed';
|
|
25
|
-
if (lower.includes('migrated') || lower.includes('✅') || lower.includes('implemented')) return 'migrated';
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function expandPath(rawPath: string, prefix: string | undefined): string | null {
|
|
30
|
-
if (/^\/?\.\.\.?$/.test(rawPath)) return null;
|
|
31
|
-
if (rawPath.startsWith('/...')) {
|
|
32
|
-
if (!prefix) return null;
|
|
33
|
-
return prefix.replace(/\/$/, '') + rawPath.slice(4);
|
|
34
|
-
}
|
|
35
|
-
if (rawPath.startsWith('.../')) {
|
|
36
|
-
if (!prefix) return null;
|
|
37
|
-
return prefix.replace(/\/$/, '') + '/' + rawPath.slice(4);
|
|
38
|
-
}
|
|
39
|
-
return rawPath;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── Per-controller doc parser → annotation map ──────────
|
|
43
|
-
// Returns Map<"METHOD path", { kind, controller, file }>
|
|
44
|
-
|
|
45
|
-
interface DocAnnotation {
|
|
46
|
-
kind: SectionKind;
|
|
47
|
-
controller: string;
|
|
48
|
-
file: string; // relative doc file
|
|
49
|
-
notes?: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function parsePerControllerDocs(projectPath: string, dirRel: string): {
|
|
53
|
-
byKey: Map<string, DocAnnotation>;
|
|
54
|
-
warnings: string[];
|
|
55
|
-
} {
|
|
56
|
-
const byKey = new Map<string, DocAnnotation>();
|
|
57
|
-
const warnings: string[] = [];
|
|
58
|
-
const dir = join(projectPath, dirRel);
|
|
59
|
-
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
60
|
-
warnings.push(`Per-controller docs dir not found: ${dir}`);
|
|
61
|
-
return { byKey, warnings };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
for (const f of readdirSync(dir)) {
|
|
65
|
-
if (!f.endsWith('.md') || f.startsWith('_')) continue;
|
|
66
|
-
const filePath = join(dir, f);
|
|
67
|
-
const content = readFileSync(filePath, 'utf8');
|
|
68
|
-
|
|
69
|
-
const titleMatch = content.match(/^#\s+([A-Za-z0-9_$]+)(?:\.java)?\b/m);
|
|
70
|
-
const controller = titleMatch?.[1] || f.replace(/\.java\.md$/i, '');
|
|
71
|
-
const pathAnno = content.match(PATH_ANNOTATION_RE)?.[1];
|
|
72
|
-
|
|
73
|
-
let currentKind: SectionKind = 'unknown';
|
|
74
|
-
let inTable = false;
|
|
75
|
-
let count = 0;
|
|
76
|
-
|
|
77
|
-
for (const raw of content.split('\n')) {
|
|
78
|
-
const line = raw.trimEnd();
|
|
79
|
-
if (/^#{2,6}\s/.test(line)) {
|
|
80
|
-
const k = classifyHeading(line);
|
|
81
|
-
if (k) currentKind = k;
|
|
82
|
-
else {
|
|
83
|
-
const lower = line.toLowerCase();
|
|
84
|
-
if (lower.includes('what it does') || lower.includes('files added') || lower.includes('changelog')) {
|
|
85
|
-
currentKind = 'unknown';
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
inTable = false;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (currentKind === 'unknown' && /url[- ]parity[- ]only/i.test(line)) currentKind = 'parity-only';
|
|
92
|
-
|
|
93
|
-
if (line.startsWith('|')) {
|
|
94
|
-
const cells = line.split('|').slice(1, -1).map(c => c.trim());
|
|
95
|
-
if (cells.length === 0) continue;
|
|
96
|
-
if (cells.every(c => /^:?-+:?$/.test(c))) continue;
|
|
97
|
-
if (!inTable) {
|
|
98
|
-
const lower = cells.map(c => c.toLowerCase()).join(' | ');
|
|
99
|
-
if (/\bhttp\b|\bpath\b|\bmethod\b|\bendpoint\b|\bverb\b/.test(lower)) {
|
|
100
|
-
inTable = true; continue;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const m = line.match(METHOD_PATH_RE);
|
|
104
|
-
if (m) {
|
|
105
|
-
const expanded = expandPath(m[2].trim(), pathAnno);
|
|
106
|
-
if (expanded) {
|
|
107
|
-
const key = `${m[1].toUpperCase()} ${expanded}`;
|
|
108
|
-
const notes = cells.slice(1).join(' | ').replace(/`/g, '').trim() || undefined;
|
|
109
|
-
byKey.set(key, { kind: currentKind === 'unknown' ? 'migrated' : currentKind, controller, file: f, notes });
|
|
110
|
-
count++;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if ((line.startsWith('-') || line.startsWith('*'))) {
|
|
117
|
-
const m = line.match(METHOD_PATH_RE);
|
|
118
|
-
if (m) {
|
|
119
|
-
const expanded = expandPath(m[2].trim(), pathAnno);
|
|
120
|
-
if (expanded) {
|
|
121
|
-
const key = `${m[1].toUpperCase()} ${expanded}`;
|
|
122
|
-
byKey.set(key, { kind: currentKind === 'unknown' ? 'migrated' : currentKind, controller, file: f });
|
|
123
|
-
count++;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (line.trim() === '') inTable = false;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (count === 0 && pathAnno) {
|
|
131
|
-
// URL-parity-only doc with no per-endpoint table — annotate by prefix later.
|
|
132
|
-
// We'll store with a sentinel so the OpenAPI walk can apply it as a fallback.
|
|
133
|
-
byKey.set(`__PREFIX__ ${pathAnno}`, { kind: 'parity-only', controller, file: f });
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return { byKey, warnings };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function classifyHistoryStatus(line: string): EndpointStatus {
|
|
141
|
-
const lower = line.toLowerCase();
|
|
142
|
-
if (lower.includes('**skip')) return 'skip';
|
|
143
|
-
if (lower.includes('**defer')) return 'defer';
|
|
144
|
-
if (lower.includes('**migrated') || lower.includes('**done')) return 'migrated';
|
|
145
|
-
if (lower.includes('**tested')) return 'tested';
|
|
146
|
-
if (lower.includes('**in-progress') || lower.includes('**in progress')) return 'in-progress';
|
|
147
|
-
return 'pending';
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
interface HistoryAnnotation {
|
|
151
|
-
status: EndpointStatus;
|
|
152
|
-
file: string;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Map controller name → history annotation
|
|
156
|
-
function parseMigrationHistory(projectPath: string, fileRel: string): Map<string, HistoryAnnotation> {
|
|
157
|
-
const m = new Map<string, HistoryAnnotation>();
|
|
158
|
-
const file = join(projectPath, fileRel);
|
|
159
|
-
if (!existsSync(file)) return m;
|
|
160
|
-
const content = readFileSync(file, 'utf8');
|
|
161
|
-
for (const line of content.split('\n')) {
|
|
162
|
-
const match = line.match(/^- \[[ xX]\]\s+`([^`]+\.java)`\s*[—-]\s*(.+)$/);
|
|
163
|
-
if (!match) continue;
|
|
164
|
-
const javaFile = match[1];
|
|
165
|
-
const status = classifyHistoryStatus(match[2]);
|
|
166
|
-
const ctrlMatch = javaFile.match(/([A-Za-z0-9_$]+)\.java$/);
|
|
167
|
-
const ctrl = ctrlMatch ? ctrlMatch[1] : javaFile;
|
|
168
|
-
m.set(ctrl, { status, file: javaFile });
|
|
169
|
-
}
|
|
170
|
-
return m;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ── Main ────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
export interface DiscoveryResult {
|
|
176
|
-
endpoints: Endpoint[];
|
|
177
|
-
warnings: string[];
|
|
178
|
-
sources: { file: string; count: number }[];
|
|
179
|
-
stats: {
|
|
180
|
-
fromOpenApi: number;
|
|
181
|
-
annotatedByDoc: number;
|
|
182
|
-
annotatedByHistory: number;
|
|
183
|
-
stubbed: number;
|
|
184
|
-
pending: number;
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function discoverEndpoints(projectPath: string, config: MigrationConfig): DiscoveryResult {
|
|
189
|
-
const warnings: string[] = [];
|
|
190
|
-
const sources: { file: string; count: number }[] = [];
|
|
191
|
-
|
|
192
|
-
// 1) Try OpenAPI as primary source
|
|
193
|
-
const openApiPath = config.endpointSource.openApiSpec;
|
|
194
|
-
let openApi: OpenApiDoc | null = null;
|
|
195
|
-
if (openApiPath) {
|
|
196
|
-
openApi = loadOpenApi(projectPath, openApiPath);
|
|
197
|
-
if (!openApi) warnings.push(`OpenAPI spec not found: ${openApiPath}`);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!openApi) {
|
|
201
|
-
// Fall back to legacy doc-only discovery
|
|
202
|
-
return legacyDocDiscovery(projectPath, config);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 2) Build annotation maps from docs
|
|
206
|
-
const docAnno = parsePerControllerDocs(projectPath, config.endpointSource.primary);
|
|
207
|
-
warnings.push(...docAnno.warnings);
|
|
208
|
-
const historyAnno = config.endpointSource.fallback
|
|
209
|
-
? parseMigrationHistory(projectPath, config.endpointSource.fallback)
|
|
210
|
-
: new Map();
|
|
211
|
-
|
|
212
|
-
// Pre-compute prefix-based annotations from URL-parity-only docs
|
|
213
|
-
const prefixAnnos: { prefix: string; anno: DocAnnotation }[] = [];
|
|
214
|
-
for (const [key, anno] of docAnno.byKey) {
|
|
215
|
-
if (key.startsWith('__PREFIX__ ')) {
|
|
216
|
-
prefixAnnos.push({ prefix: key.slice('__PREFIX__ '.length), anno });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 3) Walk OpenAPI ops, annotate
|
|
221
|
-
const endpoints: Endpoint[] = [];
|
|
222
|
-
let annotatedByDoc = 0;
|
|
223
|
-
let annotatedByHistory = 0;
|
|
224
|
-
let stubbed = 0;
|
|
225
|
-
let pending = 0;
|
|
226
|
-
|
|
227
|
-
for (const op of openApi.operations) {
|
|
228
|
-
const key = `${op.method} ${op.path}`;
|
|
229
|
-
const directAnno = docAnno.byKey.get(key);
|
|
230
|
-
|
|
231
|
-
let docNotes: string | undefined;
|
|
232
|
-
let docFile: string | undefined;
|
|
233
|
-
let docKind: SectionKind | undefined;
|
|
234
|
-
|
|
235
|
-
if (directAnno) {
|
|
236
|
-
docKind = directAnno.kind;
|
|
237
|
-
docFile = directAnno.file;
|
|
238
|
-
docNotes = directAnno.notes;
|
|
239
|
-
annotatedByDoc++;
|
|
240
|
-
} else {
|
|
241
|
-
// Try prefix match (URL-parity-only docs)
|
|
242
|
-
for (const { prefix, anno } of prefixAnnos) {
|
|
243
|
-
if (op.path === prefix || op.path.startsWith(prefix + '/') || op.path.startsWith(prefix + '?')) {
|
|
244
|
-
docKind = anno.kind;
|
|
245
|
-
docFile = anno.file;
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Tag from OpenAPI = controller for grouping
|
|
252
|
-
const tag = (op.tags && op.tags[0]) || 'untagged';
|
|
253
|
-
|
|
254
|
-
// Status: stubbed/parity → migrated stubbed; migrated → migrated; else look at history;
|
|
255
|
-
// else "pending" (in OpenAPI but no doc covers it).
|
|
256
|
-
let status: EndpointStatus = 'pending';
|
|
257
|
-
let isStubbed = false;
|
|
258
|
-
let expectedHttpStatus = 200;
|
|
259
|
-
|
|
260
|
-
if (docKind === 'migrated') status = 'migrated';
|
|
261
|
-
else if (docKind === 'stubbed' || docKind === 'parity-only') {
|
|
262
|
-
status = 'migrated';
|
|
263
|
-
isStubbed = true;
|
|
264
|
-
expectedHttpStatus = 501;
|
|
265
|
-
} else {
|
|
266
|
-
// Try history annotation by tag
|
|
267
|
-
const hist = historyAnno.get(tag) || historyAnno.get(tag + 'Service') || historyAnno.get(tag + 'Controller');
|
|
268
|
-
if (hist) {
|
|
269
|
-
status = hist.status;
|
|
270
|
-
if (hist.status === 'skip' || hist.status === 'defer') continue; // skip these
|
|
271
|
-
annotatedByHistory++;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (isStubbed) stubbed++;
|
|
276
|
-
if (status === 'pending') pending++;
|
|
277
|
-
|
|
278
|
-
const responseSchema = getResponseSchema(op, openApi);
|
|
279
|
-
|
|
280
|
-
endpoints.push({
|
|
281
|
-
id: endpointId(op.method, op.path),
|
|
282
|
-
controller: tag,
|
|
283
|
-
file: docFile,
|
|
284
|
-
docFile,
|
|
285
|
-
method: op.method as HttpMethod,
|
|
286
|
-
path: op.path,
|
|
287
|
-
status,
|
|
288
|
-
expectedHttpStatus,
|
|
289
|
-
isStubbed,
|
|
290
|
-
source: openApiPath || 'openapi',
|
|
291
|
-
notes: docNotes || op.summary,
|
|
292
|
-
operationId: op.operationId,
|
|
293
|
-
tag,
|
|
294
|
-
summary: op.summary,
|
|
295
|
-
hasResponseSchema: !!responseSchema,
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
sources.push({ file: openApiPath!, count: endpoints.length });
|
|
300
|
-
if (annotatedByDoc > 0) sources.push({ file: config.endpointSource.primary, count: annotatedByDoc });
|
|
301
|
-
if (annotatedByHistory > 0 && config.endpointSource.fallback) {
|
|
302
|
-
sources.push({ file: config.endpointSource.fallback, count: annotatedByHistory });
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return {
|
|
306
|
-
endpoints,
|
|
307
|
-
warnings,
|
|
308
|
-
sources,
|
|
309
|
-
stats: {
|
|
310
|
-
fromOpenApi: openApi.operations.length,
|
|
311
|
-
annotatedByDoc,
|
|
312
|
-
annotatedByHistory,
|
|
313
|
-
stubbed,
|
|
314
|
-
pending,
|
|
315
|
-
},
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── Legacy doc-only discovery (when no OpenAPI is configured) ──
|
|
320
|
-
|
|
321
|
-
function legacyDocDiscovery(projectPath: string, config: MigrationConfig): DiscoveryResult {
|
|
322
|
-
const warnings: string[] = [];
|
|
323
|
-
const sources: { file: string; count: number }[] = [];
|
|
324
|
-
const all: Endpoint[] = [];
|
|
325
|
-
const seen = new Set<string>();
|
|
326
|
-
const stubbed = { n: 0 };
|
|
327
|
-
|
|
328
|
-
const docAnno = parsePerControllerDocs(projectPath, config.endpointSource.primary);
|
|
329
|
-
warnings.push(...docAnno.warnings);
|
|
330
|
-
|
|
331
|
-
for (const [key, anno] of docAnno.byKey) {
|
|
332
|
-
if (key.startsWith('__PREFIX__ ')) continue;
|
|
333
|
-
const [method, path] = key.split(' ');
|
|
334
|
-
const id = endpointId(method, path);
|
|
335
|
-
if (seen.has(id)) continue;
|
|
336
|
-
seen.add(id);
|
|
337
|
-
const isStubbed = anno.kind === 'stubbed' || anno.kind === 'parity-only';
|
|
338
|
-
if (isStubbed) stubbed.n++;
|
|
339
|
-
all.push({
|
|
340
|
-
id, controller: anno.controller, file: anno.file, docFile: anno.file,
|
|
341
|
-
method: method as HttpMethod, path,
|
|
342
|
-
status: 'migrated',
|
|
343
|
-
expectedHttpStatus: isStubbed ? 501 : 200,
|
|
344
|
-
isStubbed,
|
|
345
|
-
source: anno.file,
|
|
346
|
-
notes: anno.notes,
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
if (all.length > 0) sources.push({ file: config.endpointSource.primary, count: all.length });
|
|
350
|
-
|
|
351
|
-
return {
|
|
352
|
-
endpoints: all,
|
|
353
|
-
warnings,
|
|
354
|
-
sources,
|
|
355
|
-
stats: {
|
|
356
|
-
fromOpenApi: 0,
|
|
357
|
-
annotatedByDoc: all.length,
|
|
358
|
-
annotatedByHistory: 0,
|
|
359
|
-
stubbed: stubbed.n,
|
|
360
|
-
pending: 0,
|
|
361
|
-
},
|
|
362
|
-
};
|
|
363
|
-
}
|
package/lib/migration/openapi.ts
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// OpenAPI 3.x loader + $ref resolver + per-(method, path) indexer.
|
|
2
|
-
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
|
|
6
|
-
export interface OpenApiOperation {
|
|
7
|
-
method: string; // upper-case
|
|
8
|
-
path: string; // raw OpenAPI path with {id} placeholders
|
|
9
|
-
operationId?: string;
|
|
10
|
-
tags?: string[];
|
|
11
|
-
summary?: string;
|
|
12
|
-
responses: Record<string, OpenApiResponse>;
|
|
13
|
-
parameters?: OpenApiParameter[];
|
|
14
|
-
requestBody?: any;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface OpenApiResponse {
|
|
18
|
-
description?: string;
|
|
19
|
-
content?: Record<string, { schema?: any }>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface OpenApiParameter {
|
|
23
|
-
name: string;
|
|
24
|
-
in: 'query' | 'path' | 'header' | 'cookie';
|
|
25
|
-
required?: boolean;
|
|
26
|
-
schema?: any;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface OpenApiDoc {
|
|
30
|
-
raw: any;
|
|
31
|
-
operations: OpenApiOperation[];
|
|
32
|
-
byKey: Map<string, OpenApiOperation>; // key = "METHOD path"
|
|
33
|
-
schemas: Record<string, any>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'];
|
|
37
|
-
|
|
38
|
-
function operationKey(method: string, path: string): string {
|
|
39
|
-
return `${method.toUpperCase()} ${path}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function loadOpenApi(projectPath: string, relativeFile: string): OpenApiDoc | null {
|
|
43
|
-
const file = join(projectPath, relativeFile);
|
|
44
|
-
if (!existsSync(file)) return null;
|
|
45
|
-
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
46
|
-
const operations: OpenApiOperation[] = [];
|
|
47
|
-
const byKey = new Map<string, OpenApiOperation>();
|
|
48
|
-
const paths = raw.paths || {};
|
|
49
|
-
|
|
50
|
-
for (const [p, item] of Object.entries(paths) as [string, any][]) {
|
|
51
|
-
for (const m of HTTP_METHODS) {
|
|
52
|
-
const op = item[m];
|
|
53
|
-
if (!op) continue;
|
|
54
|
-
const operation: OpenApiOperation = {
|
|
55
|
-
method: m.toUpperCase(),
|
|
56
|
-
path: p,
|
|
57
|
-
operationId: op.operationId,
|
|
58
|
-
tags: op.tags,
|
|
59
|
-
summary: op.summary,
|
|
60
|
-
responses: op.responses || {},
|
|
61
|
-
parameters: op.parameters,
|
|
62
|
-
requestBody: op.requestBody,
|
|
63
|
-
};
|
|
64
|
-
operations.push(operation);
|
|
65
|
-
byKey.set(operationKey(m, p), operation);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
raw,
|
|
71
|
-
operations,
|
|
72
|
-
byKey,
|
|
73
|
-
schemas: raw.components?.schemas || {},
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── $ref resolution ─────────────────────────────────────
|
|
78
|
-
// "#/components/schemas/Foo" → schemas.Foo, recursively inlined.
|
|
79
|
-
// Tracks visited refs to break cycles (returns the partial node when re-entered).
|
|
80
|
-
|
|
81
|
-
export function resolveSchema(node: any, doc: OpenApiDoc, visited = new Set<string>()): any {
|
|
82
|
-
if (node == null || typeof node !== 'object') return node;
|
|
83
|
-
|
|
84
|
-
if (typeof node.$ref === 'string') {
|
|
85
|
-
const ref = node.$ref;
|
|
86
|
-
if (visited.has(ref)) return { __cycle: ref };
|
|
87
|
-
visited.add(ref);
|
|
88
|
-
const target = followRef(ref, doc.raw);
|
|
89
|
-
if (!target) return { __unresolved: ref };
|
|
90
|
-
return resolveSchema(target, doc, visited);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (Array.isArray(node)) return node.map(x => resolveSchema(x, doc, visited));
|
|
94
|
-
|
|
95
|
-
const out: any = {};
|
|
96
|
-
for (const [k, v] of Object.entries(node)) {
|
|
97
|
-
out[k] = resolveSchema(v, doc, visited);
|
|
98
|
-
}
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function followRef(ref: string, root: any): any {
|
|
103
|
-
if (!ref.startsWith('#/')) return null;
|
|
104
|
-
const parts = ref.slice(2).split('/');
|
|
105
|
-
let cur = root;
|
|
106
|
-
for (const p of parts) {
|
|
107
|
-
if (cur == null) return null;
|
|
108
|
-
cur = cur[decodeURIComponent(p.replace(/~1/g, '/').replace(/~0/g, '~'))];
|
|
109
|
-
}
|
|
110
|
-
return cur;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ── Response schema extraction ──────────────────────────
|
|
114
|
-
// Pick the "main" success response (default → 200 → first 2xx → first one).
|
|
115
|
-
|
|
116
|
-
export function pickSuccessResponse(op: OpenApiOperation): OpenApiResponse | null {
|
|
117
|
-
const r = op.responses;
|
|
118
|
-
if (!r) return null;
|
|
119
|
-
if (r.default) return r.default;
|
|
120
|
-
if (r['200']) return r['200'];
|
|
121
|
-
for (const code of Object.keys(r)) {
|
|
122
|
-
if (/^2\d\d$/.test(code)) return r[code];
|
|
123
|
-
}
|
|
124
|
-
return r[Object.keys(r)[0]] || null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function getResponseSchema(op: OpenApiOperation, doc: OpenApiDoc): any | null {
|
|
128
|
-
const resp = pickSuccessResponse(op);
|
|
129
|
-
if (!resp || !resp.content) return null;
|
|
130
|
-
const json = resp.content['application/json'] || resp.content['*/*'];
|
|
131
|
-
if (!json?.schema) return null;
|
|
132
|
-
return resolveSchema(json.schema, doc);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
export function lookup(doc: OpenApiDoc, method: string, path: string): OpenApiOperation | null {
|
|
136
|
-
return doc.byKey.get(operationKey(method, path)) || null;
|
|
137
|
-
}
|