apiforge-analyzer 1.3.1 → 1.3.3
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 +165 -3
- package/dist/cjs/cli.js +236 -21
- package/dist/cjs/detect.js +34 -16
- package/dist/cjs/frameworks/fastify.js +1112 -0
- package/dist/cjs/index.js +29 -6
- package/dist/cjs/package.json +1 -1
- package/dist/esm/cli.js +237 -22
- package/dist/esm/detect.js +33 -16
- package/dist/esm/frameworks/fastify.js +1076 -0
- package/dist/esm/index.js +29 -7
- package/dist/types/detect.d.ts +7 -3
- package/dist/types/frameworks/fastify.d.ts +16 -0
- package/dist/types/index.d.ts +16 -1
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# apiforge-analyzer
|
|
2
2
|
|
|
3
|
-
CLI and library for auto-generating API documentation from live Express, NestJS, Next.js, Vite, and React Router apps.
|
|
3
|
+
CLI and library for auto-generating API documentation from live Express, Fastify, NestJS, Next.js, Vite, and React Router apps.
|
|
4
4
|
|
|
5
5
|
Routes are extracted at runtime by walking the framework's internal route stack — no static analysis, no decorators, no changes to your production code.
|
|
6
6
|
|
|
@@ -90,6 +90,108 @@ await analyze(app.getHttpServer(), {
|
|
|
90
90
|
await app.listen(3000)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### Fastify
|
|
94
|
+
|
|
95
|
+
Routes are captured via Fastify's `onRoute` hook, so composed paths (`register(fn, { prefix })`,
|
|
96
|
+
relative registrations, nested scopes) resolve to their real runtime URL rather than the literal
|
|
97
|
+
string passed to `app.get(...)`. Handlers are resolved with a TypeScript checker (not a regex),
|
|
98
|
+
so a handler that lives in another module — including one reached through an aliased import,
|
|
99
|
+
a curried factory, or a thin delegating wrapper — is still followed to its real implementation.
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
const fastify = require('fastify')
|
|
103
|
+
const { analyze } = require('apiforge-analyzer')
|
|
104
|
+
|
|
105
|
+
const app = fastify()
|
|
106
|
+
|
|
107
|
+
app.register(async (v1) => {
|
|
108
|
+
v1.get('/users', listUsers)
|
|
109
|
+
v1.post('/users', createUser)
|
|
110
|
+
}, { prefix: '/v1' })
|
|
111
|
+
|
|
112
|
+
// Call after all routes are registered, before or after app.listen — analyze()
|
|
113
|
+
// awaits app.ready() itself.
|
|
114
|
+
await analyze(app, {
|
|
115
|
+
projectName: 'My API',
|
|
116
|
+
apiKey: process.env.APIFORGE_API_KEY,
|
|
117
|
+
upload: true,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await app.listen({ port: 3000 })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
What's inferred automatically, with no annotation: the composed path, the handler's resolved
|
|
124
|
+
source location and leading JSDoc comment (lifted verbatim into `description`), header reads,
|
|
125
|
+
literal and simple-ternary response statuses, and auth — a route counts as authenticated if a
|
|
126
|
+
route-level `onRequest`/`preHandler` hook looks like an auth check by name or body, or if the
|
|
127
|
+
resolved handler calls a guard function (`requireScope`, `requireOperator`, or a local alias of
|
|
128
|
+
either — `const operator = requireOperator` is followed, not missed).
|
|
129
|
+
|
|
130
|
+
Query and body fields backed by a zod schema — matching both `schema.parse(request.query)` and
|
|
131
|
+
a wrapper call like `parse(schema, request.query)` — come out typed, not as bare field names:
|
|
132
|
+
`z.string().min/max/length()`, `.number()`, `.boolean()`, `.enum([...])` and `.default()` are
|
|
133
|
+
read off the schema and land in the standard `Route.parameters` / `Route.requestBody` fields as
|
|
134
|
+
real JSON Schema (`minLength`/`maxLength`, `enum`, `default`, etc.), the same fields
|
|
135
|
+
`uploadRoutes()` and `exportToJSON()` read — not a side channel that gets dropped on the way out.
|
|
136
|
+
A `.refine()` on the object doesn't drop the fields declared before it.
|
|
137
|
+
|
|
138
|
+
What can't be inferred safely is reported as `"unknown"` rather than guessed — an unresolved
|
|
139
|
+
handler, or a route-level hook whose name doesn't match a known auth pattern, produces
|
|
140
|
+
`authRequired: "unknown"` instead of a confident `false`.
|
|
141
|
+
|
|
142
|
+
#### Supplying what can't be inferred
|
|
143
|
+
|
|
144
|
+
Four extra `AnalyzerOptions` fields cover metadata no static analysis can recover:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
await analyze(app, {
|
|
148
|
+
projectName: 'My API',
|
|
149
|
+
apiKey: process.env.APIFORGE_API_KEY,
|
|
150
|
+
|
|
151
|
+
// scope name -> extra metadata, joined onto each route's resolved auth scope
|
|
152
|
+
scopeCatalogPath: './scope-catalog.json',
|
|
153
|
+
|
|
154
|
+
// error code -> { status, retry, message, hint? }, joined onto a route by
|
|
155
|
+
// response status, or by the `errors` list in an annotation (see below)
|
|
156
|
+
errorCatalogPath: './error-catalog.json',
|
|
157
|
+
|
|
158
|
+
// per-prefix response envelope shape — required for every versioned prefix
|
|
159
|
+
// your API serves once you use this option at all; an unlisted prefix is
|
|
160
|
+
// reported as a warning rather than silently assumed bare
|
|
161
|
+
envelopes: {
|
|
162
|
+
'/v1': { bare: true },
|
|
163
|
+
'/v2': { wrapper: 'ApiEnvelope', dataField: 'data' },
|
|
164
|
+
},
|
|
165
|
+
// With `envelopes` set, `GET /v1/things` and `GET /v2/things` get *different*
|
|
166
|
+
// `responses['200']` schemas from this one declaration — `/v2`'s nests the same
|
|
167
|
+
// bare shape under `data` instead of replacing it, so it stays a strict superset.
|
|
168
|
+
|
|
169
|
+
// "METHOD /path" -> { summary?, description?, tags?, deprecated?, errors? }
|
|
170
|
+
// overrides for whatever the analyzer got wrong or couldn't see at all
|
|
171
|
+
annotationsPath: './annotations.json',
|
|
172
|
+
})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The same per-route overrides can also be attached inline at the route, via Fastify's own
|
|
176
|
+
`config` option — no sidecar file needed for a one-off:
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
app.get('/users', {
|
|
180
|
+
config: {
|
|
181
|
+
apiforge: { summary: 'List users', tags: ['users'] },
|
|
182
|
+
},
|
|
183
|
+
}, listUsers)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
An `annotationsPath` entry that doesn't match any route Fastify actually registered (a stale
|
|
187
|
+
key left behind after a route was renamed or removed) logs a warning rather than failing
|
|
188
|
+
silently.
|
|
189
|
+
|
|
190
|
+
We deliberately do **not** read Fastify's request-side `schema` option for documentation —
|
|
191
|
+
adding a schema there gives the route a second, independent validator with its own error
|
|
192
|
+
contract, and `fast-json-stringify` silently drops response fields a *response* schema doesn't
|
|
193
|
+
declare. Use zod (auto-detected) or the annotation options above instead.
|
|
194
|
+
|
|
93
195
|
### Microservices — per-service
|
|
94
196
|
|
|
95
197
|
When running each service independently, pass `service` to tag routes with the service name:
|
|
@@ -180,9 +282,23 @@ interface AnalyzerOptions {
|
|
|
180
282
|
|
|
181
283
|
skipInProduction?: boolean // skip when NODE_ENV=production (default: true)
|
|
182
284
|
runOnce?: boolean // debounce — skip if analyzed within the last 30 s (default: true)
|
|
285
|
+
|
|
286
|
+
// Fastify only — see "Supplying what can't be inferred" above
|
|
287
|
+
scopeCatalogPath?: string
|
|
288
|
+
errorCatalogPath?: string
|
|
289
|
+
envelopes?: Record<string, { wrapper: string; dataField: string; excluded?: string[] } | { bare: true }>
|
|
290
|
+
annotationsPath?: string
|
|
183
291
|
}
|
|
184
292
|
```
|
|
185
293
|
|
|
294
|
+
`analyze()` throws instead of resolving to `[]` when the detected or forced framework has no
|
|
295
|
+
analyzer (currently: anything other than `express`, `fastify`, `nestjs`, `nextjs-pages`,
|
|
296
|
+
`nextjs-app`, `vite`, `react-router`). Every other early-exit (no API key, debounced, zero
|
|
297
|
+
routes found, `NODE_ENV=production`) still resolves to `[]` — only "this framework cannot be
|
|
298
|
+
analyzed" is a thrown error, so a caller can tell the two apart. `detectFramework()` also
|
|
299
|
+
exposes this directly: `detectFramework(app).supported` is `false` for a framework it can
|
|
300
|
+
name but not analyze (currently `koa`, `hapi`, `unknown`).
|
|
301
|
+
|
|
186
302
|
## Architecture detection
|
|
187
303
|
|
|
188
304
|
The CLI scores structural signals to pick the right architecture automatically:
|
|
@@ -216,11 +332,57 @@ Each collected route:
|
|
|
216
332
|
requestBodyFields: [],
|
|
217
333
|
responseStatuses: [200, 404],
|
|
218
334
|
framework: 'express',
|
|
219
|
-
|
|
220
|
-
|
|
335
|
+
module?: 'users', // modular-monolithic only
|
|
336
|
+
service?: 'auth-service' // microservices only
|
|
221
337
|
}
|
|
222
338
|
```
|
|
223
339
|
|
|
340
|
+
## Escape hatch — building routes yourself
|
|
341
|
+
|
|
342
|
+
If your framework has no `analyze*()` function, call `uploadRoutes()` directly with a
|
|
343
|
+
hand-built `Route[]`. This is the same function every `analyze*()` call ends up using
|
|
344
|
+
internally — it's a supported, typed, top-level export, not a private helper.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { uploadRoutes, type Route } from 'apiforge-analyzer'
|
|
348
|
+
|
|
349
|
+
const routes: Route[] = [
|
|
350
|
+
{
|
|
351
|
+
method: 'GET',
|
|
352
|
+
path: '/api/users/:id',
|
|
353
|
+
tag: 'users',
|
|
354
|
+
operationId: 'get_api_users_id',
|
|
355
|
+
summary: 'Get a user by id',
|
|
356
|
+
middleware: [],
|
|
357
|
+
authRequired: true,
|
|
358
|
+
pathParams: ['id'],
|
|
359
|
+
queryParams: [],
|
|
360
|
+
requestBodyFields: [],
|
|
361
|
+
linkedPaths: [],
|
|
362
|
+
hasAuth: true,
|
|
363
|
+
framework: 'my-framework',
|
|
364
|
+
// routeKey?: stable dedup key the dashboard uses across uploads — see below
|
|
365
|
+
},
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
await uploadRoutes(routes, {
|
|
369
|
+
projectName: 'My API',
|
|
370
|
+
apiKey: process.env.APIFORGE_API_KEY!,
|
|
371
|
+
})
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The fields above (`method`, `path`, `tag`, `operationId`, `summary`, `middleware`,
|
|
375
|
+
`authRequired`, `pathParams`, `queryParams`, `requestBodyFields`, `linkedPaths`, `hasAuth`,
|
|
376
|
+
`framework`) are the only ones `Route` requires; everything else (`description`, `tags`,
|
|
377
|
+
`headerParams`, `responseStatuses`, `parameters`, `requestBody`, `responses`, `module`,
|
|
378
|
+
`service`, ...) is optional and gets a reasonable default from `enrichRoutes()`.
|
|
379
|
+
|
|
380
|
+
**Set `routeKey` yourself.** If you don't, it defaults to `api-001`, `api-002`, ... based on
|
|
381
|
+
array position — which shifts if a route is added, removed, or reordered between uploads, so
|
|
382
|
+
the dashboard can no longer tell "this is the same endpoint as last time" from "this is a new
|
|
383
|
+
one". Use something stable per endpoint, e.g. `` `${method} ${path}` ``, as every built-in
|
|
384
|
+
analyzer does.
|
|
385
|
+
|
|
224
386
|
## ESM and CommonJS
|
|
225
387
|
|
|
226
388
|
```js
|
package/dist/cjs/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ const green = (s) => `${gn}${s}${r}`;
|
|
|
27
27
|
const yellow = (s) => `${yl}${s}${r}`;
|
|
28
28
|
const red = (s) => `${rd}${s}${r}`;
|
|
29
29
|
const white = (s) => `${wh}${s}${r}`;
|
|
30
|
-
const VERSION = '1.3.
|
|
30
|
+
const VERSION = '1.3.2';
|
|
31
31
|
// Built-in URLs — users never need to configure these
|
|
32
32
|
const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
|
|
33
33
|
const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
|
|
@@ -224,11 +224,18 @@ async function main() {
|
|
|
224
224
|
if (serverResult.status === 'error') {
|
|
225
225
|
s.stop(red(`✗ ${serverResult.message}`));
|
|
226
226
|
// Give a targeted hint for common failure modes
|
|
227
|
-
if (serverResult.message.includes('API key')) {
|
|
228
|
-
prompts_1.log.warn(yellow('Hint:
|
|
229
|
-
prompts_1.log.warn(yellow('
|
|
230
|
-
prompts_1.log.
|
|
231
|
-
prompts_1.log.
|
|
227
|
+
if (serverResult.message.includes('API key') || serverResult.message.includes('Invalid API key')) {
|
|
228
|
+
prompts_1.log.warn(yellow('Hint: Your API key was rejected by the server. Common causes:'));
|
|
229
|
+
prompts_1.log.warn(yellow(''));
|
|
230
|
+
prompts_1.log.warn(yellow(' 1. Key not yet created — go to your dashboard and generate one:'));
|
|
231
|
+
prompts_1.log.info(` ${cyan('https://apiforge.brainfogagency.com/dashboard')}`);
|
|
232
|
+
prompts_1.log.warn(yellow(''));
|
|
233
|
+
prompts_1.log.warn(yellow(' 2. Key belongs to a different account — make sure you\'re logged in'));
|
|
234
|
+
prompts_1.log.warn(yellow(' to the same account that owns this project.'));
|
|
235
|
+
prompts_1.log.warn(yellow(''));
|
|
236
|
+
prompts_1.log.warn(yellow(' 3. Server not loading .env.local — add this at the top of your entry file:'));
|
|
237
|
+
prompts_1.log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
|
|
238
|
+
prompts_1.log.warn(yellow(' Or install dotenv: npm install dotenv'));
|
|
232
239
|
}
|
|
233
240
|
else if (serverResult.message.includes('No routes')) {
|
|
234
241
|
prompts_1.log.warn(yellow('Hint: Make sure all routes are registered before analyze() is called.'));
|
|
@@ -238,6 +245,14 @@ async function main() {
|
|
|
238
245
|
prompts_1.log.warn(yellow('Hint: Your server might be taking too long to start, or the analyzer'));
|
|
239
246
|
prompts_1.log.warn(yellow(' is not being called. Check your server logs for APIForge messages.'));
|
|
240
247
|
}
|
|
248
|
+
// Show captured server output to help debug the root cause
|
|
249
|
+
const capturedOutput = serverResult.serverOutput;
|
|
250
|
+
if (capturedOutput) {
|
|
251
|
+
prompts_1.log.info('');
|
|
252
|
+
prompts_1.log.info(dim('─── Server output ───────────────────────────────'));
|
|
253
|
+
capturedOutput.split('\n').slice(0, 25).forEach((line) => prompts_1.log.info(dim(` ${line}`)));
|
|
254
|
+
prompts_1.log.info(dim('─────────────────────────────────────────────────'));
|
|
255
|
+
}
|
|
241
256
|
await offerCleanup(entryFile, injected);
|
|
242
257
|
(0, prompts_1.outro)(red('Analysis failed.'));
|
|
243
258
|
process.exit(1);
|
|
@@ -395,6 +410,158 @@ function hasDockerCompose(cwd) {
|
|
|
395
410
|
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
|
|
396
411
|
.some(f => (0, fs_1.existsSync)((0, path_1.join)(cwd, f)));
|
|
397
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Find the gateway among the discovered services (by name or by proxy-middleware
|
|
415
|
+
* usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
|
|
416
|
+
* Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
|
|
417
|
+
* pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
|
|
418
|
+
* or no recognizable mapping is found — callers should treat that as "no rewrite."
|
|
419
|
+
*/
|
|
420
|
+
function detectGatewayPrefixMap(cwd, services) {
|
|
421
|
+
const prefixMap = {};
|
|
422
|
+
for (const svc of services) {
|
|
423
|
+
const svcDir = svc.dir.replace(/^\.\//, '');
|
|
424
|
+
const relEntry = svc.entryFile ?? findEntryInDir((0, path_1.join)(cwd, svcDir));
|
|
425
|
+
if (!relEntry)
|
|
426
|
+
continue;
|
|
427
|
+
let source = '';
|
|
428
|
+
try {
|
|
429
|
+
source = (0, fs_1.readFileSync)((0, path_1.join)(cwd, svcDir, relEntry), 'utf-8');
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
|
|
435
|
+
if (!looksLikeGateway)
|
|
436
|
+
continue;
|
|
437
|
+
// '/auth': process.env.AUTH_SERVICE_URL || `http://...`
|
|
438
|
+
const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
|
|
439
|
+
let m;
|
|
440
|
+
while ((m = re.exec(source))) {
|
|
441
|
+
const prefix = m[1];
|
|
442
|
+
const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
|
|
443
|
+
if (!prefixMap[serviceName])
|
|
444
|
+
prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
|
|
445
|
+
}
|
|
446
|
+
if (Object.keys(prefixMap).length > 0)
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
return prefixMap;
|
|
450
|
+
}
|
|
451
|
+
function withGatewayPrefix(prefix, routePath) {
|
|
452
|
+
if (routePath === '/')
|
|
453
|
+
return prefix;
|
|
454
|
+
return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
|
|
455
|
+
}
|
|
456
|
+
// ─── Postman collection enrichment ─────────────────────────────────────────────
|
|
457
|
+
// Runtime introspection only sees field NAMES (via regex over handler source),
|
|
458
|
+
// never real example values — a Postman collection, when present, already has
|
|
459
|
+
// hand-written example bodies and descriptions. We fold those in as enrichment
|
|
460
|
+
// only: matched by method + path, never adds or removes an endpoint.
|
|
461
|
+
function findFileRecursive(dir, matches, maxDepth, depth = 0) {
|
|
462
|
+
if (depth > maxDepth)
|
|
463
|
+
return null;
|
|
464
|
+
let entries;
|
|
465
|
+
try {
|
|
466
|
+
entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
for (const e of entries) {
|
|
472
|
+
if (e.isFile() && matches(e.name))
|
|
473
|
+
return (0, path_1.join)(dir, e.name);
|
|
474
|
+
}
|
|
475
|
+
for (const e of entries) {
|
|
476
|
+
if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
|
|
477
|
+
const found = findFileRecursive((0, path_1.join)(dir, e.name), matches, maxDepth, depth + 1);
|
|
478
|
+
if (found)
|
|
479
|
+
return found;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
function findPostmanCollection(cwd) {
|
|
485
|
+
const isCollection = (name) => name.endsWith('.postman_collection.json');
|
|
486
|
+
for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
|
|
487
|
+
const dirPath = (0, path_1.join)(cwd, dir);
|
|
488
|
+
if (!(0, fs_1.existsSync)(dirPath))
|
|
489
|
+
continue;
|
|
490
|
+
try {
|
|
491
|
+
const match = (0, fs_1.readdirSync)(dirPath).find(isCollection);
|
|
492
|
+
if (match)
|
|
493
|
+
return (0, path_1.join)(dirPath, match);
|
|
494
|
+
}
|
|
495
|
+
catch { }
|
|
496
|
+
}
|
|
497
|
+
return findFileRecursive(cwd, isCollection, 3);
|
|
498
|
+
}
|
|
499
|
+
/** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
|
|
500
|
+
function postmanRawToRoutePath(raw) {
|
|
501
|
+
let p = raw.trim();
|
|
502
|
+
p = p.replace(/^\{\{[^}]+\}\}/, '');
|
|
503
|
+
p = p.split('?')[0];
|
|
504
|
+
p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
|
|
505
|
+
if (!p)
|
|
506
|
+
p = '/';
|
|
507
|
+
if (!p.startsWith('/'))
|
|
508
|
+
p = '/' + p;
|
|
509
|
+
if (p !== '/' && p.endsWith('/'))
|
|
510
|
+
p = p.slice(0, -1);
|
|
511
|
+
return p;
|
|
512
|
+
}
|
|
513
|
+
/** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
|
|
514
|
+
function walkPostmanItems(items, index) {
|
|
515
|
+
for (const node of items ?? []) {
|
|
516
|
+
if (Array.isArray(node.item))
|
|
517
|
+
walkPostmanItems(node.item, index);
|
|
518
|
+
if (!node.request)
|
|
519
|
+
continue;
|
|
520
|
+
const req = node.request;
|
|
521
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
522
|
+
const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
|
|
523
|
+
if (!rawUrl)
|
|
524
|
+
continue;
|
|
525
|
+
const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
|
|
526
|
+
if (index.has(key))
|
|
527
|
+
continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
|
|
528
|
+
const entry = {};
|
|
529
|
+
if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
|
|
530
|
+
entry.bodyExample = req.body.raw.trim();
|
|
531
|
+
}
|
|
532
|
+
if (typeof req.description === 'string' && req.description.trim()) {
|
|
533
|
+
entry.description = req.description.trim();
|
|
534
|
+
}
|
|
535
|
+
if (entry.bodyExample || entry.description)
|
|
536
|
+
index.set(key, entry);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
|
|
540
|
+
function enrichRoutesFromPostman(cwd, routes) {
|
|
541
|
+
const collectionPath = findPostmanCollection(cwd);
|
|
542
|
+
if (!collectionPath)
|
|
543
|
+
return;
|
|
544
|
+
try {
|
|
545
|
+
const collection = JSON.parse((0, fs_1.readFileSync)(collectionPath, 'utf-8'));
|
|
546
|
+
const index = new Map();
|
|
547
|
+
walkPostmanItems(collection.item, index);
|
|
548
|
+
let enriched = 0;
|
|
549
|
+
for (const route of routes) {
|
|
550
|
+
const match = index.get(`${route.method} ${route.path}`);
|
|
551
|
+
if (!match)
|
|
552
|
+
continue;
|
|
553
|
+
if (match.bodyExample)
|
|
554
|
+
route.bodyExample = match.bodyExample;
|
|
555
|
+
if (match.description && !route.description)
|
|
556
|
+
route.description = match.description;
|
|
557
|
+
enriched++;
|
|
558
|
+
}
|
|
559
|
+
if (enriched > 0) {
|
|
560
|
+
prompts_1.log.info(dim(`Enriched ${enriched} routes with examples from ${(0, path_1.basename)(collectionPath)}`));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch { }
|
|
564
|
+
}
|
|
398
565
|
async function microservicesFlow(config, opts) {
|
|
399
566
|
const cwd = process.cwd();
|
|
400
567
|
const allRoutes = [];
|
|
@@ -437,6 +604,14 @@ async function microservicesFlow(config, opts) {
|
|
|
437
604
|
'',
|
|
438
605
|
dim('Each service is started individually and its routes are collected.'),
|
|
439
606
|
].join('\n'), 'Multi-service analysis');
|
|
607
|
+
// Routes are collected at each service's own internal paths (e.g. auth's /register),
|
|
608
|
+
// not the path clients actually call through the gateway (/auth/register) — Express
|
|
609
|
+
// strips the mount prefix before the service ever sees the request. Rewrite paths
|
|
610
|
+
// here so the docs match what's actually callable.
|
|
611
|
+
const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
|
|
612
|
+
if (Object.keys(gatewayPrefixes).length > 0) {
|
|
613
|
+
prompts_1.log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
|
|
614
|
+
}
|
|
440
615
|
for (const svc of config.services) {
|
|
441
616
|
const svcSpin = (0, prompts_1.spinner)();
|
|
442
617
|
svcSpin.start(`${bold(svc.name)} — starting…`);
|
|
@@ -465,13 +640,14 @@ async function microservicesFlow(config, opts) {
|
|
|
465
640
|
detached: false,
|
|
466
641
|
});
|
|
467
642
|
// Capture output so failures are debuggable without re-running the service by hand.
|
|
468
|
-
//
|
|
643
|
+
// Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
|
|
644
|
+
// first, then unwinds the call stack — a long stack trace must not push that
|
|
645
|
+
// opening line out of the window, so stop growing once capped instead of sliding.
|
|
469
646
|
let output = '';
|
|
470
|
-
const MAX_OUTPUT =
|
|
647
|
+
const MAX_OUTPUT = 6000;
|
|
471
648
|
const capture = (chunk) => {
|
|
472
|
-
output
|
|
473
|
-
|
|
474
|
-
output = output.slice(-MAX_OUTPUT);
|
|
649
|
+
if (output.length < MAX_OUTPUT)
|
|
650
|
+
output += chunk.toString();
|
|
475
651
|
};
|
|
476
652
|
child.stdout?.on('data', capture);
|
|
477
653
|
child.stderr?.on('data', capture);
|
|
@@ -484,7 +660,12 @@ async function microservicesFlow(config, opts) {
|
|
|
484
660
|
catch { }
|
|
485
661
|
tryUnlink((0, path_1.join)(cwd, statusFile));
|
|
486
662
|
if (routes && routes.length > 0) {
|
|
487
|
-
const
|
|
663
|
+
const prefix = gatewayPrefixes[svc.name];
|
|
664
|
+
const annotated = routes.map((r) => ({
|
|
665
|
+
...r,
|
|
666
|
+
service: svc.name,
|
|
667
|
+
path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
|
|
668
|
+
}));
|
|
488
669
|
allRoutes.push(...annotated);
|
|
489
670
|
serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
|
|
490
671
|
svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
|
|
@@ -493,7 +674,7 @@ async function microservicesFlow(config, opts) {
|
|
|
493
674
|
svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
|
|
494
675
|
const trimmed = output.trim();
|
|
495
676
|
if (trimmed) {
|
|
496
|
-
prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(
|
|
677
|
+
prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
|
|
497
678
|
}
|
|
498
679
|
}
|
|
499
680
|
// Remove injected code
|
|
@@ -503,6 +684,7 @@ async function microservicesFlow(config, opts) {
|
|
|
503
684
|
// Safety: clean up any remaining injections (e.g. if process was interrupted)
|
|
504
685
|
for (const f of injectedFiles)
|
|
505
686
|
removeInjectedCode(f);
|
|
687
|
+
enrichRoutesFromPostman(cwd, allRoutes);
|
|
506
688
|
if (allRoutes.length === 0) {
|
|
507
689
|
prompts_1.log.error(red('No routes were collected from any service.'));
|
|
508
690
|
(0, prompts_1.outro)(red('Analysis failed.'));
|
|
@@ -582,11 +764,20 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
582
764
|
const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
|
|
583
765
|
const child = (0, child_process_1.spawn)(command, {
|
|
584
766
|
cwd: process.cwd(),
|
|
585
|
-
stdio: 'ignore',
|
|
767
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
586
768
|
shell: true,
|
|
587
769
|
env: { ...process.env },
|
|
588
770
|
detached: false,
|
|
589
771
|
});
|
|
772
|
+
// Capture server output (head-capped) so we can show it on failure
|
|
773
|
+
let serverOutput = '';
|
|
774
|
+
const MAX_OUTPUT = 4000;
|
|
775
|
+
const captureOutput = (chunk) => {
|
|
776
|
+
if (serverOutput.length < MAX_OUTPUT)
|
|
777
|
+
serverOutput += chunk.toString();
|
|
778
|
+
};
|
|
779
|
+
child.stdout?.on('data', captureOutput);
|
|
780
|
+
child.stderr?.on('data', captureOutput);
|
|
590
781
|
let resolved = false;
|
|
591
782
|
function finish(result) {
|
|
592
783
|
if (resolved)
|
|
@@ -598,7 +789,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
|
|
|
598
789
|
child.kill('SIGTERM');
|
|
599
790
|
}
|
|
600
791
|
catch { }
|
|
601
|
-
resolve(result);
|
|
792
|
+
resolve({ ...result, serverOutput: serverOutput.trim() });
|
|
602
793
|
}
|
|
603
794
|
// Poll for the status file every 500 ms
|
|
604
795
|
const poller = setInterval(() => {
|
|
@@ -1023,13 +1214,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1023
1214
|
` upload: true,`,
|
|
1024
1215
|
` runOnce: true,`,
|
|
1025
1216
|
].join('\n');
|
|
1217
|
+
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1218
|
+
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1219
|
+
// Poll briefly for app._router to be populated (Express-shaped apps only)
|
|
1220
|
+
// before giving up and collecting whatever is there — bounded well under
|
|
1221
|
+
// the CLI's 30s per-service timeout so a hung connect still surfaces.
|
|
1222
|
+
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1223
|
+
const call = collectionOpts
|
|
1224
|
+
? [
|
|
1225
|
+
` let __apiforgeTries = 0`,
|
|
1226
|
+
` const __apiforgeTryCollect = () => {`,
|
|
1227
|
+
` const hasRoutes = !${appVar} || !${appVar}._router`,
|
|
1228
|
+
` ? true`,
|
|
1229
|
+
` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
|
|
1230
|
+
` if (hasRoutes || __apiforgeTries++ >= 26) {`,
|
|
1231
|
+
` ${runAnalyze}`,
|
|
1232
|
+
` } else {`,
|
|
1233
|
+
` setTimeout(__apiforgeTryCollect, 300)`,
|
|
1234
|
+
` }`,
|
|
1235
|
+
` }`,
|
|
1236
|
+
` __apiforgeTryCollect()`,
|
|
1237
|
+
].join('\n')
|
|
1238
|
+
: ` ${runAnalyze}`;
|
|
1026
1239
|
if (isESM) {
|
|
1027
1240
|
return [
|
|
1028
1241
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1029
1242
|
`import('apiforge-analyzer').then(({ default: APIForge }) => {`,
|
|
1030
|
-
|
|
1031
|
-
opts,
|
|
1032
|
-
` })`,
|
|
1243
|
+
call,
|
|
1033
1244
|
`}).catch(() => {})`,
|
|
1034
1245
|
'',
|
|
1035
1246
|
].join('\n');
|
|
@@ -1038,9 +1249,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1038
1249
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1039
1250
|
`Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
|
|
1040
1251
|
` const APIForge = mod.default || mod`,
|
|
1041
|
-
|
|
1042
|
-
opts,
|
|
1043
|
-
` })`,
|
|
1252
|
+
call,
|
|
1044
1253
|
`}).catch(() => {})`,
|
|
1045
1254
|
'',
|
|
1046
1255
|
].join('\n');
|
|
@@ -1064,6 +1273,12 @@ function detectAppVariable(content) {
|
|
|
1064
1273
|
/(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
|
|
1065
1274
|
/(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
|
|
1066
1275
|
/(?:const|let|var)\s+(\w+)\s*=\s*(?:Router|createApp|createServer)\s*\(/,
|
|
1276
|
+
// Inline `require('fastify')(...)` / `require('express')(...)` — the app
|
|
1277
|
+
// factory is never bound to a local name, only its return value is. This
|
|
1278
|
+
// is at least as common as the named-import style above (it's the pattern
|
|
1279
|
+
// in Fastify's own project generator output), and the patterns above never
|
|
1280
|
+
// match it because there's no `fastify`/`express` identifier in the source.
|
|
1281
|
+
/(?:const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*['"](?:fastify|express)['"]\s*\)\s*(?:\.default\s*)?\(/,
|
|
1067
1282
|
];
|
|
1068
1283
|
for (const re of patterns) {
|
|
1069
1284
|
const m = content.match(re);
|
package/dist/cjs/detect.js
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_FRAMEWORKS = void 0;
|
|
3
4
|
exports.detectFramework = detectFramework;
|
|
4
5
|
const fs_1 = require("fs");
|
|
5
6
|
const path_1 = require("path");
|
|
7
|
+
/**
|
|
8
|
+
* Frameworks analyze() can actually dispatch to. Detection can identify more
|
|
9
|
+
* frameworks than this (koa, hapi) — those are named so the detector is honest
|
|
10
|
+
* about what it saw, but `supported: false` tells callers not to expect output.
|
|
11
|
+
*/
|
|
12
|
+
exports.SUPPORTED_FRAMEWORKS = new Set([
|
|
13
|
+
'express',
|
|
14
|
+
'fastify',
|
|
15
|
+
'nestjs',
|
|
16
|
+
'nextjs-pages',
|
|
17
|
+
'nextjs-app',
|
|
18
|
+
'vite',
|
|
19
|
+
'react-router',
|
|
20
|
+
]);
|
|
6
21
|
const cwd = () => process.cwd();
|
|
7
22
|
function fileExists(rel) {
|
|
8
23
|
return (0, fs_1.existsSync)((0, path_1.join)(cwd(), rel));
|
|
@@ -21,16 +36,19 @@ function tryResolve(mod) {
|
|
|
21
36
|
* Detect the web framework from the live app instance + filesystem hints.
|
|
22
37
|
* Confidence is used to pick the best match when multiple signals fire.
|
|
23
38
|
*/
|
|
39
|
+
function mk(name, confidence) {
|
|
40
|
+
return { name, confidence, supported: exports.SUPPORTED_FRAMEWORKS.has(name) };
|
|
41
|
+
}
|
|
24
42
|
function detectFramework(app) {
|
|
25
43
|
// ── Instance-based detection (highest confidence) ─────────────────────────
|
|
26
44
|
if (app && typeof app === 'function') {
|
|
27
45
|
// Express: _router or lazyrouter
|
|
28
46
|
if (app._router || (app.lazyrouter && typeof app.lazyrouter === 'function')) {
|
|
29
|
-
return
|
|
47
|
+
return mk('express', 1.0);
|
|
30
48
|
}
|
|
31
49
|
// Koa: middleware array
|
|
32
50
|
if (Array.isArray(app.middleware) && typeof app.use === 'function' && app.context) {
|
|
33
|
-
return
|
|
51
|
+
return mk('koa', 0.95);
|
|
34
52
|
}
|
|
35
53
|
}
|
|
36
54
|
// Fastify: inject method and route method
|
|
@@ -38,7 +56,7 @@ function detectFramework(app) {
|
|
|
38
56
|
typeof app === 'object' &&
|
|
39
57
|
typeof app.inject === 'function' &&
|
|
40
58
|
typeof app.route === 'function') {
|
|
41
|
-
return
|
|
59
|
+
return mk('fastify', 1.0);
|
|
42
60
|
}
|
|
43
61
|
// Hapi: server with route + start methods
|
|
44
62
|
if (app &&
|
|
@@ -46,43 +64,43 @@ function detectFramework(app) {
|
|
|
46
64
|
typeof app.route === 'function' &&
|
|
47
65
|
typeof app.start === 'function' &&
|
|
48
66
|
typeof app.stop === 'function') {
|
|
49
|
-
return
|
|
67
|
+
return mk('hapi', 0.95);
|
|
50
68
|
}
|
|
51
69
|
// NestJS application
|
|
52
70
|
if (app &&
|
|
53
71
|
typeof app === 'object' &&
|
|
54
72
|
typeof app.getHttpServer === 'function' &&
|
|
55
73
|
typeof app.listen === 'function') {
|
|
56
|
-
return
|
|
74
|
+
return mk('nestjs', 0.95);
|
|
57
75
|
}
|
|
58
76
|
// ── Filesystem detection ──────────────────────────────────────────────────
|
|
59
77
|
// Next.js App Router
|
|
60
78
|
if (fileExists('app') && (fileExists('app/layout.tsx') || fileExists('app/layout.js'))) {
|
|
61
|
-
return
|
|
79
|
+
return mk('nextjs-app', 0.92);
|
|
62
80
|
}
|
|
63
81
|
// Next.js Pages Router
|
|
64
82
|
if (fileExists('pages/api') || fileExists('src/pages/api')) {
|
|
65
|
-
return
|
|
83
|
+
return mk('nextjs-pages', 0.90);
|
|
66
84
|
}
|
|
67
85
|
if (fileExists('next.config.js') ||
|
|
68
86
|
fileExists('next.config.mjs') ||
|
|
69
87
|
fileExists('next.config.ts')) {
|
|
70
|
-
return
|
|
88
|
+
return mk('nextjs-pages', 0.75);
|
|
71
89
|
}
|
|
72
90
|
// ── Dependency-based detection (lowest confidence) ────────────────────────
|
|
73
91
|
if (tryResolve('express'))
|
|
74
|
-
return
|
|
92
|
+
return mk('express', 0.70);
|
|
75
93
|
if (tryResolve('fastify'))
|
|
76
|
-
return
|
|
94
|
+
return mk('fastify', 0.70);
|
|
77
95
|
if (tryResolve('koa'))
|
|
78
|
-
return
|
|
96
|
+
return mk('koa', 0.70);
|
|
79
97
|
if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
|
|
80
|
-
return
|
|
98
|
+
return mk('hapi', 0.70);
|
|
81
99
|
if (tryResolve('@nestjs/core'))
|
|
82
|
-
return
|
|
100
|
+
return mk('nestjs', 0.70);
|
|
83
101
|
if (tryResolve('vite'))
|
|
84
|
-
return
|
|
102
|
+
return mk('vite', 0.60);
|
|
85
103
|
if (tryResolve('react-router-dom'))
|
|
86
|
-
return
|
|
87
|
-
return
|
|
104
|
+
return mk('react-router', 0.55);
|
|
105
|
+
return mk('unknown', 0);
|
|
88
106
|
}
|