@laphilosophia/api-tape 1.4.0 → 1.5.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 +30 -0
  2. package/dist/index.js +93 -16
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -11,6 +11,7 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
11
11
  - **Hybrid Mode** — Replays cached tapes, falls back to upstream on cache miss
12
12
  - **Tape Management Commands** — List, inspect, clear, and prune tapes from CLI
13
13
  - **Header Redaction** — Mask sensitive response headers before writing tapes
14
+ - **JSON Body Redaction** — Redact selected JSON paths before persisting response bodies
14
15
  - **Runtime Metrics** — Periodic and shutdown stats for replay hit/miss, upstream calls, and latency
15
16
  - **Zero Config** — Works out of the box with sensible defaults
16
17
  - **Binary Safe** — Handles images, compressed responses, and any content type
@@ -18,6 +19,8 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
18
19
  - **Versioned Tape Schema** — Each tape includes `schemaVersion` for compatibility checks
19
20
  - **Match Strategies** — `exact` and `normalized` matching for better replay hit rates
20
21
 
22
+ ---
23
+
21
24
  ## Installation
22
25
 
23
26
  ```bash
@@ -30,6 +33,8 @@ Or use it directly with npx:
30
33
  npx api-tape --target "https://api.example.com" --mode record
31
34
  ```
32
35
 
36
+ ---
37
+
33
38
  ## Quick Start
34
39
 
35
40
  ### Step 1: Record API Responses
@@ -72,6 +77,8 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
72
77
  - If tape is missing → upstream request is proxied.
73
78
  - With `--record-on-miss true`, miss responses are automatically saved as new tapes.
74
79
 
80
+ ---
81
+
75
82
  ## CLI Options
76
83
 
77
84
  ### Serve command
@@ -86,6 +93,9 @@ Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve -
86
93
  | `-d, --dir <path>` | Directory to save tapes | `./tapes` |
87
94
  | `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
88
95
  | `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | — |
96
+ | `--redact-json-path <paths>` | Comma-separated JSON paths to redact in JSON response bodies | — |
97
+ | `--stats-interval <seconds>` | Emit runtime metrics every N seconds (`0` disables) | `0` |
98
+ | `--stats-json` | Emit metrics as JSON lines | `false` |
89
99
  | `--match-strategy <strategy>` | Tape matching strategy: `exact` or `normalized` | `exact` |
90
100
 
91
101
  ### Runtime stats
@@ -102,6 +112,16 @@ tape serve --target "https://jsonplaceholder.typicode.com" --stats-interval 10 -
102
112
 
103
113
  On shutdown, API Tape always prints a final summary (`FINAL_STATS`).
104
114
 
115
+ ### Redaction options
116
+
117
+ ```bash
118
+ tape serve --target "https://api.example.com" --mode record \
119
+ --redact-header authorization,cookie \
120
+ --redact-json-path user.profile.email,token
121
+ ```
122
+
123
+ `--redact-json-path` applies only when response `content-type` is JSON.
124
+
105
125
  ### Match strategy
106
126
 
107
127
  - `exact` (default): hashes `METHOD|URL` as-is.
@@ -116,6 +136,8 @@ tape tape clear --yes --dir ./tapes
116
136
  tape tape prune --older-than 30 --dir ./tapes
117
137
  ```
118
138
 
139
+ ---
140
+
119
141
  ## Tape Format
120
142
 
121
143
  Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
@@ -137,6 +159,8 @@ Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
137
159
 
138
160
  The body is base64-encoded for binary safety.
139
161
 
162
+ ---
163
+
140
164
  ## Development
141
165
 
142
166
  ```bash
@@ -144,10 +168,14 @@ npm run build
144
168
  npm test
