also looking at this
feat: fluent request builder + test faking/mocking
#1761
7 files
+860
-4
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import type { Result } from 'ts-error-handling' |
| 2 | import type { FakeRequest } from './fake' | |
| 2 | 3 | import type { HttxConfig, HttxResponse, RequestCompleteRecord, RequestOptions, RetryOptions } from './types' |
| 3 | 4 | import { err, ok } from 'ts-error-handling' |
| 4 | 5 | import { CircuitBreaker, CircuitOpenError } from './circuit-breaker' |
| 5 | 6 | import { HttxNetworkError, HttxRequestError, HttxResponseError, HttxTimeoutError } from './errors' |
| 7 | import { buildFakeResponse, HttxFakeUnmatchedError, isFaking, resolveFake } from './fake' | |
| 6 | 8 | import { debugLog, sleep } from './utils' |
| 7 | 9 | |
| 8 | 10 | export class HttxClient { |
| @@ -207,10 +209,17 @@ export class HttxClient { | ||
| 207 | 209 | body, |
| 208 | 210 | } |
| 209 | 211 | |
| 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 | }) | |
| 214 | 223 | |
| 215 | 224 | // Handle streaming response |
| 216 | 225 | if (options.stream && response.body) { |
| @@ -278,6 +287,58 @@ export class HttxClient { | ||
| 278 | 287 | } |
| 279 | 288 | } |
| 280 | 289 | |
| 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 | ||
| 281 | 342 | private fireRequestComplete<T>( |
| 282 | 343 | options: RequestOptions, |
| 283 | 344 | url: string, |