api-tracer-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +466 -0
  4. package/cli/bin/api-tracer.mjs +266 -0
  5. package/cli/config.mjs +224 -0
  6. package/cli/import.mjs +231 -0
  7. package/cli/index.mjs +10 -0
  8. package/cli/presets.mjs +212 -0
  9. package/cli/report.mjs +346 -0
  10. package/cli/scan.mjs +142 -0
  11. package/cli/server.mjs +1576 -0
  12. package/cli/shape.mjs +90 -0
  13. package/cli/test.mjs +342 -0
  14. package/cli/web/app.css +1424 -0
  15. package/cli/web/app.js +2260 -0
  16. package/cli/web/favicon.svg +5 -0
  17. package/cli/web/index.html +159 -0
  18. package/cli/web/logo.svg +7 -0
  19. package/dist/axios.cjs +856 -0
  20. package/dist/axios.cjs.map +1 -0
  21. package/dist/axios.d.cts +27 -0
  22. package/dist/axios.d.ts +27 -0
  23. package/dist/axios.js +853 -0
  24. package/dist/axios.js.map +1 -0
  25. package/dist/index.cjs +872 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +74 -0
  28. package/dist/index.d.ts +74 -0
  29. package/dist/index.js +857 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/react.cjs +896 -0
  32. package/dist/react.cjs.map +1 -0
  33. package/dist/react.d.cts +22 -0
  34. package/dist/react.d.ts +22 -0
  35. package/dist/react.js +893 -0
  36. package/dist/react.js.map +1 -0
  37. package/dist/tracer-BUWdU2lG.d.ts +76 -0
  38. package/dist/tracer-DG2YUqK0.d.cts +76 -0
  39. package/dist/types-Bl2-K6_g.d.cts +111 -0
  40. package/dist/types-Bl2-K6_g.d.ts +111 -0
  41. package/dist/ui.cjs +1162 -0
  42. package/dist/ui.cjs.map +1 -0
  43. package/dist/ui.d.cts +16 -0
  44. package/dist/ui.d.ts +16 -0
  45. package/dist/ui.js +1157 -0
  46. package/dist/ui.js.map +1 -0
  47. package/package.json +92 -0
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * api-tracer — map, exercise and watch the API a project calls.
4
+ *
5
+ * api-tracer scan read the source and write the endpoint catalog
6
+ * api-tracer serve the console, at http://localhost:4400
7
+ * api-tracer start scan, then serve
8
+ * api-tracer report print or export the insight report
9
+ * api-tracer init write a config file, only if the guesses need help
10
+ *
11
+ * Everything works with no config file. `init` exists for the cases guessing
12
+ * cannot cover, and it writes what it guessed so there is something to edit
13
+ * rather than a blank page.
14
+ */
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { loadConfig, guessConfig, defaults, CONFIG_NAMES } from '../config.mjs';
18
+ import { scanToFile, scanProject } from '../scan.mjs';
19
+ import { buildReport, toMarkdown } from '../report.mjs';
20
+
21
+ const [, , command = 'help', ...rest] = process.argv;
22
+ const flag = (name, fallback) => {
23
+ const at = rest.indexOf(`--${name}`);
24
+ return at === -1 ? fallback : (rest[at + 1] ?? true);
25
+ };
26
+
27
+ const ROOT = resolve(String(flag('root', process.cwd())));
28
+ const DATA = resolve(String(flag('data', process.env.API_TRACER_DATA ?? join(ROOT, '.api-tracer'))));
29
+ const CATALOG = join(DATA, 'endpoints.json');
30
+
31
+ const say = (...a) => console.log(...a);
32
+
33
+ async function config() {
34
+ const loaded = await loadConfig(ROOT);
35
+ if (flag('preset')) loaded.preset = String(flag('preset'));
36
+ return loaded;
37
+ }
38
+
39
+ /* ------------------------------------------------------------------- scan */
40
+
41
+ async function scan() {
42
+ const cfg = await config();
43
+ mkdirSync(DATA, { recursive: true });
44
+ const catalog = scanToFile(ROOT, cfg, CATALOG);
45
+
46
+ if (catalog.kept) {
47
+ say(`no source found under ${ROOT}; keeping the catalog already in ${CATALOG}`);
48
+ return catalog;
49
+ }
50
+ if (!catalog.endpoints.length) {
51
+ say(`no endpoints found in ${ROOT}.`);
52
+ say('The scan tries every preset and keeps whichever reads your code. If none did:');
53
+ say(' api-tracer init write a config with what it guessed, then edit it');
54
+ say(` --preset <name> force one of: service-object, axios-direct, fetch-direct`);
55
+ say('Live recording still works without a catalog: every call becomes an endpoint.');
56
+ return catalog;
57
+ }
58
+
59
+ const modules = new Set(catalog.endpoints.map((e) => e.module));
60
+ const byMethod = {};
61
+ for (const e of catalog.endpoints) byMethod[e.method] = (byMethod[e.method] ?? 0) + 1;
62
+
63
+ say(`${catalog.endpoints.length} endpoints across ${modules.size} modules -> ${CATALOG}`);
64
+ say(`preset "${catalog.preset}" ${Object.entries(byMethod).map(([m, n]) => `${m} ${n}`).join(' ')}`);
65
+ if (Object.keys(catalog.baseUrls).length) {
66
+ say(`base URLs: ${Object.entries(catalog.baseUrls).map(([k, v]) => `${k}=${v}`).join(' ')}`);
67
+ } else {
68
+ say('no base URL found; add baseUrls to a config file, or the console cannot send anything');
69
+ }
70
+ say(`auth header: ${catalog.auth.header}`);
71
+
72
+ const dead = catalog.endpoints.filter((e) => e.usedIn === 0);
73
+ if (dead.length) say(`${dead.length} endpoint(s) referenced nowhere outside their own file`);
74
+ return catalog;
75
+ }
76
+
77
+ /* ------------------------------------------------------------------- init */
78
+
79
+ /** what a config file looks like, filled in with what the scan worked out */
80
+ function configFile(guess, found) {
81
+ const urls = Object.entries(guess.baseUrls);
82
+ return `/**
83
+ * api-tracer config. Every field is optional — this file was generated from
84
+ * what the scan found, so it is a starting point, not a requirement.
85
+ */
86
+ export default {
87
+ name: ${JSON.stringify(guess.name ?? 'API')},
88
+
89
+ // the preset that read this codebase best (${found} endpoints)
90
+ preset: ${JSON.stringify(guess.preset)},
91
+ ${guess.preset === 'service-object' ? ` // the object key holding the path\n pathKey: ${JSON.stringify(defaults.pathKey)},\n` : ''}
92
+ // where to look for API calls
93
+ sources: ${JSON.stringify(defaults.sources)},
94
+
95
+ baseUrls: {
96
+ ${urls.map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`).join('\n') || ' // dev: "https://api.example.com",'}
97
+ },
98
+
99
+ auth: { header: ${JSON.stringify(guess.auth.header)} },
100
+ ${guess.auth.alternatives?.length ? ` // other header names seen in the source: ${guess.auth.alternatives.join(', ')}\n` : ''}
101
+ /*
102
+ * Some APIs answer HTTP 200 and put the real verdict in the body. Set this to
103
+ * false if yours uses the status line honestly.
104
+ */
105
+ envelope: { codeFields: ['status', 'code'], okField: 'success', failFrom: 400 },
106
+
107
+ /*
108
+ * An optional sign-in chain, so nobody has to paste a token. Each step
109
+ * declares the fields to ask for, the call to make, and what to do with the
110
+ * answer. Delete this if pasting a token is enough.
111
+ *
112
+ * login: {
113
+ * steps: [
114
+ * {
115
+ * name: 'credentials',
116
+ * fields: [{ name: 'email', type: 'email' }, { name: 'password', type: 'password' }],
117
+ * path: '/users/login.json',
118
+ * body: (input) => ({ user: input }),
119
+ * then: (data) => ({ token: data.token, owner: { email: data.email } }),
120
+ * },
121
+ * ],
122
+ * },
123
+ */
124
+ };
125
+ `;
126
+ }
127
+
128
+ async function init() {
129
+ const existing = CONFIG_NAMES.map((n) => join(ROOT, n)).find((f) => existsSync(f));
130
+ if (existing && !flag('force')) {
131
+ say(`${existing} already exists; pass --force to overwrite it`);
132
+ return;
133
+ }
134
+
135
+ const guess = guessConfig(ROOT, defaults);
136
+ const found = guess.scores.find((s) => s.preset === guess.preset)?.found ?? 0;
137
+ say(`${guess.files.length} source files scanned`);
138
+ for (const s of guess.scores) say(` ${s.preset.padEnd(16)} ${s.found}`);
139
+
140
+ if (!guess.preset) {
141
+ say('\nNo preset read this codebase. A config file has been written anyway —');
142
+ say('set `preset` or supply your own `parse(src, config)` in it.');
143
+ }
144
+
145
+ const out = join(ROOT, CONFIG_NAMES[0]);
146
+ writeFileSync(out, configFile(guess, found));
147
+ say(`\nwrote ${out}`);
148
+ say('Nothing else is needed: `api-tracer start` works from here.');
149
+ }
150
+
151
+ /* ----------------------------------------------------------------- report */
152
+
153
+ async function report() {
154
+ if (!existsSync(CATALOG)) {
155
+ say(`no catalog at ${CATALOG}; run \`api-tracer scan\` first`);
156
+ process.exit(1);
157
+ }
158
+ const catalog = JSON.parse(readFileSync(CATALOG, 'utf8'));
159
+ const read = (name, fallback) => {
160
+ try {
161
+ return JSON.parse(readFileSync(join(DATA, name), 'utf8'));
162
+ } catch {
163
+ return fallback;
164
+ }
165
+ };
166
+ const runs = (() => {
167
+ try {
168
+ return readFileSync(join(DATA, 'runs.jsonl'), 'utf8')
169
+ .split('\n')
170
+ .filter(Boolean)
171
+ .flatMap((l) => {
172
+ try {
173
+ return [JSON.parse(l)];
174
+ } catch {
175
+ return [];
176
+ }
177
+ });
178
+ } catch {
179
+ return [];
180
+ }
181
+ })();
182
+
183
+ const extras = read('uncatalogued.json', {});
184
+ const model = buildReport({
185
+ catalog,
186
+ endpoints: [...catalog.endpoints, ...Object.values(extras)],
187
+ samples: read('samples.json', {}),
188
+ results: read('results.json', {}),
189
+ contracts: read('contracts.json', {}),
190
+ runs,
191
+ env: String(flag('env', 'dev')),
192
+ });
193
+
194
+ const format = String(flag('format', 'md'));
195
+ const body = format === 'json' ? `${JSON.stringify(model, null, 2)}\n` : toMarkdown(model);
196
+ const out = flag('out');
197
+ if (out === undefined || out === true) process.stdout.write(body);
198
+ else {
199
+ writeFileSync(resolve(String(out)), body);
200
+ say(`wrote ${resolve(String(out))}`);
201
+ }
202
+ }
203
+
204
+ /* ------------------------------------------------------------------ serve */
205
+
206
+ async function serve() {
207
+ process.env.API_TRACER_DATA = DATA;
208
+ const server = await import('../server.mjs');
209
+ const cfg = await config();
210
+ server.useConfig(cfg);
211
+ const port = Number(flag('port', process.env.PORT ?? 4400));
212
+ const host = String(flag('host', process.env.HOST ?? '127.0.0.1'));
213
+ server.startServer({ port, host });
214
+ say(`api-tracer console -> http://${host}:${port}${process.env.BASE_PATH ?? ''}/`);
215
+ say(`data: ${DATA}`);
216
+ }
217
+
218
+ /* -------------------------------------------------------------------- run */
219
+
220
+ const HELP = `api-tracer
221
+
222
+ api-tracer scan read the source, write the endpoint catalog
223
+ api-tracer serve run the console (default http://127.0.0.1:4400)
224
+ api-tracer start scan, then serve
225
+ api-tracer report build the insight report
226
+ api-tracer init write a config file from what the scan guessed
227
+
228
+ Options
229
+ --root <dir> the project to read (default: cwd)
230
+ --data <dir> where captures are kept (default: <root>/.api-tracer)
231
+ --port <n> console port (default: 4400)
232
+ --host <addr> console host (default: 127.0.0.1)
233
+ --preset <name> force a scanner preset (service-object|axios-direct|fetch-direct)
234
+ --format md|json report format (default: md)
235
+ --out <file> write the report to a file (default: stdout)
236
+ --env <name> which base URL the report describes
237
+
238
+ No configuration file is required.
239
+ `;
240
+
241
+ const commands = {
242
+ scan,
243
+ init,
244
+ report,
245
+ serve,
246
+ start: async () => {
247
+ await scan();
248
+ await serve();
249
+ },
250
+ help: async () => say(HELP),
251
+ '--help': async () => say(HELP),
252
+ '-h': async () => say(HELP),
253
+ '--version': async () =>
254
+ say(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version),
255
+ };
256
+
257
+ const run = commands[command];
258
+ if (!run) {
259
+ say(`unknown command "${command}"\n`);
260
+ say(HELP);
261
+ process.exit(1);
262
+ }
263
+ run().catch((e) => {
264
+ console.error(e.message);
265
+ process.exit(1);
266
+ });
package/cli/config.mjs ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Configuration, and doing without it.
3
+ *
4
+ * The goal is that `api-tracer scan` works in a project it has never seen. So
5
+ * every setting has a default, and the ones that cannot have a useful default —
6
+ * which preset reads this codebase, where its API lives — are guessed by trying
7
+ * each option and keeping whichever actually finds endpoints.
8
+ *
9
+ * A config file is optional. It exists for the cases guessing cannot cover: a
10
+ * bespoke service shape, a base URL that is not written down in the source, an
11
+ * auth header the scanner cannot see.
12
+ */
13
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
14
+ import { join, resolve, relative, basename, extname } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { PRESETS, PRESET_NAMES, stripComments } from './presets.mjs';
17
+
18
+ export const CONFIG_NAMES = [
19
+ 'api-tracer.config.mjs',
20
+ 'api-tracer.config.js',
21
+ 'api-tracer.config.json',
22
+ ];
23
+
24
+ export const defaults = {
25
+ /** where to look for API calls, relative to the project root */
26
+ sources: ['src', 'app', 'lib', 'services', 'api'],
27
+ /** never walked */
28
+ ignore: ['node_modules', 'dist', 'build', 'coverage', '.git', '.next', 'out', 'vendor', '__snapshots__'],
29
+ extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'],
30
+ /** which preset reads this codebase; null means guess */
31
+ preset: null,
32
+ /** the object key holding the path, for the service-object preset */
33
+ pathKey: 'subUrl',
34
+ /** environment -> base URL. Guessed from the source when absent. */
35
+ baseUrls: {},
36
+ /** the header the API authenticates with */
37
+ auth: { header: 'Authorization' },
38
+ /** how to read pass/fail out of a 2xx body; false turns it off */
39
+ envelope: { codeFields: ['status', 'code'], okField: 'success', failFrom: 400 },
40
+ /** the module a file's endpoints belong to; the filename by default */
41
+ moduleOf: null,
42
+ };
43
+
44
+ /** every source file under the configured roots */
45
+ export function collectFiles(root, config = defaults) {
46
+ const out = [];
47
+ const ignore = new Set(config.ignore ?? defaults.ignore);
48
+ const extensions = new Set(config.extensions ?? defaults.extensions);
49
+
50
+ const walk = (dir) => {
51
+ let entries;
52
+ try {
53
+ entries = readdirSync(dir, { withFileTypes: true });
54
+ } catch {
55
+ return; // unreadable directory is not a reason to fail the scan
56
+ }
57
+ for (const entry of entries) {
58
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
59
+ if (ignore.has(entry.name)) continue;
60
+ const full = join(dir, entry.name);
61
+ if (entry.isDirectory()) walk(full);
62
+ else if (extensions.has(extname(entry.name))) out.push(full);
63
+ }
64
+ };
65
+
66
+ const roots = (config.sources ?? defaults.sources)
67
+ .map((s) => resolve(root, s))
68
+ .filter((p) => existsSync(p) && statSync(p).isDirectory());
69
+ // no recognisable source directory: fall back to the project root itself
70
+ for (const dir of roots.length ? roots : [root]) walk(dir);
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Files most likely to hold the API layer, so a guess is made on the code that
76
+ * matters rather than on the first forty components in alphabetical order.
77
+ */
78
+ function pickSample(files, limit = 120) {
79
+ const weight = (f) => {
80
+ const p = f.toLowerCase();
81
+ if (/(services?|api|http|client|endpoints?|requests?|queries)[/\\]/.test(p)) return 0;
82
+ if (/(services?|api|http|client)\./.test(basename(p))) return 1;
83
+ return 2;
84
+ };
85
+ return [...files].sort((a, b) => weight(a) - weight(b)).slice(0, limit);
86
+ }
87
+
88
+ const read = (file) => {
89
+ try {
90
+ return stripComments(readFileSync(file, 'utf8'));
91
+ } catch {
92
+ return '';
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Runs every preset over a sample of the codebase and keeps whichever finds the
98
+ * most. Guessing by looking for marker strings was tried first and was wrong
99
+ * often enough to matter — a project can import axios and still not use it for
100
+ * its API layer. Running the presets asks the only question that counts: which
101
+ * one actually reads this code?
102
+ */
103
+ export function guessPreset(files, config = defaults) {
104
+ const sample = pickSample(files);
105
+ const scores = [];
106
+
107
+ for (const name of PRESET_NAMES) {
108
+ const parse = PRESETS[name];
109
+ let found = 0;
110
+ for (const file of sample) {
111
+ try {
112
+ found += parse(read(file), config).length;
113
+ } catch {
114
+ /* a preset that throws on odd syntax simply scores nothing there */
115
+ }
116
+ }
117
+ scores.push({ preset: name, found });
118
+ }
119
+
120
+ scores.sort((a, b) => b.found - a.found);
121
+ return { preset: scores[0].found ? scores[0].preset : null, scores };
122
+ }
123
+
124
+ /**
125
+ * Base URLs, read out of the app rather than copied into a config, so dev and
126
+ * stage stay in sync with what the app actually calls. Looks for an object
127
+ * mapping environment names to URLs, then for any bare API-looking URL.
128
+ */
129
+ export function guessBaseUrls(files) {
130
+ const ENV = /^(dev|develop|development|stage|staging|uat|qa|test|prod|production|local)$/i;
131
+ const urls = {};
132
+
133
+ for (const file of pickSample(files, 200)) {
134
+ const src = read(file);
135
+ for (const block of src.matchAll(/\{([^{}]*https?:\/\/[^{}]*)\}/g)) {
136
+ for (const [, env, url] of block[1].matchAll(/["']?(\w+)["']?\s*:\s*["'`](https?:\/\/[^"'`]+)["'`]/g)) {
137
+ if (ENV.test(env)) urls[env.toLowerCase()] = url;
138
+ }
139
+ }
140
+ if (Object.keys(urls).length) break;
141
+ }
142
+
143
+ if (!Object.keys(urls).length) {
144
+ // one URL and no environment map is still better than none
145
+ for (const file of pickSample(files, 200)) {
146
+ const hit = read(file).match(/["'`](https?:\/\/[^"'`\s]*\/api[^"'`\s]*)["'`]/);
147
+ if (hit) {
148
+ urls.dev = hit[1];
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ return urls;
154
+ }
155
+
156
+ /** the auth header name, and where a human can read its value */
157
+ export function guessAuth(files) {
158
+ const CANDIDATE = /^(authorization|auth[-_]?token|access[-_]?token|x-auth-token|x-api-key|api[-_]?key|token)$/i;
159
+ const counts = new Map();
160
+
161
+ for (const file of pickSample(files, 200)) {
162
+ const src = read(file);
163
+ // used as a key: `{ AUTH_TOKEN: token }` or `headers["AUTH_TOKEN"]`
164
+ for (const m of src.matchAll(/["'`]([A-Za-z][\w-]{2,30})["'`]\s*\]?\s*[:=]/g)) {
165
+ if (CANDIDATE.test(m[1])) counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
166
+ }
167
+ for (const m of src.matchAll(/headers?\s*(?:\.|\[["'`])\s*([A-Za-z][\w-]{2,30})/g)) {
168
+ if (CANDIDATE.test(m[1])) counts.set(m[1], (counts.get(m[1]) ?? 0) + 2);
169
+ }
170
+ /*
171
+ * Held in a named constant -- `const authTokenKey = "AUTH_TOKEN"` -- which
172
+ * is how a codebase that uses the header in more than one place tends to
173
+ * write it, so it is the strongest signal of the three.
174
+ */
175
+ for (const m of src.matchAll(
176
+ /\b(?:const|let|var)\s+\w*(?:auth|token|header|api[-_]?key)\w*\s*=\s*["'`]([A-Za-z][\w-]{2,30})["'`]/gi,
177
+ )) {
178
+ /*
179
+ * A constant named for auth is trusted whatever it holds. Header names
180
+ * are project inventions -- X_AUTH, Sesh, Client-Token -- so a fixed list
181
+ * of known names would miss most of them, and the variable name has
182
+ * already said what this value is for.
183
+ */
184
+ counts.set(m[1], (counts.get(m[1]) ?? 0) + (CANDIDATE.test(m[1]) ? 6 : 5));
185
+ }
186
+ }
187
+
188
+ const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
189
+ return {
190
+ header: ranked[0]?.[0] ?? defaults.auth.header,
191
+ alternatives: ranked.slice(1, 4).map(([name]) => name),
192
+ };
193
+ }
194
+
195
+ /** everything the scanner needs, guessed from the codebase */
196
+ export function guessConfig(root, config = defaults) {
197
+ const files = collectFiles(root, config);
198
+ const { preset, scores } = guessPreset(files, config);
199
+ return {
200
+ files,
201
+ preset,
202
+ scores,
203
+ baseUrls: guessBaseUrls(files),
204
+ auth: guessAuth(files),
205
+ };
206
+ }
207
+
208
+ /** loads a config file if there is one; every field stays optional */
209
+ export async function loadConfig(root) {
210
+ for (const name of CONFIG_NAMES) {
211
+ const file = join(root, name);
212
+ if (!existsSync(file)) continue;
213
+ try {
214
+ if (name.endsWith('.json')) {
215
+ return { ...defaults, ...JSON.parse(readFileSync(file, 'utf8')), configFile: file };
216
+ }
217
+ const mod = await import(pathToFileURL(file).href);
218
+ return { ...defaults, ...(mod.default ?? mod), configFile: file };
219
+ } catch (e) {
220
+ throw new Error(`could not read ${relative(root, file)}: ${e.message}`);
221
+ }
222
+ }
223
+ return { ...defaults, configFile: null };
224
+ }