apiforge-analyzer 1.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/README.md +279 -0
- package/dist/cjs/cli.js +419 -0
- package/dist/cjs/detect.js +68 -0
- package/dist/cjs/export.js +321 -0
- package/dist/cjs/frameworks/express.js +245 -0
- package/dist/cjs/frameworks/nextjs-app.js +181 -0
- package/dist/cjs/frameworks/nextjs-pages.js +154 -0
- package/dist/cjs/frameworks/react-router.js +115 -0
- package/dist/cjs/frameworks/vite.js +84 -0
- package/dist/cjs/index.js +125 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/uploader.js +34 -0
- package/dist/esm/cli.js +417 -0
- package/dist/esm/detect.js +65 -0
- package/dist/esm/export.js +283 -0
- package/dist/esm/frameworks/express.js +242 -0
- package/dist/esm/frameworks/nextjs-app.js +145 -0
- package/dist/esm/frameworks/nextjs-pages.js +118 -0
- package/dist/esm/frameworks/react-router.js +79 -0
- package/dist/esm/frameworks/vite.js +48 -0
- package/dist/esm/index.js +115 -0
- package/dist/esm/uploader.js +28 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/detect.d.ts +13 -0
- package/dist/types/export.d.ts +38 -0
- package/dist/types/frameworks/express.d.ts +23 -0
- package/dist/types/frameworks/nextjs-app.d.ts +19 -0
- package/dist/types/frameworks/nextjs-pages.d.ts +17 -0
- package/dist/types/frameworks/react-router.d.ts +17 -0
- package/dist/types/frameworks/vite.d.ts +17 -0
- package/dist/types/index.d.ts +44 -0
- package/dist/types/uploader.d.ts +11 -0
- package/package.json +63 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
/**
|
|
4
|
+
* Export routes to JSON file
|
|
5
|
+
*/
|
|
6
|
+
export async function exportToJSON(routes, projectName, filePath, baseUrl) {
|
|
7
|
+
const enrichedRoutes = enrichRoutes(routes);
|
|
8
|
+
const exportData = {
|
|
9
|
+
projectName,
|
|
10
|
+
timestamp: new Date().toISOString(),
|
|
11
|
+
schemaVersion: 'apiforge.route-export.v2',
|
|
12
|
+
framework: enrichedRoutes[0]?.framework || 'unknown',
|
|
13
|
+
baseUrl,
|
|
14
|
+
routes: enrichedRoutes,
|
|
15
|
+
metadata: {
|
|
16
|
+
totalRoutes: enrichedRoutes.length,
|
|
17
|
+
tags: Array.from(new Set(enrichedRoutes.map(r => r.tag))),
|
|
18
|
+
extractedAt: new Date().toISOString(),
|
|
19
|
+
routeIds: enrichedRoutes.map(r => ({ id: r.id, routeKey: r.routeKey, method: r.method, path: r.path })),
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const fullPath = path.join(process.cwd(), filePath);
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(fullPath, JSON.stringify(exportData, null, 2), 'utf-8');
|
|
25
|
+
console.log(`💾 Saved API export to: ${fullPath}`);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error('Failed to save JSON:', error.message);
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function enrichRoutes(routes) {
|
|
33
|
+
const withIds = routes.map((route, index) => enrichRoute(route, index + 1));
|
|
34
|
+
return withIds.map(route => ({
|
|
35
|
+
...route,
|
|
36
|
+
linkedPaths: route.linkedPaths?.length ? route.linkedPaths : inferLinkedPaths(route, withIds),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
export function enrichRoute(route, index) {
|
|
40
|
+
const id = route.id || index;
|
|
41
|
+
const routeKey = route.routeKey || `api-${String(id).padStart(3, '0')}`;
|
|
42
|
+
const pathParams = unique([...(route.pathParams || []), ...extractPathParams(route.path)]);
|
|
43
|
+
const queryParams = unique(route.queryParams || []);
|
|
44
|
+
const requestBodyFields = unique(route.requestBodyFields || []);
|
|
45
|
+
const headerParams = unique(route.headerParams || []);
|
|
46
|
+
const tag = route.tag || inferTag(route.path);
|
|
47
|
+
const parameters = buildParameters(route.parameters, pathParams, queryParams, headerParams);
|
|
48
|
+
const requestBody = route.requestBody || buildRequestBody(route.method, requestBodyFields);
|
|
49
|
+
const responses = route.responses || buildResponses(route.method, route.path, tag, route.responseStatuses);
|
|
50
|
+
const authRequired = Boolean(route.authRequired || route.hasAuth || detectAuth(route.middleware || []));
|
|
51
|
+
return {
|
|
52
|
+
...route,
|
|
53
|
+
id,
|
|
54
|
+
routeKey,
|
|
55
|
+
tag,
|
|
56
|
+
tags: route.tags?.length ? route.tags : [tag],
|
|
57
|
+
summary: route.summary || generateSummary(route.method, route.path),
|
|
58
|
+
description: route.description || generateDescription(route.method, route.path, tag),
|
|
59
|
+
authRequired,
|
|
60
|
+
hasAuth: authRequired,
|
|
61
|
+
pathParams,
|
|
62
|
+
queryParams,
|
|
63
|
+
requestBodyFields,
|
|
64
|
+
headerParams,
|
|
65
|
+
parameters,
|
|
66
|
+
requestBody,
|
|
67
|
+
responses,
|
|
68
|
+
sourceContext: {
|
|
69
|
+
framework: route.framework,
|
|
70
|
+
routeKey,
|
|
71
|
+
detectedFrom: route.framework,
|
|
72
|
+
confidence: confidenceFor(route, parameters, requestBody, responses),
|
|
73
|
+
middleware: route.middleware || [],
|
|
74
|
+
authRequired,
|
|
75
|
+
pathParams,
|
|
76
|
+
queryParams,
|
|
77
|
+
requestBodyFields,
|
|
78
|
+
headerParams,
|
|
79
|
+
responseStatuses: route.responseStatuses || [],
|
|
80
|
+
notes: buildNotes(route, requestBodyFields, queryParams, headerParams),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function buildParameters(existing, pathParams, queryParams, headerParams = []) {
|
|
85
|
+
const params = new Map();
|
|
86
|
+
(existing || []).forEach(param => params.set(`${param.in}:${param.name}`, param));
|
|
87
|
+
pathParams.forEach(name => params.set(`path:${name}`, {
|
|
88
|
+
name,
|
|
89
|
+
in: 'path',
|
|
90
|
+
required: true,
|
|
91
|
+
type: 'string',
|
|
92
|
+
schema: { type: 'string' },
|
|
93
|
+
description: `Required path parameter: ${name}.`,
|
|
94
|
+
}));
|
|
95
|
+
queryParams.forEach(name => {
|
|
96
|
+
const key = `query:${name}`;
|
|
97
|
+
if (!params.has(key))
|
|
98
|
+
params.set(key, {
|
|
99
|
+
name,
|
|
100
|
+
in: 'query',
|
|
101
|
+
required: false,
|
|
102
|
+
type: 'string',
|
|
103
|
+
schema: { type: 'string' },
|
|
104
|
+
description: `Optional query parameter: ${name}.`,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
headerParams.forEach(name => {
|
|
108
|
+
const key = `header:${name}`;
|
|
109
|
+
if (!params.has(key))
|
|
110
|
+
params.set(key, {
|
|
111
|
+
name,
|
|
112
|
+
in: 'header',
|
|
113
|
+
required: name.toLowerCase() === 'authorization',
|
|
114
|
+
type: 'string',
|
|
115
|
+
schema: { type: 'string' },
|
|
116
|
+
description: `Request header: ${name}.`,
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
return Array.from(params.values());
|
|
120
|
+
}
|
|
121
|
+
function buildRequestBody(method, fields) {
|
|
122
|
+
if (!['POST', 'PUT', 'PATCH'].includes(method.toUpperCase()) || !fields.length)
|
|
123
|
+
return undefined;
|
|
124
|
+
const properties = {};
|
|
125
|
+
fields.forEach(field => {
|
|
126
|
+
properties[field] = {
|
|
127
|
+
type: inferFieldType(field),
|
|
128
|
+
description: `Detected request body field: ${field}.`,
|
|
129
|
+
example: exampleForField(field),
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
required: true,
|
|
134
|
+
content: {
|
|
135
|
+
'application/json': {
|
|
136
|
+
schema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
required: fields,
|
|
139
|
+
properties,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function buildResponses(method, path, tag, detectedStatuses) {
|
|
146
|
+
// Prefer statuses seen in the source; fall back to method-based inference
|
|
147
|
+
const m = method.toUpperCase();
|
|
148
|
+
const inferredPrimary = m === 'POST' ? 201 : m === 'DELETE' ? 204 : 200;
|
|
149
|
+
const primaryStatus = detectedStatuses?.find(s => s >= 200 && s < 300) ?? inferredPrimary;
|
|
150
|
+
const status = String(primaryStatus);
|
|
151
|
+
const successBody = status === '204' ? undefined : {
|
|
152
|
+
'application/json': {
|
|
153
|
+
schema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
success: { type: 'boolean', example: true },
|
|
157
|
+
endpoint: { type: 'string', example: `${m} ${path}` },
|
|
158
|
+
resource: { type: 'string', example: tag.toLowerCase() },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
const base = {
|
|
164
|
+
[status]: {
|
|
165
|
+
description: status === '204' ? 'Deleted successfully' : 'Successful response',
|
|
166
|
+
content: successBody,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
(detectedStatuses || []).forEach(s => {
|
|
170
|
+
if (s >= 200 && s < 300)
|
|
171
|
+
return; // already covered
|
|
172
|
+
base[String(s)] = { description: statusDescription(s) };
|
|
173
|
+
});
|
|
174
|
+
// Always include standard errors if not already present
|
|
175
|
+
if (!base['400'])
|
|
176
|
+
base['400'] = { description: 'Invalid request input' };
|
|
177
|
+
if (!base['401'])
|
|
178
|
+
base['401'] = { description: 'Authentication required or invalid credentials' };
|
|
179
|
+
if (!base['500'])
|
|
180
|
+
base['500'] = { description: 'Unexpected server error' };
|
|
181
|
+
return base;
|
|
182
|
+
}
|
|
183
|
+
function statusDescription(code) {
|
|
184
|
+
const map = {
|
|
185
|
+
400: 'Invalid request input',
|
|
186
|
+
401: 'Authentication required or invalid credentials',
|
|
187
|
+
403: 'Forbidden — insufficient permissions',
|
|
188
|
+
404: 'Resource not found',
|
|
189
|
+
409: 'Conflict — resource already exists',
|
|
190
|
+
422: 'Unprocessable entity',
|
|
191
|
+
429: 'Too many requests — rate limit exceeded',
|
|
192
|
+
500: 'Unexpected server error',
|
|
193
|
+
503: 'Service unavailable',
|
|
194
|
+
};
|
|
195
|
+
return map[code] || `HTTP ${code}`;
|
|
196
|
+
}
|
|
197
|
+
function inferLinkedPaths(route, routes) {
|
|
198
|
+
return routes
|
|
199
|
+
.filter(other => other.id !== route.id)
|
|
200
|
+
.filter(other => other.tag === route.tag || shareResource(route.path, other.path))
|
|
201
|
+
.slice(0, 5)
|
|
202
|
+
.map(other => `${other.method.toUpperCase()} ${other.path}`);
|
|
203
|
+
}
|
|
204
|
+
function extractPathParams(routePath) {
|
|
205
|
+
return unique([...(routePath.matchAll(/:(\w+)/g))].map(match => match[1]));
|
|
206
|
+
}
|
|
207
|
+
function detectAuth(middleware) {
|
|
208
|
+
const authNames = ['authenticateToken', 'authenticate', 'verifyToken', 'requireAuth', 'auth', 'protect', 'isAuth', 'checkAuth', 'jwtAuth', 'bearerAuth'];
|
|
209
|
+
return middleware.some(name => authNames.some(authName => name.toLowerCase().includes(authName.toLowerCase())));
|
|
210
|
+
}
|
|
211
|
+
function inferTag(routePath) {
|
|
212
|
+
const skip = new Set(['api', 'v1', 'v2', 'v3']);
|
|
213
|
+
return routePath.split('/').filter(Boolean).find(part => !skip.has(part) && !part.startsWith(':')) || 'general';
|
|
214
|
+
}
|
|
215
|
+
function generateSummary(method, routePath) {
|
|
216
|
+
const resource = routePath.split('/').filter(Boolean).filter(part => !part.startsWith(':')).pop() || 'resource';
|
|
217
|
+
const action = {
|
|
218
|
+
GET: `Get ${resource}`,
|
|
219
|
+
POST: `Create ${resource}`,
|
|
220
|
+
PUT: `Replace ${resource}`,
|
|
221
|
+
PATCH: `Update ${resource}`,
|
|
222
|
+
DELETE: `Delete ${resource}`,
|
|
223
|
+
};
|
|
224
|
+
return action[method.toUpperCase()] || `${method.toUpperCase()} ${routePath}`;
|
|
225
|
+
}
|
|
226
|
+
function generateDescription(method, routePath, tag) {
|
|
227
|
+
return `${method.toUpperCase()} ${routePath} in the ${tag} API group. Generated from runtime route metadata, middleware names, path parameters, query reads, and request body reads.`;
|
|
228
|
+
}
|
|
229
|
+
function shareResource(a, b) {
|
|
230
|
+
const clean = (value) => value.split('/').filter(Boolean).filter(part => !part.startsWith(':') && !['api', 'v1', 'v2', 'v3'].includes(part));
|
|
231
|
+
return clean(a).some(part => clean(b).includes(part));
|
|
232
|
+
}
|
|
233
|
+
function confidenceFor(route, parameters, requestBody, responses) {
|
|
234
|
+
let score = 0.55;
|
|
235
|
+
if (parameters.length)
|
|
236
|
+
score += 0.15;
|
|
237
|
+
if (requestBody)
|
|
238
|
+
score += 0.15;
|
|
239
|
+
if (responses)
|
|
240
|
+
score += 0.1;
|
|
241
|
+
if (route.middleware?.length)
|
|
242
|
+
score += 0.05;
|
|
243
|
+
return Math.min(score, 0.95);
|
|
244
|
+
}
|
|
245
|
+
function buildNotes(route, bodyFields, queryParams, headerParams = []) {
|
|
246
|
+
const notes = [];
|
|
247
|
+
if (bodyFields.length)
|
|
248
|
+
notes.push(`Detected request body fields: ${bodyFields.join(', ')}.`);
|
|
249
|
+
if (queryParams.length)
|
|
250
|
+
notes.push(`Detected query parameters: ${queryParams.join(', ')}.`);
|
|
251
|
+
if (headerParams.length)
|
|
252
|
+
notes.push(`Detected request headers: ${headerParams.join(', ')}.`);
|
|
253
|
+
if (route.responseStatuses?.length)
|
|
254
|
+
notes.push(`Detected response status codes: ${route.responseStatuses.join(', ')}.`);
|
|
255
|
+
if (route.middleware?.length)
|
|
256
|
+
notes.push(`Middleware chain: ${route.middleware.join(', ')}.`);
|
|
257
|
+
return notes;
|
|
258
|
+
}
|
|
259
|
+
function inferFieldType(name) {
|
|
260
|
+
if (/^(is|has|can|should)[A-Z_]/.test(name) || /active|enabled|verified/i.test(name))
|
|
261
|
+
return 'boolean';
|
|
262
|
+
if (/count|total|amount|price|limit|offset|page|age|qty|quantity/i.test(name))
|
|
263
|
+
return 'number';
|
|
264
|
+
return 'string';
|
|
265
|
+
}
|
|
266
|
+
function exampleForField(name) {
|
|
267
|
+
if (/email/i.test(name))
|
|
268
|
+
return 'user@example.com';
|
|
269
|
+
if (/password/i.test(name))
|
|
270
|
+
return 'SecurePass123!';
|
|
271
|
+
if (/name/i.test(name))
|
|
272
|
+
return 'Jane Developer';
|
|
273
|
+
if (/id$/i.test(name))
|
|
274
|
+
return 'id_123';
|
|
275
|
+
if (/count|total|amount|price|limit|offset|page|age|qty|quantity/i.test(name))
|
|
276
|
+
return 10;
|
|
277
|
+
if (/active|enabled|verified/i.test(name))
|
|
278
|
+
return true;
|
|
279
|
+
return `example_${name}`;
|
|
280
|
+
}
|
|
281
|
+
function unique(items) {
|
|
282
|
+
return Array.from(new Set(items.filter(Boolean)));
|
|
283
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express analyzer — accurate route + payload extraction
|
|
3
|
+
*/
|
|
4
|
+
// ─── Path normalisation ───────────────────────────────────────────────────────
|
|
5
|
+
function normalizePath(raw) {
|
|
6
|
+
return raw
|
|
7
|
+
.replace(/\\\//g, '/')
|
|
8
|
+
.replace(/\(\?[^)]*\)/g, '')
|
|
9
|
+
.replace(/\([^)]*\)/g, '')
|
|
10
|
+
.replace(/\\\w/g, '')
|
|
11
|
+
.replace(/[\^\$\?\\]/g, '')
|
|
12
|
+
.replace(/\/+/g, '/')
|
|
13
|
+
.replace(/\/$/, '')
|
|
14
|
+
|| '/';
|
|
15
|
+
}
|
|
16
|
+
function tagFromPath(path) {
|
|
17
|
+
const skip = new Set(['api', 'v1', 'v2', 'v3', 'v4']);
|
|
18
|
+
const meaningful = path
|
|
19
|
+
.split('/')
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.find(s => !skip.has(s) && !s.startsWith(':'));
|
|
22
|
+
return meaningful || 'general';
|
|
23
|
+
}
|
|
24
|
+
function generateSummary(method, path) {
|
|
25
|
+
const parts = path.split('/').filter(s => s && !s.startsWith(':'));
|
|
26
|
+
const action = parts[parts.length - 1] || '';
|
|
27
|
+
const resource = parts[parts.length - 2] || '';
|
|
28
|
+
const map = {
|
|
29
|
+
GET: action ? `Get ${action}` : `List ${resource}`,
|
|
30
|
+
POST: action ? `Create ${action}` : `Create ${resource}`,
|
|
31
|
+
PUT: action ? `Update ${action}` : `Update ${resource}`,
|
|
32
|
+
PATCH: action ? `Patch ${action}` : `Patch ${resource}`,
|
|
33
|
+
DELETE: action ? `Delete ${action}` : `Delete ${resource}`,
|
|
34
|
+
};
|
|
35
|
+
const base = map[method] || `${method} ${path}`;
|
|
36
|
+
return (base.charAt(0).toUpperCase() + base.slice(1)).replace(/[-_]/g, ' ');
|
|
37
|
+
}
|
|
38
|
+
// ─── Main analyser ────────────────────────────────────────────────────────────
|
|
39
|
+
export function analyzeExpress(app) {
|
|
40
|
+
const routes = [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
if (typeof app.lazyrouter === 'function')
|
|
43
|
+
app.lazyrouter();
|
|
44
|
+
const stack = app._router?.stack ??
|
|
45
|
+
app.router?.stack ??
|
|
46
|
+
app.layerStack ??
|
|
47
|
+
app.stack ??
|
|
48
|
+
null;
|
|
49
|
+
if (!stack?.length) {
|
|
50
|
+
console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
|
|
51
|
+
return routes;
|
|
52
|
+
}
|
|
53
|
+
for (const layer of stack) {
|
|
54
|
+
if (layer.route) {
|
|
55
|
+
const r = buildRoute(layer.route, '');
|
|
56
|
+
if (r && !seen.has(r.method + r.path)) {
|
|
57
|
+
routes.push(r);
|
|
58
|
+
seen.add(r.method + r.path);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
else if (layer.handle?.stack) {
|
|
62
|
+
let base = '';
|
|
63
|
+
if (layer.regexp?.source)
|
|
64
|
+
base = normalizePath(layer.regexp.source);
|
|
65
|
+
else if (layer.path)
|
|
66
|
+
base = normalizePath(layer.path);
|
|
67
|
+
else if (typeof layer.route === 'string')
|
|
68
|
+
base = normalizePath(layer.route);
|
|
69
|
+
for (const sub of layer.handle.stack) {
|
|
70
|
+
if (sub.route) {
|
|
71
|
+
const r = buildRoute(sub.route, base);
|
|
72
|
+
if (r && !seen.has(r.method + r.path)) {
|
|
73
|
+
routes.push(r);
|
|
74
|
+
seen.add(r.method + r.path);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return routes;
|
|
81
|
+
}
|
|
82
|
+
// ─── Route builder ────────────────────────────────────────────────────────────
|
|
83
|
+
function buildRoute(route, basePath) {
|
|
84
|
+
if (!route.path)
|
|
85
|
+
return null;
|
|
86
|
+
const methods = Object.keys(route.methods || {})
|
|
87
|
+
.filter(m => route.methods[m])
|
|
88
|
+
.map(m => m.toUpperCase());
|
|
89
|
+
if (!methods.length)
|
|
90
|
+
return null;
|
|
91
|
+
const cleanBase = normalizePath(basePath);
|
|
92
|
+
const cleanRoute = normalizePath(route.path);
|
|
93
|
+
let fullPath = (cleanBase === '/' ? '' : cleanBase) + cleanRoute;
|
|
94
|
+
if (!fullPath.startsWith('/'))
|
|
95
|
+
fullPath = '/' + fullPath;
|
|
96
|
+
if (fullPath !== '/' && fullPath.endsWith('/'))
|
|
97
|
+
fullPath = fullPath.slice(0, -1);
|
|
98
|
+
const stack = route.stack || [];
|
|
99
|
+
// ── Middleware names ───────────────────────────────────────────────────────
|
|
100
|
+
const middleware = stack
|
|
101
|
+
.map((l) => l.name)
|
|
102
|
+
.filter((n) => n && n !== '<anonymous>' && n !== 'bound dispatch' && n !== 'bound ');
|
|
103
|
+
// ── Join all handler source text ───────────────────────────────────────────
|
|
104
|
+
const src = stack.map((l) => l.handle?.toString() ?? '').join('\n');
|
|
105
|
+
// ── Path params (from route pattern) ──────────────────────────────────────
|
|
106
|
+
const pathParams = unique([...fullPath.matchAll(/:(\w+)/g)].map(m => m[1]));
|
|
107
|
+
// ── Query params ───────────────────────────────────────────────────────────
|
|
108
|
+
const queryParams = unique([
|
|
109
|
+
// req.query.foo
|
|
110
|
+
...matchAll(src, /req\.query\.([a-zA-Z0-9_$]+)/g),
|
|
111
|
+
// { foo, bar } = req.query / { foo: renamed } = req.query
|
|
112
|
+
...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.query/g),
|
|
113
|
+
// req.query['foo']
|
|
114
|
+
...matchAll(src, /req\.query\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
|
|
115
|
+
]);
|
|
116
|
+
// ── Request body fields ────────────────────────────────────────────────────
|
|
117
|
+
const requestBodyFields = unique([
|
|
118
|
+
// req.body.foo
|
|
119
|
+
...matchAll(src, /req\.body\.([a-zA-Z0-9_$]+)/g),
|
|
120
|
+
// { foo, bar: alias, baz = default } = req.body
|
|
121
|
+
...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.body/g),
|
|
122
|
+
// req.body['foo']
|
|
123
|
+
...matchAll(src, /req\.body\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
|
|
124
|
+
// body.foo (when `const body = req.body`)
|
|
125
|
+
...bodyAliasFields(src),
|
|
126
|
+
// Zod: z.object({ field: z.string() }).parse(req.body)
|
|
127
|
+
...zodFields(src),
|
|
128
|
+
// Joi: Joi.object({ field: Joi.string() }).validate(req.body)
|
|
129
|
+
...joiFields(src),
|
|
130
|
+
]);
|
|
131
|
+
// ── Header params ──────────────────────────────────────────────────────────
|
|
132
|
+
const headerParams = unique([
|
|
133
|
+
...matchAll(src, /req\.headers\[['"]([a-zA-Z0-9_$-]+)['"]\]/g),
|
|
134
|
+
...matchAll(src, /req\.get\s*\(\s*['"]([a-zA-Z0-9_$-]+)['"]\s*\)/g),
|
|
135
|
+
// req.headers.authorization, req.headers['x-api-key'], etc.
|
|
136
|
+
...matchAll(src, /req\.headers\.([a-zA-Z0-9_$]+)/g),
|
|
137
|
+
]);
|
|
138
|
+
// ── Response status codes from `res.status(nnn)` ──────────────────────────
|
|
139
|
+
const responseStatuses = unique([...src.matchAll(/res\.status\s*\(\s*(\d{3})\s*\)/g)].map(m => parseInt(m[1], 10)));
|
|
140
|
+
// ── Auth detection ─────────────────────────────────────────────────────────
|
|
141
|
+
const AUTH_NAMES = new Set([
|
|
142
|
+
'authenticateToken', 'authenticate', 'verifyToken', 'requireAuth', 'auth',
|
|
143
|
+
'protect', 'isAuth', 'checkAuth', 'jwtAuth', 'bearerAuth', 'isAuthenticated',
|
|
144
|
+
'requireLogin', 'ensureAuth', 'verifyJWT', 'checkToken', 'authMiddleware',
|
|
145
|
+
'passport.authenticate', 'jwt', 'bearer',
|
|
146
|
+
]);
|
|
147
|
+
const hasAuth = middleware.some(m => AUTH_NAMES.has(m) || [...AUTH_NAMES].some(a => m.toLowerCase().includes(a.toLowerCase()))) ||
|
|
148
|
+
/req\.user|req\.auth|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
|
|
149
|
+
const method = methods[0];
|
|
150
|
+
const tag = tagFromPath(fullPath);
|
|
151
|
+
const summary = generateSummary(method, fullPath);
|
|
152
|
+
const operationId = `${method.toLowerCase()}_${fullPath
|
|
153
|
+
.replace(/[^a-zA-Z0-9]/g, '_')
|
|
154
|
+
.replace(/_+/g, '_')
|
|
155
|
+
.replace(/^_|_$/g, '')}`;
|
|
156
|
+
return {
|
|
157
|
+
method,
|
|
158
|
+
path: fullPath,
|
|
159
|
+
tag,
|
|
160
|
+
operationId,
|
|
161
|
+
summary,
|
|
162
|
+
middleware,
|
|
163
|
+
authRequired: hasAuth,
|
|
164
|
+
pathParams,
|
|
165
|
+
queryParams,
|
|
166
|
+
requestBodyFields,
|
|
167
|
+
linkedPaths: [],
|
|
168
|
+
hasAuth,
|
|
169
|
+
framework: 'express',
|
|
170
|
+
responseStatuses: responseStatuses.length ? responseStatuses : undefined,
|
|
171
|
+
headerParams: headerParams.length ? headerParams : undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// ─── Extraction helpers ───────────────────────────────────────────────────────
|
|
175
|
+
/** Run a global regex and return capture group 1 for every match. */
|
|
176
|
+
function matchAll(src, re) {
|
|
177
|
+
return [...src.matchAll(re)].map(m => m[1]).filter(Boolean);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Extract field names from all destructuring patterns like:
|
|
181
|
+
* const { a, b: alias, c = default, d: alias2 = default } = req.body
|
|
182
|
+
* Runs ALL occurrences of the given regex, not just the first.
|
|
183
|
+
*/
|
|
184
|
+
function destructured(src, re) {
|
|
185
|
+
const results = [];
|
|
186
|
+
for (const match of src.matchAll(re)) {
|
|
187
|
+
match[1]
|
|
188
|
+
.split(',')
|
|
189
|
+
.map(s => s.trim())
|
|
190
|
+
// handle "alias: original" — we want the local binding (left side)
|
|
191
|
+
.map(s => s.split(':')[0].trim())
|
|
192
|
+
// strip default value
|
|
193
|
+
.map(s => s.split('=')[0].trim())
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.forEach(f => results.push(f));
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* When a developer writes `const body = req.body` and then accesses `body.foo`.
|
|
201
|
+
*/
|
|
202
|
+
function bodyAliasFields(src) {
|
|
203
|
+
const aliasMatch = src.match(/(?:const|let|var)\s+(\w+)\s*=\s*req\.body\b/);
|
|
204
|
+
if (!aliasMatch)
|
|
205
|
+
return [];
|
|
206
|
+
const alias = aliasMatch[1];
|
|
207
|
+
if (alias === 'body')
|
|
208
|
+
return matchAll(src, /body\.([a-zA-Z0-9_$]+)/g);
|
|
209
|
+
const re = new RegExp(`\\b${alias}\\.([a-zA-Z0-9_$]+)`, 'g');
|
|
210
|
+
return matchAll(src, re);
|
|
211
|
+
}
|
|
212
|
+
/** Extract field names from Zod object schemas. */
|
|
213
|
+
function zodFields(src) {
|
|
214
|
+
const results = [];
|
|
215
|
+
// z.object({ field: z.xxx(), ... }).parse(req.body) or .safeParse
|
|
216
|
+
const zodRe = /z\.object\s*\(\s*\{([^}]+)\}/g;
|
|
217
|
+
for (const match of src.matchAll(zodRe)) {
|
|
218
|
+
match[1]
|
|
219
|
+
.split(',')
|
|
220
|
+
.map(s => s.trim().split(':')[0].trim())
|
|
221
|
+
.filter(s => /^[a-zA-Z_$]/.test(s))
|
|
222
|
+
.forEach(f => results.push(f));
|
|
223
|
+
}
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
/** Extract field names from Joi object schemas. */
|
|
227
|
+
function joiFields(src) {
|
|
228
|
+
const results = [];
|
|
229
|
+
// Joi.object({ field: Joi.xxx() }) or yup.object().shape({ field: ... })
|
|
230
|
+
const joiRe = /(?:Joi|yup)\.object\s*\(?\s*(?:\.shape\s*\()?\s*\{([^}]+)\}/g;
|
|
231
|
+
for (const match of src.matchAll(joiRe)) {
|
|
232
|
+
match[1]
|
|
233
|
+
.split(',')
|
|
234
|
+
.map(s => s.trim().split(':')[0].trim())
|
|
235
|
+
.filter(s => /^[a-zA-Z_$]/.test(s))
|
|
236
|
+
.forEach(f => results.push(f));
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
function unique(items) {
|
|
241
|
+
return Array.from(new Set(items.filter(Boolean)));
|
|
242
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router analyzer
|
|
3
|
+
* Analyzes route handlers in app directory
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { exportToJSON } from '../export.js';
|
|
8
|
+
import { uploadRoutes } from '../uploader.js';
|
|
9
|
+
/**
|
|
10
|
+
* Analyze Next.js App Router routes
|
|
11
|
+
*/
|
|
12
|
+
export async function analyzeNextApp(options = 'app') {
|
|
13
|
+
// Support both string path and options object
|
|
14
|
+
const appDir = typeof options === 'string' ? options : (options.appDir || 'app');
|
|
15
|
+
const opts = typeof options === 'string' ? { projectName: '' } : options;
|
|
16
|
+
const routes = [];
|
|
17
|
+
try {
|
|
18
|
+
const baseDir = path.join(process.cwd(), appDir);
|
|
19
|
+
if (!fs.existsSync(baseDir)) {
|
|
20
|
+
console.warn('app directory not found');
|
|
21
|
+
return routes;
|
|
22
|
+
}
|
|
23
|
+
// Recursively scan for route.ts/route.js files
|
|
24
|
+
const scanDir = (dir, basePath = '') => {
|
|
25
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const fullPath = path.join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
// Handle dynamic routes like [id]
|
|
30
|
+
const dirName = entry.name.startsWith('[') && entry.name.endsWith(']')
|
|
31
|
+
? `:${entry.name.slice(1, -1)}`
|
|
32
|
+
: entry.name;
|
|
33
|
+
// Skip non-route directories
|
|
34
|
+
if (['_components', '_lib', '_utils'].includes(entry.name)) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
scanDir(fullPath, `${basePath}/${dirName}`);
|
|
38
|
+
}
|
|
39
|
+
else if (entry.isFile() && /route\.(ts|js)$/.test(entry.name)) {
|
|
40
|
+
// Found a route file
|
|
41
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
42
|
+
const methods = detectAppRouterMethods(content);
|
|
43
|
+
methods.forEach(method => {
|
|
44
|
+
const pathString = basePath || '/';
|
|
45
|
+
const operationId = `${method.toLowerCase()}_${pathString.replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`;
|
|
46
|
+
const tags = pathString.startsWith('/api') ? ['api'] : ['app'];
|
|
47
|
+
const middleware = detectMiddleware(content);
|
|
48
|
+
const authRequired = middleware.includes('auth');
|
|
49
|
+
const pathParams = extractParameters(pathString).map(p => p.name);
|
|
50
|
+
// Basic static extraction
|
|
51
|
+
const queryParams = [];
|
|
52
|
+
const queryHits = [...content.matchAll(/searchParams\.get\(['"]([^'"]+)['"]\)/g)];
|
|
53
|
+
queryHits.forEach(m => queryParams.push(m[1]));
|
|
54
|
+
const requestBodyFields = [];
|
|
55
|
+
const bodyDestruct = content.match(/const\s+\{([^}]+)\}\s*=\s*await\s+req\.json\(\)/);
|
|
56
|
+
if (bodyDestruct) {
|
|
57
|
+
bodyDestruct[1].split(',').map(s => s.trim().split(':')[0]).filter(Boolean).forEach(m => requestBodyFields.push(m));
|
|
58
|
+
}
|
|
59
|
+
routes.push({
|
|
60
|
+
method,
|
|
61
|
+
path: pathString,
|
|
62
|
+
tag: tags[0] || 'General',
|
|
63
|
+
operationId,
|
|
64
|
+
summary: `${method} ${pathString}`,
|
|
65
|
+
middleware,
|
|
66
|
+
authRequired,
|
|
67
|
+
pathParams: Array.from(new Set(pathParams)),
|
|
68
|
+
queryParams: Array.from(new Set(queryParams)),
|
|
69
|
+
requestBodyFields: Array.from(new Set(requestBodyFields)),
|
|
70
|
+
linkedPaths: [],
|
|
71
|
+
hasAuth: authRequired,
|
|
72
|
+
framework: 'nextjs-app'
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
scanDir(baseDir);
|
|
79
|
+
console.log(`✅ Found ${routes.length} routes`);
|
|
80
|
+
// Export to JSON if requested
|
|
81
|
+
if (opts.saveJson && opts.projectName) {
|
|
82
|
+
const filePath = opts.jsonPath || 'api-export.json';
|
|
83
|
+
await exportToJSON(routes, opts.projectName, filePath, opts.baseUrl);
|
|
84
|
+
}
|
|
85
|
+
// Upload to APIForge if requested
|
|
86
|
+
if (opts.upload && opts.apiKey && opts.projectName) {
|
|
87
|
+
await uploadRoutes(routes, {
|
|
88
|
+
projectName: opts.projectName,
|
|
89
|
+
apiKey: opts.apiKey,
|
|
90
|
+
baseUrl: opts.baseUrl,
|
|
91
|
+
apiForgeUrl: opts.apiForgeUrl
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error('Error analyzing Next.js App routes:', error.message);
|
|
97
|
+
}
|
|
98
|
+
return routes;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Detect exported HTTP method handlers
|
|
102
|
+
*/
|
|
103
|
+
function detectAppRouterMethods(content) {
|
|
104
|
+
const methods = [];
|
|
105
|
+
const httpMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
|
106
|
+
httpMethods.forEach(method => {
|
|
107
|
+
// Check for export async function METHOD or export function METHOD
|
|
108
|
+
const regex = new RegExp(`export\\s+(async\\s+)?function\\s+${method}\\s*\\(`, 'g');
|
|
109
|
+
if (regex.test(content)) {
|
|
110
|
+
methods.push(method);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return methods;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Detect middleware usage
|
|
117
|
+
*/
|
|
118
|
+
function detectMiddleware(content) {
|
|
119
|
+
const middleware = [];
|
|
120
|
+
if (content.includes('auth') || content.includes('authenticate')) {
|
|
121
|
+
middleware.push('auth');
|
|
122
|
+
}
|
|
123
|
+
if (content.includes('cors')) {
|
|
124
|
+
middleware.push('cors');
|
|
125
|
+
}
|
|
126
|
+
return middleware;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Extract parameters from route path
|
|
130
|
+
*/
|
|
131
|
+
function extractParameters(routePath) {
|
|
132
|
+
const params = [];
|
|
133
|
+
const paramMatches = routePath.match(/:(\w+)/g) || [];
|
|
134
|
+
paramMatches.forEach(param => {
|
|
135
|
+
const name = param.slice(1);
|
|
136
|
+
params.push({
|
|
137
|
+
name,
|
|
138
|
+
in: 'path',
|
|
139
|
+
required: true,
|
|
140
|
+
type: 'string',
|
|
141
|
+
description: `Path parameter: ${name}`
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return params;
|
|
145
|
+
}
|