@yolk-sdk/conformance 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +316 -0
- package/dist/case.d.mts +89 -0
- package/dist/case.d.mts.map +1 -0
- package/dist/case.mjs +101 -0
- package/dist/case.mjs.map +1 -0
- package/dist/fixture.d.mts +432 -0
- package/dist/fixture.d.mts.map +1 -0
- package/dist/fixture.mjs +391 -0
- package/dist/fixture.mjs.map +1 -0
- package/dist/record.d.mts +96 -0
- package/dist/record.d.mts.map +1 -0
- package/dist/record.mjs +212 -0
- package/dist/record.mjs.map +1 -0
- package/dist/replay.d.mts +316 -0
- package/dist/replay.d.mts.map +1 -0
- package/dist/replay.mjs +272 -0
- package/dist/replay.mjs.map +1 -0
- package/dist/runner.d.mts +163 -0
- package/dist/runner.d.mts.map +1 -0
- package/dist/runner.mjs +264 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/wire-internal.d.mts +90 -0
- package/dist/wire-internal.d.mts.map +1 -0
- package/dist/wire-internal.mjs +212 -0
- package/dist/wire-internal.mjs.map +1 -0
- package/package.json +75 -0
- package/src/case.ts +153 -0
- package/src/fixture.ts +625 -0
- package/src/record.ts +377 -0
- package/src/replay.ts +617 -0
- package/src/runner.ts +559 -0
- package/src/wire-internal.ts +382 -0
package/src/replay.ts
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline replay of recorded wire fixtures as an Effect `HttpClient`.
|
|
3
|
+
*
|
|
4
|
+
* Replay never performs network I/O. Requests are matched by method and
|
|
5
|
+
* normalized absolute URL (hash removed, query parameters sorted); each
|
|
6
|
+
* recorded exchange is consumed once, in recorded order among exchanges with
|
|
7
|
+
* the same method and URL. A request with no remaining match fails closed with
|
|
8
|
+
* a typed `HttpClientError` and is still written to the ledger.
|
|
9
|
+
*
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
Cause,
|
|
14
|
+
Context,
|
|
15
|
+
Data,
|
|
16
|
+
Effect,
|
|
17
|
+
Exit,
|
|
18
|
+
Fiber,
|
|
19
|
+
Layer,
|
|
20
|
+
Match,
|
|
21
|
+
Option,
|
|
22
|
+
Predicate,
|
|
23
|
+
Ref,
|
|
24
|
+
Result
|
|
25
|
+
} from 'effect'
|
|
26
|
+
import type * as Schema from 'effect/Schema'
|
|
27
|
+
import {
|
|
28
|
+
HttpClient,
|
|
29
|
+
HttpClientError,
|
|
30
|
+
HttpClientResponse,
|
|
31
|
+
type HttpClientRequest
|
|
32
|
+
} from 'effect/unstable/http'
|
|
33
|
+
import {
|
|
34
|
+
isWireBase64BodyResponse,
|
|
35
|
+
isWireStreamResponse,
|
|
36
|
+
type WireChunk,
|
|
37
|
+
type WireExchange,
|
|
38
|
+
type WireFixture,
|
|
39
|
+
type WireHeaders,
|
|
40
|
+
type WireResponse
|
|
41
|
+
} from './fixture.ts'
|
|
42
|
+
import {
|
|
43
|
+
decodeBase64Bytes,
|
|
44
|
+
headerRecord,
|
|
45
|
+
isNullBodyStatus,
|
|
46
|
+
normalizeWireUrl,
|
|
47
|
+
parseJsonText,
|
|
48
|
+
redactHeaders,
|
|
49
|
+
requestBodyText,
|
|
50
|
+
urlMatchesPattern
|
|
51
|
+
} from './wire-internal.ts'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Optional fault filter. `method` compares case-insensitively. `url` matches
|
|
55
|
+
* the normalized request URL exactly, or as a prefix when it ends with `*`.
|
|
56
|
+
* An omitted field matches every request.
|
|
57
|
+
*/
|
|
58
|
+
export type WireFaultMatch = {
|
|
59
|
+
readonly method?: string
|
|
60
|
+
readonly url?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wire faults injected by the replay client. `attempt` is 1-based and counted
|
|
65
|
+
* per method + normalized URL.
|
|
66
|
+
*
|
|
67
|
+
* - `StatusOnAttempt`: respond with this status instead of consuming a recorded
|
|
68
|
+
* exchange, so the next attempt still gets the recording. It fires only when
|
|
69
|
+
* an unconsumed recorded exchange exists for the method + normalized URL;
|
|
70
|
+
* unknown or exhausted requests still fail closed as `unmatched`.
|
|
71
|
+
* - `FailAfterChunks`: emit the first `chunks` recorded chunks, then fail the
|
|
72
|
+
* body stream the way a dropped connection does under `FetchHttpClient`.
|
|
73
|
+
* - `TruncateAfterChunks`: emit the first `chunks` recorded chunks, then end
|
|
74
|
+
* the body cleanly.
|
|
75
|
+
* - `HoldAfterChunks`: emit the first `chunks` recorded chunks, run `release`,
|
|
76
|
+
* then emit the rest.
|
|
77
|
+
*
|
|
78
|
+
* `attempt` omitted means every matching attempt. A chunk fault that cannot
|
|
79
|
+
* take effect fails the request with a typed `HttpClientError` (and a ledger
|
|
80
|
+
* `invalid` outcome) instead of silently doing nothing: any chunk fault matched
|
|
81
|
+
* against a whole-body response, `TruncateAfterChunks`/`HoldAfterChunks` with
|
|
82
|
+
* `chunks` >= the recorded chunk count, or `FailAfterChunks` with `chunks` >
|
|
83
|
+
* the recorded chunk count. That error's reason is a `TransportError`, so
|
|
84
|
+
* transient-retry policies may retry past it: assert the ledger has no
|
|
85
|
+
* `invalid` outcome when testing through a retrying client.
|
|
86
|
+
*/
|
|
87
|
+
export type WireFault = Data.TaggedEnum<{
|
|
88
|
+
StatusOnAttempt: {
|
|
89
|
+
readonly match?: WireFaultMatch
|
|
90
|
+
readonly attempt: number
|
|
91
|
+
readonly status: number
|
|
92
|
+
readonly headers?: WireHeaders
|
|
93
|
+
readonly body?: string
|
|
94
|
+
}
|
|
95
|
+
FailAfterChunks: {
|
|
96
|
+
readonly match?: WireFaultMatch
|
|
97
|
+
readonly attempt?: number
|
|
98
|
+
readonly chunks: number
|
|
99
|
+
}
|
|
100
|
+
TruncateAfterChunks: {
|
|
101
|
+
readonly match?: WireFaultMatch
|
|
102
|
+
readonly attempt?: number
|
|
103
|
+
readonly chunks: number
|
|
104
|
+
}
|
|
105
|
+
HoldAfterChunks: {
|
|
106
|
+
readonly match?: WireFaultMatch
|
|
107
|
+
readonly attempt?: number
|
|
108
|
+
readonly chunks: number
|
|
109
|
+
readonly release: Effect.Effect<void>
|
|
110
|
+
}
|
|
111
|
+
}>
|
|
112
|
+
|
|
113
|
+
export const WireFault = Data.taggedEnum<WireFault>()
|
|
114
|
+
|
|
115
|
+
export type WireFaultTag = WireFault['_tag']
|
|
116
|
+
|
|
117
|
+
type ChunkFault = Exclude<WireFault, { readonly _tag: 'StatusOnAttempt' }>
|
|
118
|
+
|
|
119
|
+
/** Cause attached to a body stream failed by `FailAfterChunks`. */
|
|
120
|
+
export class WireTransportFault extends Data.TaggedError('WireTransportFault')<{
|
|
121
|
+
readonly afterChunks: number
|
|
122
|
+
}> {
|
|
123
|
+
override get message(): string {
|
|
124
|
+
return `injected transport failure after ${this.afterChunks} chunks`
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* How a replayed request was answered: a recorded exchange, a response
|
|
130
|
+
* injected by a `StatusOnAttempt` fault, nothing (fail closed), or a matched
|
|
131
|
+
* exchange that could not be replayed (`invalid`: a chunk fault that cannot
|
|
132
|
+
* take effect, or undecodable base64). `invalid` requests fail with a typed
|
|
133
|
+
* `HttpClientError` and do not consume the exchange.
|
|
134
|
+
*/
|
|
135
|
+
export type ReplayLedgerMatch =
|
|
136
|
+
| { readonly outcome: 'matched'; readonly fixtureId: string; readonly exchangeIndex: number }
|
|
137
|
+
| { readonly outcome: 'injected' }
|
|
138
|
+
| { readonly outcome: 'unmatched' }
|
|
139
|
+
| {
|
|
140
|
+
readonly outcome: 'invalid'
|
|
141
|
+
readonly fixtureId: string
|
|
142
|
+
readonly exchangeIndex: number
|
|
143
|
+
/** Why the recording could not be replayed; never contains request or response data. */
|
|
144
|
+
readonly reason: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ReplayLedgerEntry = {
|
|
148
|
+
readonly method: string
|
|
149
|
+
/** Normalized absolute URL. */
|
|
150
|
+
readonly url: string
|
|
151
|
+
/** Request headers with credential headers replaced by `<redacted>`. */
|
|
152
|
+
readonly headers: Readonly<Record<string, string>>
|
|
153
|
+
readonly bodyText?: string
|
|
154
|
+
/** Parsed request body when `bodyText` is valid JSON. */
|
|
155
|
+
readonly bodyJson?: Schema.Json
|
|
156
|
+
/** 1-based attempt number for this method + URL. */
|
|
157
|
+
readonly attempt: number
|
|
158
|
+
readonly match: ReplayLedgerMatch
|
|
159
|
+
/** Tag of the fault that shaped this response, if any (never set for a fault that did not apply). */
|
|
160
|
+
readonly fault?: WireFaultTag
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ReplayExchangeRef = {
|
|
164
|
+
readonly fixtureId: string
|
|
165
|
+
readonly exchangeIndex: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type ReplayLedgerApi = {
|
|
169
|
+
/** Every request seen by the replay client, in arrival order. */
|
|
170
|
+
readonly entries: Effect.Effect<ReadonlyArray<ReplayLedgerEntry>>
|
|
171
|
+
/** Recorded exchanges not consumed yet, in fixture order. */
|
|
172
|
+
readonly remaining: Effect.Effect<ReadonlyArray<ReplayExchangeRef>>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class ReplayLedger extends Context.Service<ReplayLedger, ReplayLedgerApi>()(
|
|
176
|
+
'@yolk-sdk/conformance/ReplayLedger'
|
|
177
|
+
) {}
|
|
178
|
+
|
|
179
|
+
export type ReplayHttpClientOptions = {
|
|
180
|
+
readonly faults?: ReadonlyArray<WireFault>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type Candidate = {
|
|
184
|
+
readonly id: number
|
|
185
|
+
readonly key: string
|
|
186
|
+
readonly fixtureId: string
|
|
187
|
+
readonly exchangeIndex: number
|
|
188
|
+
readonly exchange: WireExchange
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type ReplayState = {
|
|
192
|
+
readonly consumed: ReadonlySet<number>
|
|
193
|
+
readonly attempts: ReadonlyMap<string, number>
|
|
194
|
+
readonly entries: ReadonlyArray<ReplayLedgerEntry>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Bytes to replay: one whole body, or one Uint8Array per recorded chunk.
|
|
198
|
+
type ReplayBody =
|
|
199
|
+
| { readonly kind: 'body'; readonly body: string | Uint8Array<ArrayBuffer> }
|
|
200
|
+
| {
|
|
201
|
+
readonly kind: 'chunks'
|
|
202
|
+
readonly chunks: ReadonlyArray<Uint8Array>
|
|
203
|
+
readonly fault: ChunkFault | undefined
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
type ReplayDecision =
|
|
207
|
+
| { readonly kind: 'status'; readonly fault: Extract<WireFault, { _tag: 'StatusOnAttempt' }> }
|
|
208
|
+
| {
|
|
209
|
+
readonly kind: 'exchange'
|
|
210
|
+
readonly response: WireResponse
|
|
211
|
+
readonly body: ReplayBody
|
|
212
|
+
}
|
|
213
|
+
| { readonly kind: 'invalid'; readonly reason: string }
|
|
214
|
+
| { readonly kind: 'unmatched' }
|
|
215
|
+
|
|
216
|
+
type LedgerEntryFields = {
|
|
217
|
+
method: string
|
|
218
|
+
url: string
|
|
219
|
+
headers: Readonly<Record<string, string>>
|
|
220
|
+
bodyText?: string
|
|
221
|
+
bodyJson?: Schema.Json
|
|
222
|
+
attempt: number
|
|
223
|
+
match: ReplayLedgerMatch
|
|
224
|
+
fault?: WireFaultTag
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const requestKey = (method: string, normalizedUrl: string) =>
|
|
228
|
+
`${method.toUpperCase()} ${normalizedUrl}`
|
|
229
|
+
|
|
230
|
+
const faultMatches = (
|
|
231
|
+
match: WireFaultMatch | undefined,
|
|
232
|
+
method: string,
|
|
233
|
+
normalizedUrl: string
|
|
234
|
+
): boolean =>
|
|
235
|
+
(match?.method === undefined || match.method.toUpperCase() === method.toUpperCase()) &&
|
|
236
|
+
(match?.url === undefined || urlMatchesPattern(match.url, normalizedUrl))
|
|
237
|
+
|
|
238
|
+
const candidatesFrom = (fixtures: ReadonlyArray<WireFixture>): ReadonlyArray<Candidate> => {
|
|
239
|
+
const candidates: Array<Candidate> = []
|
|
240
|
+
|
|
241
|
+
for (const fixture of fixtures) {
|
|
242
|
+
fixture.exchanges.forEach((exchange, exchangeIndex) => {
|
|
243
|
+
candidates.push({
|
|
244
|
+
id: candidates.length,
|
|
245
|
+
key: requestKey(exchange.request.method, normalizeWireUrl(exchange.request.url)),
|
|
246
|
+
fixtureId: fixture.id,
|
|
247
|
+
exchangeIndex,
|
|
248
|
+
exchange
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return candidates
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const chunkBytes = (chunk: WireChunk): Option.Option<Uint8Array> =>
|
|
257
|
+
Predicate.isString(chunk)
|
|
258
|
+
? Option.some(new TextEncoder().encode(chunk))
|
|
259
|
+
: decodeBase64Bytes(chunk.base64)
|
|
260
|
+
|
|
261
|
+
const chunkFaultProblem = (fault: ChunkFault, recorded: number): string | undefined => {
|
|
262
|
+
if (!Number.isSafeInteger(fault.chunks) || fault.chunks < 0) {
|
|
263
|
+
return `${fault._tag} needs a non-negative integer chunk count`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// `FailAfterChunks` may fail after the last chunk; truncating or holding
|
|
267
|
+
// there would change nothing.
|
|
268
|
+
const applies = WireFault.$is('FailAfterChunks')(fault)
|
|
269
|
+
? fault.chunks <= recorded
|
|
270
|
+
: fault.chunks < recorded
|
|
271
|
+
|
|
272
|
+
return applies
|
|
273
|
+
? undefined
|
|
274
|
+
: `${fault._tag} after ${fault.chunks} chunk(s) cannot apply to a response with ${recorded} recorded chunk(s)`
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Resolve the exact bytes to replay, or why the recording cannot be replayed as requested. */
|
|
278
|
+
const replayBody = (
|
|
279
|
+
response: WireResponse,
|
|
280
|
+
fault: ChunkFault | undefined
|
|
281
|
+
): Result.Result<ReplayBody, string> => {
|
|
282
|
+
if (isWireStreamResponse(response)) {
|
|
283
|
+
const chunks = Option.all(response.chunks.map(chunkBytes))
|
|
284
|
+
|
|
285
|
+
if (Option.isNone(chunks)) {
|
|
286
|
+
return Result.fail('a recorded chunk is not valid base64')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const problem = fault === undefined ? undefined : chunkFaultProblem(fault, chunks.value.length)
|
|
290
|
+
|
|
291
|
+
return problem === undefined
|
|
292
|
+
? Result.succeed({ kind: 'chunks', chunks: chunks.value, fault })
|
|
293
|
+
: Result.fail(problem)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (fault !== undefined) {
|
|
297
|
+
return Result.fail(`${fault._tag} cannot apply to a whole-body response`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (isWireBase64BodyResponse(response)) {
|
|
301
|
+
return Option.match(decodeBase64Bytes(response.bodyBase64), {
|
|
302
|
+
onNone: () => Result.fail('the recorded bodyBase64 is not valid base64'),
|
|
303
|
+
// Copy into an ArrayBuffer-backed view, as `Response` requires.
|
|
304
|
+
onSome: bytes => Result.succeed({ kind: 'body', body: new Uint8Array(bytes) })
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return Result.succeed({ kind: 'body', body: response.body })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Strictly pull-driven body: one `Uint8Array` per recorded chunk (including
|
|
313
|
+
* empty ones), and chunk `k` is produced only when the consumer pulls it,
|
|
314
|
+
* never ahead of demand. The fault (already validated against the chunk count)
|
|
315
|
+
* applies when the consumer pulls past `fault.chunks` chunks.
|
|
316
|
+
*/
|
|
317
|
+
const bodyReadable = (
|
|
318
|
+
chunks: ReadonlyArray<Uint8Array>,
|
|
319
|
+
fault: ChunkFault | undefined
|
|
320
|
+
): ReadableStream<Uint8Array> => {
|
|
321
|
+
let next = 0
|
|
322
|
+
let faultApplied = false
|
|
323
|
+
let cancelled = false
|
|
324
|
+
let held: Fiber.Fiber<void> | undefined
|
|
325
|
+
|
|
326
|
+
const emitNext = (controller: ReadableStreamDefaultController<Uint8Array>): void => {
|
|
327
|
+
const chunk = chunks[next]
|
|
328
|
+
|
|
329
|
+
if (chunk === undefined) {
|
|
330
|
+
controller.close()
|
|
331
|
+
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
next += 1
|
|
336
|
+
controller.enqueue(chunk)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return new ReadableStream<Uint8Array>(
|
|
340
|
+
{
|
|
341
|
+
pull: controller => {
|
|
342
|
+
if (fault === undefined || faultApplied || next !== fault.chunks) {
|
|
343
|
+
emitNext(controller)
|
|
344
|
+
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
faultApplied = true
|
|
349
|
+
|
|
350
|
+
return Match.valueTags(fault, {
|
|
351
|
+
FailAfterChunks: ({ chunks: afterChunks }) =>
|
|
352
|
+
controller.error(new WireTransportFault({ afterChunks })),
|
|
353
|
+
TruncateAfterChunks: () => controller.close(),
|
|
354
|
+
HoldAfterChunks: ({ release }) => {
|
|
355
|
+
// Keep the release fiber so cancelling the body interrupts it (and runs its finalizers).
|
|
356
|
+
// `runFork` starts `release` synchronously, so a cancel can land before `held` is set.
|
|
357
|
+
const fiber = Effect.runFork(release)
|
|
358
|
+
held = fiber
|
|
359
|
+
|
|
360
|
+
if (cancelled) {
|
|
361
|
+
held = undefined
|
|
362
|
+
|
|
363
|
+
return Effect.runPromise(Fiber.interrupt(fiber))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return Effect.runPromise(Fiber.await(fiber)).then(exit => {
|
|
367
|
+
held = undefined
|
|
368
|
+
|
|
369
|
+
if (cancelled) return
|
|
370
|
+
|
|
371
|
+
if (Exit.isSuccess(exit)) {
|
|
372
|
+
emitNext(controller)
|
|
373
|
+
} else {
|
|
374
|
+
controller.error(Cause.squash(exit.cause))
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
},
|
|
380
|
+
cancel: () => {
|
|
381
|
+
cancelled = true
|
|
382
|
+
|
|
383
|
+
if (held === undefined) return
|
|
384
|
+
|
|
385
|
+
return Effect.runPromise(Fiber.interrupt(held))
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{ highWaterMark: 0 }
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const webResponse = (status: number, headers: WireHeaders, body: ReplayBody): Response => {
|
|
393
|
+
const init = { status, headers: { ...headers } }
|
|
394
|
+
|
|
395
|
+
if (isNullBodyStatus(status)) {
|
|
396
|
+
return new Response(null, init)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (body.kind === 'body') {
|
|
400
|
+
return new Response(body.body, init)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return new Response(bodyReadable(body.chunks, body.fault), init)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const unmatchedError = (request: HttpClientRequest.HttpClientRequest) =>
|
|
407
|
+
new HttpClientError.HttpClientError({
|
|
408
|
+
reason: new HttpClientError.TransportError({
|
|
409
|
+
request,
|
|
410
|
+
description: 'replay has no remaining recorded exchange'
|
|
411
|
+
})
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
const invalidRecordingError = (request: HttpClientRequest.HttpClientRequest, cause: unknown) =>
|
|
415
|
+
new HttpClientError.HttpClientError({
|
|
416
|
+
reason: new HttpClientError.TransportError({
|
|
417
|
+
request,
|
|
418
|
+
cause,
|
|
419
|
+
description: 'recorded response could not be replayed'
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Cause attached to the `HttpClientError` (reason `TransportError`) of a request
|
|
425
|
+
* whose recording could not be replayed. Retrying clients may retry past it;
|
|
426
|
+
* check the ledger for an `invalid` outcome.
|
|
427
|
+
*/
|
|
428
|
+
export class WireReplayInvalid extends Data.TaggedError('WireReplayInvalid')<{
|
|
429
|
+
readonly reason: string
|
|
430
|
+
}> {
|
|
431
|
+
override get message(): string {
|
|
432
|
+
return this.reason
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Build a replay `HttpClient` and its ledger over the given fixtures. Each
|
|
438
|
+
* call has independent consumption, attempt, and ledger state.
|
|
439
|
+
*/
|
|
440
|
+
export const makeReplayHttpClient = (
|
|
441
|
+
fixtures: ReadonlyArray<WireFixture>,
|
|
442
|
+
options: ReplayHttpClientOptions = {}
|
|
443
|
+
): Effect.Effect<{ readonly client: HttpClient.HttpClient; readonly ledger: ReplayLedgerApi }> =>
|
|
444
|
+
Effect.gen(function* () {
|
|
445
|
+
const candidates = candidatesFrom(fixtures)
|
|
446
|
+
const faults = options.faults ?? []
|
|
447
|
+
|
|
448
|
+
const state = yield* Ref.make<ReplayState>({
|
|
449
|
+
consumed: new Set(),
|
|
450
|
+
attempts: new Map(),
|
|
451
|
+
entries: []
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const decide = (
|
|
455
|
+
method: string,
|
|
456
|
+
normalizedUrl: string,
|
|
457
|
+
entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'>
|
|
458
|
+
) =>
|
|
459
|
+
Ref.modify(state, (current): [ReplayDecision, ReplayState] => {
|
|
460
|
+
const key = requestKey(method, normalizedUrl)
|
|
461
|
+
const attempt = (current.attempts.get(key) ?? 0) + 1
|
|
462
|
+
const attempts = new Map(current.attempts).set(key, attempt)
|
|
463
|
+
const fields: LedgerEntryFields = { ...entry, attempt, match: { outcome: 'unmatched' } }
|
|
464
|
+
|
|
465
|
+
const candidate = candidates.find(
|
|
466
|
+
item => item.key === key && !current.consumed.has(item.id)
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
// Fail closed first: faults never answer unknown or exhausted requests.
|
|
470
|
+
if (candidate === undefined) {
|
|
471
|
+
return [
|
|
472
|
+
{ kind: 'unmatched' },
|
|
473
|
+
{ ...current, attempts, entries: [...current.entries, fields] }
|
|
474
|
+
]
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const statusFault = faults
|
|
478
|
+
.filter(WireFault.$is('StatusOnAttempt'))
|
|
479
|
+
.find(
|
|
480
|
+
fault => fault.attempt === attempt && faultMatches(fault.match, method, normalizedUrl)
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if (statusFault !== undefined) {
|
|
484
|
+
fields.match = { outcome: 'injected' }
|
|
485
|
+
fields.fault = statusFault._tag
|
|
486
|
+
|
|
487
|
+
return [
|
|
488
|
+
{ kind: 'status', fault: statusFault },
|
|
489
|
+
{ ...current, attempts, entries: [...current.entries, fields] }
|
|
490
|
+
]
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const response = candidate.exchange.response
|
|
494
|
+
|
|
495
|
+
const chunkFault = faults
|
|
496
|
+
.filter((fault): fault is ChunkFault => !WireFault.$is('StatusOnAttempt')(fault))
|
|
497
|
+
.find(
|
|
498
|
+
fault =>
|
|
499
|
+
(fault.attempt === undefined || fault.attempt === attempt) &&
|
|
500
|
+
faultMatches(fault.match, method, normalizedUrl)
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
const body = replayBody(response, chunkFault)
|
|
504
|
+
const ref = { fixtureId: candidate.fixtureId, exchangeIndex: candidate.exchangeIndex }
|
|
505
|
+
|
|
506
|
+
if (Result.isFailure(body)) {
|
|
507
|
+
// The fault (or recording) did not apply: record the failure, not the
|
|
508
|
+
// fault, and leave the exchange unconsumed.
|
|
509
|
+
fields.match = { outcome: 'invalid', ...ref, reason: body.failure }
|
|
510
|
+
|
|
511
|
+
return [
|
|
512
|
+
{ kind: 'invalid', reason: body.failure },
|
|
513
|
+
{ ...current, attempts, entries: [...current.entries, fields] }
|
|
514
|
+
]
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
fields.match = { outcome: 'matched', ...ref }
|
|
518
|
+
|
|
519
|
+
if (chunkFault !== undefined) {
|
|
520
|
+
fields.fault = chunkFault._tag
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return [
|
|
524
|
+
{ kind: 'exchange', response, body: body.success },
|
|
525
|
+
{
|
|
526
|
+
consumed: new Set(current.consumed).add(candidate.id),
|
|
527
|
+
attempts,
|
|
528
|
+
entries: [...current.entries, fields]
|
|
529
|
+
}
|
|
530
|
+
]
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
const client = HttpClient.make((request, url) =>
|
|
534
|
+
Effect.gen(function* () {
|
|
535
|
+
const normalizedUrl = normalizeWireUrl(url.toString())
|
|
536
|
+
const bodyText = requestBodyText(request)
|
|
537
|
+
|
|
538
|
+
const entry: Omit<LedgerEntryFields, 'attempt' | 'match' | 'fault'> = {
|
|
539
|
+
method: request.method,
|
|
540
|
+
url: normalizedUrl,
|
|
541
|
+
headers: redactHeaders(headerRecord(request.headers))
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (bodyText !== undefined) {
|
|
545
|
+
entry.bodyText = bodyText
|
|
546
|
+
|
|
547
|
+
const bodyJson = yield* parseJsonText(bodyText)
|
|
548
|
+
|
|
549
|
+
if (Option.isSome(bodyJson)) {
|
|
550
|
+
entry.bodyJson = bodyJson.value
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const decision = yield* decide(request.method, normalizedUrl, entry)
|
|
555
|
+
|
|
556
|
+
if (decision.kind === 'unmatched') {
|
|
557
|
+
return yield* Effect.fail(unmatchedError(request))
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (decision.kind === 'invalid') {
|
|
561
|
+
return yield* Effect.fail(
|
|
562
|
+
invalidRecordingError(request, new WireReplayInvalid({ reason: decision.reason }))
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const source = yield* Effect.try({
|
|
567
|
+
try: () =>
|
|
568
|
+
decision.kind === 'status'
|
|
569
|
+
? webResponse(decision.fault.status, decision.fault.headers ?? {}, {
|
|
570
|
+
kind: 'body',
|
|
571
|
+
body: decision.fault.body ?? ''
|
|
572
|
+
})
|
|
573
|
+
: webResponse(decision.response.status, decision.response.headers, decision.body),
|
|
574
|
+
catch: cause => invalidRecordingError(request, cause)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
return HttpClientResponse.fromWeb(request, source)
|
|
578
|
+
})
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
const ledger: ReplayLedgerApi = {
|
|
582
|
+
entries: Ref.get(state).pipe(Effect.map(current => current.entries)),
|
|
583
|
+
remaining: Ref.get(state).pipe(
|
|
584
|
+
Effect.map(current =>
|
|
585
|
+
candidates
|
|
586
|
+
.filter(candidate => !current.consumed.has(candidate.id))
|
|
587
|
+
.map(candidate => ({
|
|
588
|
+
fixtureId: candidate.fixtureId,
|
|
589
|
+
exchangeIndex: candidate.exchangeIndex
|
|
590
|
+
}))
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return { client, ledger }
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
export const ReplayHttpClient = {
|
|
599
|
+
/**
|
|
600
|
+
* Layer providing a replay `HttpClient` and its `ReplayLedger`. Each layer
|
|
601
|
+
* build gets fresh consumption and ledger state.
|
|
602
|
+
*/
|
|
603
|
+
layer: (
|
|
604
|
+
fixtures: ReadonlyArray<WireFixture>,
|
|
605
|
+
options: ReplayHttpClientOptions = {}
|
|
606
|
+
): Layer.Layer<HttpClient.HttpClient | ReplayLedger> =>
|
|
607
|
+
Layer.unwrap(
|
|
608
|
+
makeReplayHttpClient(fixtures, options).pipe(
|
|
609
|
+
Effect.map(({ client, ledger }) =>
|
|
610
|
+
Layer.mergeAll(
|
|
611
|
+
Layer.succeed(HttpClient.HttpClient, client),
|
|
612
|
+
Layer.succeed(ReplayLedger, ledger)
|
|
613
|
+
)
|
|
614
|
+
)
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
} as const
|