apiforge-analyzer 1.3.2 → 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 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
- moduleName?: 'users', // modular-monolithic only
220
- serviceName?: 'auth-service' // microservices only
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
@@ -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: The CLI wrote your key to .env.local — but your server may not load it.'));
229
- prompts_1.log.warn(yellow(' Add this at the top of your entry file before calling analyze():'));
230
- prompts_1.log.info(` ${cyan("require('dotenv').config({ path: '.env.local' })")}`);
231
- prompts_1.log.warn(yellow(' Or install dotenv: npm install dotenv'));
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);
@@ -749,11 +764,20 @@ function startAndWaitForAnalysis(command, timeoutMs) {
749
764
  const statusPath = (0, path_1.join)(process.cwd(), STATUS_FILE);
750
765
  const child = (0, child_process_1.spawn)(command, {
751
766
  cwd: process.cwd(),
752
- stdio: 'ignore',
767
+ stdio: ['ignore', 'pipe', 'pipe'],
753
768
  shell: true,
754
769
  env: { ...process.env },
755
770
  detached: false,
756
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);
757
781
  let resolved = false;
758
782
  function finish(result) {
759
783
  if (resolved)
@@ -765,7 +789,7 @@ function startAndWaitForAnalysis(command, timeoutMs) {
765
789
  child.kill('SIGTERM');
766
790
  }
767
791
  catch { }
768
- resolve(result);
792
+ resolve({ ...result, serverOutput: serverOutput.trim() });
769
793
  }
770
794
  // Poll for the status file every 500 ms
771
795
  const poller = setInterval(() => {
@@ -1249,6 +1273,12 @@ function detectAppVariable(content) {
1249
1273
  /(?:const|let|var)\s+(\w+)\s*=\s*(?:fastify|Fastify)\s*\(/,
1250
1274
  /(?:const|let|var)\s+(\w+)\s*=\s*new\s+Koa\s*\(/,
1251
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*)?\(/,
1252
1282
  ];
1253
1283
  for (const re of patterns) {
1254
1284
  const m = content.match(re);
@@ -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 { name: 'express', confidence: 1.0 };
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 { name: 'koa', confidence: 0.95 };
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 { name: 'fastify', confidence: 1.0 };
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 { name: 'hapi', confidence: 0.95 };
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 { name: 'nestjs', confidence: 0.95 };
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 { name: 'nextjs-app', confidence: 0.92 };
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 { name: 'nextjs-pages', confidence: 0.90 };
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 { name: 'nextjs-pages', confidence: 0.75 };
88
+ return mk('nextjs-pages', 0.75);
71
89
  }
72
90
  // ── Dependency-based detection (lowest confidence) ────────────────────────
73
91
  if (tryResolve('express'))
74
- return { name: 'express', confidence: 0.70 };
92
+ return mk('express', 0.70);
75
93
  if (tryResolve('fastify'))
76
- return { name: 'fastify', confidence: 0.70 };
94
+ return mk('fastify', 0.70);
77
95
  if (tryResolve('koa'))
78
- return { name: 'koa', confidence: 0.70 };
96
+ return mk('koa', 0.70);
79
97
  if (tryResolve('@hapi/hapi') || tryResolve('hapi'))
80
- return { name: 'hapi', confidence: 0.70 };
98
+ return mk('hapi', 0.70);
81
99
  if (tryResolve('@nestjs/core'))
82
- return { name: 'nestjs', confidence: 0.70 };
100
+ return mk('nestjs', 0.70);
83
101
  if (tryResolve('vite'))
84
- return { name: 'vite', confidence: 0.60 };
102
+ return mk('vite', 0.60);
85
103
  if (tryResolve('react-router-dom'))
86
- return { name: 'react-router', confidence: 0.55 };
87
- return { name: 'unknown', confidence: 0 };
104
+ return mk('react-router', 0.55);
105
+ return mk('unknown', 0);
88
106
  }