ReviewOS

also looking at this

stacks/ts-http

feat: fluent request builder + test faking/mocking

#1761
Merged glennmichael123 wants to merge feat/fluent-builder-and-faking into main
7 files +860 -4
packages/httx/src/client.tsmodified+65-4
Changes to packages/httx/src/client.ts
@@ -1,8 +1,10 @@
11import type { Result } from 'ts-error-handling'
2import type { FakeRequest } from './fake'
23import type { HttxConfig, HttxResponse, RequestCompleteRecord, RequestOptions, RetryOptions } from './types'
34import { err, ok } from 'ts-error-handling'
45import { CircuitBreaker, CircuitOpenError } from './circuit-breaker'
56import { HttxNetworkError, HttxRequestError, HttxResponseError, HttxTimeoutError } from './errors'
7import { buildFakeResponse, HttxFakeUnmatchedError, isFaking, resolveFake } from './fake'
68import { debugLog, sleep } from './utils'
79
810export class HttxClient {
@@ -207,10 +209,17 @@ export class HttxClient {
207209 body,
208210 }
209211
210 const response = await fetch(finalUrl, {
211 ...requestInit,
212 verbose: options.verbose || this.config.verbose !== false,
213 })
212 // Faking intercept (stacksjs/ts-http#1760): when fakes are
213 // installed, resolve a stub instead of hitting the network. The
214 // recorder captures every request (matched or not) so
215 // `assertSent` can inspect it. A real `Response` is synthesized
216 // so the parse path below is identical to a live request.
217 const response = isFaking()
218 ? await this.fetchFaked(finalUrl, headers, body, options)
219 : await fetch(finalUrl, {
220 ...requestInit,
221 verbose: options.verbose || this.config.verbose !== false,
222 })
214223
215224 // Handle streaming response
216225 if (options.stream && response.body) {
@@ -278,6 +287,58 @@ export class HttxClient {
278287 }
279288 }
280289
290 /**
291 * Resolve a faked response (stacksjs/ts-http#1760). Records the
292 * request, then either returns a synthesized `Response` from the
293 * matched stub, falls through to the real network (passthrough), or
294 * throws `HttxFakeUnmatchedError` (strict default).
295 */
296 private async fetchFaked(
297 finalUrl: string,
298 headers: Headers,
299 body: BodyInit | undefined,
300 options: RequestOptions,
301 ): Promise<Response> {
302 // Record headers with the caller's original casing (HTTP header
303 // names are case-insensitive, but assertions read a plain object,
304 // so `req.headers.Authorization` should match what the caller
305 // wrote). Mirror buildHeaders' content-type/accept derivation.
306 const headerObj: Record<string, string> = { ...this.config.defaultHeaders }
307 if (options.headers && !(options.headers instanceof Headers) && !Array.isArray(options.headers)) {
308 Object.assign(headerObj, options.headers as Record<string, string>)
309 }
310 else if (options.headers) {
311 new Headers(options.headers).forEach((value, key) => { headerObj[key] = value })
312 }
313 if (options.json) headerObj['Content-Type'] = 'application/json'
314 else if (options.form) headerObj['Content-Type'] = 'application/x-www-form-urlencoded'
315 else if (options.multipart) delete headerObj['Content-Type']
316 if (options.acceptHeader) headerObj.Accept = options.acceptHeader
317 else if (options.json && !('Accept' in headerObj)) headerObj.Accept = 'application/json'
318
319 const fakeReq: FakeRequest = {
320 method: options.method,
321 url: finalUrl,
322 headers: headerObj,
323 body: typeof body === 'string' ? body : undefined,
324 }
325
326 const resolution = resolveFake(fakeReq)
327 if (resolution.matched && resolution.spec) {
328 return buildFakeResponse(resolution.spec)
329 }
330 if (resolution.passthrough) {
331 return fetch(finalUrl, {
332 method: options.method,
333 headers,
334 signal: options.signal,
335 body,
336 verbose: options.verbose || this.config.verbose !== false,
337 })
338 }
339 throw new HttxFakeUnmatchedError(options.method, finalUrl)
340 }
341
281342 private fireRequestComplete<T>(
282343 options: RequestOptions,
283344 url: string,