apiforge-analyzer 1.3.1 → 1.3.2
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/dist/cjs/cli.js +199 -14
- package/dist/esm/cli.js +200 -15
- package/package.json +1 -1
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';
|
|
@@ -395,6 +395,158 @@ function hasDockerCompose(cwd) {
|
|
|
395
395
|
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
|
|
396
396
|
.some(f => (0, fs_1.existsSync)((0, path_1.join)(cwd, f)));
|
|
397
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Find the gateway among the discovered services (by name or by proxy-middleware
|
|
400
|
+
* usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
|
|
401
|
+
* Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
|
|
402
|
+
* pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
|
|
403
|
+
* or no recognizable mapping is found — callers should treat that as "no rewrite."
|
|
404
|
+
*/
|
|
405
|
+
function detectGatewayPrefixMap(cwd, services) {
|
|
406
|
+
const prefixMap = {};
|
|
407
|
+
for (const svc of services) {
|
|
408
|
+
const svcDir = svc.dir.replace(/^\.\//, '');
|
|
409
|
+
const relEntry = svc.entryFile ?? findEntryInDir((0, path_1.join)(cwd, svcDir));
|
|
410
|
+
if (!relEntry)
|
|
411
|
+
continue;
|
|
412
|
+
let source = '';
|
|
413
|
+
try {
|
|
414
|
+
source = (0, fs_1.readFileSync)((0, path_1.join)(cwd, svcDir, relEntry), 'utf-8');
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
|
|
420
|
+
if (!looksLikeGateway)
|
|
421
|
+
continue;
|
|
422
|
+
// '/auth': process.env.AUTH_SERVICE_URL || `http://...`
|
|
423
|
+
const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
|
|
424
|
+
let m;
|
|
425
|
+
while ((m = re.exec(source))) {
|
|
426
|
+
const prefix = m[1];
|
|
427
|
+
const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
|
|
428
|
+
if (!prefixMap[serviceName])
|
|
429
|
+
prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
|
|
430
|
+
}
|
|
431
|
+
if (Object.keys(prefixMap).length > 0)
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
return prefixMap;
|
|
435
|
+
}
|
|
436
|
+
function withGatewayPrefix(prefix, routePath) {
|
|
437
|
+
if (routePath === '/')
|
|
438
|
+
return prefix;
|
|
439
|
+
return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
|
|
440
|
+
}
|
|
441
|
+
// ─── Postman collection enrichment ─────────────────────────────────────────────
|
|
442
|
+
// Runtime introspection only sees field NAMES (via regex over handler source),
|
|
443
|
+
// never real example values — a Postman collection, when present, already has
|
|
444
|
+
// hand-written example bodies and descriptions. We fold those in as enrichment
|
|
445
|
+
// only: matched by method + path, never adds or removes an endpoint.
|
|
446
|
+
function findFileRecursive(dir, matches, maxDepth, depth = 0) {
|
|
447
|
+
if (depth > maxDepth)
|
|
448
|
+
return null;
|
|
449
|
+
let entries;
|
|
450
|
+
try {
|
|
451
|
+
entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
for (const e of entries) {
|
|
457
|
+
if (e.isFile() && matches(e.name))
|
|
458
|
+
return (0, path_1.join)(dir, e.name);
|
|
459
|
+
}
|
|
460
|
+
for (const e of entries) {
|
|
461
|
+
if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
|
|
462
|
+
const found = findFileRecursive((0, path_1.join)(dir, e.name), matches, maxDepth, depth + 1);
|
|
463
|
+
if (found)
|
|
464
|
+
return found;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
function findPostmanCollection(cwd) {
|
|
470
|
+
const isCollection = (name) => name.endsWith('.postman_collection.json');
|
|
471
|
+
for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
|
|
472
|
+
const dirPath = (0, path_1.join)(cwd, dir);
|
|
473
|
+
if (!(0, fs_1.existsSync)(dirPath))
|
|
474
|
+
continue;
|
|
475
|
+
try {
|
|
476
|
+
const match = (0, fs_1.readdirSync)(dirPath).find(isCollection);
|
|
477
|
+
if (match)
|
|
478
|
+
return (0, path_1.join)(dirPath, match);
|
|
479
|
+
}
|
|
480
|
+
catch { }
|
|
481
|
+
}
|
|
482
|
+
return findFileRecursive(cwd, isCollection, 3);
|
|
483
|
+
}
|
|
484
|
+
/** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
|
|
485
|
+
function postmanRawToRoutePath(raw) {
|
|
486
|
+
let p = raw.trim();
|
|
487
|
+
p = p.replace(/^\{\{[^}]+\}\}/, '');
|
|
488
|
+
p = p.split('?')[0];
|
|
489
|
+
p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
|
|
490
|
+
if (!p)
|
|
491
|
+
p = '/';
|
|
492
|
+
if (!p.startsWith('/'))
|
|
493
|
+
p = '/' + p;
|
|
494
|
+
if (p !== '/' && p.endsWith('/'))
|
|
495
|
+
p = p.slice(0, -1);
|
|
496
|
+
return p;
|
|
497
|
+
}
|
|
498
|
+
/** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
|
|
499
|
+
function walkPostmanItems(items, index) {
|
|
500
|
+
for (const node of items ?? []) {
|
|
501
|
+
if (Array.isArray(node.item))
|
|
502
|
+
walkPostmanItems(node.item, index);
|
|
503
|
+
if (!node.request)
|
|
504
|
+
continue;
|
|
505
|
+
const req = node.request;
|
|
506
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
507
|
+
const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
|
|
508
|
+
if (!rawUrl)
|
|
509
|
+
continue;
|
|
510
|
+
const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
|
|
511
|
+
if (index.has(key))
|
|
512
|
+
continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
|
|
513
|
+
const entry = {};
|
|
514
|
+
if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
|
|
515
|
+
entry.bodyExample = req.body.raw.trim();
|
|
516
|
+
}
|
|
517
|
+
if (typeof req.description === 'string' && req.description.trim()) {
|
|
518
|
+
entry.description = req.description.trim();
|
|
519
|
+
}
|
|
520
|
+
if (entry.bodyExample || entry.description)
|
|
521
|
+
index.set(key, entry);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
|
|
525
|
+
function enrichRoutesFromPostman(cwd, routes) {
|
|
526
|
+
const collectionPath = findPostmanCollection(cwd);
|
|
527
|
+
if (!collectionPath)
|
|
528
|
+
return;
|
|
529
|
+
try {
|
|
530
|
+
const collection = JSON.parse((0, fs_1.readFileSync)(collectionPath, 'utf-8'));
|
|
531
|
+
const index = new Map();
|
|
532
|
+
walkPostmanItems(collection.item, index);
|
|
533
|
+
let enriched = 0;
|
|
534
|
+
for (const route of routes) {
|
|
535
|
+
const match = index.get(`${route.method} ${route.path}`);
|
|
536
|
+
if (!match)
|
|
537
|
+
continue;
|
|
538
|
+
if (match.bodyExample)
|
|
539
|
+
route.bodyExample = match.bodyExample;
|
|
540
|
+
if (match.description && !route.description)
|
|
541
|
+
route.description = match.description;
|
|
542
|
+
enriched++;
|
|
543
|
+
}
|
|
544
|
+
if (enriched > 0) {
|
|
545
|
+
prompts_1.log.info(dim(`Enriched ${enriched} routes with examples from ${(0, path_1.basename)(collectionPath)}`));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch { }
|
|
549
|
+
}
|
|
398
550
|
async function microservicesFlow(config, opts) {
|
|
399
551
|
const cwd = process.cwd();
|
|
400
552
|
const allRoutes = [];
|
|
@@ -437,6 +589,14 @@ async function microservicesFlow(config, opts) {
|
|
|
437
589
|
'',
|
|
438
590
|
dim('Each service is started individually and its routes are collected.'),
|
|
439
591
|
].join('\n'), 'Multi-service analysis');
|
|
592
|
+
// Routes are collected at each service's own internal paths (e.g. auth's /register),
|
|
593
|
+
// not the path clients actually call through the gateway (/auth/register) — Express
|
|
594
|
+
// strips the mount prefix before the service ever sees the request. Rewrite paths
|
|
595
|
+
// here so the docs match what's actually callable.
|
|
596
|
+
const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
|
|
597
|
+
if (Object.keys(gatewayPrefixes).length > 0) {
|
|
598
|
+
prompts_1.log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
|
|
599
|
+
}
|
|
440
600
|
for (const svc of config.services) {
|
|
441
601
|
const svcSpin = (0, prompts_1.spinner)();
|
|
442
602
|
svcSpin.start(`${bold(svc.name)} — starting…`);
|
|
@@ -465,13 +625,14 @@ async function microservicesFlow(config, opts) {
|
|
|
465
625
|
detached: false,
|
|
466
626
|
});
|
|
467
627
|
// Capture output so failures are debuggable without re-running the service by hand.
|
|
468
|
-
//
|
|
628
|
+
// Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
|
|
629
|
+
// first, then unwinds the call stack — a long stack trace must not push that
|
|
630
|
+
// opening line out of the window, so stop growing once capped instead of sliding.
|
|
469
631
|
let output = '';
|
|
470
|
-
const MAX_OUTPUT =
|
|
632
|
+
const MAX_OUTPUT = 6000;
|
|
471
633
|
const capture = (chunk) => {
|
|
472
|
-
output
|
|
473
|
-
|
|
474
|
-
output = output.slice(-MAX_OUTPUT);
|
|
634
|
+
if (output.length < MAX_OUTPUT)
|
|
635
|
+
output += chunk.toString();
|
|
475
636
|
};
|
|
476
637
|
child.stdout?.on('data', capture);
|
|
477
638
|
child.stderr?.on('data', capture);
|
|
@@ -484,7 +645,12 @@ async function microservicesFlow(config, opts) {
|
|
|
484
645
|
catch { }
|
|
485
646
|
tryUnlink((0, path_1.join)(cwd, statusFile));
|
|
486
647
|
if (routes && routes.length > 0) {
|
|
487
|
-
const
|
|
648
|
+
const prefix = gatewayPrefixes[svc.name];
|
|
649
|
+
const annotated = routes.map((r) => ({
|
|
650
|
+
...r,
|
|
651
|
+
service: svc.name,
|
|
652
|
+
path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
|
|
653
|
+
}));
|
|
488
654
|
allRoutes.push(...annotated);
|
|
489
655
|
serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
|
|
490
656
|
svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
|
|
@@ -493,7 +659,7 @@ async function microservicesFlow(config, opts) {
|
|
|
493
659
|
svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
|
|
494
660
|
const trimmed = output.trim();
|
|
495
661
|
if (trimmed) {
|
|
496
|
-
prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(
|
|
662
|
+
prompts_1.log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
|
|
497
663
|
}
|
|
498
664
|
}
|
|
499
665
|
// Remove injected code
|
|
@@ -503,6 +669,7 @@ async function microservicesFlow(config, opts) {
|
|
|
503
669
|
// Safety: clean up any remaining injections (e.g. if process was interrupted)
|
|
504
670
|
for (const f of injectedFiles)
|
|
505
671
|
removeInjectedCode(f);
|
|
672
|
+
enrichRoutesFromPostman(cwd, allRoutes);
|
|
506
673
|
if (allRoutes.length === 0) {
|
|
507
674
|
prompts_1.log.error(red('No routes were collected from any service.'));
|
|
508
675
|
(0, prompts_1.outro)(red('Analysis failed.'));
|
|
@@ -1023,13 +1190,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1023
1190
|
` upload: true,`,
|
|
1024
1191
|
` runOnce: true,`,
|
|
1025
1192
|
].join('\n');
|
|
1193
|
+
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1194
|
+
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1195
|
+
// Poll briefly for app._router to be populated (Express-shaped apps only)
|
|
1196
|
+
// before giving up and collecting whatever is there — bounded well under
|
|
1197
|
+
// the CLI's 30s per-service timeout so a hung connect still surfaces.
|
|
1198
|
+
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1199
|
+
const call = collectionOpts
|
|
1200
|
+
? [
|
|
1201
|
+
` let __apiforgeTries = 0`,
|
|
1202
|
+
` const __apiforgeTryCollect = () => {`,
|
|
1203
|
+
` const hasRoutes = !${appVar} || !${appVar}._router`,
|
|
1204
|
+
` ? true`,
|
|
1205
|
+
` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
|
|
1206
|
+
` if (hasRoutes || __apiforgeTries++ >= 26) {`,
|
|
1207
|
+
` ${runAnalyze}`,
|
|
1208
|
+
` } else {`,
|
|
1209
|
+
` setTimeout(__apiforgeTryCollect, 300)`,
|
|
1210
|
+
` }`,
|
|
1211
|
+
` }`,
|
|
1212
|
+
` __apiforgeTryCollect()`,
|
|
1213
|
+
].join('\n')
|
|
1214
|
+
: ` ${runAnalyze}`;
|
|
1026
1215
|
if (isESM) {
|
|
1027
1216
|
return [
|
|
1028
1217
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1029
1218
|
`import('apiforge-analyzer').then(({ default: APIForge }) => {`,
|
|
1030
|
-
|
|
1031
|
-
opts,
|
|
1032
|
-
` })`,
|
|
1219
|
+
call,
|
|
1033
1220
|
`}).catch(() => {})`,
|
|
1034
1221
|
'',
|
|
1035
1222
|
].join('\n');
|
|
@@ -1038,9 +1225,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1038
1225
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1039
1226
|
`Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
|
|
1040
1227
|
` const APIForge = mod.default || mod`,
|
|
1041
|
-
|
|
1042
|
-
opts,
|
|
1043
|
-
` })`,
|
|
1228
|
+
call,
|
|
1044
1229
|
`}).catch(() => {})`,
|
|
1045
1230
|
'',
|
|
1046
1231
|
].join('\n');
|
package/dist/esm/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { group, intro, outro, text, select, password, confirm, tasks, note, log, isCancel, cancel, spinner, } from '@clack/prompts';
|
|
3
3
|
import { readFileSync, existsSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
4
|
+
import { join, basename } from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import axios from 'axios';
|
|
7
7
|
import { detectArchitectureType } from './arch-detect.js';
|
|
@@ -22,7 +22,7 @@ const green = (s) => `${gn}${s}${r}`;
|
|
|
22
22
|
const yellow = (s) => `${yl}${s}${r}`;
|
|
23
23
|
const red = (s) => `${rd}${s}${r}`;
|
|
24
24
|
const white = (s) => `${wh}${s}${r}`;
|
|
25
|
-
const VERSION = '1.3.
|
|
25
|
+
const VERSION = '1.3.2';
|
|
26
26
|
// Built-in URLs — users never need to configure these
|
|
27
27
|
const BACKEND_PROD = 'https://apiforgeapi.brainfogagency.com';
|
|
28
28
|
const DASHBOARD_PROD = 'https://apiforge.brainfogagency.com/dashboard';
|
|
@@ -390,6 +390,158 @@ function hasDockerCompose(cwd) {
|
|
|
390
390
|
return ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']
|
|
391
391
|
.some(f => existsSync(join(cwd, f)));
|
|
392
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Find the gateway among the discovered services (by name or by proxy-middleware
|
|
395
|
+
* usage) and parse its prefix → service map, e.g. { auth: '/auth', gold_rates: '/gold-rates' }.
|
|
396
|
+
* Matches the common `'/prefix': process.env.X_SERVICE_URL || ...` gateway route-table
|
|
397
|
+
* pattern (express-gateway / http-proxy-middleware style). Returns {} if no gateway
|
|
398
|
+
* or no recognizable mapping is found — callers should treat that as "no rewrite."
|
|
399
|
+
*/
|
|
400
|
+
function detectGatewayPrefixMap(cwd, services) {
|
|
401
|
+
const prefixMap = {};
|
|
402
|
+
for (const svc of services) {
|
|
403
|
+
const svcDir = svc.dir.replace(/^\.\//, '');
|
|
404
|
+
const relEntry = svc.entryFile ?? findEntryInDir(join(cwd, svcDir));
|
|
405
|
+
if (!relEntry)
|
|
406
|
+
continue;
|
|
407
|
+
let source = '';
|
|
408
|
+
try {
|
|
409
|
+
source = readFileSync(join(cwd, svcDir, relEntry), 'utf-8');
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const looksLikeGateway = /gateway/i.test(svc.name) || /createProxyMiddleware|http-proxy-middleware/.test(source);
|
|
415
|
+
if (!looksLikeGateway)
|
|
416
|
+
continue;
|
|
417
|
+
// '/auth': process.env.AUTH_SERVICE_URL || `http://...`
|
|
418
|
+
const re = /['"](\/[a-zA-Z0-9_-]*)['"]\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/g;
|
|
419
|
+
let m;
|
|
420
|
+
while ((m = re.exec(source))) {
|
|
421
|
+
const prefix = m[1];
|
|
422
|
+
const serviceName = m[2].replace(/_SERVICE_URL$|_URL$/, '').toLowerCase();
|
|
423
|
+
if (!prefixMap[serviceName])
|
|
424
|
+
prefixMap[serviceName] = prefix; // first (primary) mount wins over aliases
|
|
425
|
+
}
|
|
426
|
+
if (Object.keys(prefixMap).length > 0)
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
return prefixMap;
|
|
430
|
+
}
|
|
431
|
+
function withGatewayPrefix(prefix, routePath) {
|
|
432
|
+
if (routePath === '/')
|
|
433
|
+
return prefix;
|
|
434
|
+
return prefix.replace(/\/$/, '') + (routePath.startsWith('/') ? routePath : `/${routePath}`);
|
|
435
|
+
}
|
|
436
|
+
// ─── Postman collection enrichment ─────────────────────────────────────────────
|
|
437
|
+
// Runtime introspection only sees field NAMES (via regex over handler source),
|
|
438
|
+
// never real example values — a Postman collection, when present, already has
|
|
439
|
+
// hand-written example bodies and descriptions. We fold those in as enrichment
|
|
440
|
+
// only: matched by method + path, never adds or removes an endpoint.
|
|
441
|
+
function findFileRecursive(dir, matches, maxDepth, depth = 0) {
|
|
442
|
+
if (depth > maxDepth)
|
|
443
|
+
return null;
|
|
444
|
+
let entries;
|
|
445
|
+
try {
|
|
446
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
for (const e of entries) {
|
|
452
|
+
if (e.isFile() && matches(e.name))
|
|
453
|
+
return join(dir, e.name);
|
|
454
|
+
}
|
|
455
|
+
for (const e of entries) {
|
|
456
|
+
if (e.isDirectory() && !['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name)) {
|
|
457
|
+
const found = findFileRecursive(join(dir, e.name), matches, maxDepth, depth + 1);
|
|
458
|
+
if (found)
|
|
459
|
+
return found;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
function findPostmanCollection(cwd) {
|
|
465
|
+
const isCollection = (name) => name.endsWith('.postman_collection.json');
|
|
466
|
+
for (const dir of ['postman', 'docs/postman', '.postman', 'collections']) {
|
|
467
|
+
const dirPath = join(cwd, dir);
|
|
468
|
+
if (!existsSync(dirPath))
|
|
469
|
+
continue;
|
|
470
|
+
try {
|
|
471
|
+
const match = readdirSync(dirPath).find(isCollection);
|
|
472
|
+
if (match)
|
|
473
|
+
return join(dirPath, match);
|
|
474
|
+
}
|
|
475
|
+
catch { }
|
|
476
|
+
}
|
|
477
|
+
return findFileRecursive(cwd, isCollection, 3);
|
|
478
|
+
}
|
|
479
|
+
/** "{{baseUrl}}/gold-rates/graph/{{userId}}?interval=x" -> "/gold-rates/graph/:userId" */
|
|
480
|
+
function postmanRawToRoutePath(raw) {
|
|
481
|
+
let p = raw.trim();
|
|
482
|
+
p = p.replace(/^\{\{[^}]+\}\}/, '');
|
|
483
|
+
p = p.split('?')[0];
|
|
484
|
+
p = p.replace(/\{\{([a-zA-Z0-9_]+)\}\}/g, ':$1');
|
|
485
|
+
if (!p)
|
|
486
|
+
p = '/';
|
|
487
|
+
if (!p.startsWith('/'))
|
|
488
|
+
p = '/' + p;
|
|
489
|
+
if (p !== '/' && p.endsWith('/'))
|
|
490
|
+
p = p.slice(0, -1);
|
|
491
|
+
return p;
|
|
492
|
+
}
|
|
493
|
+
/** Recursively walk a Postman collection's item tree (folders nest via `item`; requests carry `request`). */
|
|
494
|
+
function walkPostmanItems(items, index) {
|
|
495
|
+
for (const node of items ?? []) {
|
|
496
|
+
if (Array.isArray(node.item))
|
|
497
|
+
walkPostmanItems(node.item, index);
|
|
498
|
+
if (!node.request)
|
|
499
|
+
continue;
|
|
500
|
+
const req = node.request;
|
|
501
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
502
|
+
const rawUrl = typeof req.url === 'string' ? req.url : req.url?.raw;
|
|
503
|
+
if (!rawUrl)
|
|
504
|
+
continue;
|
|
505
|
+
const key = `${method} ${postmanRawToRoutePath(rawUrl)}`;
|
|
506
|
+
if (index.has(key))
|
|
507
|
+
continue; // first match wins — collection often repeats the same endpoint in a smoke-test folder
|
|
508
|
+
const entry = {};
|
|
509
|
+
if (req.body?.mode === 'raw' && typeof req.body.raw === 'string' && req.body.raw.trim()) {
|
|
510
|
+
entry.bodyExample = req.body.raw.trim();
|
|
511
|
+
}
|
|
512
|
+
if (typeof req.description === 'string' && req.description.trim()) {
|
|
513
|
+
entry.description = req.description.trim();
|
|
514
|
+
}
|
|
515
|
+
if (entry.bodyExample || entry.description)
|
|
516
|
+
index.set(key, entry);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Enrich collected routes in-place with Postman example bodies/descriptions, matched by method+path. Never adds or drops routes. */
|
|
520
|
+
function enrichRoutesFromPostman(cwd, routes) {
|
|
521
|
+
const collectionPath = findPostmanCollection(cwd);
|
|
522
|
+
if (!collectionPath)
|
|
523
|
+
return;
|
|
524
|
+
try {
|
|
525
|
+
const collection = JSON.parse(readFileSync(collectionPath, 'utf-8'));
|
|
526
|
+
const index = new Map();
|
|
527
|
+
walkPostmanItems(collection.item, index);
|
|
528
|
+
let enriched = 0;
|
|
529
|
+
for (const route of routes) {
|
|
530
|
+
const match = index.get(`${route.method} ${route.path}`);
|
|
531
|
+
if (!match)
|
|
532
|
+
continue;
|
|
533
|
+
if (match.bodyExample)
|
|
534
|
+
route.bodyExample = match.bodyExample;
|
|
535
|
+
if (match.description && !route.description)
|
|
536
|
+
route.description = match.description;
|
|
537
|
+
enriched++;
|
|
538
|
+
}
|
|
539
|
+
if (enriched > 0) {
|
|
540
|
+
log.info(dim(`Enriched ${enriched} routes with examples from ${basename(collectionPath)}`));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch { }
|
|
544
|
+
}
|
|
393
545
|
async function microservicesFlow(config, opts) {
|
|
394
546
|
const cwd = process.cwd();
|
|
395
547
|
const allRoutes = [];
|
|
@@ -432,6 +584,14 @@ async function microservicesFlow(config, opts) {
|
|
|
432
584
|
'',
|
|
433
585
|
dim('Each service is started individually and its routes are collected.'),
|
|
434
586
|
].join('\n'), 'Multi-service analysis');
|
|
587
|
+
// Routes are collected at each service's own internal paths (e.g. auth's /register),
|
|
588
|
+
// not the path clients actually call through the gateway (/auth/register) — Express
|
|
589
|
+
// strips the mount prefix before the service ever sees the request. Rewrite paths
|
|
590
|
+
// here so the docs match what's actually callable.
|
|
591
|
+
const gatewayPrefixes = detectGatewayPrefixMap(cwd, config.services);
|
|
592
|
+
if (Object.keys(gatewayPrefixes).length > 0) {
|
|
593
|
+
log.info(dim(`Gateway prefixes detected: ${Object.entries(gatewayPrefixes).map(([s, p]) => `${s}→${p}`).join(', ')}`));
|
|
594
|
+
}
|
|
435
595
|
for (const svc of config.services) {
|
|
436
596
|
const svcSpin = spinner();
|
|
437
597
|
svcSpin.start(`${bold(svc.name)} — starting…`);
|
|
@@ -460,13 +620,14 @@ async function microservicesFlow(config, opts) {
|
|
|
460
620
|
detached: false,
|
|
461
621
|
});
|
|
462
622
|
// Capture output so failures are debuggable without re-running the service by hand.
|
|
463
|
-
//
|
|
623
|
+
// Keep the HEAD, not the tail: Node prints "Error: ..." / "Cannot find module ..."
|
|
624
|
+
// first, then unwinds the call stack — a long stack trace must not push that
|
|
625
|
+
// opening line out of the window, so stop growing once capped instead of sliding.
|
|
464
626
|
let output = '';
|
|
465
|
-
const MAX_OUTPUT =
|
|
627
|
+
const MAX_OUTPUT = 6000;
|
|
466
628
|
const capture = (chunk) => {
|
|
467
|
-
output
|
|
468
|
-
|
|
469
|
-
output = output.slice(-MAX_OUTPUT);
|
|
629
|
+
if (output.length < MAX_OUTPUT)
|
|
630
|
+
output += chunk.toString();
|
|
470
631
|
};
|
|
471
632
|
child.stdout?.on('data', capture);
|
|
472
633
|
child.stderr?.on('data', capture);
|
|
@@ -479,7 +640,12 @@ async function microservicesFlow(config, opts) {
|
|
|
479
640
|
catch { }
|
|
480
641
|
tryUnlink(join(cwd, statusFile));
|
|
481
642
|
if (routes && routes.length > 0) {
|
|
482
|
-
const
|
|
643
|
+
const prefix = gatewayPrefixes[svc.name];
|
|
644
|
+
const annotated = routes.map((r) => ({
|
|
645
|
+
...r,
|
|
646
|
+
service: svc.name,
|
|
647
|
+
path: prefix ? withGatewayPrefix(prefix, r.path) : r.path,
|
|
648
|
+
}));
|
|
483
649
|
allRoutes.push(...annotated);
|
|
484
650
|
serviceMeta.push({ name: svc.name, baseUrl: svc.baseUrl });
|
|
485
651
|
svcSpin.stop(green(`✓ ${svc.name} — ${routes.length} routes collected`));
|
|
@@ -488,7 +654,7 @@ async function microservicesFlow(config, opts) {
|
|
|
488
654
|
svcSpin.stop(yellow(`◌ ${svc.name} — no routes collected (timeout or error)`));
|
|
489
655
|
const trimmed = output.trim();
|
|
490
656
|
if (trimmed) {
|
|
491
|
-
log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(
|
|
657
|
+
log.error(dim(` ${bold(svc.name)} output:\n` + trimmed.split('\n').slice(0, 30).map(l => ` ${l}`).join('\n')));
|
|
492
658
|
}
|
|
493
659
|
}
|
|
494
660
|
// Remove injected code
|
|
@@ -498,6 +664,7 @@ async function microservicesFlow(config, opts) {
|
|
|
498
664
|
// Safety: clean up any remaining injections (e.g. if process was interrupted)
|
|
499
665
|
for (const f of injectedFiles)
|
|
500
666
|
removeInjectedCode(f);
|
|
667
|
+
enrichRoutesFromPostman(cwd, allRoutes);
|
|
501
668
|
if (allRoutes.length === 0) {
|
|
502
669
|
log.error(red('No routes were collected from any service.'));
|
|
503
670
|
outro(red('Analysis failed.'));
|
|
@@ -1018,13 +1185,33 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1018
1185
|
` upload: true,`,
|
|
1019
1186
|
` runOnce: true,`,
|
|
1020
1187
|
].join('\n');
|
|
1188
|
+
// Collection mode: some services only mount routes inside an async bootstrap
|
|
1189
|
+
// gated behind a DB/broker connect (which may hang/fail outside Docker).
|
|
1190
|
+
// Poll briefly for app._router to be populated (Express-shaped apps only)
|
|
1191
|
+
// before giving up and collecting whatever is there — bounded well under
|
|
1192
|
+
// the CLI's 30s per-service timeout so a hung connect still surfaces.
|
|
1193
|
+
const runAnalyze = `APIForge.analyze(${appVar}, {\n${opts}\n })`;
|
|
1194
|
+
const call = collectionOpts
|
|
1195
|
+
? [
|
|
1196
|
+
` let __apiforgeTries = 0`,
|
|
1197
|
+
` const __apiforgeTryCollect = () => {`,
|
|
1198
|
+
` const hasRoutes = !${appVar} || !${appVar}._router`,
|
|
1199
|
+
` ? true`,
|
|
1200
|
+
` : Array.isArray(${appVar}._router.stack) && ${appVar}._router.stack.length > 0`,
|
|
1201
|
+
` if (hasRoutes || __apiforgeTries++ >= 26) {`,
|
|
1202
|
+
` ${runAnalyze}`,
|
|
1203
|
+
` } else {`,
|
|
1204
|
+
` setTimeout(__apiforgeTryCollect, 300)`,
|
|
1205
|
+
` }`,
|
|
1206
|
+
` }`,
|
|
1207
|
+
` __apiforgeTryCollect()`,
|
|
1208
|
+
].join('\n')
|
|
1209
|
+
: ` ${runAnalyze}`;
|
|
1021
1210
|
if (isESM) {
|
|
1022
1211
|
return [
|
|
1023
1212
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1024
1213
|
`import('apiforge-analyzer').then(({ default: APIForge }) => {`,
|
|
1025
|
-
|
|
1026
|
-
opts,
|
|
1027
|
-
` })`,
|
|
1214
|
+
call,
|
|
1028
1215
|
`}).catch(() => {})`,
|
|
1029
1216
|
'',
|
|
1030
1217
|
].join('\n');
|
|
@@ -1033,9 +1220,7 @@ function buildSnippet(projectName, apiKey, baseUrl, backendUrl, appVar, isESM, a
|
|
|
1033
1220
|
`// APIForge — https://apiforge.brainfogagency.com`,
|
|
1034
1221
|
`Promise.resolve().then(() => require('apiforge-analyzer')).then(mod => {`,
|
|
1035
1222
|
` const APIForge = mod.default || mod`,
|
|
1036
|
-
|
|
1037
|
-
opts,
|
|
1038
|
-
` })`,
|
|
1223
|
+
call,
|
|
1039
1224
|
`}).catch(() => {})`,
|
|
1040
1225
|
'',
|
|
1041
1226
|
].join('\n');
|
package/package.json
CHANGED