apiforge-analyzer 1.1.0 → 1.3.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.
@@ -1,65 +1,85 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ const cwd = () => process.cwd();
4
+ function fileExists(rel) {
5
+ return existsSync(join(cwd(), rel));
6
+ }
7
+ function tryResolve(mod) {
8
+ try {
9
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
+ require.resolve(mod, { paths: [cwd()] });
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
1
17
  /**
2
- * Framework detection utilities
3
- */
4
- /**
5
- * Detect which framework is being used
18
+ * Detect the web framework from the live app instance + filesystem hints.
19
+ * Confidence is used to pick the best match when multiple signals fire.
6
20
  */
7
21
  export function detectFramework(app) {
8
- // Express detection
22
+ // ── Instance-based detection (highest confidence) ─────────────────────────
9
23
  if (app && typeof app === 'function') {
24
+ // Express: _router or lazyrouter
10
25
  if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
11
26
  return { name: 'express', confidence: 1.0 };
12
27
  }
13
- }
14
- // Next.js detection
15
- try {
16
- const fs = require('fs');
17
- const path = require('path');
18
- // Check for Next.js Pages Router
19
- if (fs.existsSync(path.join(process.cwd(), 'pages'))) {
20
- const hasApiFolder = fs.existsSync(path.join(process.cwd(), 'pages', 'api'));
21
- if (hasApiFolder) {
22
- return { name: 'nextjs-pages', confidence: 0.9 };
23
- }
24
- }
25
- // Check for Next.js App Router
26
- if (fs.existsSync(path.join(process.cwd(), 'app'))) {
27
- return { name: 'nextjs-app', confidence: 0.9 };
28
+ // Koa: middleware array
29
+ if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
30
+ return { name: 'koa', confidence: 0.95 };
28
31
  }
29
- // Check for next.config
30
- if (fs.existsSync(path.join(process.cwd(), 'next.config.js')) ||
31
- fs.existsSync(path.join(process.cwd(), 'next.config.mjs'))) {
32
- return { name: 'nextjs-pages', confidence: 0.7 };
33
- }
34
- }
35
- catch (e) {
36
- // Ignore fs errors
37
32
  }
38
- // Vite detection
39
- try {
40
- require.resolve('vite');
41
- return { name: 'vite', confidence: 0.8 };
33
+ // Fastify: inject method and route method
34
+ if (app &&
35
+ typeof app === 'object' &&
36
+ typeof app.inject === 'function' &&
37
+ typeof app.route === 'function') {
38
+ return { name: 'fastify', confidence: 1.0 };
42
39
  }
43
- catch (e) {
44
- // Vite not installed
40
+ // Hapi: server with route + start methods
41
+ if (app &&
42
+ typeof app === 'object' &&
43
+ typeof app.route === 'function' &&
44
+ typeof app.start === 'function' &&
45
+ typeof app.stop === 'function') {
46
+ return { name: 'hapi', confidence: 0.95 };
45
47
  }
46
- // React Router detection
47
- try {
48
- require.resolve('react-router-dom');
49
- return { name: 'react-router', confidence: 0.7 };
48
+ // NestJS application
49
+ if (app &&
50
+ typeof app === 'object' &&
51
+ typeof app.getHttpServer === 'function' &&
52
+ typeof app.listen === 'function') {
53
+ return { name: 'nestjs', confidence: 0.95 };
50
54
  }
51
- catch (e) {
52
- // React Router not installed
55
+ // ── Filesystem detection ──────────────────────────────────────────────────
56
+ // Next.js App Router
57
+ if (fileExists('app') && (fileExists('app/layout.tsx') || fileExists('app/layout.js'))) {
58
+ return { name: 'nextjs-app', confidence: 0.92 };
53
59
  }
54
- // Node HTTP detection
55
- try {
56
- const http = require('http');
57
- if (http) {
58
- return { name: 'node-http', confidence: 0.5 };
59
- }
60
+ // Next.js Pages Router
61
+ if (fileExists('pages/api') || fileExists('src/pages/api')) {
62
+ return { name: 'nextjs-pages', confidence: 0.90 };
60
63
  }
61
- catch (e) {
62
- // HTTP module not available
64
+ if (fileExists('next.config.js') ||
65
+ fileExists('next.config.mjs') ||
66
+ fileExists('next.config.ts')) {
67
+ return { name: 'nextjs-pages', confidence: 0.75 };
63
68
  }
69
+ // ── Dependency-based detection (lowest confidence) ────────────────────────
70
+ if (tryResolve('express'))
71
+ return { name: 'express', confidence: 0.70 };
72
+ if (tryResolve('fastify'))
73
+ return { name: 'fastify', confidence: 0.70 };
74
+ if (tryResolve('koa'))
75
+ return { name: 'koa', confidence: 0.70 };
76
+ if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
77
+ return { name: 'hapi', confidence: 0.70 };
78
+ if (tryResolve('@nestjs/core'))
79
+ return { name: 'nestjs', confidence: 0.70 };
80
+ if (tryResolve('vite'))
81
+ return { name: 'vite', confidence: 0.60 };
82
+ if (tryResolve('react-router-dom'))
83
+ return { name: 'react-router', confidence: 0.55 };
64
84
  return { name: 'unknown', confidence: 0 };
65
85
  }
@@ -54,6 +54,8 @@ export function enrichRoute(route, index) {
54
54
  routeKey,
55
55
  tag,
56
56
  tags: route.tags?.length ? route.tags : [tag],
57
+ module: route.module,
58
+ service: route.service,
57
59
  summary: route.summary || generateSummary(route.method, route.path),
58
60
  description: route.description || generateDescription(route.method, route.path, tag),
59
61
  authRequired,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Express analyzer — accurate route + payload extraction
2
+ * Express route analyzer — deep extraction of routes, parameters, and auth patterns
3
3
  */
4
- // ─── Path normalisation ───────────────────────────────────────────────────────
4
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
5
5
  function normalizePath(raw) {
6
6
  return raw
7
7
  .replace(/\\\//g, '/')
@@ -10,11 +10,10 @@ function normalizePath(raw) {
10
10
  .replace(/\\\w/g, '')
11
11
  .replace(/[\^\$\?\\]/g, '')
12
12
  .replace(/\/+/g, '/')
13
- .replace(/\/$/, '')
14
- || '/';
13
+ .replace(/\/$/, '') || '/';
15
14
  }
16
15
  function tagFromPath(path) {
17
- const skip = new Set(['api', 'v1', 'v2', 'v3', 'v4']);
16
+ const skip = new Set(['api', 'v1', 'v2', 'v3', 'v4', 'v5']);
18
17
  const meaningful = path
19
18
  .split('/')
20
19
  .filter(Boolean)
@@ -24,18 +23,18 @@ function tagFromPath(path) {
24
23
  function generateSummary(method, path) {
25
24
  const parts = path.split('/').filter(s => s && !s.startsWith(':'));
26
25
  const action = parts[parts.length - 1] || '';
27
- const resource = parts[parts.length - 2] || '';
26
+ const res = parts[parts.length - 2] || '';
28
27
  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}`,
28
+ GET: action ? `Get ${action}` : `List ${res}`,
29
+ POST: action ? `Create ${action}` : `Create ${res}`,
30
+ PUT: action ? `Update ${action}` : `Replace ${res}`,
31
+ PATCH: action ? `Patch ${action}` : `Patch ${res}`,
32
+ DELETE: action ? `Delete ${action}` : `Delete ${res}`,
34
33
  };
35
34
  const base = map[method] || `${method} ${path}`;
36
35
  return (base.charAt(0).toUpperCase() + base.slice(1)).replace(/[-_]/g, ' ');
37
36
  }
38
- // ─── Main analyser ────────────────────────────────────────────────────────────
37
+ // ─── Main export ─────────────────────────────────────────────────────────────
39
38
  export function analyzeExpress(app) {
40
39
  const routes = [];
41
40
  const seen = new Set();
@@ -50,36 +49,45 @@ export function analyzeExpress(app) {
50
49
  console.warn('⚠️ No routes found — ensure routes are registered before analyze()');
51
50
  return routes;
52
51
  }
52
+ walkStack(stack, '', routes, seen);
53
+ return routes;
54
+ }
55
+ function walkStack(stack, basePath, out, seen) {
53
56
  for (const layer of stack) {
54
57
  if (layer.route) {
55
- const r = buildRoute(layer.route, '');
58
+ const r = buildRoute(layer.route, basePath);
56
59
  if (r && !seen.has(r.method + r.path)) {
57
- routes.push(r);
60
+ out.push(r);
58
61
  seen.add(r.method + r.path);
59
62
  }
60
63
  }
61
64
  else if (layer.handle?.stack) {
62
- let base = '';
65
+ // Router or sub-app mounted at a prefix
66
+ let prefix = '';
63
67
  if (layer.regexp?.source)
64
- base = normalizePath(layer.regexp.source);
68
+ prefix = normalizePath(layer.regexp.source);
65
69
  else if (layer.path)
66
- base = normalizePath(layer.path);
70
+ prefix = normalizePath(layer.path);
67
71
  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
- }
72
+ prefix = normalizePath(layer.route);
73
+ const combined = combinePaths(basePath, prefix);
74
+ walkStack(layer.handle.stack, combined, out, seen);
78
75
  }
79
76
  }
80
- return routes;
77
+ }
78
+ function combinePaths(base, suffix) {
79
+ const b = base === '/' ? '' : base;
80
+ const s = suffix === '/' ? '' : suffix;
81
+ return b + s || '/';
81
82
  }
82
83
  // ─── Route builder ────────────────────────────────────────────────────────────
84
+ const AUTH_NAMES = new Set([
85
+ 'authenticateToken', 'authenticate', 'verifyToken', 'requireAuth',
86
+ 'auth', 'protect', 'isAuth', 'checkAuth', 'jwtAuth', 'bearerAuth',
87
+ 'isAuthenticated', 'requireLogin', 'ensureAuth', 'verifyJWT',
88
+ 'checkToken', 'authMiddleware', 'jwt', 'bearer', 'apiKeyAuth',
89
+ 'passport.authenticate',
90
+ ]);
83
91
  function buildRoute(route, basePath) {
84
92
  if (!route.path)
85
93
  return null;
@@ -100,59 +108,44 @@ function buildRoute(route, basePath) {
100
108
  const middleware = stack
101
109
  .map((l) => l.name)
102
110
  .filter((n) => n && n !== '<anonymous>' && n !== 'bound dispatch' && n !== 'bound ');
103
- // ── Join all handler source text ───────────────────────────────────────────
111
+ // ── Join all handler source ────────────────────────────────────────────────
104
112
  const src = stack.map((l) => l.handle?.toString() ?? '').join('\n');
105
- // ── Path params (from route pattern) ──────────────────────────────────────
113
+ // ── Path params from route pattern ─────────────────────────────────────────
106
114
  const pathParams = unique([...fullPath.matchAll(/:(\w+)/g)].map(m => m[1]));
107
115
  // ── Query params ───────────────────────────────────────────────────────────
108
116
  const queryParams = unique([
109
- // req.query.foo
110
117
  ...matchAll(src, /req\.query\.([a-zA-Z0-9_$]+)/g),
111
- // { foo, bar } = req.query / { foo: renamed } = req.query
112
118
  ...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.query/g),
113
- // req.query['foo']
114
119
  ...matchAll(src, /req\.query\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
115
120
  ]);
116
121
  // ── Request body fields ────────────────────────────────────────────────────
117
122
  const requestBodyFields = unique([
118
- // req.body.foo
119
123
  ...matchAll(src, /req\.body\.([a-zA-Z0-9_$]+)/g),
120
- // { foo, bar: alias, baz = default } = req.body
121
124
  ...destructured(src, /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*req\.body/g),
122
- // req.body['foo']
123
125
  ...matchAll(src, /req\.body\[['"]([a-zA-Z0-9_$]+)['"]\]/g),
124
- // body.foo (when `const body = req.body`)
125
126
  ...bodyAliasFields(src),
126
- // Zod: z.object({ field: z.string() }).parse(req.body)
127
127
  ...zodFields(src),
128
- // Joi: Joi.object({ field: Joi.string() }).validate(req.body)
129
128
  ...joiFields(src),
129
+ ...typeInterfaceFields(src),
130
130
  ]);
131
131
  // ── Header params ──────────────────────────────────────────────────────────
132
132
  const headerParams = unique([
133
133
  ...matchAll(src, /req\.headers\[['"]([a-zA-Z0-9_$-]+)['"]\]/g),
134
134
  ...matchAll(src, /req\.get\s*\(\s*['"]([a-zA-Z0-9_$-]+)['"]\s*\)/g),
135
- // req.headers.authorization, req.headers['x-api-key'], etc.
136
135
  ...matchAll(src, /req\.headers\.([a-zA-Z0-9_$]+)/g),
137
136
  ]);
138
- // ── Response status codes from `res.status(nnn)` ──────────────────────────
137
+ // ── Response status codes ──────────────────────────────────────────────────
139
138
  const responseStatuses = unique([...src.matchAll(/res\.status\s*\(\s*(\d{3})\s*\)/g)].map(m => parseInt(m[1], 10)));
140
139
  // ── 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
140
  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);
141
+ /req\.user|req\.auth|req\.currentUser|verifyToken|jwt\.verify|passport\.authenticate/.test(src);
149
142
  const method = methods[0];
150
143
  const tag = tagFromPath(fullPath);
151
144
  const summary = generateSummary(method, fullPath);
152
145
  const operationId = `${method.toLowerCase()}_${fullPath
153
146
  .replace(/[^a-zA-Z0-9]/g, '_')
154
147
  .replace(/_+/g, '_')
155
- .replace(/^_|_$/g, '')}`;
148
+ .replace(/^_|_$/, '')}`;
156
149
  return {
157
150
  method,
158
151
  path: fullPath,
@@ -172,49 +165,33 @@ function buildRoute(route, basePath) {
172
165
  };
173
166
  }
174
167
  // ─── Extraction helpers ───────────────────────────────────────────────────────
175
- /** Run a global regex and return capture group 1 for every match. */
176
168
  function matchAll(src, re) {
177
169
  return [...src.matchAll(re)].map(m => m[1]).filter(Boolean);
178
170
  }
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
171
  function destructured(src, re) {
185
172
  const results = [];
186
173
  for (const match of src.matchAll(re)) {
187
174
  match[1]
188
175
  .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())
176
+ .map(s => s.trim().split(':')[0].trim().split('=')[0].trim())
194
177
  .filter(Boolean)
195
178
  .forEach(f => results.push(f));
196
179
  }
197
180
  return results;
198
181
  }
199
- /**
200
- * When a developer writes `const body = req.body` and then accesses `body.foo`.
201
- */
202
182
  function bodyAliasFields(src) {
203
- const aliasMatch = src.match(/(?:const|let|var)\s+(\w+)\s*=\s*req\.body\b/);
204
- if (!aliasMatch)
183
+ const m = src.match(/(?:const|let|var)\s+(\w+)\s*=\s*req\.body\b/);
184
+ if (!m)
205
185
  return [];
206
- const alias = aliasMatch[1];
186
+ const alias = m[1];
207
187
  if (alias === 'body')
208
188
  return matchAll(src, /body\.([a-zA-Z0-9_$]+)/g);
209
189
  const re = new RegExp(`\\b${alias}\\.([a-zA-Z0-9_$]+)`, 'g');
210
190
  return matchAll(src, re);
211
191
  }
212
- /** Extract field names from Zod object schemas. */
213
192
  function zodFields(src) {
214
193
  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)) {
194
+ for (const match of src.matchAll(/z\.object\s*\(\s*\{([^}]+)\}/g)) {
218
195
  match[1]
219
196
  .split(',')
220
197
  .map(s => s.trim().split(':')[0].trim())
@@ -223,12 +200,9 @@ function zodFields(src) {
223
200
  }
224
201
  return results;
225
202
  }
226
- /** Extract field names from Joi object schemas. */
227
203
  function joiFields(src) {
228
204
  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)) {
205
+ for (const match of src.matchAll(/(?:Joi|yup)\.object\s*\(?\s*(?:\.shape\s*\()?\s*\{([^}]+)\}/g)) {
232
206
  match[1]
233
207
  .split(',')
234
208
  .map(s => s.trim().split(':')[0].trim())
@@ -237,6 +211,38 @@ function joiFields(src) {
237
211
  }
238
212
  return results;
239
213
  }
214
+ /**
215
+ * Detects TypeScript interface/type fields used as request body type parameters.
216
+ * Handles patterns like:
217
+ * const { name, email } = req.body as CreateUserDto
218
+ * function handler(req: Request<{}, {}, CreateUserBody>) { ... }
219
+ * This runs the interface declaration through a lightweight regex rather than a
220
+ * full TS AST to keep the package zero-dependency.
221
+ */
222
+ function typeInterfaceFields(src) {
223
+ const results = [];
224
+ // Extract type name used as body generic: Request<P, ResBody, BodyType>
225
+ const bodyTypeNames = new Set();
226
+ for (const m of src.matchAll(/Request\s*<[^,]*,[^,]*,\s*([A-Z][a-zA-Z0-9_]*)\s*[,>]/g)) {
227
+ bodyTypeNames.add(m[1]);
228
+ }
229
+ // `req.body as SomeType`
230
+ for (const m of src.matchAll(/req\.body\s+as\s+([A-Z][a-zA-Z0-9_]*)/g)) {
231
+ bodyTypeNames.add(m[1]);
232
+ }
233
+ for (const typeName of bodyTypeNames) {
234
+ // Find interface { field: type; ... } or type alias { field: type }
235
+ const blockRe = new RegExp(`(?:interface|type)\\s+${typeName}\\s*(?:extends[^{]+)?\\{([^}]+)\\}`, 'g');
236
+ for (const match of src.matchAll(blockRe)) {
237
+ match[1]
238
+ .split(/[;,\n]/)
239
+ .map(line => line.trim().split(/[?:]/)[0].trim())
240
+ .filter(f => /^[a-zA-Z_$]/.test(f))
241
+ .forEach(f => results.push(f));
242
+ }
243
+ }
244
+ return results;
245
+ }
240
246
  function unique(items) {
241
247
  return Array.from(new Set(items.filter(Boolean)));
242
248
  }
@@ -0,0 +1,35 @@
1
+ import { analyzeExpress } from './express.js';
2
+ import { detectNestModules } from '../modules/nestjs.js';
3
+ import { detectExpressModules, annotateRoutesWithModules } from '../modules/express.js';
4
+ /**
5
+ * Analyze a NestJS application by extracting the underlying Express (or Fastify)
6
+ * HTTP adapter and reusing the existing Express route-walker.
7
+ *
8
+ * NestJS wraps an inner Express app under app.getHttpServer()._events.request.
9
+ * When that's available we get full runtime accuracy. Otherwise we warn and
10
+ * fall back to an empty array so the CLI can report the limitation.
11
+ */
12
+ export async function analyzeNestJS(app, rootDir) {
13
+ if (!app || typeof app.getHttpServer !== 'function') {
14
+ console.warn('⚠️ APIForge: NestJS app instance required. Pass the Nest application object.');
15
+ return [];
16
+ }
17
+ const httpServer = app.getHttpServer();
18
+ // NestJS wraps Express under _events.request when using the default adapter
19
+ const innerApp = httpServer?._events?.request ||
20
+ httpServer?._events?.['request'] ||
21
+ null;
22
+ if (!innerApp || typeof innerApp !== 'function') {
23
+ console.warn('⚠️ APIForge: Could not access inner Express adapter from NestJS app.');
24
+ return [];
25
+ }
26
+ const routes = await analyzeExpress(innerApp);
27
+ if (routes.length === 0)
28
+ return [];
29
+ // Detect module structure and annotate each route
30
+ const dir = rootDir || process.cwd();
31
+ const modules = detectNestModules(dir).length > 0
32
+ ? detectNestModules(dir)
33
+ : detectExpressModules(dir);
34
+ return annotateRoutesWithModules(routes, modules);
35
+ }