145
169
  ```
146
170
 
171
+ ---
172
+
147
173
  ## CI
148
174
 
149
175
  A GitHub Actions workflow runs `npm test` on both Linux and Windows for pushes and pull requests.
150
176
 
177
+ ---
178
+
151
179
  ## Use Cases
152
180
 
153
181
  - **Offline Development** — Work without internet or VPN
@@ -155,6 +183,8 @@ A GitHub Actions workflow runs `npm test` on both Linux and Windows for pushes a
155
183
  - **Demo Environments** — Reproducible API responses for presentations
156
184
  - **Rate Limit Bypass** — Develop against recorded responses
157
185
 
186
+ ---
187
+
158
188
  ## License
159
189
 
160
190
  MIT © [Erdem Arslan](https://github.com/laphilosophia)
package/dist/index.js CHANGED
@@ -112,27 +112,89 @@ var redactHeaders = (headers, redactedHeaders) => {
112
112
  });
113
113
  return nextHeaders;
114
114
  };
115
+ var readHeaderValue = (headerValue) => {
116
+ if (Array.isArray(headerValue)) {
117
+ return headerValue.join(",");
118
+ }
119
+ return headerValue || "";
120
+ };
121
+ var redactJsonAtPath = (target, pathExpression) => {
122
+ const segments = pathExpression.split(".").map((segment) => segment.trim()).filter(Boolean);
123
+ if (segments.length === 0 || target === null || typeof target !== "object") {
124
+ return false;
125
+ }
126
+ let cursor = target;
127
+ for (let index = 0; index < segments.length - 1; index += 1) {
128
+ const key = segments[index];
129
+ if (cursor === null || typeof cursor !== "object" || !(key in cursor)) {
130
+ return false;
131
+ }
132
+ cursor = cursor[key];
133
+ }
134
+ const leaf = segments[segments.length - 1];
135
+ if (cursor !== null && typeof cursor === "object" && leaf in cursor) {
136
+ cursor[leaf] = "[REDACTED]";
137
+ return true;
138
+ }
139
+ return false;
140
+ };
141
+ var redactJsonBody = (bodyBuffer, contentTypeHeader, redactedJsonPaths) => {
142
+ if (redactedJsonPaths.length === 0) {
143
+ return { bodyBuffer, appliedJsonPaths: [] };
144
+ }
145
+ const contentType = readHeaderValue(contentTypeHeader).toLowerCase();
146
+ if (!contentType.includes("application/json")) {
147
+ return { bodyBuffer, appliedJsonPaths: [] };
148
+ }
149
+ try {
150
+ const parsed = JSON.parse(bodyBuffer.toString("utf8"));
151
+ const appliedJsonPaths = redactedJsonPaths.filter(
152
+ (jsonPath) => redactJsonAtPath(parsed, jsonPath)
153
+ );
154
+ if (appliedJsonPaths.length === 0) {
155
+ return { bodyBuffer, appliedJsonPaths: [] };
156
+ }
157
+ return {
158
+ bodyBuffer: Buffer.from(JSON.stringify(parsed)),
159
+ appliedJsonPaths
160
+ };
161
+ } catch {
162
+ return { bodyBuffer, appliedJsonPaths: [] };
163
+ }
164
+ };
115
165
  var ensureSchemaCompatibility = (record, tapePath) => {
116
166
  const schemaVersion = record.schemaVersion ?? 0;
117
167
  if (![0, CURRENT_SCHEMA_VERSION].includes(schemaVersion)) {
118
168
  throw new Error(`Unsupported tape schema version at ${tapePath}: ${schemaVersion}`);
119
169
  }
120
170
  };
121
- var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders, matchStrategy) => ({
122
- schemaVersion: CURRENT_SCHEMA_VERSION,
123
- meta: {
124
- url: req.url,
125
- method: req.method,
126
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
127
- matchStrategy
128
- },
129
- statusCode: proxyRes.statusCode || 200,
130
- headers: redactHeaders(
171
+ var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders, redactedJsonPaths, matchStrategy) => {
172
+ const tapeHeaders = redactHeaders(
131
173
  proxyRes.headers,
132
174
  redactedHeaders
133
- ),
134
- body: bodyBuffer.toString("base64")
135
- });
175
+ );
176
+ const { bodyBuffer: redactedBodyBuffer, appliedJsonPaths } = redactJsonBody(
177
+ bodyBuffer,
178
+ proxyRes.headers["content-type"],
179
+ redactedJsonPaths
180
+ );
181
+ return {
182
+ schemaVersion: CURRENT_SCHEMA_VERSION,
183
+ meta: {
184
+ url: req.url,
185
+ method: req.method,
186
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
187
+ matchStrategy,
188
+ redactionsApplied: {
189
+ headers: redactedHeaders,
190
+ jsonPaths: appliedJsonPaths
191
+ }
192
+ },
193
+ statusCode: proxyRes.statusCode || 200,
194
+ headers: tapeHeaders,
195
+ body: redactedBodyBuffer.toString("base64")
196
+ };
197
+ };
136
198
  var buildMetricsSnapshot = (metrics) => ({
137
199
  totalRequests: metrics.totalRequests,
138
200
  replayHits: metrics.replayHits,
@@ -159,6 +221,7 @@ var runServe = (opts) => {
159
221
  const tapesDir = import_path.default.resolve(opts.dir);
160
222
  const recordOnMiss = opts.recordOnMiss;
161
223
  const redactedHeaders = parseCsv(opts.redactHeader);
224
+ const redactedJsonPaths = parseCsv(opts.redactJsonPath);
162
225
  const statsIntervalSec = parseNonNegativeInt(opts.statsInterval, "stats-interval");
163
226
  const statsJson = opts.statsJson;
164
227
  const matchStrategy = opts.matchStrategy;
@@ -273,7 +336,14 @@ var runServe = (opts) => {
273
336
  const bodyBuffer = Buffer.concat(bodyChunks);
274
337
  const shouldRecord = recordByRequest.get(req) ?? true;
275
338
  if (shouldRecord) {
276
- const tapeData = createTapeRecord(req, proxyRes, bodyBuffer, redactedHeaders, matchStrategy);
339
+ const tapeData = createTapeRecord(
340
+ req,
341
+ proxyRes,
342
+ bodyBuffer,
343
+ redactedHeaders,
344
+ redactedJsonPaths,
345
+ matchStrategy
346
+ );
277
347
  const tapeKey = getTapeKey(req, matchStrategy);
278
348
  const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
279
349
  import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
@@ -321,6 +391,9 @@ var runServe = (opts) => {
321
391
  if (redactedHeaders.length > 0) {
322
392
  console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
323
393
  }
394
+ if (redactedJsonPaths.length > 0) {
395
+ console.log(` ${import_chalk2.default.dim("Redact JSON Paths:")} ${redactedJsonPaths.join(", ")}`);
396
+ }
324
397
  if (statsIntervalSec > 0) {
325
398
  console.log(` ${import_chalk2.default.dim("Stats Every:")} ${statsIntervalSec}s`);
326
399
  }
@@ -405,19 +478,23 @@ var addServeOptions = (command) => command.requiredOption("-t, --target <url>",
405
478
  "--redact-header <headers>",
406
479
  "Comma-separated response header names to redact before saving",
407
480
  ""
481
+ ).option(
482
+ "--redact-json-path <paths>",
483
+ "Comma-separated JSON paths to redact in JSON response bodies",
484
+ ""
408
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");
409
486
  var run = () => {
410
487
  const argv = process.argv;
411
488
  const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
412
489
  if (!hasSubCommand) {
413
- const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0").action((options) => {
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) => {
414
491
  runServe(options);
415
492
  });
416
493
  legacy.parse(argv);
417
494
  return;
418
495
  }
419
496
  const program = new import_commander.Command();
420
- program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0");
497
+ program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.5.0");
421
498
  addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
422
499
  (options) => {
423
500
  runServe(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laphilosophia/api-tape",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Record and Replay HTTP API responses for offline development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsup src/index.ts --format cjs --clean",
18
18
  "prepublishOnly": "npm run build",
19
- "test": "npm run build && node --test test/**/*.test.cjs"
19
+ "test": "npm run build && node --test"
20
20
  },
21
21
  "keywords": [
22
22
  "api",