@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.
- package/README.md +30 -0
- package/dist/index.js +93 -16
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
19
|
+
"test": "npm run build && node --test"
|
|
20
20
|
},
|
|
21
21
|
"keywords": [
|
|
22
22
|
"api",
|