@useairfoil/effect-vcr 0.4.3 → 0.4.5
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 +15 -5
- package/dist/{cassette-store-CnXX5OLB.js → cassette-store-BF9e0rz3.js} +5 -7
- package/dist/cassette-store-BF9e0rz3.js.map +1 -0
- package/dist/{cassette-store-BunkvLoV.d.ts → cassette-store-BMtcjh5h.d.ts} +29 -5
- package/dist/cassette-store.d.ts +2 -2
- package/dist/cassette-store.js +2 -1
- package/dist/{file-system-cassette-store-DUEtpVCu.d.ts → file-system-cassette-store-CYmQT7qj.d.ts} +3 -3
- package/dist/{file-system-cassette-store-zWkrhhlO.js → file-system-cassette-store-CyOsu3z5.js} +3 -3
- package/dist/{file-system-cassette-store-zWkrhhlO.js.map → file-system-cassette-store-CyOsu3z5.js.map} +1 -1
- package/dist/file-system-cassette-store.d.ts +3 -3
- package/dist/file-system-cassette-store.js +4 -3
- package/dist/index.d.ts +5 -5
- package/dist/index.js +6 -6
- package/dist/types-DMuz_JGd.js +45 -0
- package/dist/types-DMuz_JGd.js.map +1 -0
- package/dist/types-XZehMu_v.d.ts +138 -0
- package/dist/types.d.ts +2 -2
- package/dist/types.js +2 -2
- package/dist/{vcr-http-client-BaMxQun5.js → vcr-http-client-Ba0WYPV9.js} +143 -66
- package/dist/vcr-http-client-Ba0WYPV9.js.map +1 -0
- package/dist/{vcr-http-client-Mwb8ZMsc.d.ts → vcr-http-client-zQZTK-ru.d.ts} +4 -4
- package/dist/vcr-http-client.d.ts +3 -3
- package/dist/vcr-http-client.js +4 -3
- package/dist/{vitest-state-BOynp4Va.js → vitest-state-CiW5dzXZ.js} +1 -1
- package/dist/{vitest-state-BOynp4Va.js.map → vitest-state-CiW5dzXZ.js.map} +1 -1
- package/package.json +6 -5
- package/src/cassette-store.ts +15 -11
- package/src/sanitize.ts +166 -48
- package/src/types.ts +40 -26
- package/src/vcr-http-client.ts +33 -30
- package/dist/cassette-store-CnXX5OLB.js.map +0 -1
- package/dist/types-BsuPrIpu.js +0 -1
- package/dist/types-DWwkMzxG.d.ts +0 -85
- package/dist/vcr-http-client-BaMxQun5.js.map +0 -1
package/src/sanitize.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Effect, Option } from "effect";
|
|
1
|
+
import { Effect, Option, Record } from "effect";
|
|
2
|
+
import { UrlParams } from "effect/unstable/http";
|
|
2
3
|
import stableStringify from "json-stable-stringify";
|
|
3
4
|
|
|
4
|
-
import type { VcrRequest, VcrResponse } from "./types";
|
|
5
|
+
import type { VcrRedactedValue, VcrRequest, VcrResponse } from "./types";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Normalize header names to match HttpClient's lowercase behavior.
|
|
@@ -13,15 +14,8 @@ const normalizeHeaderKey = (key: string) => key.toLowerCase();
|
|
|
13
14
|
*/
|
|
14
15
|
const toHeaderRecord = (headers?: Record<string, string>) => {
|
|
15
16
|
if (!headers) return undefined;
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
);
|
|
19
|
-
entries.sort(([a], [b]) => a.localeCompare(b));
|
|
20
|
-
const record: Record<string, string> = {};
|
|
21
|
-
for (const [key, value] of entries) {
|
|
22
|
-
record[key] = value;
|
|
23
|
-
}
|
|
24
|
-
return record;
|
|
17
|
+
const lowered = Record.mapKeys(headers, normalizeHeaderKey);
|
|
18
|
+
return Record.fromEntries(Record.toEntries(lowered).sort(([a], [b]) => a.localeCompare(b)));
|
|
25
19
|
};
|
|
26
20
|
|
|
27
21
|
/**
|
|
@@ -34,13 +28,7 @@ const omitHeaderKeys = (
|
|
|
34
28
|
if (!headers) return Option.none();
|
|
35
29
|
if (!ignore || ignore.length === 0) return Option.some(headers);
|
|
36
30
|
const ignoreSet = new Set(ignore.map(normalizeHeaderKey));
|
|
37
|
-
|
|
38
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
39
|
-
if (!ignoreSet.has(normalizeHeaderKey(key))) {
|
|
40
|
-
filtered[key] = value;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return Option.some(filtered);
|
|
31
|
+
return Option.some(Record.filter(headers, (_, key) => !ignoreSet.has(normalizeHeaderKey(key))));
|
|
44
32
|
};
|
|
45
33
|
|
|
46
34
|
/**
|
|
@@ -48,44 +36,126 @@ const omitHeaderKeys = (
|
|
|
48
36
|
*/
|
|
49
37
|
const tryParseJson = Option.liftThrowable((input: string) => JSON.parse(input));
|
|
50
38
|
|
|
39
|
+
type BodyOptions = {
|
|
40
|
+
readonly omit?: ReadonlyArray<string>;
|
|
41
|
+
readonly replacements?: Readonly<Record<string, VcrRedactedValue>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type BodyTransformation<A> = {
|
|
45
|
+
readonly value: A;
|
|
46
|
+
readonly redacted: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
51
49
|
/**
|
|
52
|
-
* Recursively
|
|
50
|
+
* Recursively remove or replace keys in JSON values.
|
|
53
51
|
*/
|
|
54
|
-
const
|
|
52
|
+
const transformJsonKeys = (
|
|
53
|
+
value: unknown,
|
|
54
|
+
omit: ReadonlySet<string>,
|
|
55
|
+
replacements: Readonly<Record<string, VcrRedactedValue>>,
|
|
56
|
+
): BodyTransformation<unknown> => {
|
|
55
57
|
if (Array.isArray(value)) {
|
|
56
|
-
|
|
58
|
+
let redacted = false;
|
|
59
|
+
const values = value.map((item) => {
|
|
60
|
+
const transformed = transformJsonKeys(item, omit, replacements);
|
|
61
|
+
redacted ||= transformed.redacted;
|
|
62
|
+
return transformed.value;
|
|
63
|
+
});
|
|
64
|
+
return { value: values, redacted };
|
|
57
65
|
}
|
|
58
66
|
if (value && typeof value === "object") {
|
|
59
67
|
const record = value as Record<string, unknown>;
|
|
60
68
|
const next: Record<string, unknown> = {};
|
|
69
|
+
let redacted = false;
|
|
61
70
|
for (const [key, entry] of Object.entries(record)) {
|
|
62
|
-
if (
|
|
63
|
-
|
|
71
|
+
if (omit.has(key)) {
|
|
72
|
+
redacted = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (Record.has(replacements, key)) {
|
|
76
|
+
next[key] = replacements[key];
|
|
77
|
+
redacted = true;
|
|
78
|
+
continue;
|
|
64
79
|
}
|
|
80
|
+
const transformed = transformJsonKeys(entry, omit, replacements);
|
|
81
|
+
next[key] = transformed.value;
|
|
82
|
+
redacted ||= transformed.redacted;
|
|
65
83
|
}
|
|
66
|
-
return next;
|
|
84
|
+
return { value: next, redacted };
|
|
67
85
|
}
|
|
68
|
-
return value;
|
|
86
|
+
return { value, redacted: false };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const headerValue = (
|
|
90
|
+
headers: Readonly<Record<string, string>> | undefined,
|
|
91
|
+
name: string,
|
|
92
|
+
): string | undefined => {
|
|
93
|
+
if (!headers) return undefined;
|
|
94
|
+
const normalizedName = normalizeHeaderKey(name);
|
|
95
|
+
return Object.entries(headers).find(([key]) => normalizeHeaderKey(key) === normalizedName)?.[1];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const isUrlEncoded = (headers: Readonly<Record<string, string>> | undefined): boolean =>
|
|
99
|
+
headerValue(headers, "content-type")?.split(";", 1)[0]?.trim().toLowerCase() ===
|
|
100
|
+
"application/x-www-form-urlencoded";
|
|
101
|
+
|
|
102
|
+
const transformUrlParams = (
|
|
103
|
+
body: string,
|
|
104
|
+
omit: ReadonlySet<string>,
|
|
105
|
+
replacements: Readonly<Record<string, VcrRedactedValue>>,
|
|
106
|
+
): BodyTransformation<string> => {
|
|
107
|
+
let redacted = false;
|
|
108
|
+
const value = UrlParams.fromInput(new URLSearchParams(body)).pipe(
|
|
109
|
+
UrlParams.transform((params) =>
|
|
110
|
+
params.flatMap(([key, value]) => {
|
|
111
|
+
if (omit.has(key)) {
|
|
112
|
+
redacted = true;
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
if (Record.has(replacements, key)) {
|
|
116
|
+
redacted = true;
|
|
117
|
+
return [[key, String(replacements[key])] as const];
|
|
118
|
+
}
|
|
119
|
+
return [[key, value] as const];
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
UrlParams.toString,
|
|
123
|
+
);
|
|
124
|
+
return { value, redacted };
|
|
69
125
|
};
|
|
70
126
|
|
|
71
127
|
/**
|
|
72
|
-
* Remove
|
|
128
|
+
* Remove or replace JSON and URL-encoded body fields when possible.
|
|
73
129
|
*/
|
|
74
|
-
const
|
|
130
|
+
const transformBody = (
|
|
75
131
|
body: string | undefined,
|
|
76
|
-
|
|
77
|
-
|
|
132
|
+
headers: Readonly<Record<string, string>> | undefined,
|
|
133
|
+
options: BodyOptions,
|
|
134
|
+
): Option.Option<BodyTransformation<string>> => {
|
|
78
135
|
if (!body) return Option.none();
|
|
136
|
+
const omit = new Set(options.omit);
|
|
137
|
+
const replacements = options.replacements ?? {};
|
|
138
|
+
if (isUrlEncoded(headers)) {
|
|
139
|
+
const hasMatch = Array.from(new URLSearchParams(body).keys()).some(
|
|
140
|
+
(key) => omit.has(key) || Record.has(replacements, key),
|
|
141
|
+
);
|
|
142
|
+
if (!hasMatch) {
|
|
143
|
+
return Option.some({ value: body, redacted: false });
|
|
144
|
+
}
|
|
145
|
+
return Option.some(transformUrlParams(body, omit, replacements));
|
|
146
|
+
}
|
|
79
147
|
return Option.some(
|
|
80
148
|
tryParseJson(body).pipe(
|
|
81
149
|
Option.match({
|
|
82
|
-
onNone: () => body,
|
|
150
|
+
onNone: () => ({ value: body, redacted: false }),
|
|
83
151
|
onSome: (parsed) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
152
|
+
const transformed = transformJsonKeys(parsed, omit, replacements);
|
|
153
|
+
return {
|
|
154
|
+
// stableStringify's return type is `string | undefined` for cyclic input;
|
|
155
|
+
// parsed JSON is never cyclic, but the fallback keeps the types honest.
|
|
156
|
+
value: stableStringify(transformed.value) ?? body,
|
|
157
|
+
redacted: transformed.redacted,
|
|
158
|
+
};
|
|
89
159
|
},
|
|
90
160
|
}),
|
|
91
161
|
),
|
|
@@ -93,7 +163,7 @@ const omitBodyKeys = (
|
|
|
93
163
|
};
|
|
94
164
|
|
|
95
165
|
/**
|
|
96
|
-
* Normalize request for matching: header canonicalization
|
|
166
|
+
* Normalize request for matching: header canonicalization and structured body filtering.
|
|
97
167
|
*/
|
|
98
168
|
export const sanitizeRequest = (
|
|
99
169
|
request: VcrRequest,
|
|
@@ -102,11 +172,22 @@ export const sanitizeRequest = (
|
|
|
102
172
|
readonly ignoreBodyKeys?: ReadonlyArray<string>;
|
|
103
173
|
},
|
|
104
174
|
): VcrRequest => {
|
|
105
|
-
const
|
|
175
|
+
const transformedBody = transformBody(request.body, request.headers, {
|
|
176
|
+
omit: options.ignoreBodyKeys,
|
|
177
|
+
});
|
|
178
|
+
const body = Option.getOrUndefined(
|
|
179
|
+
transformedBody.pipe(Option.map((transformed) => transformed.value)),
|
|
180
|
+
);
|
|
181
|
+
const filteredHeaders = omitHeaderKeys(
|
|
182
|
+
request.headers,
|
|
183
|
+
transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
|
|
184
|
+
? [...(options.ignoreHeaders ?? []), "content-length"]
|
|
185
|
+
: options.ignoreHeaders,
|
|
186
|
+
);
|
|
106
187
|
return {
|
|
107
188
|
...request,
|
|
108
189
|
headers: toHeaderRecord(Option.getOrUndefined(filteredHeaders)),
|
|
109
|
-
body
|
|
190
|
+
body,
|
|
110
191
|
};
|
|
111
192
|
};
|
|
112
193
|
|
|
@@ -125,6 +206,8 @@ export const buildRequestKey = (
|
|
|
125
206
|
ignoreHeaders: options.ignoreHeaders,
|
|
126
207
|
ignoreBodyKeys: options.ignoreBodyKeys,
|
|
127
208
|
});
|
|
209
|
+
// stableStringify only returns undefined for cyclic input; this literal never is,
|
|
210
|
+
// but its declared type is `string | undefined`, so keep the fallback for TS.
|
|
128
211
|
return (
|
|
129
212
|
stableStringify({
|
|
130
213
|
method: sanitized.method.toUpperCase(),
|
|
@@ -149,12 +232,29 @@ export const redactRequest = (
|
|
|
149
232
|
options: {
|
|
150
233
|
readonly redactHeaders?: ReadonlyArray<string>;
|
|
151
234
|
readonly redactBodyKeys?: ReadonlyArray<string>;
|
|
235
|
+
readonly bodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
|
|
152
236
|
},
|
|
153
|
-
): VcrRequest =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
});
|
|
237
|
+
): VcrRequest => {
|
|
238
|
+
const transformedBody = transformBody(request.body, request.headers, {
|
|
239
|
+
omit: options.redactBodyKeys,
|
|
240
|
+
replacements: options.bodyReplacements,
|
|
241
|
+
});
|
|
242
|
+
const body = Option.getOrUndefined(
|
|
243
|
+
transformedBody.pipe(Option.map((transformed) => transformed.value)),
|
|
244
|
+
);
|
|
245
|
+
return {
|
|
246
|
+
...request,
|
|
247
|
+
headers: Option.getOrUndefined(
|
|
248
|
+
omitHeaderKeys(
|
|
249
|
+
request.headers,
|
|
250
|
+
transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
|
|
251
|
+
? [...(options.redactHeaders ?? []), "content-length"]
|
|
252
|
+
: options.redactHeaders,
|
|
253
|
+
),
|
|
254
|
+
),
|
|
255
|
+
body,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
158
258
|
|
|
159
259
|
/**
|
|
160
260
|
* Remove sensitive response data before persisting to a cassette.
|
|
@@ -164,9 +264,27 @@ export const redactResponse = (
|
|
|
164
264
|
options: {
|
|
165
265
|
readonly redactHeaders?: ReadonlyArray<string>;
|
|
166
266
|
readonly redactBodyKeys?: ReadonlyArray<string>;
|
|
267
|
+
readonly bodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
|
|
167
268
|
},
|
|
168
|
-
): VcrResponse =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
});
|
|
269
|
+
): VcrResponse => {
|
|
270
|
+
const transformedBody = transformBody(response.body, response.headers, {
|
|
271
|
+
omit: options.redactBodyKeys,
|
|
272
|
+
replacements: options.bodyReplacements,
|
|
273
|
+
});
|
|
274
|
+
const body = Option.getOrElse(
|
|
275
|
+
transformedBody.pipe(Option.map((transformed) => transformed.value)),
|
|
276
|
+
() => response.body,
|
|
277
|
+
);
|
|
278
|
+
return {
|
|
279
|
+
...response,
|
|
280
|
+
headers: Option.getOrUndefined(
|
|
281
|
+
omitHeaderKeys(
|
|
282
|
+
response.headers,
|
|
283
|
+
transformedBody.pipe(Option.exists((transformed) => transformed.redacted))
|
|
284
|
+
? [...(options.redactHeaders ?? []), "content-length"]
|
|
285
|
+
: options.redactHeaders,
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
body,
|
|
289
|
+
};
|
|
290
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* VCR operating mode.
|
|
3
5
|
* - record: always call the live client and persist.
|
|
@@ -6,50 +8,60 @@
|
|
|
6
8
|
*/
|
|
7
9
|
export type VcrMode = "record" | "replay" | "auto";
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Safe scalar value written in place of a sensitive body field.
|
|
13
|
+
*/
|
|
14
|
+
export type VcrRedactedValue = string | number | boolean | null;
|
|
15
|
+
|
|
9
16
|
/**
|
|
10
17
|
* Serialized request shape stored in a cassette.
|
|
11
18
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
19
|
+
export const VcrRequestSchema = Schema.Struct({
|
|
20
|
+
method: Schema.String,
|
|
21
|
+
url: Schema.String,
|
|
22
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
23
|
+
body: Schema.optional(Schema.String),
|
|
24
|
+
});
|
|
25
|
+
export type VcrRequest = Schema.Schema.Type<typeof VcrRequestSchema>;
|
|
18
26
|
|
|
19
27
|
/**
|
|
20
28
|
* Serialized response shape stored in a cassette.
|
|
21
29
|
*/
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
};
|
|
30
|
+
export const VcrResponseSchema = Schema.Struct({
|
|
31
|
+
status: Schema.Number,
|
|
32
|
+
body: Schema.String,
|
|
33
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
34
|
+
});
|
|
35
|
+
export type VcrResponse = Schema.Schema.Type<typeof VcrResponseSchema>;
|
|
27
36
|
|
|
28
37
|
/**
|
|
29
38
|
* A single request/response pair keyed in the cassette.
|
|
30
39
|
*/
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
};
|
|
40
|
+
export const VcrEntrySchema = Schema.Struct({
|
|
41
|
+
request: VcrRequestSchema,
|
|
42
|
+
response: VcrResponseSchema,
|
|
43
|
+
});
|
|
44
|
+
export type VcrEntry = Schema.Schema.Type<typeof VcrEntrySchema>;
|
|
35
45
|
|
|
36
46
|
/**
|
|
37
47
|
* Cassette file format.
|
|
38
48
|
*/
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
};
|
|
49
|
+
export const CassetteSchema = Schema.Struct({
|
|
50
|
+
meta: Schema.Struct({
|
|
51
|
+
createdAt: Schema.String,
|
|
52
|
+
version: Schema.String,
|
|
53
|
+
}),
|
|
54
|
+
entries: Schema.Record(Schema.String, VcrEntrySchema),
|
|
55
|
+
});
|
|
56
|
+
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>;
|
|
46
57
|
|
|
47
58
|
/**
|
|
48
59
|
* Cassette file format with multiple exports.
|
|
49
60
|
*/
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
};
|
|
61
|
+
export const CassetteFileSchema = Schema.Struct({
|
|
62
|
+
exports: Schema.Record(Schema.String, CassetteSchema),
|
|
63
|
+
});
|
|
64
|
+
export type CassetteFile = Schema.Schema.Type<typeof CassetteFileSchema>;
|
|
53
65
|
|
|
54
66
|
/**
|
|
55
67
|
* VCR configuration.
|
|
@@ -65,13 +77,15 @@ export type VcrConfig = {
|
|
|
65
77
|
readonly cassetteName?: string;
|
|
66
78
|
readonly mode?: VcrMode;
|
|
67
79
|
/**
|
|
68
|
-
* Remove sensitive data before writing to disk.
|
|
80
|
+
* Remove or replace sensitive data before writing to disk.
|
|
69
81
|
*/
|
|
70
82
|
readonly redact?: {
|
|
71
83
|
readonly requestHeaders?: ReadonlyArray<string>;
|
|
72
84
|
readonly responseHeaders?: ReadonlyArray<string>;
|
|
73
85
|
readonly requestBodyKeys?: ReadonlyArray<string>;
|
|
74
86
|
readonly responseBodyKeys?: ReadonlyArray<string>;
|
|
87
|
+
readonly requestBodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
|
|
88
|
+
readonly responseBodyReplacements?: Readonly<Record<string, VcrRedactedValue>>;
|
|
75
89
|
};
|
|
76
90
|
/**
|
|
77
91
|
* Ignore fields when computing the request key for replay matching.
|
package/src/vcr-http-client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Config, Data, Effect, Option, Path } from "effect";
|
|
1
|
+
import { Array as Arr, Config, Data, Effect, Option, Path } from "effect";
|
|
2
2
|
import {
|
|
3
3
|
HttpClient,
|
|
4
4
|
HttpClientError,
|
|
@@ -64,20 +64,31 @@ const resolveCassetteLocation = Effect.fnUntraced(function* (config: VcrConfig)
|
|
|
64
64
|
/**
|
|
65
65
|
* Apply defaults for common VCR behavior while preserving explicit overrides.
|
|
66
66
|
*/
|
|
67
|
-
const normalizeConfig = (config: VcrConfig): VcrConfig =>
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
67
|
+
const normalizeConfig = (config: VcrConfig): VcrConfig => {
|
|
68
|
+
const redactedRequestBodyKeys = Arr.union(
|
|
69
|
+
config.redact?.requestBodyKeys ?? [],
|
|
70
|
+
Object.keys(config.redact?.requestBodyReplacements ?? {}),
|
|
71
|
+
);
|
|
72
|
+
const redactedRequestHeaders = Arr.union(["authorization"], config.redact?.requestHeaders ?? []);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
vcrName: config.vcrName,
|
|
76
|
+
cassetteName: config.cassetteName,
|
|
77
|
+
mode: config.mode ?? "auto",
|
|
78
|
+
matchIgnore: {
|
|
79
|
+
requestHeaders: Arr.union(redactedRequestHeaders, config.matchIgnore?.requestHeaders ?? []),
|
|
80
|
+
requestBodyKeys: Arr.union(
|
|
81
|
+
redactedRequestBodyKeys,
|
|
82
|
+
config.matchIgnore?.requestBodyKeys ?? [],
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
redact: {
|
|
86
|
+
...config.redact,
|
|
87
|
+
requestHeaders: redactedRequestHeaders,
|
|
88
|
+
},
|
|
89
|
+
match: config.match,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
81
92
|
|
|
82
93
|
const AckDisableVcrConfig = Config.option(Config.string("ACK_DISABLE_VCR")).pipe(
|
|
83
94
|
Config.map((value) =>
|
|
@@ -120,15 +131,9 @@ const toRequestError = (
|
|
|
120
131
|
});
|
|
121
132
|
|
|
122
133
|
/**
|
|
123
|
-
*
|
|
134
|
+
* Best-effort JSON serialization for opaque raw bodies.
|
|
124
135
|
*/
|
|
125
|
-
const
|
|
126
|
-
const record: Record<string, string> = {};
|
|
127
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
128
|
-
record[key] = value;
|
|
129
|
-
}
|
|
130
|
-
return record;
|
|
131
|
-
};
|
|
136
|
+
const tryStringify = Option.liftThrowable((value: unknown) => JSON.stringify(value));
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
139
|
* Serialize an Effect HttpClientRequest into a cassette request shape.
|
|
@@ -138,7 +143,7 @@ const recordFromHeaders = (headers: Record<string, string>) => {
|
|
|
138
143
|
* live requests.
|
|
139
144
|
*/
|
|
140
145
|
const toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest => {
|
|
141
|
-
const headers
|
|
146
|
+
const headers: Record<string, string> = { ...request.headers };
|
|
142
147
|
const body = (() => {
|
|
143
148
|
switch (request.body._tag) {
|
|
144
149
|
case "Empty":
|
|
@@ -149,11 +154,7 @@ const toVcrRequest = (request: HttpClientRequest.HttpClientRequest): VcrRequest
|
|
|
149
154
|
const raw = request.body.body as unknown;
|
|
150
155
|
if (typeof raw === "string") return raw;
|
|
151
156
|
if (raw instanceof Uint8Array) return decoder.decode(raw);
|
|
152
|
-
|
|
153
|
-
return JSON.stringify(raw);
|
|
154
|
-
} catch {
|
|
155
|
-
return "[raw]";
|
|
156
|
-
}
|
|
157
|
+
return tryStringify(raw).pipe(Option.getOrElse(() => "[raw]"));
|
|
157
158
|
}
|
|
158
159
|
case "FormData":
|
|
159
160
|
return "[form-data]";
|
|
@@ -179,7 +180,7 @@ const toVcrResponse = (
|
|
|
179
180
|
): VcrResponse => ({
|
|
180
181
|
status: response.status,
|
|
181
182
|
body,
|
|
182
|
-
headers:
|
|
183
|
+
headers: { ...response.headers },
|
|
183
184
|
});
|
|
184
185
|
|
|
185
186
|
/**
|
|
@@ -309,12 +310,14 @@ const record = Effect.fnUntraced(function* (
|
|
|
309
310
|
? redactRequest(vcrRequest, {
|
|
310
311
|
redactHeaders: config.redact.requestHeaders,
|
|
311
312
|
redactBodyKeys: config.redact.requestBodyKeys,
|
|
313
|
+
bodyReplacements: config.redact.requestBodyReplacements,
|
|
312
314
|
})
|
|
313
315
|
: vcrRequest;
|
|
314
316
|
const sanitizedResponse = config.redact
|
|
315
317
|
? redactResponse(vcrResponse, {
|
|
316
318
|
redactHeaders: config.redact.responseHeaders,
|
|
317
319
|
redactBodyKeys: config.redact.responseBodyKeys,
|
|
320
|
+
bodyReplacements: config.redact.responseBodyReplacements,
|
|
318
321
|
})
|
|
319
322
|
: vcrResponse;
|
|
320
323
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cassette-store-CnXX5OLB.js","names":[],"sources":["../src/cassette-store.ts"],"sourcesContent":["import { Context, Data, DateTime, Effect } from \"effect\";\n\nimport type { Cassette, CassetteFile } from \"./types\";\n\nexport type CassetteStoreOperation = \"exists\" | \"load\" | \"save\" | \"loadOrInit\" | \"resolveRoot\";\n\n/**\n * Storage contract for VCR cassettes.\n *\n */\nexport interface CassetteStoreService {\n /** Returns true if a cassette with the given name exists. */\n readonly exists: (name: string) => Effect.Effect<boolean, CassetteStoreError>;\n\n /** Loads a cassette by name, returning a `CassetteFile` if found. */\n readonly load: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n\n /** Saves a cassette by name, overwriting any existing cassette with the same name. */\n readonly save: (name: string, content: CassetteFile) => Effect.Effect<void, CassetteStoreError>;\n\n /** Loads a cassette by name, initializing a new empty cassette if not found. */\n readonly loadOrInit: (name: string) => Effect.Effect<CassetteFile, CassetteStoreError>;\n}\n\n/**\n * Effect service tag for the cassette store.\n */\nexport class CassetteStore extends Context.Service<CassetteStore, CassetteStoreService>()(\n \"@useairfoil/effect-vcr/CassetteStore\",\n) {}\n\n/**\n * Creates a new empty cassette with a timestamp and format version.\n */\nexport const createEmptyCassette = Effect.fnUntraced(function* (): Effect.fn.Return<Cassette> {\n const now = yield* DateTime.now;\n return {\n meta: {\n createdAt: DateTime.formatIso(now),\n version: \"1\",\n },\n entries: {},\n };\n});\n\nexport const createEmptyCassetteFile = (): Effect.Effect<CassetteFile> =>\n Effect.map(createEmptyCassette(), (cassette) => ({\n exports: { default: cassette },\n }));\n\n/**\n * Structured error for cassette persistence operations.\n *\n * `operation` indicates which store function failed.\n * `path` is the file or directory path that was targeted.\n */\nexport class CassetteStoreError extends Data.TaggedError(\"CassetteStoreError\")<{\n readonly operation: CassetteStoreOperation;\n readonly path: string;\n readonly message?: string;\n readonly cause?: unknown;\n}> {}\n\nexport const toStoreError = (\n operation: CassetteStoreOperation,\n path: string,\n cause?: unknown,\n message?: string,\n) =>\n new CassetteStoreError({\n operation,\n path,\n cause,\n message,\n });\n\n/**\n * Parses cassette JSON and maps invalid JSON to a store error.\n */\nexport const parseCassette = (content: string, path: string) =>\n Effect.try({\n try: () => JSON.parse(content) as CassetteFile,\n catch: (error) => toStoreError(\"load\", path, error, \"Invalid JSON\"),\n }).pipe(\n Effect.flatMap((parsed) => {\n if (!parsed || typeof parsed !== \"object\" || !(\"exports\" in parsed)) {\n return Effect.fail(toStoreError(\"load\", path, undefined, \"Invalid cassette file format\"));\n }\n return Effect.succeed(parsed);\n }),\n );\n\n/** Creates a `CassetteStore` implementation that uses the given `impl` object. */\nexport const make = (impl: CassetteStoreService): CassetteStoreService =>\n CassetteStore.of({ ...impl });\n"],"mappings":";;;;;;;;;;;;;;;;AA2BA,IAAa,gBAAb,cAAmC,QAAQ,SAA8C,CACvF,uCACD,CAAC;;;;AAKF,MAAa,sBAAsB,OAAO,WAAW,aAAyC;CAC5F,MAAM,MAAM,OAAO,SAAS;AAC5B,QAAO;EACL,MAAM;GACJ,WAAW,SAAS,UAAU,IAAI;GAClC,SAAS;GACV;EACD,SAAS,EAAE;EACZ;EACD;AAEF,MAAa,gCACX,OAAO,IAAI,qBAAqB,GAAG,cAAc,EAC/C,SAAS,EAAE,SAAS,UAAU,EAC/B,EAAE;;;;;;;AAQL,IAAa,qBAAb,cAAwC,KAAK,YAAY,qBAAqB,CAK3E;AAEH,MAAa,gBACX,WACA,MACA,OACA,YAEA,IAAI,mBAAmB;CACrB;CACA;CACA;CACA;CACD,CAAC;;;;AAKJ,MAAa,iBAAiB,SAAiB,SAC7C,OAAO,IAAI;CACT,WAAW,KAAK,MAAM,QAAQ;CAC9B,QAAQ,UAAU,aAAa,QAAQ,MAAM,OAAO,eAAe;CACpE,CAAC,CAAC,KACD,OAAO,SAAS,WAAW;AACzB,KAAI,CAAC,UAAU,OAAO,WAAW,YAAY,EAAE,aAAa,QAC1D,QAAO,OAAO,KAAK,aAAa,QAAQ,MAAM,QAAW,+BAA+B,CAAC;AAE3F,QAAO,OAAO,QAAQ,OAAO;EAC7B,CACH;;AAGH,MAAa,QAAQ,SACnB,cAAc,GAAG,EAAE,GAAG,MAAM,CAAC"}
|
package/dist/types-BsuPrIpu.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { };
|
package/dist/types-DWwkMzxG.d.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
//#region src/types.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* VCR operating mode.
|
|
4
|
-
* - record: always call the live client and persist.
|
|
5
|
-
* - replay: serve only from cassette, fail if missing.
|
|
6
|
-
* - auto: replay if cassette exists, otherwise record (fail in CI).
|
|
7
|
-
*/
|
|
8
|
-
type VcrMode = "record" | "replay" | "auto";
|
|
9
|
-
/**
|
|
10
|
-
* Serialized request shape stored in a cassette.
|
|
11
|
-
*/
|
|
12
|
-
type VcrRequest = {
|
|
13
|
-
readonly method: string;
|
|
14
|
-
readonly url: string;
|
|
15
|
-
readonly headers?: Record<string, string>;
|
|
16
|
-
readonly body?: string;
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Serialized response shape stored in a cassette.
|
|
20
|
-
*/
|
|
21
|
-
type VcrResponse = {
|
|
22
|
-
readonly status: number;
|
|
23
|
-
readonly body: string;
|
|
24
|
-
readonly headers?: Record<string, string>;
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* A single request/response pair keyed in the cassette.
|
|
28
|
-
*/
|
|
29
|
-
type VcrEntry = {
|
|
30
|
-
readonly request: VcrRequest;
|
|
31
|
-
readonly response: VcrResponse;
|
|
32
|
-
};
|
|
33
|
-
/**
|
|
34
|
-
* Cassette file format.
|
|
35
|
-
*/
|
|
36
|
-
type Cassette = {
|
|
37
|
-
readonly meta: {
|
|
38
|
-
readonly createdAt: string;
|
|
39
|
-
readonly version: string;
|
|
40
|
-
};
|
|
41
|
-
readonly entries: Record<string, VcrEntry>;
|
|
42
|
-
};
|
|
43
|
-
/**
|
|
44
|
-
* Cassette file format with multiple exports.
|
|
45
|
-
*/
|
|
46
|
-
type CassetteFile = {
|
|
47
|
-
readonly exports: Record<string, Cassette>;
|
|
48
|
-
};
|
|
49
|
-
/**
|
|
50
|
-
* VCR configuration.
|
|
51
|
-
*/
|
|
52
|
-
type VcrConfig = {
|
|
53
|
-
readonly vcrName?: string;
|
|
54
|
-
/**
|
|
55
|
-
* Cassette file name or basename.
|
|
56
|
-
*
|
|
57
|
-
* `users` resolves to `users.cassette`.
|
|
58
|
-
* `users.cassette` is preserved as-is.
|
|
59
|
-
*/
|
|
60
|
-
readonly cassetteName?: string;
|
|
61
|
-
readonly mode?: VcrMode;
|
|
62
|
-
/**
|
|
63
|
-
* Remove sensitive data before writing to disk.
|
|
64
|
-
*/
|
|
65
|
-
readonly redact?: {
|
|
66
|
-
readonly requestHeaders?: ReadonlyArray<string>;
|
|
67
|
-
readonly responseHeaders?: ReadonlyArray<string>;
|
|
68
|
-
readonly requestBodyKeys?: ReadonlyArray<string>;
|
|
69
|
-
readonly responseBodyKeys?: ReadonlyArray<string>;
|
|
70
|
-
};
|
|
71
|
-
/**
|
|
72
|
-
* Ignore fields when computing the request key for replay matching.
|
|
73
|
-
*/
|
|
74
|
-
readonly matchIgnore?: {
|
|
75
|
-
readonly requestHeaders?: ReadonlyArray<string>;
|
|
76
|
-
readonly requestBodyKeys?: ReadonlyArray<string>;
|
|
77
|
-
};
|
|
78
|
-
/**
|
|
79
|
-
* Custom matcher to override key-based lookup.
|
|
80
|
-
*/
|
|
81
|
-
readonly match?: (request: VcrRequest, entry: VcrEntry) => boolean;
|
|
82
|
-
};
|
|
83
|
-
//#endregion
|
|
84
|
-
export { VcrMode as a, VcrEntry as i, CassetteFile as n, VcrRequest as o, VcrConfig as r, VcrResponse as s, Cassette as t };
|
|
85
|
-
//# sourceMappingURL=types-DWwkMzxG.d.ts.map
|