apiforge-analyzer 1.3.9 → 1.3.10

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,474 +1,474 @@
1
- # apiforge-analyzer
2
-
3
- CLI and library for auto-generating API documentation from live Express, Fastify, NestJS, Next.js, Vite, and React Router apps — and Python FastAPI, Flask, and Django apps.
4
-
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
-
7
- ## CLI usage
8
-
9
- ```bash
10
- npx apiforge
11
- ```
12
-
13
- Run this from your project root. The CLI will:
14
-
15
- 1. Detect your architecture (monolithic / modular-monolithic / microservices)
16
- 2. Find your server entry file (or ask)
17
- 3. Inject a temporary analysis snippet
18
- 4. Start your server with the command you choose
19
- 5. Collect all registered routes
20
- 6. Upload to your APIForge dashboard for review
21
- 7. Remove the injected code
22
-
23
- For **microservices**, if an `apiforge.config.json` exists the CLI iterates every service sequentially, collects routes from each, then uploads everything in one batch. If no config file exists, the CLI scans your `services/` directory, shows what it found, and writes the config file for you — so the next run is fully automatic.
24
-
25
- ```json
26
- // apiforge.config.json — auto-created on first run, commit this file
27
- {
28
- "projectName": "My Backend",
29
- "architectureType": "microservices",
30
- "services": [
31
- { "name": "auth-service", "dir": "./services/auth-service" },
32
- { "name": "user-service", "dir": "./services/user-service" },
33
- { "name": "order-service", "dir": "./services/order-service" }
34
- ]
35
- }
36
- ```
37
-
38
- Each service entry can also include `"startCommand"` to override the default (`node <entryFile>`):
39
-
40
- ```json
41
- { "name": "auth-service", "dir": "./services/auth", "startCommand": "npx tsx services/auth/src/index.ts" }
42
- ```
43
-
44
- ## Programmatic API
45
-
46
- Install:
47
-
48
- ```bash
49
- npm install apiforge-analyzer
50
- ```
51
-
52
- ### Express
53
-
54
- ```js
55
- const express = require('express')
56
- const { analyze } = require('apiforge-analyzer')
57
-
58
- const app = express()
59
- app.use(express.json())
60
-
61
- app.get('/api/users', getUsers)
62
- app.post('/api/users', createUser)
63
- app.get('/api/users/:id', getUserById)
64
-
65
- // Call after all routes are registered
66
- analyze(app, {
67
- projectName: 'My API',
68
- apiKey: process.env.APIFORGE_API_KEY,
69
- baseUrl: 'https://api.myapp.com',
70
- upload: true,
71
- })
72
-
73
- app.listen(3000)
74
- ```
75
-
76
- ### NestJS
77
-
78
- ```ts
79
- import { analyze } from 'apiforge-analyzer'
80
-
81
- const app = await NestFactory.create(AppModule)
82
- await app.init()
83
-
84
- await analyze(app.getHttpServer(), {
85
- projectName: 'My NestJS API',
86
- apiKey: process.env.APIFORGE_API_KEY,
87
- upload: true,
88
- })
89
-
90
- await app.listen(3000)
91
- ```
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
-
195
- ### Microservices — per-service
196
-
197
- When running each service independently, pass `service` to tag routes with the service name:
198
-
199
- ```js
200
- const { analyze } = require('apiforge-analyzer')
201
-
202
- analyze(app, {
203
- projectName: 'My Platform',
204
- apiKey: process.env.APIFORGE_API_KEY,
205
- architectureType: 'microservices',
206
- service: 'auth-service',
207
- upload: true,
208
- })
209
- ```
210
-
211
- ### Next.js Pages Router
212
-
213
- ```ts
214
- import { analyzeNextPages } from 'apiforge-analyzer'
215
-
216
- await analyzeNextPages({
217
- projectName: 'My Next.js API',
218
- apiKey: process.env.APIFORGE_API_KEY,
219
- nextDir: 'pages/api',
220
- upload: true,
221
- })
222
- ```
223
-
224
- ### Next.js App Router
225
-
226
- ```ts
227
- import { analyzeNextApp } from 'apiforge-analyzer'
228
-
229
- await analyzeNextApp({
230
- projectName: 'My Next.js API',
231
- apiKey: process.env.APIFORGE_API_KEY,
232
- nextDir: 'app',
233
- upload: true,
234
- })
235
- ```
236
-
237
- ### Vite
238
-
239
- ```ts
240
- import { analyzeVite } from 'apiforge-analyzer'
241
-
242
- await analyzeVite({
243
- projectName: 'My App',
244
- apiKey: process.env.APIFORGE_API_KEY,
245
- nextDir: 'vite.config.ts',
246
- upload: true,
247
- })
248
- ```
249
-
250
- ### React Router
251
-
252
- ```ts
253
- import { analyzeReactRouter } from 'apiforge-analyzer'
254
-
255
- await analyzeReactRouter({
256
- projectName: 'My App',
257
- apiKey: process.env.APIFORGE_API_KEY,
258
- nextDir: 'src/routes.tsx',
259
- upload: true,
260
- })
261
- ```
262
-
263
- ### Python — FastAPI, Flask, Django / DRF
264
-
265
- Run `npx apiforge` from the Python project root (Node ≥ 18 is still needed to run the CLI).
266
- The project is recognised from `manage.py` or from `fastapi` / `flask` / `django` in
267
- `requirements.txt`, `pyproject.toml`, `Pipfile`, `setup.py`, or `setup.cfg`.
268
-
269
- It works like the Node flow. The CLI:
270
-
271
- 1. Finds the file to patch: the module that creates the app (`main.py`, the Flask `create_app` module), or
272
- Django's `manage.py`.
273
- 2. Checks that the framework is installed. If it isn't, it offers to create `.venv` and `pip install` the
274
- project's dependencies from `requirements.txt`, `setup.py`/`pyproject.toml` (`pip install -e .`), or a
275
- bare `pyproject.toml` dependency list. This is the `npm install` step, done for you.
276
- 3. Asks which command starts your server, like the npm-script picker:
277
- - FastAPI: `fastapi dev`, `fastapi run`, `uvicorn main:app`, `python main.py`
278
- - Flask: `flask --app app run`, `python app.py`
279
- - Django: `manage.py runserver --noreload`, `manage.py runserver`
280
-
281
- You can also type your own. All of these use the project's venv Python.
282
- 4. Adds a temporary snippet to that file and runs the command. The snippet reads the routes as soon as
283
- the app is loaded, before the server binds its port.
284
- 5. If the server crashes with `No module named 'x'`, offers to install the package and retry. Only packages
285
- the project declares (including extras such as `[test]`) or well-known import≠pip names (`jose` →
286
- `python-jose`) are offered.
287
- 6. Stops the server and uploads the routes. At the end it asks "Remove APIForge code changes?", like the
288
- Node flow. Ctrl+C removes the snippet immediately.
289
-
290
- The snippet holds no API key and does nothing unless the CLI runs it: it needs an `APIFORGE_INTROSPECT`
291
- environment variable that only the CLI sets. It writes routes to a temp file outside the project, and the
292
- CLI uploads them. If you keep the snippet, it does nothing on its own, and the next run replaces it.
293
- Because your own start command runs, the server needs what it normally needs, such as env vars like a
294
- database URL. If it fails, the CLI shows the last lines of its output, where Python prints the actual error.
295
-
296
- The routes are read from the framework's live route table:
297
-
298
- | Framework | Source of truth |
299
- |---|---|
300
- | FastAPI | `app.openapi()`, which gives real Pydantic types for params, bodies, and responses. It also picks up dependency names, `HTTPException` codes raised in handlers, and `app.mount()` sub-apps. |
301
- | Flask | `app.url_map` plus each view's source: `request.args`, `request.get_json()`, `abort()`, decorators. Blueprints and `MethodView` are supported. |
302
- | Django | The root URLconf resolver, including `path()`/`re_path()`, class-based views, DRF ViewSets/routers, `@api_view`, serializers, and `permission_classes`. Admin and DRF login URLs are skipped. |
303
-
304
- The suggested commands use `APIFORGE_PYTHON`, else the active virtualenv, else `.venv/`, `venv/` or `env/`
305
- in the project, else the system `python`. That interpreter must have the app's dependencies installed.
306
-
307
- Programmatic use from Node doesn't inject anything or start a server. It imports the app with that same
308
- interpreter and reads the route table directly:
309
-
310
- ```ts
311
- import { analyze } from 'apiforge-analyzer'
312
-
313
- await analyze(null, {
314
- projectName: 'My API',
315
- framework: 'fastapi', // or 'flask' / 'django' — auto-detected when omitted
316
- pythonApp: 'main:app', // Flask factory: 'app:create_app' + pythonFactory: true
317
- // djangoSettings: 'mysite.settings', // Django: defaults to the one manage.py sets
318
- })
319
- ```
320
-
321
- ## Options
322
-
323
- ```ts
324
- interface AnalyzerOptions {
325
- projectName: string // required
326
-
327
- apiKey?: string // APIForge API key — also read from APIFORGE_API_KEY env var
328
- baseUrl?: string // base URL shown in generated docs (e.g. https://api.myapp.com)
329
- apiForgeUrl?: string // override backend URL (default: https://apiforgeapi.brainfogagency.com)
330
-
331
- upload?: boolean // upload to dashboard (default: true when apiKey is set)
332
- saveJson?: boolean // write routes to a local JSON file
333
- jsonPath?: string // path for the JSON file (default: api-export.json)
334
-
335
- architectureType?: 'monolithic' | 'modular-monolithic' | 'microservices'
336
- service?: string // service name for microservices mode — stamped on every route
337
-
338
- framework?: string // skip auto-detection and force a framework
339
- nextDir?: string // directory or config file path for Next.js / Vite / React Router
340
-
341
- skipInProduction?: boolean // skip when NODE_ENV=production (default: true)
342
- runOnce?: boolean // debounce — skip if analyzed within the last 30 s (default: true)
343
-
344
- // Fastify only — see "Supplying what can't be inferred" above
345
- scopeCatalogPath?: string
346
- errorCatalogPath?: string
347
- envelopes?: Record<string, { wrapper: string; dataField: string; excluded?: string[] } | { bare: true }>
348
- annotationsPath?: string
349
-
350
- // Python only — see "Python" above
351
- pythonApp?: string // app import path, e.g. 'main:app' (auto-detected when omitted)
352
- pythonFactory?: boolean // call pythonApp as a factory (Flask create_app)
353
- pythonPath?: string // interpreter (default: APIFORGE_PYTHON / active venv / project venv)
354
- djangoSettings?: string // e.g. 'mysite.settings' (default: read from manage.py)
355
- }
356
- ```
357
-
358
- `analyze()` throws instead of resolving to `[]` when the detected or forced framework has no
359
- analyzer (currently: anything other than `express`, `fastify`, `nestjs`, `nextjs-pages`,
360
- `nextjs-app`, `vite`, `react-router`, `fastapi`, `flask`, `django`). Every other early-exit (no API key, debounced, zero
361
- routes found, `NODE_ENV=production`) still resolves to `[]` — only "this framework cannot be
362
- analyzed" is a thrown error, so a caller can tell the two apart. `detectFramework()` also
363
- exposes this directly: `detectFramework(app).supported` is `false` for a framework it can
364
- name but not analyze (currently `koa`, `hapi`, `unknown`).
365
-
366
- ## Architecture detection
367
-
368
- The CLI scores structural signals to pick the right architecture automatically:
369
-
370
- | Signal | Architecture |
371
- |---|---|
372
- | `apiforge.config.json` with `services[]` | microservices |
373
- | `docker-compose.yml` with 3+ node services | microservices |
374
- | Multiple `package.json` files in sibling dirs | microservices |
375
- | `src/modules/` or `src/features/` directory | modular-monolithic |
376
- | `*.module.ts` files (NestJS `@Module`) | modular-monolithic |
377
- | None of the above | monolithic |
378
-
379
- For modular-monolithic projects, routes are automatically grouped by module using path-prefix matching — `/api/users/*` gets tagged as the `users` module.
380
-
381
- ## Output format
382
-
383
- Each collected route:
384
-
385
- ```ts
386
- {
387
- method: 'GET',
388
- path: '/api/users/:id',
389
- tag: 'users',
390
- operationId: 'get-api-users-id',
391
- summary: 'Get api users',
392
- middleware: ['authenticate'],
393
- authRequired: true,
394
- pathParams: ['id'],
395
- queryParams: [],
396
- requestBodyFields: [],
397
- responseStatuses: [200, 404],
398
- framework: 'express',
399
- module?: 'users', // modular-monolithic only
400
- service?: 'auth-service' // microservices only
401
- }
402
- ```
403
-
404
- ## Escape hatch — building routes yourself
405
-
406
- If your framework has no `analyze*()` function, call `uploadRoutes()` directly with a
407
- hand-built `Route[]`. This is the same function every `analyze*()` call ends up using
408
- internally — it's a supported, typed, top-level export, not a private helper.
409
-
410
- ```ts
411
- import { uploadRoutes, type Route } from 'apiforge-analyzer'
412
-
413
- const routes: Route[] = [
414
- {
415
- method: 'GET',
416
- path: '/api/users/:id',
417
- tag: 'users',
418
- operationId: 'get_api_users_id',
419
- summary: 'Get a user by id',
420
- middleware: [],
421
- authRequired: true,
422
- pathParams: ['id'],
423
- queryParams: [],
424
- requestBodyFields: [],
425
- linkedPaths: [],
426
- hasAuth: true,
427
- framework: 'my-framework',
428
- // routeKey?: stable dedup key the dashboard uses across uploads — see below
429
- },
430
- ]
431
-
432
- await uploadRoutes(routes, {
433
- projectName: 'My API',
434
- apiKey: process.env.APIFORGE_API_KEY!,
435
- })
436
- ```
437
-
438
- The fields above (`method`, `path`, `tag`, `operationId`, `summary`, `middleware`,
439
- `authRequired`, `pathParams`, `queryParams`, `requestBodyFields`, `linkedPaths`, `hasAuth`,
440
- `framework`) are the only ones `Route` requires; everything else (`description`, `tags`,
441
- `headerParams`, `responseStatuses`, `parameters`, `requestBody`, `responses`, `module`,
442
- `service`, ...) is optional and gets a reasonable default from `enrichRoutes()`.
443
-
444
- **Set `routeKey` yourself.** If you don't, it defaults to `api-001`, `api-002`, ... based on
445
- array position — which shifts if a route is added, removed, or reordered between uploads, so
446
- the dashboard can no longer tell "this is the same endpoint as last time" from "this is a new
447
- one". Use something stable per endpoint, e.g. `` `${method} ${path}` ``, as every built-in
448
- analyzer does.
449
-
450
- ## ESM and CommonJS
451
-
452
- ```js
453
- // ESM
454
- import { analyze } from 'apiforge-analyzer'
455
-
456
- // CommonJS
457
- const { analyze } = require('apiforge-analyzer')
458
- ```
459
-
460
- ## Environment variables
461
-
462
- | Variable | Description |
463
- |---|---|
464
- | `APIFORGE_API_KEY` | API key — loaded automatically, no dotenv required |
465
- | `APIFORGE_URL` | Override backend URL |
466
- | `NODE_ENV` | Set to `production` to disable analysis |
467
-
468
- ## Get an API key
469
-
470
- [apiforge.brainfogagency.com/dashboard](https://apiforge.brainfogagency.com/dashboard)
471
-
472
- ## License
473
-
474
- MIT
1
+ # apiforge-analyzer
2
+
3
+ CLI and library for auto-generating API documentation from live Express, Fastify, NestJS, Next.js, Vite, and React Router apps — and Python FastAPI, Flask, and Django apps.
4
+
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
+
7
+ ## CLI usage
8
+
9
+ ```bash
10
+ npx apiforge
11
+ ```
12
+
13
+ Run this from your project root. The CLI will:
14
+
15
+ 1. Detect your architecture (monolithic / modular-monolithic / microservices)
16
+ 2. Find your server entry file (or ask)
17
+ 3. Inject a temporary analysis snippet
18
+ 4. Start your server with the command you choose
19
+ 5. Collect all registered routes
20
+ 6. Upload to your APIForge dashboard for review
21
+ 7. Remove the injected code
22
+
23
+ For **microservices**, if an `apiforge.config.json` exists the CLI iterates every service sequentially, collects routes from each, then uploads everything in one batch. If no config file exists, the CLI scans your `services/` directory, shows what it found, and writes the config file for you — so the next run is fully automatic.
24
+
25
+ ```json
26
+ // apiforge.config.json — auto-created on first run, commit this file
27
+ {
28
+ "projectName": "My Backend",
29
+ "architectureType": "microservices",
30
+ "services": [
31
+ { "name": "auth-service", "dir": "./services/auth-service" },
32
+ { "name": "user-service", "dir": "./services/user-service" },
33
+ { "name": "order-service", "dir": "./services/order-service" }
34
+ ]
35
+ }
36
+ ```
37
+
38
+ Each service entry can also include `"startCommand"` to override the default (`node <entryFile>`):
39
+
40
+ ```json
41
+ { "name": "auth-service", "dir": "./services/auth", "startCommand": "npx tsx services/auth/src/index.ts" }
42
+ ```
43
+
44
+ ## Programmatic API
45
+
46
+ Install:
47
+
48
+ ```bash
49
+ npm install apiforge-analyzer
50
+ ```
51
+
52
+ ### Express
53
+
54
+ ```js
55
+ const express = require('express')
56
+ const { analyze } = require('apiforge-analyzer')
57
+
58
+ const app = express()
59
+ app.use(express.json())
60
+
61
+ app.get('/api/users', getUsers)
62
+ app.post('/api/users', createUser)
63
+ app.get('/api/users/:id', getUserById)
64
+
65
+ // Call after all routes are registered
66
+ analyze(app, {
67
+ projectName: 'My API',
68
+ apiKey: process.env.APIFORGE_API_KEY,
69
+ baseUrl: 'https://api.myapp.com',
70
+ upload: true,
71
+ })
72
+
73
+ app.listen(3000)
74
+ ```
75
+
76
+ ### NestJS
77
+
78
+ ```ts
79
+ import { analyze } from 'apiforge-analyzer'
80
+
81
+ const app = await NestFactory.create(AppModule)
82
+ await app.init()
83
+
84
+ await analyze(app.getHttpServer(), {
85
+ projectName: 'My NestJS API',
86
+ apiKey: process.env.APIFORGE_API_KEY,
87
+ upload: true,
88
+ })
89
+
90
+ await app.listen(3000)
91
+ ```
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
+
195
+ ### Microservices — per-service
196
+
197
+ When running each service independently, pass `service` to tag routes with the service name:
198
+
199
+ ```js
200
+ const { analyze } = require('apiforge-analyzer')
201
+
202
+ analyze(app, {
203
+ projectName: 'My Platform',
204
+ apiKey: process.env.APIFORGE_API_KEY,
205
+ architectureType: 'microservices',
206
+ service: 'auth-service',
207
+ upload: true,
208
+ })
209
+ ```
210
+
211
+ ### Next.js Pages Router
212
+
213
+ ```ts
214
+ import { analyzeNextPages } from 'apiforge-analyzer'
215
+
216
+ await analyzeNextPages({
217
+ projectName: 'My Next.js API',
218
+ apiKey: process.env.APIFORGE_API_KEY,
219
+ nextDir: 'pages/api',
220
+ upload: true,
221
+ })
222
+ ```
223
+
224
+ ### Next.js App Router
225
+
226
+ ```ts
227
+ import { analyzeNextApp } from 'apiforge-analyzer'
228
+
229
+ await analyzeNextApp({
230
+ projectName: 'My Next.js API',
231
+ apiKey: process.env.APIFORGE_API_KEY,
232
+ nextDir: 'app',
233
+ upload: true,
234
+ })
235
+ ```
236
+
237
+ ### Vite
238
+
239
+ ```ts
240
+ import { analyzeVite } from 'apiforge-analyzer'
241
+
242
+ await analyzeVite({
243
+ projectName: 'My App',
244
+ apiKey: process.env.APIFORGE_API_KEY,
245
+ nextDir: 'vite.config.ts',
246
+ upload: true,
247
+ })
248
+ ```
249
+
250
+ ### React Router
251
+
252
+ ```ts
253
+ import { analyzeReactRouter } from 'apiforge-analyzer'
254
+
255
+ await analyzeReactRouter({
256
+ projectName: 'My App',
257
+ apiKey: process.env.APIFORGE_API_KEY,
258
+ nextDir: 'src/routes.tsx',
259
+ upload: true,
260
+ })
261
+ ```
262
+
263
+ ### Python — FastAPI, Flask, Django / DRF
264
+
265
+ Run `npx apiforge` from the Python project root (Node ≥ 18 is still needed to run the CLI).
266
+ The project is recognised from `manage.py` or from `fastapi` / `flask` / `django` in
267
+ `requirements.txt`, `pyproject.toml`, `Pipfile`, `setup.py`, or `setup.cfg`.
268
+
269
+ It works like the Node flow. The CLI:
270
+
271
+ 1. Finds the file to patch: the module that creates the app (`main.py`, the Flask `create_app` module), or
272
+ Django's `manage.py`.
273
+ 2. Checks that the framework is installed. If it isn't, it offers to create `.venv` and `pip install` the
274
+ project's dependencies from `requirements.txt`, `setup.py`/`pyproject.toml` (`pip install -e .`), or a
275
+ bare `pyproject.toml` dependency list. This is the `npm install` step, done for you.
276
+ 3. Asks which command starts your server, like the npm-script picker:
277
+ - FastAPI: `fastapi dev`, `fastapi run`, `uvicorn main:app`, `python main.py`
278
+ - Flask: `flask --app app run`, `python app.py`
279
+ - Django: `manage.py runserver --noreload`, `manage.py runserver`
280
+
281
+ You can also type your own. All of these use the project's venv Python.
282
+ 4. Adds a temporary snippet to that file and runs the command. The snippet reads the routes as soon as
283
+ the app is loaded, before the server binds its port.
284
+ 5. If the server crashes with `No module named 'x'`, offers to install the package and retry. Only packages
285
+ the project declares (including extras such as `[test]`) or well-known import≠pip names (`jose` →
286
+ `python-jose`) are offered.
287
+ 6. Stops the server and uploads the routes. At the end it asks "Remove APIForge code changes?", like the
288
+ Node flow. Ctrl+C removes the snippet immediately.
289
+
290
+ The snippet holds no API key and does nothing unless the CLI runs it: it needs an `APIFORGE_INTROSPECT`
291
+ environment variable that only the CLI sets. It writes routes to a temp file outside the project, and the
292
+ CLI uploads them. If you keep the snippet, it does nothing on its own, and the next run replaces it.
293
+ Because your own start command runs, the server needs what it normally needs, such as env vars like a
294
+ database URL. If it fails, the CLI shows the last lines of its output, where Python prints the actual error.
295
+
296
+ The routes are read from the framework's live route table:
297
+
298
+ | Framework | Source of truth |
299
+ |---|---|
300
+ | FastAPI | `app.openapi()`, which gives real Pydantic types for params, bodies, and responses. It also picks up dependency names, `HTTPException` codes raised in handlers, and `app.mount()` sub-apps. |
301
+ | Flask | `app.url_map` plus each view's source: `request.args`, `request.get_json()`, `abort()`, decorators. Blueprints and `MethodView` are supported. |
302
+ | Django | The root URLconf resolver, including `path()`/`re_path()`, class-based views, DRF ViewSets/routers, `@api_view`, serializers, and `permission_classes`. Admin and DRF login URLs are skipped. |
303
+
304
+ The suggested commands use `APIFORGE_PYTHON`, else the active virtualenv, else `.venv/`, `venv/` or `env/`
305
+ in the project, else the system `python`. That interpreter must have the app's dependencies installed.
306
+
307
+ Programmatic use from Node doesn't inject anything or start a server. It imports the app with that same
308
+ interpreter and reads the route table directly:
309
+
310
+ ```ts
311
+ import { analyze } from 'apiforge-analyzer'
312
+
313
+ await analyze(null, {
314
+ projectName: 'My API',
315
+ framework: 'fastapi', // or 'flask' / 'django' — auto-detected when omitted
316
+ pythonApp: 'main:app', // Flask factory: 'app:create_app' + pythonFactory: true
317
+ // djangoSettings: 'mysite.settings', // Django: defaults to the one manage.py sets
318
+ })
319
+ ```
320
+
321
+ ## Options
322
+
323
+ ```ts
324
+ interface AnalyzerOptions {
325
+ projectName: string // required
326
+
327
+ apiKey?: string // APIForge API key — also read from APIFORGE_API_KEY env var
328
+ baseUrl?: string // base URL shown in generated docs (e.g. https://api.myapp.com)
329
+ apiForgeUrl?: string // override backend URL (default: https://apiforgeapi.brainfogagency.com)
330
+
331
+ upload?: boolean // upload to dashboard (default: true when apiKey is set)
332
+ saveJson?: boolean // write routes to a local JSON file
333
+ jsonPath?: string // path for the JSON file (default: api-export.json)
334
+
335
+ architectureType?: 'monolithic' | 'modular-monolithic' | 'microservices'
336
+ service?: string // service name for microservices mode — stamped on every route
337
+
338
+ framework?: string // skip auto-detection and force a framework
339
+ nextDir?: string // directory or config file path for Next.js / Vite / React Router
340
+
341
+ skipInProduction?: boolean // skip when NODE_ENV=production (default: true)
342
+ runOnce?: boolean // debounce — skip if analyzed within the last 30 s (default: true)
343
+
344
+ // Fastify only — see "Supplying what can't be inferred" above
345
+ scopeCatalogPath?: string
346
+ errorCatalogPath?: string
347
+ envelopes?: Record<string, { wrapper: string; dataField: string; excluded?: string[] } | { bare: true }>
348
+ annotationsPath?: string
349
+
350
+ // Python only — see "Python" above
351
+ pythonApp?: string // app import path, e.g. 'main:app' (auto-detected when omitted)
352
+ pythonFactory?: boolean // call pythonApp as a factory (Flask create_app)
353
+ pythonPath?: string // interpreter (default: APIFORGE_PYTHON / active venv / project venv)
354
+ djangoSettings?: string // e.g. 'mysite.settings' (default: read from manage.py)
355
+ }
356
+ ```
357
+
358
+ `analyze()` throws instead of resolving to `[]` when the detected or forced framework has no
359
+ analyzer (currently: anything other than `express`, `fastify`, `nestjs`, `nextjs-pages`,
360
+ `nextjs-app`, `vite`, `react-router`, `fastapi`, `flask`, `django`). Every other early-exit (no API key, debounced, zero
361
+ routes found, `NODE_ENV=production`) still resolves to `[]` — only "this framework cannot be
362
+ analyzed" is a thrown error, so a caller can tell the two apart. `detectFramework()` also
363
+ exposes this directly: `detectFramework(app).supported` is `false` for a framework it can
364
+ name but not analyze (currently `koa`, `hapi`, `unknown`).
365
+
366
+ ## Architecture detection
367
+
368
+ The CLI scores structural signals to pick the right architecture automatically:
369
+
370
+ | Signal | Architecture |
371
+ |---|---|
372
+ | `apiforge.config.json` with `services[]` | microservices |
373
+ | `docker-compose.yml` with 3+ node services | microservices |
374
+ | Multiple `package.json` files in sibling dirs | microservices |
375
+ | `src/modules/` or `src/features/` directory | modular-monolithic |
376
+ | `*.module.ts` files (NestJS `@Module`) | modular-monolithic |
377
+ | None of the above | monolithic |
378
+
379
+ For modular-monolithic projects, routes are automatically grouped by module using path-prefix matching — `/api/users/*` gets tagged as the `users` module.
380
+
381
+ ## Output format
382
+
383
+ Each collected route:
384
+
385
+ ```ts
386
+ {
387
+ method: 'GET',
388
+ path: '/api/users/:id',
389
+ tag: 'users',
390
+ operationId: 'get-api-users-id',
391
+ summary: 'Get api users',
392
+ middleware: ['authenticate'],
393
+ authRequired: true,
394
+ pathParams: ['id'],
395
+ queryParams: [],
396
+ requestBodyFields: [],
397
+ responseStatuses: [200, 404],
398
+ framework: 'express',
399
+ module?: 'users', // modular-monolithic only
400
+ service?: 'auth-service' // microservices only
401
+ }
402
+ ```
403
+
404
+ ## Escape hatch — building routes yourself
405
+
406
+ If your framework has no `analyze*()` function, call `uploadRoutes()` directly with a
407
+ hand-built `Route[]`. This is the same function every `analyze*()` call ends up using
408
+ internally — it's a supported, typed, top-level export, not a private helper.
409
+
410
+ ```ts
411
+ import { uploadRoutes, type Route } from 'apiforge-analyzer'
412
+
413
+ const routes: Route[] = [
414
+ {
415
+ method: 'GET',
416
+ path: '/api/users/:id',
417
+ tag: 'users',
418
+ operationId: 'get_api_users_id',
419
+ summary: 'Get a user by id',
420
+ middleware: [],
421
+ authRequired: true,
422
+ pathParams: ['id'],
423
+ queryParams: [],
424
+ requestBodyFields: [],
425
+ linkedPaths: [],
426
+ hasAuth: true,
427
+ framework: 'my-framework',
428
+ // routeKey?: stable dedup key the dashboard uses across uploads — see below
429
+ },
430
+ ]
431
+
432
+ await uploadRoutes(routes, {
433
+ projectName: 'My API',
434
+ apiKey: process.env.APIFORGE_API_KEY!,
435
+ })
436
+ ```
437
+
438
+ The fields above (`method`, `path`, `tag`, `operationId`, `summary`, `middleware`,
439
+ `authRequired`, `pathParams`, `queryParams`, `requestBodyFields`, `linkedPaths`, `hasAuth`,
440
+ `framework`) are the only ones `Route` requires; everything else (`description`, `tags`,
441
+ `headerParams`, `responseStatuses`, `parameters`, `requestBody`, `responses`, `module`,
442
+ `service`, ...) is optional and gets a reasonable default from `enrichRoutes()`.
443
+
444
+ **Set `routeKey` yourself.** If you don't, it defaults to `api-001`, `api-002`, ... based on
445
+ array position — which shifts if a route is added, removed, or reordered between uploads, so
446
+ the dashboard can no longer tell "this is the same endpoint as last time" from "this is a new
447
+ one". Use something stable per endpoint, e.g. `` `${method} ${path}` ``, as every built-in
448
+ analyzer does.
449
+
450
+ ## ESM and CommonJS
451
+
452
+ ```js
453
+ // ESM
454
+ import { analyze } from 'apiforge-analyzer'
455
+
456
+ // CommonJS
457
+ const { analyze } = require('apiforge-analyzer')
458
+ ```
459
+
460
+ ## Environment variables
461
+
462
+ | Variable | Description |
463
+ |---|---|
464
+ | `APIFORGE_API_KEY` | API key — loaded automatically, no dotenv required |
465
+ | `APIFORGE_URL` | Override backend URL |
466
+ | `NODE_ENV` | Set to `production` to disable analysis |
467
+
468
+ ## Get an API key
469
+
470
+ [apiforge.brainfogagency.com/dashboard](https://apiforge.brainfogagency.com/dashboard)
471
+
472
+ ## License
473
+
474
+ MIT