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
package/cli/import.mjs ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Turns real traffic into samples for the scanned endpoints.
3
+ *
4
+ * Input is either a HAR file (DevTools -> Network -> Save all as HAR with content)
5
+ * or a single copied cURL command. Either way the job is the same: take a concrete
6
+ * request like
7
+ *
8
+ * POST https://api.example.com/appointments/4821/accept
9
+ *
10
+ * and match it back to the template the scanner found in the source
11
+ *
12
+ * /appointments/${data.id}/accept
13
+ *
14
+ * so the captured query params, body, and path values land on the right endpoint.
15
+ */
16
+
17
+ /** anything that smells like a credential never reaches disk */
18
+ const SECRET_HEADER = /^(auth_token|authorization|access_token|secret_token|cookie|x-api-key|api[-_]?key|cs_token)$/i;
19
+ const SECRET_FIELD = /(token|password|secret|api[-_]?key|authorization)/i;
20
+
21
+ /** `/appointments/${data.id}.json` -> /^\/appointments\/([^/?]+)\.json$/ */
22
+ function templateToRegex(subUrl) {
23
+ const escaped = subUrl
24
+ .split(/\$\{[^}]+\}/)
25
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
26
+ .join('([^/?]+)');
27
+ return new RegExp(`^${escaped}$`);
28
+ }
29
+
30
+ /**
31
+ * Strips the host and any known API base so what is left lines up with a subUrl.
32
+ * Returns null when the request is not ours at all.
33
+ */
34
+ export function toComparablePath(rawUrl, baseUrls) {
35
+ let url;
36
+ try {
37
+ url = new URL(rawUrl);
38
+ } catch {
39
+ return null;
40
+ }
41
+ const full = `${url.origin}${url.pathname}`;
42
+ for (const base of Object.values(baseUrls)) {
43
+ if (full.startsWith(base)) return full.slice(base.length) || '/';
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Best endpoint for a concrete request. Exact templates win over ones with holes,
50
+ * and among those, fewer holes wins -- so a literal path is never swallowed by a
51
+ * greedier pattern.
52
+ */
53
+ export function matchEndpoint(method, path, endpoints) {
54
+ const candidates = endpoints
55
+ .filter((ep) => ep.method === method && !ep.absolute && !ep.dynamicUrl)
56
+ .map((ep) => {
57
+ const m = templateToRegex(ep.subUrl.split('?')[0]).exec(path);
58
+ return m ? { ep, values: m.slice(1) } : null;
59
+ })
60
+ .filter(Boolean);
61
+
62
+ if (!candidates.length) return null;
63
+ candidates.sort((a, b) => a.ep.holes.length - b.ep.holes.length);
64
+ const best = candidates[0];
65
+ return {
66
+ endpoint: best.ep,
67
+ // hole expression -> the real value seen in traffic
68
+ pathValues: Object.fromEntries(best.ep.holes.map((h, i) => [h.expr, best.values[i]])),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * `a=1&b[]=2&b[]=3&f[x]=9` -> { a: '1', b: ['2','3'], f: { x: '9' } }
74
+ *
75
+ * ponytail: covers the three shapes axios emits. Deeper nesting like `a[b][c]`
76
+ * is kept as a flat key rather than guessed at.
77
+ */
78
+ export function parseQuery(search) {
79
+ const out = {};
80
+ for (const [rawKey, value] of new URLSearchParams(search)) {
81
+ const asArray = /^(.+)\[\]$/.exec(rawKey);
82
+ const asNested = /^([^[]+)\[([^\]]+)\]$/.exec(rawKey);
83
+ if (asArray) (out[asArray[1]] ??= []).push(value);
84
+ else if (asNested) (out[asNested[1]] ??= {})[asNested[2]] = value;
85
+ else out[rawKey] = value;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** replaces secret-looking values, keeping the shape so the sample stays useful */
91
+ export function redact(value) {
92
+ if (Array.isArray(value)) return value.map(redact);
93
+ if (value && typeof value === 'object') {
94
+ return Object.fromEntries(
95
+ Object.entries(value).map(([k, v]) => [k, SECRET_FIELD.test(k) ? '<redacted>' : redact(v)]),
96
+ );
97
+ }
98
+ return value;
99
+ }
100
+
101
+ function parseBody(text, mimeType = '') {
102
+ if (!text) return undefined;
103
+ if (/json/i.test(mimeType) || /^\s*[[{]/.test(text)) {
104
+ try {
105
+ return redact(JSON.parse(text));
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ if (/x-www-form-urlencoded/i.test(mimeType)) return redact(parseQuery(text));
111
+ return undefined; // multipart and friends are not worth guessing at
112
+ }
113
+
114
+ /** one sample per request, ready to merge into the catalog */
115
+ function toSample({ method, url, queryString, bodyText, mimeType }, ctx) {
116
+ const path = toComparablePath(url, ctx.baseUrls);
117
+ if (path === null) return { skipped: 'not an API host' };
118
+
119
+ const hit = matchEndpoint(method, path, ctx.endpoints);
120
+
121
+ return {
122
+ id: hit?.endpoint.id ?? null,
123
+ method,
124
+ path,
125
+ sample: {
126
+ pathValues: hit?.pathValues ?? {},
127
+ params: redact(parseQuery(queryString ?? new URL(url).search)),
128
+ data: parseBody(bodyText, mimeType),
129
+ seenAt: new Date().toISOString(),
130
+ from: `${method} ${path}`,
131
+ },
132
+ };
133
+ }
134
+
135
+ /* --------------------------------------------------------------------- HAR */
136
+
137
+ export function importHar(harText, ctx) {
138
+ let har;
139
+ try {
140
+ har = JSON.parse(harText);
141
+ } catch (e) {
142
+ throw new Error(`not a valid HAR file: ${e.message}`);
143
+ }
144
+ const entries = har?.log?.entries;
145
+ if (!Array.isArray(entries)) throw new Error('no log.entries in that HAR');
146
+
147
+ const requests = entries
148
+ .filter((e) => e?.request?.url)
149
+ .map((e) => ({
150
+ method: (e.request.method ?? 'GET').toUpperCase(),
151
+ url: e.request.url,
152
+ bodyText: e.request.postData?.text,
153
+ mimeType: e.request.postData?.mimeType,
154
+ }));
155
+
156
+ return applyAll(requests, ctx);
157
+ }
158
+
159
+ /* -------------------------------------------------------------------- cURL */
160
+
161
+ /** splits a shell-ish command, respecting single and double quotes */
162
+ function tokenize(command) {
163
+ const out = [];
164
+ const re = /'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g;
165
+ let m;
166
+ while ((m = re.exec(command))) {
167
+ out.push(m[1] ?? m[2]?.replace(/\\(.)/g, '$1') ?? m[3]);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ /** parses what Chrome's "Copy as cURL" produces */
173
+ export function parseCurl(command) {
174
+ const tokens = tokenize(command.replace(/\\\r?\n/g, ' ')).filter((t) => t !== 'curl');
175
+ const req = { method: null, url: null, headers: {}, bodyText: undefined, mimeType: '' };
176
+
177
+ for (let i = 0; i < tokens.length; i++) {
178
+ const t = tokens[i];
179
+ if (t === '-X' || t === '--request') req.method = tokens[++i]?.toUpperCase();
180
+ else if (t === '-H' || t === '--header') {
181
+ const [, k, v] = /^([^:]+):\s*(.*)$/.exec(tokens[++i] ?? '') ?? [];
182
+ if (k) req.headers[k.trim()] = v;
183
+ } else if (['-d', '--data', '--data-raw', '--data-binary', '--data-ascii'].includes(t)) {
184
+ req.bodyText = tokens[++i];
185
+ } else if (t === '-b' || t === '--cookie') i++; // dropped on purpose
186
+ else if (!t.startsWith('-') && !req.url) req.url = t;
187
+ }
188
+
189
+ if (!req.url) throw new Error('no URL found in that cURL command');
190
+ req.mimeType = req.headers['content-type'] ?? req.headers['Content-Type'] ?? '';
191
+ req.method ??= req.bodyText ? 'POST' : 'GET';
192
+ return req;
193
+ }
194
+
195
+ export function importCurl(command, ctx) {
196
+ return applyAll([parseCurl(command)], ctx);
197
+ }
198
+
199
+ /* ------------------------------------------------------------------ shared */
200
+
201
+ function applyAll(requests, ctx) {
202
+ const samples = {};
203
+ const unmatched = [];
204
+ let matched = 0;
205
+
206
+ const unmatchedCalls = [];
207
+ for (const req of requests) {
208
+ const out = toSample(req, ctx);
209
+ if (out.skipped) continue; // not one of our hosts at all
210
+
211
+ if (!out.id) {
212
+ // recorded anyway, as an endpoint no service file explains
213
+ unmatched.push(`no endpoint matches ${out.method} ${out.path}`);
214
+ unmatchedCalls.push(out);
215
+ continue;
216
+ }
217
+ matched++;
218
+ // later requests win: the most recent capture is usually the most relevant
219
+ samples[out.id] = out.sample;
220
+ }
221
+
222
+ return {
223
+ matched,
224
+ endpoints: Object.keys(samples).length,
225
+ unmatched: [...new Set(unmatched)],
226
+ unmatchedCalls,
227
+ samples,
228
+ };
229
+ }
230
+
231
+ export const _internal = { templateToRegex, SECRET_HEADER };
package/cli/index.mjs ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `api-tracer-kit/cli` — the console's pieces, for anyone driving it from code
3
+ * rather than from the command line.
4
+ */
5
+ export { scanProject, scanToFile, writeCatalog } from './scan.mjs';
6
+ export { loadConfig, guessConfig, guessPreset, guessBaseUrls, guessAuth, collectFiles, defaults } from './config.mjs';
7
+ export { PRESETS, PRESET_NAMES, holesIn, stripComments } from './presets.mjs';
8
+ export { buildReport, toMarkdown } from './report.mjs';
9
+ export { shapeOf, flatten, diffShapes, summarizeDrift, shapeOfBody } from './shape.mjs';
10
+ export { importHar, importCurl, matchEndpoint, toComparablePath, parseQuery } from './import.mjs';
@@ -0,0 +1,212 @@
1
+ /**
2
+ * How to find endpoints in a codebase.
3
+ *
4
+ * Each preset takes one file's source and returns the endpoints in it. They are
5
+ * regex-based on purpose: a parser would need a dependency per language and per
6
+ * flavour of syntax, and would still not understand a template literal built
7
+ * from three variables. A preset that reads 90% of a codebase and says so is
8
+ * more useful than a parser that refuses to start.
9
+ *
10
+ * A project whose services look like none of these can supply its own `parse`
11
+ * in the config file rather than fork the tool.
12
+ */
13
+
14
+ /** `${data.id}` -> { expr: 'data.id', label: 'id' } */
15
+ export function holesIn(subUrl) {
16
+ return [...subUrl.matchAll(/\$\{([^}]+)\}|:([A-Za-z_][\w]*)|\{([A-Za-z_][\w]*)\}/g)].map((m) => {
17
+ const expr = (m[1] ?? m[2] ?? m[3]).trim();
18
+ return { expr, label: expr.replace(/^(data|params|request|body|payload)\??\.?\./, '') };
19
+ });
20
+ }
21
+
22
+ export const lineOf = (src, index) => src.slice(0, index).split('\n').length;
23
+
24
+ /**
25
+ * Blanks out commented-out lines, keeping line numbers intact.
26
+ *
27
+ * Services routinely keep the previous URL commented directly above the live
28
+ * one, and reading the first match in the block would silently take the dead
29
+ * path. Only a line-leading `//` is stripped, so an `https://` inside a string
30
+ * survives.
31
+ */
32
+ export function stripComments(src) {
33
+ return src
34
+ .split('\n')
35
+ .map((line) => (/^\s*\/\//.test(line) ? '' : line))
36
+ .join('\n');
37
+ }
38
+
39
+ /** reads the text between quotes, tracking `${...}` nesting inside a template */
40
+ function readQuoted(rest, keyPattern) {
41
+ const quote = rest.match(keyPattern);
42
+ if (!quote) return null;
43
+ const q = quote[quote.length - 1];
44
+ let i = quote.index + quote[0].length;
45
+ let out = '';
46
+ let depth = 0;
47
+ while (i < rest.length) {
48
+ const c = rest[i];
49
+ if (c === '\\') {
50
+ out += rest[i] + rest[i + 1];
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (q === '`' && c === '$' && rest[i + 1] === '{') depth++;
55
+ if (depth > 0 && c === '}') depth--;
56
+ if (c === q && depth === 0) return out;
57
+ out += c;
58
+ i++;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /* ------------------------------------------------- preset: service-object */
64
+
65
+ const SERVICE_VERBS = {
66
+ get: 'GET', get2: 'GET', getRequest: 'GET',
67
+ post: 'POST', postRequest: 'POST',
68
+ put: 'PUT', patch: 'PATCH',
69
+ delete: 'DELETE', deletee: 'DELETE', del: 'DELETE', destroy: 'DELETE',
70
+ };
71
+
72
+ /**
73
+ * One exported function per endpoint, building a request object:
74
+ *
75
+ * export const getPatients = (params) => {
76
+ * const request = { subUrl: `/v1/doctor/patients.json`, params };
77
+ * return get(request);
78
+ * };
79
+ */
80
+ function serviceObject(src, { pathKey = 'subUrl' } = {}) {
81
+ const found = [];
82
+ const verbs = Object.keys(SERVICE_VERBS).join('|');
83
+ const callRe = new RegExp(`\\b(${verbs})\\s*\\(\\s*(request|config|options|req)\\b`);
84
+ const keyRe = new RegExp(`\\b${pathKey}\\s*:\\s*(["'\`])`);
85
+ const identRe = new RegExp(`\\b${pathKey}\\s*:\\s*([A-Za-z_$][\\w$]*)`);
86
+
87
+ // leading whitespace is allowed: an export inside a block is still an export
88
+ const starts = [...src.matchAll(/^[ \t]*export\s+(?:const|let|async\s+function|function)\s+(\w+)/gm)];
89
+ for (let i = 0; i < starts.length; i++) {
90
+ const m = starts[i];
91
+ const chunk = src.slice(m.index, starts[i + 1]?.index ?? src.length);
92
+
93
+ const call = chunk.match(callRe);
94
+ if (!call) continue;
95
+
96
+ const at = chunk.search(new RegExp(`\\b${pathKey}\\s*:`));
97
+ if (at === -1) continue;
98
+ const rest = chunk.slice(at);
99
+
100
+ let subUrl = readQuoted(rest, keyRe);
101
+ let dynamicUrl;
102
+ if (subUrl === null) {
103
+ // `subUrl: APIURL` — the caller supplies the whole URL, nothing to read
104
+ const ident = rest.match(identRe);
105
+ if (!ident) continue;
106
+ subUrl = `\${${ident[1]}}`;
107
+ dynamicUrl = true;
108
+ }
109
+
110
+ const requestObj = rest;
111
+ found.push({
112
+ name: m[1],
113
+ method: SERVICE_VERBS[call[1]],
114
+ subUrl,
115
+ dynamicUrl,
116
+ usesParams: /(^|[\s,{])params\s*[,:}]/.test(requestObj),
117
+ usesData: /(^|[\s,{])data\s*[,:}]/.test(requestObj),
118
+ absolute: /\bisOnlyURL\s*:\s*true\b/.test(chunk) || /^https?:\/\//.test(subUrl),
119
+ chunk,
120
+ index: m.index,
121
+ });
122
+ }
123
+ return found;
124
+ }
125
+
126
+ /* -------------------------------------------------- preset: axios-direct */
127
+
128
+ /** `axios.get('/users')`, `api.post(\`/users/${id}\`, body)`, `http.delete(url)` */
129
+ function axiosDirect(src) {
130
+ const found = [];
131
+ const re = /\b([A-Za-z_$][\w$]*)\.(get|post|put|patch|delete|head|options|request)\s*\(\s*(["'`])/g;
132
+ const hits = [...src.matchAll(re)];
133
+
134
+ for (let i = 0; i < hits.length; i++) {
135
+ const m = hits[i];
136
+ const rest = src.slice(m.index);
137
+ const subUrl = readQuoted(rest, /\.\w+\s*\(\s*(["'`])/);
138
+ if (subUrl === null || !subUrl.trim()) continue;
139
+ // a bare word or a full sentence is not a path
140
+ if (!/^(https?:\/\/|\/|\$\{)/.test(subUrl)) continue;
141
+
142
+ const method = m[2].toUpperCase();
143
+ const chunk = chunkFrom(src, m.index, hits[i + 1]?.index);
144
+ found.push({
145
+ name: nameNear(src, m.index) ?? `${method.toLowerCase()}${subUrl.replace(/[^\w]+/g, '_')}`,
146
+ method: method === 'REQUEST' ? 'GET' : method,
147
+ subUrl,
148
+ usesParams: /\bparams\s*:/.test(chunk),
149
+ usesData: ['POST', 'PUT', 'PATCH'].includes(method),
150
+ absolute: /^https?:\/\//.test(subUrl),
151
+ chunk,
152
+ index: m.index,
153
+ });
154
+ }
155
+ return found;
156
+ }
157
+
158
+ /* -------------------------------------------------- preset: fetch-direct */
159
+
160
+ /** `fetch('/api/users', { method: 'POST' })` */
161
+ function fetchDirect(src) {
162
+ const found = [];
163
+ const re = /\bfetch\s*\(\s*(["'`])/g;
164
+ const hits = [...src.matchAll(re)];
165
+
166
+ for (let i = 0; i < hits.length; i++) {
167
+ const m = hits[i];
168
+ const rest = src.slice(m.index);
169
+ const subUrl = readQuoted(rest, /\bfetch\s*\(\s*(["'`])/);
170
+ if (subUrl === null || !/^(https?:\/\/|\/|\$\{)/.test(subUrl)) continue;
171
+ const chunk = chunkFrom(src, m.index, hits[i + 1]?.index);
172
+ const method = (chunk.match(/method\s*:\s*["'`](\w+)["'`]/)?.[1] ?? 'GET').toUpperCase();
173
+ found.push({
174
+ name: nameNear(src, m.index) ?? `fetch${subUrl.replace(/[^\w]+/g, '_')}`,
175
+ method,
176
+ subUrl,
177
+ usesParams: false,
178
+ usesData: ['POST', 'PUT', 'PATCH'].includes(method),
179
+ absolute: /^https?:\/\//.test(subUrl),
180
+ chunk,
181
+ index: m.index,
182
+ });
183
+ }
184
+ return found;
185
+ }
186
+
187
+ /**
188
+ * The text belonging to one call, and not to the one after it.
189
+ *
190
+ * A fixed window would read `method: "POST"` out of the *next* fetch on the
191
+ * following line and label this call a POST, so the window stops at whichever
192
+ * comes first: the next call site, or a sensible cap.
193
+ */
194
+ function chunkFrom(src, index, nextIndex, cap = 400) {
195
+ return src.slice(index, Math.min(nextIndex ?? src.length, index + cap));
196
+ }
197
+
198
+ /** the nearest enclosing function or const above this point, for a readable name */
199
+ function nameNear(src, index) {
200
+ const before = src.slice(0, index);
201
+ const names = [...before.matchAll(/(?:function\s+(\w+)|(?:const|let|var)\s+(\w+)\s*=|(\w+)\s*[:(]\s*(?:async\s*)?\()/g)];
202
+ const last = names[names.length - 1];
203
+ return last ? last[1] ?? last[2] ?? last[3] : null;
204
+ }
205
+
206
+ export const PRESETS = {
207
+ 'service-object': serviceObject,
208
+ 'axios-direct': axiosDirect,
209
+ 'fetch-direct': fetchDirect,
210
+ };
211
+
212
+ export const PRESET_NAMES = Object.keys(PRESETS);