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.
@@ -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
+ }