@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.
Files changed (3) hide show
  1. package/README.md +5 -4
  2. package/dist/index.js +86 -15
  3. 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 `normalized` matching for better replay hit rates
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 `normalized` | `exact` |
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
- const tapeKey = getTapeKey(req, matchStrategy);
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) ?? true;
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 tapeKey = getTapeKey(req, matchStrategy);
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("--match-strategy <strategy>", "Tape matching strategy: exact or normalized", "exact");
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.5.0").action((options) => {
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.5.0");
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laphilosophia/api-tape",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Record and Replay HTTP API responses for offline development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {