@laphilosophia/api-tape 1.2.0 β 1.3.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 +33 -18
- package/dist/index.js +82 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# API Tape
|
|
2
2
|
|
|
3
3
|
**Record and Replay HTTP API responses for offline development.**
|
|
4
4
|
|
|
5
5
|
API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It records API responses to local JSON files ("tapes") and replays them instantlyβperfect for offline development, flaky API testing, and reproducible demos.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
9
|
+
- **Record Mode** β Proxies requests to your target API and saves responses
|
|
10
|
+
- **Replay Mode** β Serves cached responses instantly from disk
|
|
11
|
+
- **Hybrid Mode** β Replays cached tapes, falls back to upstream on cache miss
|
|
12
|
+
- **Tape Management Commands** β List, inspect, clear, and prune tapes from CLI
|
|
13
|
+
- **Header Redaction** β Mask sensitive response headers before writing tapes
|
|
14
|
+
- **Runtime Metrics** β Periodic and shutdown stats for replay hit/miss, upstream calls, and latency
|
|
15
|
+
- **Zero Config** β Works out of the box with sensible defaults
|
|
16
|
+
- **Binary Safe** β Handles images, compressed responses, and any content type
|
|
17
|
+
- **Replay Header** β Responses include `X-Api-Tape: Replayed` for easy debugging
|
|
18
|
+
- **Versioned Tape Schema** β Each tape includes `schemaVersion` for compatibility checks
|
|
18
19
|
|
|
19
|
-
##
|
|
20
|
+
## Installation
|
|
20
21
|
|
|
21
22
|
```bash
|
|
22
23
|
npm install -g api-tape
|
|
@@ -28,7 +29,7 @@ Or use it directly with npx:
|
|
|
28
29
|
npx api-tape --target "https://api.example.com" --mode record
|
|
29
30
|
```
|
|
30
31
|
|
|
31
|
-
##
|
|
32
|
+
## Quick Start
|
|
32
33
|
|
|
33
34
|
### Step 1: Record API Responses
|
|
34
35
|
|
|
@@ -70,7 +71,7 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
|
|
|
70
71
|
- If tape is missing β upstream request is proxied.
|
|
71
72
|
- With `--record-on-miss true`, miss responses are automatically saved as new tapes.
|
|
72
73
|
|
|
73
|
-
##
|
|
74
|
+
## CLI Options
|
|
74
75
|
|
|
75
76
|
### Serve command
|
|
76
77
|
|
|
@@ -85,6 +86,20 @@ Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve -
|
|
|
85
86
|
| `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
|
|
86
87
|
| `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | β |
|
|
87
88
|
|
|
89
|
+
### Runtime stats
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
tape serve --target "https://jsonplaceholder.typicode.com" --mode hybrid --stats-interval 10
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
For machine-readable output:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
tape serve --target "https://jsonplaceholder.typicode.com" --stats-interval 10 --stats-json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
On shutdown, API Tape always prints a final summary (`FINAL_STATS`).
|
|
102
|
+
|
|
88
103
|
### Tape management commands
|
|
89
104
|
|
|
90
105
|
```bash
|
|
@@ -94,7 +109,7 @@ tape tape clear --yes --dir ./tapes
|
|
|
94
109
|
tape tape prune --older-than 30 --dir ./tapes
|
|
95
110
|
```
|
|
96
111
|
|
|
97
|
-
##
|
|
112
|
+
## Tape Format
|
|
98
113
|
|
|
99
114
|
Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
|
|
100
115
|
|
|
@@ -114,20 +129,20 @@ Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
|
|
|
114
129
|
|
|
115
130
|
The body is base64-encoded for binary safety.
|
|
116
131
|
|
|
117
|
-
##
|
|
132
|
+
## Development
|
|
118
133
|
|
|
119
134
|
```bash
|
|
120
135
|
npm run build
|
|
121
136
|
npm test
|
|
122
137
|
```
|
|
123
138
|
|
|
124
|
-
##
|
|
139
|
+
## Use Cases
|
|
125
140
|
|
|
126
141
|
- **Offline Development** β Work without internet or VPN
|
|
127
142
|
- **Flaky API Testing** β Eliminate network inconsistencies in tests
|
|
128
143
|
- **Demo Environments** β Reproducible API responses for presentations
|
|
129
144
|
- **Rate Limit Bypass** β Develop against recorded responses
|
|
130
145
|
|
|
131
|
-
##
|
|
146
|
+
## License
|
|
132
147
|
|
|
133
148
|
MIT Β© [Erdem Arslan](https://github.com/laphilosophia)
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,13 @@ var parsePositiveInt = (value, label) => {
|
|
|
55
55
|
}
|
|
56
56
|
return parsed;
|
|
57
57
|
};
|
|
58
|
+
var parseNonNegativeInt = (value, label) => {
|
|
59
|
+
const parsed = parseInt(value, 10);
|
|
60
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
61
|
+
throw new Error(`${label} must be a non-negative integer.`);
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
};
|
|
58
65
|
var parseCsv = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
59
66
|
|
|
60
67
|
// src/index.ts
|
|
@@ -96,6 +103,25 @@ var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
|
|
|
96
103
|
),
|
|
97
104
|
body: bodyBuffer.toString("base64")
|
|
98
105
|
});
|
|
106
|
+
var buildMetricsSnapshot = (metrics) => ({
|
|
107
|
+
totalRequests: metrics.totalRequests,
|
|
108
|
+
replayHits: metrics.replayHits,
|
|
109
|
+
replayMisses: metrics.replayMisses,
|
|
110
|
+
upstreamRequests: metrics.upstreamRequests,
|
|
111
|
+
upstreamErrors: metrics.upstreamErrors,
|
|
112
|
+
completedResponses: metrics.completedResponses,
|
|
113
|
+
averageLatencyMs: metrics.completedResponses === 0 ? 0 : Number((metrics.totalLatencyMs / metrics.completedResponses).toFixed(2))
|
|
114
|
+
});
|
|
115
|
+
var printMetrics = (metrics, asJson, prefix = "STATS") => {
|
|
116
|
+
const snapshot = buildMetricsSnapshot(metrics);
|
|
117
|
+
if (asJson) {
|
|
118
|
+
console.log(JSON.stringify({ event: prefix, ...snapshot }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
console.log(
|
|
122
|
+
`${timestamp()} ${import_chalk2.default.cyan(prefix)} total=${snapshot.totalRequests} replay_hit=${snapshot.replayHits} replay_miss=${snapshot.replayMisses} upstream=${snapshot.upstreamRequests} upstream_errors=${snapshot.upstreamErrors} avg_latency_ms=${snapshot.averageLatencyMs}`
|
|
123
|
+
);
|
|
124
|
+
};
|
|
99
125
|
var runServe = (opts) => {
|
|
100
126
|
const targetUrl = opts.target;
|
|
101
127
|
const port = parsePositiveInt(opts.port, "Port");
|
|
@@ -103,6 +129,8 @@ var runServe = (opts) => {
|
|
|
103
129
|
const tapesDir = import_path.default.resolve(opts.dir);
|
|
104
130
|
const recordOnMiss = opts.recordOnMiss;
|
|
105
131
|
const redactedHeaders = parseCsv(opts.redactHeader);
|
|
132
|
+
const statsIntervalSec = parseNonNegativeInt(opts.statsInterval, "stats-interval");
|
|
133
|
+
const statsJson = opts.statsJson;
|
|
106
134
|
const validModes = ["record", "replay", "hybrid"];
|
|
107
135
|
if (!validModes.includes(mode)) {
|
|
108
136
|
throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
|
|
@@ -114,6 +142,16 @@ var runServe = (opts) => {
|
|
|
114
142
|
selfHandleResponse: true
|
|
115
143
|
});
|
|
116
144
|
const recordByRequest = /* @__PURE__ */ new WeakMap();
|
|
145
|
+
const requestStartByResponse = /* @__PURE__ */ new WeakMap();
|
|
146
|
+
const metrics = {
|
|
147
|
+
totalRequests: 0,
|
|
148
|
+
replayHits: 0,
|
|
149
|
+
replayMisses: 0,
|
|
150
|
+
upstreamRequests: 0,
|
|
151
|
+
upstreamErrors: 0,
|
|
152
|
+
totalLatencyMs: 0,
|
|
153
|
+
completedResponses: 0
|
|
154
|
+
};
|
|
117
155
|
const replayTape = (req, res, tapePath) => {
|
|
118
156
|
if (!import_fs_extra.default.existsSync(tapePath)) {
|
|
119
157
|
return false;
|
|
@@ -127,6 +165,7 @@ var runServe = (opts) => {
|
|
|
127
165
|
res.setHeader("X-Api-Tape", "Replayed");
|
|
128
166
|
res.writeHead(tape.statusCode);
|
|
129
167
|
res.end(Buffer.from(tape.body, "base64"));
|
|
168
|
+
metrics.replayHits += 1;
|
|
130
169
|
console.log(`${timestamp()} ${import_chalk2.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
|
|
131
170
|
return true;
|
|
132
171
|
} catch (error) {
|
|
@@ -142,19 +181,31 @@ var runServe = (opts) => {
|
|
|
142
181
|
};
|
|
143
182
|
const proxyRequest = (req, res, shouldRecord, logPrefix) => {
|
|
144
183
|
recordByRequest.set(req, shouldRecord);
|
|
184
|
+
metrics.upstreamRequests += 1;
|
|
145
185
|
console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
|
|
146
186
|
proxy.web(req, res, {}, (error) => {
|
|
187
|
+
metrics.upstreamErrors += 1;
|
|
147
188
|
console.error(import_chalk2.default.red("Proxy Error:"), error.message);
|
|
148
189
|
res.statusCode = 502;
|
|
149
190
|
res.end("Proxy Error");
|
|
150
191
|
});
|
|
151
192
|
};
|
|
152
193
|
const server = import_http.default.createServer((req, res) => {
|
|
194
|
+
metrics.totalRequests += 1;
|
|
195
|
+
requestStartByResponse.set(res, Date.now());
|
|
196
|
+
res.on("finish", () => {
|
|
197
|
+
const startedAt = requestStartByResponse.get(res);
|
|
198
|
+
if (typeof startedAt === "number") {
|
|
199
|
+
metrics.totalLatencyMs += Date.now() - startedAt;
|
|
200
|
+
metrics.completedResponses += 1;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
153
203
|
const tapeKey = getTapeKey(req);
|
|
154
204
|
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
155
205
|
if (mode === "replay") {
|
|
156
206
|
const hit2 = replayTape(req, res, tapePath);
|
|
157
207
|
if (!hit2) {
|
|
208
|
+
metrics.replayMisses += 1;
|
|
158
209
|
console.log(`${timestamp()} ${import_chalk2.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
159
210
|
res.statusCode = 404;
|
|
160
211
|
res.end(`Tape not found for: ${req.method} ${req.url}`);
|
|
@@ -169,6 +220,7 @@ var runServe = (opts) => {
|
|
|
169
220
|
if (hit) {
|
|
170
221
|
return;
|
|
171
222
|
}
|
|
223
|
+
metrics.replayMisses += 1;
|
|
172
224
|
console.log(`${timestamp()} ${import_chalk2.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
173
225
|
proxyRequest(
|
|
174
226
|
req,
|
|
@@ -197,6 +249,27 @@ var runServe = (opts) => {
|
|
|
197
249
|
res.end(bodyBuffer);
|
|
198
250
|
});
|
|
199
251
|
});
|
|
252
|
+
let metricsTimer;
|
|
253
|
+
if (statsIntervalSec > 0) {
|
|
254
|
+
metricsTimer = setInterval(() => {
|
|
255
|
+
printMetrics(metrics, statsJson);
|
|
256
|
+
}, statsIntervalSec * 1e3);
|
|
257
|
+
}
|
|
258
|
+
const shutdown = (signal) => {
|
|
259
|
+
if (metricsTimer) {
|
|
260
|
+
clearInterval(metricsTimer);
|
|
261
|
+
metricsTimer = void 0;
|
|
262
|
+
}
|
|
263
|
+
printMetrics(metrics, statsJson, "FINAL_STATS");
|
|
264
|
+
server.close(() => {
|
|
265
|
+
process.exit(0);
|
|
266
|
+
});
|
|
267
|
+
setTimeout(() => {
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}, 2e3).unref();
|
|
270
|
+
};
|
|
271
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
272
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
200
273
|
console.log(import_chalk2.default.bold(`
|
|
201
274
|
\u{1F4FC} API Tape Running`));
|
|
202
275
|
console.log(
|
|
@@ -211,6 +284,12 @@ var runServe = (opts) => {
|
|
|
211
284
|
if (redactedHeaders.length > 0) {
|
|
212
285
|
console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
|
|
213
286
|
}
|
|
287
|
+
if (statsIntervalSec > 0) {
|
|
288
|
+
console.log(` ${import_chalk2.default.dim("Stats Every:")} ${statsIntervalSec}s`);
|
|
289
|
+
}
|
|
290
|
+
if (statsJson) {
|
|
291
|
+
console.log(` ${import_chalk2.default.dim("Stats Format:")} json`);
|
|
292
|
+
}
|
|
214
293
|
console.log("");
|
|
215
294
|
server.listen(port);
|
|
216
295
|
};
|
|
@@ -288,19 +367,19 @@ var addServeOptions = (command) => command.requiredOption("-t, --target <url>",
|
|
|
288
367
|
"--redact-header <headers>",
|
|
289
368
|
"Comma-separated response header names to redact before saving",
|
|
290
369
|
""
|
|
291
|
-
);
|
|
370
|
+
).option("--stats-interval <seconds>", "Emit runtime stats every N seconds (0 disables)", "0").option("--stats-json", "Emit stats in JSON format", false);
|
|
292
371
|
var run = () => {
|
|
293
372
|
const argv = process.argv;
|
|
294
373
|
const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
|
|
295
374
|
if (!hasSubCommand) {
|
|
296
|
-
const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
375
|
+
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) => {
|
|
297
376
|
runServe(options);
|
|
298
377
|
});
|
|
299
378
|
legacy.parse(argv);
|
|
300
379
|
return;
|
|
301
380
|
}
|
|
302
381
|
const program = new import_commander.Command();
|
|
303
|
-
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
382
|
+
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0");
|
|
304
383
|
addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
|
|
305
384
|
(options) => {
|
|
306
385
|
runServe(options);
|