@laphilosophia/api-tape 1.5.0 → 1.6.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.
- package/README.md +5 -4
- package/dist/index.js +86 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,20 +17,20 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
|
|
|
17
17
|
- **Binary Safe** — Handles images, compressed responses, and any content type
|
|
18
18
|
- **Replay Header** — Responses include `X-Api-Tape: Replayed` for easy debugging
|
|
19
19
|
- **Versioned Tape Schema** — Each tape includes `schemaVersion` for compatibility checks
|
|
20
|
-
- **Match Strategies** — `exact` and `
|
|
20
|
+
- **Match Strategies** — `exact`, `normalized`, and `body-aware` matching for better replay hit rates
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
## Installation
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
npm install -g api-tape
|
|
27
|
+
npm install -g @laphilosophia/api-tape
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Or use it directly with npx:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npx api-tape --target "https://api.example.com" --mode record
|
|
33
|
+
npx @laphilosophia/api-tape --target "https://api.example.com" --mode record
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
---
|
|
@@ -96,7 +96,7 @@ Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve -
|
|
|
96
96
|
| `--redact-json-path <paths>` | Comma-separated JSON paths to redact in JSON response bodies | — |
|
|
97
97
|
| `--stats-interval <seconds>` | Emit runtime metrics every N seconds (`0` disables) | `0` |
|
|
98
98
|
| `--stats-json` | Emit metrics as JSON lines | `false` |
|
|
99
|
-
| `--match-strategy <strategy>` | Tape matching strategy: `exact` or `
|
|
99
|
+
| `--match-strategy <strategy>` | Tape matching strategy: `exact`, `normalized`, or `body-aware` | `exact` |
|
|
100
100
|
|
|
101
101
|
### Runtime stats
|
|
102
102
|
|
|
@@ -126,6 +126,7 @@ tape serve --target "https://api.example.com" --mode record \
|
|
|
126
126
|
|
|
127
127
|
- `exact` (default): hashes `METHOD|URL` as-is.
|
|
128
128
|
- `normalized`: sorts query params before hashing, so `/search?a=1&b=2` and `/search?b=2&a=1` map to the same tape.
|
|
129
|
+
- `body-aware`: uses normalized URL plus request body signature (JSON canonicalized when possible), useful for POST/PUT APIs sharing paths.
|
|
129
130
|
|
|
130
131
|
### Tape management commands
|
|
131
132
|
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ var import_fs_extra = __toESM(require("fs-extra"));
|
|
|
31
31
|
var import_http = __toESM(require("http"));
|
|
32
32
|
var import_http_proxy = __toESM(require("http-proxy"));
|
|
33
33
|
var import_path = __toESM(require("path"));
|
|
34
|
+
var import_stream = require("stream");
|
|
34
35
|
|
|
35
36
|
// src/constants.ts
|
|
36
37
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
@@ -63,8 +64,56 @@ var parseNonNegativeInt = (value, label) => {
|
|
|
63
64
|
return parsed;
|
|
64
65
|
};
|
|
65
66
|
var parseCsv = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
67
|
+
var stableStringify = (value) => {
|
|
68
|
+
if (value === null || typeof value !== "object") {
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
73
|
+
}
|
|
74
|
+
const obj = value;
|
|
75
|
+
const keys = Object.keys(obj).sort();
|
|
76
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
|
|
77
|
+
};
|
|
66
78
|
|
|
67
79
|
// src/index.ts
|
|
80
|
+
var getRequestBodySignature = (req, bodyBuffer) => {
|
|
81
|
+
if (bodyBuffer.length === 0) {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
const contentType = readHeaderValue(req.headers["content-type"]).toLowerCase();
|
|
85
|
+
if (contentType.includes("application/json")) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(bodyBuffer.toString("utf8"));
|
|
88
|
+
return stableStringify(parsed);
|
|
89
|
+
} catch {
|
|
90
|
+
return bodyBuffer.toString("base64");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return bodyBuffer.toString("base64");
|
|
94
|
+
};
|
|
95
|
+
var collectRequestBody = (req) => new Promise((resolve, reject) => {
|
|
96
|
+
if (!req.readable || req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS") {
|
|
97
|
+
resolve(Buffer.alloc(0));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const chunks = [];
|
|
101
|
+
req.on("data", (chunk) => {
|
|
102
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
103
|
+
});
|
|
104
|
+
req.on("end", () => {
|
|
105
|
+
resolve(Buffer.concat(chunks));
|
|
106
|
+
});
|
|
107
|
+
req.on("error", reject);
|
|
108
|
+
});
|
|
109
|
+
var createProxyBuffer = (requestBody) => {
|
|
110
|
+
if (requestBody.length === 0) {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
const stream = new import_stream.PassThrough();
|
|
114
|
+
stream.end(requestBody);
|
|
115
|
+
return stream;
|
|
116
|
+
};
|
|
68
117
|
var normalizeUrl = (rawUrl) => {
|
|
69
118
|
if (!rawUrl) {
|
|
70
119
|
return "/";
|
|
@@ -87,15 +136,18 @@ var normalizeUrl = (rawUrl) => {
|
|
|
87
136
|
const normalizedQuery = normalized.toString();
|
|
88
137
|
return normalizedQuery ? `${pathname || "/"}?${normalizedQuery}` : pathname || "/";
|
|
89
138
|
};
|
|
90
|
-
var buildRequestSignature = (req, matchStrategy) => {
|
|
139
|
+
var buildRequestSignature = (req, matchStrategy, requestBody) => {
|
|
91
140
|
const method = req.method || "GET";
|
|
92
141
|
if (matchStrategy === "normalized") {
|
|
93
142
|
return `${method}|${normalizeUrl(req.url)}`;
|
|
94
143
|
}
|
|
144
|
+
if (matchStrategy === "body-aware") {
|
|
145
|
+
return `${method}|${normalizeUrl(req.url)}|${getRequestBodySignature(req, requestBody)}`;
|
|
146
|
+
}
|
|
95
147
|
return `${method}|${req.url}`;
|
|
96
148
|
};
|
|
97
|
-
var getTapeKey = (req, matchStrategy) => {
|
|
98
|
-
const key = buildRequestSignature(req, matchStrategy);
|
|
149
|
+
var getTapeKey = (req, matchStrategy, requestBody) => {
|
|
150
|
+
const key = buildRequestSignature(req, matchStrategy, requestBody);
|
|
99
151
|
return import_crypto.default.createHash("md5").update(key).digest("hex");
|
|
100
152
|
};
|
|
101
153
|
var readTape = (tapePath) => import_fs_extra.default.readJsonSync(tapePath);
|
|
@@ -229,7 +281,7 @@ var runServe = (opts) => {
|
|
|
229
281
|
if (!validModes.includes(mode)) {
|
|
230
282
|
throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
|
|
231
283
|
}
|
|
232
|
-
const validMatchStrategies = ["exact", "normalized"];
|
|
284
|
+
const validMatchStrategies = ["exact", "normalized", "body-aware"];
|
|
233
285
|
if (!validMatchStrategies.includes(matchStrategy)) {
|
|
234
286
|
throw new Error(
|
|
235
287
|
`Invalid match strategy: ${matchStrategy}. Expected one of: ${validMatchStrategies.join(", ")}`
|
|
@@ -242,6 +294,7 @@ var runServe = (opts) => {
|
|
|
242
294
|
selfHandleResponse: true
|
|
243
295
|
});
|
|
244
296
|
const recordByRequest = /* @__PURE__ */ new WeakMap();
|
|
297
|
+
const requestBodyByRequest = /* @__PURE__ */ new WeakMap();
|
|
245
298
|
const requestStartByResponse = /* @__PURE__ */ new WeakMap();
|
|
246
299
|
const metrics = {
|
|
247
300
|
totalRequests: 0,
|
|
@@ -279,18 +332,18 @@ var runServe = (opts) => {
|
|
|
279
332
|
return true;
|
|
280
333
|
}
|
|
281
334
|
};
|
|
282
|
-
const proxyRequest = (req, res, shouldRecord, logPrefix) => {
|
|
335
|
+
const proxyRequest = (req, res, shouldRecord, logPrefix, requestBody) => {
|
|
283
336
|
recordByRequest.set(req, shouldRecord);
|
|
284
337
|
metrics.upstreamRequests += 1;
|
|
285
338
|
console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
|
|
286
|
-
proxy.web(req, res, {}, (error) => {
|
|
339
|
+
proxy.web(req, res, { buffer: createProxyBuffer(requestBody) }, (error) => {
|
|
287
340
|
metrics.upstreamErrors += 1;
|
|
288
341
|
console.error(import_chalk2.default.red("Proxy Error:"), error.message);
|
|
289
342
|
res.statusCode = 502;
|
|
290
343
|
res.end("Proxy Error");
|
|
291
344
|
});
|
|
292
345
|
};
|
|
293
|
-
const server = import_http.default.createServer((req, res) => {
|
|
346
|
+
const server = import_http.default.createServer(async (req, res) => {
|
|
294
347
|
metrics.totalRequests += 1;
|
|
295
348
|
requestStartByResponse.set(res, Date.now());
|
|
296
349
|
res.on("finish", () => {
|
|
@@ -300,7 +353,18 @@ var runServe = (opts) => {
|
|
|
300
353
|
metrics.completedResponses += 1;
|
|
301
354
|
}
|
|
302
355
|
});
|
|
303
|
-
|
|
356
|
+
let requestBody = Buffer.alloc(0);
|
|
357
|
+
if (matchStrategy === "body-aware") {
|
|
358
|
+
try {
|
|
359
|
+
requestBody = await collectRequestBody(req);
|
|
360
|
+
} catch {
|
|
361
|
+
res.statusCode = 400;
|
|
362
|
+
res.end("Unable to read request body");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
requestBodyByRequest.set(req, requestBody);
|
|
367
|
+
const tapeKey = getTapeKey(req, matchStrategy, requestBody);
|
|
304
368
|
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
305
369
|
if (mode === "replay") {
|
|
306
370
|
const hit2 = replayTape(req, res, tapePath);
|
|
@@ -313,7 +377,7 @@ var runServe = (opts) => {
|
|
|
313
377
|
return;
|
|
314
378
|
}
|
|
315
379
|
if (mode === "record") {
|
|
316
|
-
proxyRequest(req, res, true, import_chalk2.default.blue("\u25CF RECORD"));
|
|
380
|
+
proxyRequest(req, res, true, import_chalk2.default.blue("\u25CF RECORD"), requestBody);
|
|
317
381
|
return;
|
|
318
382
|
}
|
|
319
383
|
const hit = replayTape(req, res, tapePath);
|
|
@@ -326,7 +390,8 @@ var runServe = (opts) => {
|
|
|
326
390
|
req,
|
|
327
391
|
res,
|
|
328
392
|
recordOnMiss,
|
|
329
|
-
recordOnMiss ? import_chalk2.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk2.default.magenta("\u21E2 FALLBACK_PROXY")
|
|
393
|
+
recordOnMiss ? import_chalk2.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk2.default.magenta("\u21E2 FALLBACK_PROXY"),
|
|
394
|
+
requestBody
|
|
330
395
|
);
|
|
331
396
|
});
|
|
332
397
|
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
@@ -334,7 +399,7 @@ var runServe = (opts) => {
|
|
|
334
399
|
proxyRes.on("data", (chunk) => bodyChunks.push(chunk));
|
|
335
400
|
proxyRes.on("end", () => {
|
|
336
401
|
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
337
|
-
const shouldRecord = recordByRequest.get(req) ??
|
|
402
|
+
const shouldRecord = recordByRequest.get(req) ?? (mode === "record" || mode === "hybrid" && recordOnMiss);
|
|
338
403
|
if (shouldRecord) {
|
|
339
404
|
const tapeData = createTapeRecord(
|
|
340
405
|
req,
|
|
@@ -344,7 +409,8 @@ var runServe = (opts) => {
|
|
|
344
409
|
redactedJsonPaths,
|
|
345
410
|
matchStrategy
|
|
346
411
|
);
|
|
347
|
-
const
|
|
412
|
+
const requestBody = requestBodyByRequest.get(req) || Buffer.alloc(0);
|
|
413
|
+
const tapeKey = getTapeKey(req, matchStrategy, requestBody);
|
|
348
414
|
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
349
415
|
import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
|
|
350
416
|
console.log(`${timestamp()} ${import_chalk2.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
|
|
@@ -377,6 +443,7 @@ var runServe = (opts) => {
|
|
|
377
443
|
};
|
|
378
444
|
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
379
445
|
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
446
|
+
process.once("SIGBREAK", () => shutdown("SIGBREAK"));
|
|
380
447
|
console.log(import_chalk2.default.bold(`
|
|
381
448
|
\u{1F4FC} API Tape Running`));
|
|
382
449
|
console.log(
|
|
@@ -482,19 +549,23 @@ var addServeOptions = (command) => command.requiredOption("-t, --target <url>",
|
|
|
482
549
|
"--redact-json-path <paths>",
|
|
483
550
|
"Comma-separated JSON paths to redact in JSON response bodies",
|
|
484
551
|
""
|
|
485
|
-
).option("--stats-interval <seconds>", "Emit runtime stats every N seconds (0 disables)", "0").option("--stats-json", "Emit stats in JSON format", false).option(
|
|
552
|
+
).option("--stats-interval <seconds>", "Emit runtime stats every N seconds (0 disables)", "0").option("--stats-json", "Emit stats in JSON format", false).option(
|
|
553
|
+
"--match-strategy <strategy>",
|
|
554
|
+
"Tape matching strategy: exact, normalized, or body-aware",
|
|
555
|
+
"exact"
|
|
556
|
+
);
|
|
486
557
|
var run = () => {
|
|
487
558
|
const argv = process.argv;
|
|
488
559
|
const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
|
|
489
560
|
if (!hasSubCommand) {
|
|
490
|
-
const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
561
|
+
const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.6.0").action((options) => {
|
|
491
562
|
runServe(options);
|
|
492
563
|
});
|
|
493
564
|
legacy.parse(argv);
|
|
494
565
|
return;
|
|
495
566
|
}
|
|
496
567
|
const program = new import_commander.Command();
|
|
497
|
-
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
568
|
+
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.6.0");
|
|
498
569
|
addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
|
|
499
570
|
(options) => {
|
|
500
571
|
runServe(options);
|