also looking at this
feat: fluent request builder + test faking/mocking
#1761
7 files
+860
-4
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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, |
| @@ -0,0 +1,246 @@ | ||
| 1 | /** | |
| 2 | * Test faking / mocking for httx (stacksjs/ts-http#1760). | |
| 3 | * | |
| 4 | * Intercepts dispatch before the network call so tests can stub | |
| 5 | * responses and assert what was sent — no real HTTP, no per-test | |
| 6 | * `globalThis.fetch` monkey-patch. Mirrors Laravel's `Http::fake()`. | |
| 7 | * | |
| 8 | * ```ts | |
| 9 | * import { httx } from '@stacksjs/httx' | |
| 10 | * | |
| 11 | * httx.fake({ 'api.github.com/*': { status: 200, body: { login: 'octocat' } } }) | |
| 12 | * const res = await httx.get('https://api.github.com/users/octocat') | |
| 13 | * httx.assertSent(req => req.method === 'GET') | |
| 14 | * httx.restoreFetch() | |
| 15 | * ``` | |
| 16 | */ | |
| 17 | ||
| 18 | /** A request as seen by the fake layer (and the recorder). */ | |
| 19 | export interface FakeRequest { | |
| 20 | method: string | |
| 21 | url: string | |
| 22 | headers: Record<string, string> | |
| 23 | body?: string | |
| 24 | } | |
| 25 | ||
| 26 | /** A canned response. All fields optional — defaults to an empty 200. */ | |
| 27 | export interface FakeResponseSpec { | |
| 28 | status?: number | |
| 29 | body?: unknown | |
| 30 | headers?: Record<string, string> | |
| 31 | /** Override the reason phrase; defaults to the standard for `status`. */ | |
| 32 | statusText?: string | |
| 33 | } | |
| 34 | ||
| 35 | /** Dynamic stub: compute the response from the outgoing request. */ | |
| 36 | export type FakeResponder = FakeResponseSpec | ((req: FakeRequest) => FakeResponseSpec) | |
| 37 | ||
| 38 | /** | |
| 39 | * Ordered responses for a single pattern — first call returns the | |
| 40 | * first spec, second the second, etc. The last spec repeats once the | |
| 41 | * sequence is exhausted (matches Laravel's `Http::sequence()` "pad" | |
| 42 | * behaviour rather than throwing). | |
| 43 | */ | |
| 44 | export class FakeSequence { | |
| 45 | private index = 0 | |
| 46 | constructor(private readonly specs: FakeResponseSpec[]) { | |
| 47 | if (specs.length === 0) | |
| 48 | throw new Error('[httx.sequence] needs at least one response spec') | |
| 49 | } | |
| 50 | ||
| 51 | next(): FakeResponseSpec { | |
| 52 | const spec = this.specs[Math.min(this.index, this.specs.length - 1)] | |
| 53 | this.index++ | |
| 54 | return spec | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | export type FakeStub = FakeResponder | FakeSequence | |
| 59 | ||
| 60 | /** Map of URL glob (or `'METHOD url-glob'`) → stub. */ | |
| 61 | export type FakeMap = Record<string, FakeStub> | |
| 62 | ||
| 63 | export interface FakeOptions { | |
| 64 | /** | |
| 65 | * When a request matches no stub: `false` (default) throws so a | |
| 66 | * test can't silently hit the network; `true` lets it fall through | |
| 67 | * to the real `fetch`. | |
| 68 | */ | |
| 69 | passthrough?: boolean | |
| 70 | } | |
| 71 | ||
| 72 | interface FakeState { | |
| 73 | stubs: FakeMap | null // null → fake everything with an empty 200 | |
| 74 | passthrough: boolean | |
| 75 | } | |
| 76 | ||
| 77 | let state: FakeState | null = null | |
| 78 | const recorded: FakeRequest[] = [] | |
| 79 | ||
| 80 | /** Error thrown when faking is strict and a request matches no stub. */ | |
| 81 | export class HttxFakeUnmatchedError extends Error { | |
| 82 | constructor(method: string, url: string) { | |
| 83 | super( | |
| 84 | `[httx.fake] No stub matched ${method} ${url}. ` | |
| 85 | + `Add a matching pattern to httx.fake({...}), pass { passthrough: true } to allow real requests, ` | |
| 86 | + `or call httx.fake() with no arguments to stub everything.`, | |
| 87 | ) | |
| 88 | this.name = 'HttxFakeUnmatchedError' | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Turn a glob pattern into a RegExp. `*` matches any run of | |
| 94 | * characters; everything else is matched literally. The protocol | |
| 95 | * (`https://`) is optional in patterns, so `api.test/*` matches | |
| 96 | * `https://api.test/x`. | |
| 97 | */ | |
| 98 | function globToRegExp(pattern: string): RegExp { | |
| 99 | const escaped = pattern | |
| 100 | .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex metachars (not `*`) | |
| 101 | .replace(/\*/g, '.*') | |
| 102 | return new RegExp(`^${escaped}$`) | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Does a `'pattern'` (optionally `'METHOD pattern'`) match this | |
| 107 | * request? URL is compared both as-is and with the protocol stripped, | |
| 108 | * so patterns can omit `https://`. | |
| 109 | */ | |
| 110 | function patternMatches(pattern: string, req: FakeRequest): boolean { | |
| 111 | let urlPattern = pattern | |
| 112 | const spaceIdx = pattern.indexOf(' ') | |
| 113 | if (spaceIdx > 0) { | |
| 114 | const maybeMethod = pattern.slice(0, spaceIdx).toUpperCase() | |
| 115 | if (/^[A-Z]+$/.test(maybeMethod) && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(maybeMethod)) { | |
| 116 | if (maybeMethod !== req.method.toUpperCase()) return false | |
| 117 | urlPattern = pattern.slice(spaceIdx + 1).trim() | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | const re = globToRegExp(urlPattern) | |
| 122 | const stripped = req.url.replace(/^[a-z]+:\/\//i, '') | |
| 123 | return re.test(req.url) || re.test(stripped) | |
| 124 | } | |
| 125 | ||
| 126 | /** Enable faking. No args → stub everything with an empty 200. */ | |
| 127 | export function installFakes(stubs?: FakeMap, options: FakeOptions = {}): void { | |
| 128 | state = { stubs: stubs ?? null, passthrough: options.passthrough ?? false } | |
| 129 | recorded.length = 0 | |
| 130 | } | |
| 131 | ||
| 132 | /** Disable faking and clear recorded requests. */ | |
| 133 | export function uninstallFakes(): void { | |
| 134 | state = null | |
| 135 | recorded.length = 0 | |
| 136 | } | |
| 137 | ||
| 138 | export function isFaking(): boolean { | |
| 139 | return state !== null | |
| 140 | } | |
| 141 | ||
| 142 | export interface FakeResolution { | |
| 143 | matched: boolean | |
| 144 | spec?: FakeResponseSpec | |
| 145 | /** True when faking is on but no stub matched and passthrough is set. */ | |
| 146 | passthrough?: boolean | |
| 147 | } | |
| 148 | ||
| 149 | /** | |
| 150 | * Record the request, then resolve a stub for it. Always records | |
| 151 | * (even unmatched) so `assertSent` sees every attempt. | |
| 152 | */ | |
| 153 | export function resolveFake(req: FakeRequest): FakeResolution { | |
| 154 | if (!state) return { matched: false } | |
| 155 | recorded.push(req) | |
| 156 | ||
| 157 | // Bare fake() → everything is an empty 200. | |
| 158 | if (state.stubs === null) { | |
| 159 | return { matched: true, spec: { status: 200 } } | |
| 160 | } | |
| 161 | ||
| 162 | for (const [pattern, stub] of Object.entries(state.stubs)) { | |
| 163 | if (!patternMatches(pattern, req)) continue | |
| 164 | const spec = stub instanceof FakeSequence | |
| 165 | ? stub.next() | |
| 166 | : typeof stub === 'function' | |
| 167 | ? stub(req) | |
| 168 | : stub | |
| 169 | return { matched: true, spec } | |
| 170 | } | |
| 171 | ||
| 172 | if (state.passthrough) return { matched: false, passthrough: true } | |
| 173 | return { matched: false } | |
| 174 | } | |
| 175 | ||
| 176 | /** Build a real `Response` from a spec so the client's parse path is unchanged. */ | |
| 177 | export function buildFakeResponse(spec: FakeResponseSpec): Response { | |
| 178 | const status = spec.status ?? 200 | |
| 179 | const headers = new Headers(spec.headers ?? {}) | |
| 180 | ||
| 181 | let body: BodyInit | null = null | |
| 182 | if (spec.body != null) { | |
| 183 | if (typeof spec.body === 'string') { | |
| 184 | body = spec.body | |
| 185 | if (!headers.has('Content-Type')) headers.set('Content-Type', 'text/plain') | |
| 186 | } | |
| 187 | else { | |
| 188 | body = JSON.stringify(spec.body) | |
| 189 | if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json') | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | // 204/205/304 must not carry a body per the Fetch spec. | |
| 194 | const nullBody = status === 204 || status === 205 || status === 304 | |
| 195 | return new Response(nullBody ? null : body, { | |
| 196 | status, | |
| 197 | statusText: spec.statusText, | |
| 198 | headers, | |
| 199 | }) | |
| 200 | } | |
| 201 | ||
| 202 | // --------------------------------------------------------------------------- | |
| 203 | // Recorder + assertions | |
| 204 | // --------------------------------------------------------------------------- | |
| 205 | ||
| 206 | /** Every request dispatched while faking was active, in order. */ | |
| 207 | export function recordedRequests(): readonly FakeRequest[] { | |
| 208 | return recorded | |
| 209 | } | |
| 210 | ||
| 211 | export function clearRecorded(): void { | |
| 212 | recorded.length = 0 | |
| 213 | } | |
| 214 | ||
| 215 | export function assertSent(predicate: (req: FakeRequest) => boolean): void { | |
| 216 | if (!recorded.some(predicate)) { | |
| 217 | throw new Error( | |
| 218 | `[httx.assertSent] No recorded request matched the predicate. ` | |
| 219 | + `${recorded.length} request(s) recorded: ${summarize(recorded)}`, | |
| 220 | ) | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | export function assertNotSent(predicate: (req: FakeRequest) => boolean): void { | |
| 225 | const hit = recorded.find(predicate) | |
| 226 | if (hit) { | |
| 227 | throw new Error(`[httx.assertNotSent] A request matched the predicate but should not have: ${hit.method} ${hit.url}`) | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | export function assertSentCount(count: number): void { | |
| 232 | if (recorded.length !== count) { | |
| 233 | throw new Error(`[httx.assertSentCount] Expected ${count} request(s), recorded ${recorded.length}: ${summarize(recorded)}`) | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | export function assertNothingSent(): void { | |
| 238 | if (recorded.length > 0) { | |
| 239 | throw new Error(`[httx.assertNothingSent] Expected no requests, recorded ${recorded.length}: ${summarize(recorded)}`) | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | function summarize(reqs: readonly FakeRequest[]): string { | |
| 244 | if (reqs.length === 0) return '(none)' | |
| 245 | return reqs.map(r => `${r.method} ${r.url}`).join(', ') | |
| 246 | } | |
| @@ -0,0 +1,92 @@ | ||
| 1 | /** | |
| 2 | * Default `httx` facade — a process-wide convenience singleton that | |
| 3 | * bundles the verb methods of a shared {@link HttxClient}, the fluent | |
| 4 | * {@link http} builder, and the test-faking controls | |
| 5 | * (stacksjs/ts-http#1759, #1760). | |
| 6 | * | |
| 7 | * ```ts | |
| 8 | * import { httx } from '@stacksjs/httx' | |
| 9 | * | |
| 10 | * const res = await httx.get<User>('https://api.test/users/1') | |
| 11 | * const built = await httx.request().withToken(t).post('https://api.test/x', body) | |
| 12 | * | |
| 13 | * // tests | |
| 14 | * httx.fake({ 'api.test/*': { status: 200, body: { ok: true } } }) | |
| 15 | * await httx.get('https://api.test/ping') | |
| 16 | * httx.assertSentCount(1) | |
| 17 | * httx.restoreFetch() | |
| 18 | * ``` | |
| 19 | */ | |
| 20 | ||
| 21 | import type { Result } from 'ts-error-handling' | |
| 22 | import type { FakeMap, FakeOptions, FakeRequest, FakeResponseSpec } from './fake' | |
| 23 | import type { HttxConfig, HttxResponse, RequestOptions } from './types' | |
| 24 | import { HttxClient } from './client' | |
| 25 | import { | |
| 26 | assertNothingSent, | |
| 27 | assertNotSent, | |
| 28 | assertSent, | |
| 29 | assertSentCount, | |
| 30 | clearRecorded, | |
| 31 | FakeSequence, | |
| 32 | installFakes, | |
| 33 | recordedRequests, | |
| 34 | uninstallFakes, | |
| 35 | } from './fake' | |
| 36 | import { http, PendingRequest } from './pending-request' | |
| 37 | ||
| 38 | type VerbResult<T> = Promise<Result<HttxResponse<T>, Error>> | |
| 39 | type BodylessOptions = Omit<RequestOptions, 'method'> | |
| 40 | type BodyOptions = Omit<RequestOptions, 'method' | 'body'> | |
| 41 | ||
| 42 | /** Public shape of the {@link httx} facade. Annotated explicitly so | |
| 43 | * the package can build under `--isolatedDeclarations`. */ | |
| 44 | export interface Httx { | |
| 45 | client: HttxClient | |
| 46 | create: (config?: Partial<HttxConfig>) => HttxClient | |
| 47 | request: (config?: Partial<HttxConfig>) => PendingRequest | |
| 48 | get: <T = unknown>(url: string, options?: BodylessOptions) => VerbResult<T> | |
| 49 | post: <T = unknown>(url: string, body?: unknown, options?: BodyOptions) => VerbResult<T> | |
| 50 | put: <T = unknown>(url: string, body?: unknown, options?: BodyOptions) => VerbResult<T> | |
| 51 | patch: <T = unknown>(url: string, body?: unknown, options?: BodyOptions) => VerbResult<T> | |
| 52 | delete: <T = unknown>(url: string, options?: BodylessOptions) => VerbResult<T> | |
| 53 | head: <T = unknown>(url: string, options?: BodylessOptions) => VerbResult<T> | |
| 54 | fake: (stubs?: FakeMap, options?: FakeOptions) => void | |
| 55 | restoreFetch: () => void | |
| 56 | sequence: (specs: FakeResponseSpec[]) => FakeSequence | |
| 57 | assertSent: (predicate: (req: FakeRequest) => boolean) => void | |
| 58 | assertNotSent: (predicate: (req: FakeRequest) => boolean) => void | |
| 59 | assertSentCount: (count: number) => void | |
| 60 | assertNothingSent: () => void | |
| 61 | recorded: () => readonly FakeRequest[] | |
| 62 | clearRecorded: () => void | |
| 63 | } | |
| 64 | ||
| 65 | const defaultClient = new HttxClient() | |
| 66 | ||
| 67 | export const httx: Httx = { | |
| 68 | client: defaultClient, | |
| 69 | create: (config: Partial<HttxConfig> = {}): HttxClient => new HttxClient(config), | |
| 70 | request: (config: Partial<HttxConfig> = {}): PendingRequest => http(config), | |
| 71 | ||
| 72 | // verbs delegate to the shared client (which honours the fake registry) | |
| 73 | get: <T = unknown>(url: string, options?: BodylessOptions): VerbResult<T> => defaultClient.get<T>(url, options), | |
| 74 | post: <T = unknown>(url: string, body?: unknown, options?: BodyOptions): VerbResult<T> => defaultClient.post<T>(url, body, options), | |
| 75 | put: <T = unknown>(url: string, body?: unknown, options?: BodyOptions): VerbResult<T> => defaultClient.put<T>(url, body, options), | |
| 76 | patch: <T = unknown>(url: string, body?: unknown, options?: BodyOptions): VerbResult<T> => defaultClient.patch<T>(url, body, options), | |
| 77 | delete: <T = unknown>(url: string, options?: BodylessOptions): VerbResult<T> => defaultClient.delete<T>(url, options), | |
| 78 | head: <T = unknown>(url: string, options?: BodylessOptions): VerbResult<T> => defaultClient.head<T>(url, options), | |
| 79 | ||
| 80 | // faking (stacksjs/ts-http#1760) | |
| 81 | fake: (stubs?: FakeMap, options?: FakeOptions): void => installFakes(stubs, options), | |
| 82 | restoreFetch: (): void => uninstallFakes(), | |
| 83 | sequence: (specs: FakeResponseSpec[]): FakeSequence => new FakeSequence(specs), | |
| 84 | ||
| 85 | // assertions | |
| 86 | assertSent: (predicate: (req: FakeRequest) => boolean): void => assertSent(predicate), | |
| 87 | assertNotSent: (predicate: (req: FakeRequest) => boolean): void => assertNotSent(predicate), | |
| 88 | assertSentCount: (count: number): void => assertSentCount(count), | |
| 89 | assertNothingSent: (): void => assertNothingSent(), | |
| 90 | recorded: (): readonly FakeRequest[] => recordedRequests(), | |
| 91 | clearRecorded: (): void => clearRecorded(), | |
| 92 | } | |
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * Fluent request builder for httx (stacksjs/ts-http#1759). | |
| 3 | * | |
| 4 | * Sugar over `HttxClient.request()` — accumulates per-request config | |
| 5 | * through a chainable API, then dispatches via the existing client so | |
| 6 | * all retry / circuit-breaker / timing machinery is reused. The | |
| 7 | * options-object API on `HttxClient` stays the canonical entry point; | |
| 8 | * this is purely additive. | |
| 9 | * | |
| 10 | * ```ts | |
| 11 | * import { http } from '@stacksjs/httx' | |
| 12 | * | |
| 13 | * const res = await http() | |
| 14 | * .withToken(token) | |
| 15 | * .withHeaders({ 'X-Tenant': id }) | |
| 16 | * .acceptJson() | |
| 17 | * .retry(3, 200) | |
| 18 | * .timeout(5000) | |
| 19 | * .post<CreatedOrder>('https://api.test/orders', body) | |
| 20 | * ``` | |
| 21 | */ | |
| 22 | ||
| 23 | import type { Result } from 'ts-error-handling' | |
| 24 | import type { HttpMethod, HttxConfig, HttxResponse, RequestOptions, RetryOptions } from './types' | |
| 25 | import { HttxClient } from './client' | |
| 26 | ||
| 27 | export class PendingRequest { | |
| 28 | private clientConfig: Partial<HttxConfig> | |
| 29 | private client: HttxClient | |
| 30 | private headers: Record<string, string> = {} | |
| 31 | private query: Record<string, string> = {} | |
| 32 | private bodyMode: 'json' | 'form' | 'multipart' | null = null | |
| 33 | private perRequest: Partial<Omit<RequestOptions, 'method' | 'body'>> = {} | |
| 34 | ||
| 35 | constructor(config: Partial<HttxConfig> = {}) { | |
| 36 | this.clientConfig = config | |
| 37 | this.client = new HttxClient(config) | |
| 38 | } | |
| 39 | ||
| 40 | // --- headers ------------------------------------------------------------- | |
| 41 | ||
| 42 | /** Merge headers (existing keys win is NOT the rule — later calls overwrite). */ | |
| 43 | withHeaders(headers: Record<string, string>): this { | |
| 44 | Object.assign(this.headers, headers) | |
| 45 | return this | |
| 46 | } | |
| 47 | ||
| 48 | /** Replace the accumulated header set entirely. */ | |
| 49 | replaceHeaders(headers: Record<string, string>): this { | |
| 50 | this.headers = { ...headers } | |
| 51 | return this | |
| 52 | } | |
| 53 | ||
| 54 | /** `Authorization: <type> <token>` (defaults to Bearer). */ | |
| 55 | withToken(token: string, type = 'Bearer'): this { | |
| 56 | this.headers.Authorization = `${type} ${token}` | |
| 57 | return this | |
| 58 | } | |
| 59 | ||
| 60 | /** `Authorization: Basic base64(user:pass)`. */ | |
| 61 | withBasicAuth(username: string, password: string): this { | |
| 62 | const encoded = btoa(`${username}:${password}`) | |
| 63 | this.headers.Authorization = `Basic ${encoded}` | |
| 64 | return this | |
| 65 | } | |
| 66 | ||
| 67 | /** `User-Agent` header. */ | |
| 68 | withUserAgent(userAgent: string): this { | |
| 69 | this.headers['User-Agent'] = userAgent | |
| 70 | return this | |
| 71 | } | |
| 72 | ||
| 73 | /** `Accept` header. */ | |
| 74 | accept(mime: string): this { | |
| 75 | this.perRequest.acceptHeader = mime | |
| 76 | return this | |
| 77 | } | |
| 78 | ||
| 79 | /** Shorthand for `accept('application/json')`. */ | |
| 80 | acceptJson(): this { | |
| 81 | return this.accept('application/json') | |
| 82 | } | |
| 83 | ||
| 84 | // --- query --------------------------------------------------------------- | |
| 85 | ||
| 86 | /** Merge query-string parameters. */ | |
| 87 | withQueryParameters(params: Record<string, string>): this { | |
| 88 | Object.assign(this.query, params) | |
| 89 | return this | |
| 90 | } | |
| 91 | ||
| 92 | // --- body encoding ------------------------------------------------------- | |
| 93 | ||
| 94 | /** Send the body as JSON (default for object bodies on post/put/patch). */ | |
| 95 | asJson(): this { | |
| 96 | this.bodyMode = 'json' | |
| 97 | return this | |
| 98 | } | |
| 99 | ||
| 100 | /** Send the body as `application/x-www-form-urlencoded`. */ | |
| 101 | asForm(): this { | |
| 102 | this.bodyMode = 'form' | |
| 103 | return this | |
| 104 | } | |
| 105 | ||
| 106 | /** Send the body as `multipart/form-data`. */ | |
| 107 | asMultipart(): this { | |
| 108 | this.bodyMode = 'multipart' | |
| 109 | return this | |
| 110 | } | |
| 111 | ||
| 112 | // --- transport ----------------------------------------------------------- | |
| 113 | ||
| 114 | /** Per-request timeout in milliseconds. */ | |
| 115 | timeout(ms: number): this { | |
| 116 | this.perRequest.timeout = ms | |
| 117 | return this | |
| 118 | } | |
| 119 | ||
| 120 | /** Retry config. `retry(3)` or `retry(3, 200)` or full `RetryOptions`. */ | |
| 121 | retry(times: number | RetryOptions, delayMs?: number, shouldRetry?: RetryOptions['shouldRetry']): this { | |
| 122 | this.perRequest.retry = typeof times === 'number' | |
| 123 | ? { retries: times, ...(delayMs != null ? { retryDelay: delayMs } : {}), ...(shouldRetry ? { shouldRetry } : {}) } | |
| 124 | : times | |
| 125 | return this | |
| 126 | } | |
| 127 | ||
| 128 | /** Per-builder base URL. Rebuilds the underlying client. */ | |
| 129 | baseUrl(url: string): this { | |
| 130 | this.clientConfig = { ...this.clientConfig, baseUrl: url } | |
| 131 | this.client = new HttxClient(this.clientConfig) | |
| 132 | return this | |
| 133 | } | |
| 134 | ||
| 135 | /** Escape hatch: merge raw `RequestOptions` (minus method/body). */ | |
| 136 | withOptions(options: Partial<Omit<RequestOptions, 'method' | 'body'>>): this { | |
| 137 | Object.assign(this.perRequest, options) | |
| 138 | return this | |
| 139 | } | |
| 140 | ||
| 141 | // --- terminal verbs ------------------------------------------------------ | |
| 142 | ||
| 143 | get<T = unknown>(url: string): Promise<Result<HttxResponse<T>, Error>> { | |
| 144 | return this.dispatch<T>('GET', url) | |
| 145 | } | |
| 146 | ||
| 147 | post<T = unknown>(url: string, body?: unknown): Promise<Result<HttxResponse<T>, Error>> { | |
| 148 | return this.dispatch<T>('POST', url, body) | |
| 149 | } | |
| 150 | ||
| 151 | put<T = unknown>(url: string, body?: unknown): Promise<Result<HttxResponse<T>, Error>> { | |
| 152 | return this.dispatch<T>('PUT', url, body) | |
| 153 | } | |
| 154 | ||
| 155 | patch<T = unknown>(url: string, body?: unknown): Promise<Result<HttxResponse<T>, Error>> { | |
| 156 | return this.dispatch<T>('PATCH', url, body) | |
| 157 | } | |
| 158 | ||
| 159 | delete<T = unknown>(url: string, body?: unknown): Promise<Result<HttxResponse<T>, Error>> { | |
| 160 | return this.dispatch<T>('DELETE', url, body) | |
| 161 | } | |
| 162 | ||
| 163 | head<T = unknown>(url: string): Promise<Result<HttxResponse<T>, Error>> { | |
| 164 | return this.dispatch<T>('HEAD', url) | |
| 165 | } | |
| 166 | ||
| 167 | private dispatch<T>(method: HttpMethod, url: string, body?: unknown): Promise<Result<HttxResponse<T>, Error>> { | |
| 168 | const options: RequestOptions = { | |
| 169 | ...this.perRequest, | |
| 170 | method, | |
| 171 | headers: this.headers, | |
| 172 | } | |
| 173 | ||
| 174 | if (Object.keys(this.query).length > 0) { | |
| 175 | options.query = this.query | |
| 176 | } | |
| 177 | ||
| 178 | if (body !== undefined) { | |
| 179 | options.body = body as RequestOptions['body'] | |
| 180 | // Default to JSON for object bodies unless an explicit mode was set. | |
| 181 | const mode = this.bodyMode | |
| 182 | ?? (typeof body === 'object' && body !== null && !(body instanceof FormData) ? 'json' : null) | |
| 183 | if (mode) options[mode] = true | |
| 184 | } | |
| 185 | else if (this.bodyMode) { | |
| 186 | options[this.bodyMode] = true | |
| 187 | } | |
| 188 | ||
| 189 | return this.client.request<T>(url, options) | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * Start a fluent request. Pass a config (baseUrl, defaultHeaders, | |
| 195 | * timeout, retry, circuitBreaker) to seed defaults for this chain. | |
| 196 | */ | |
| 197 | export function http(config: Partial<HttxConfig> = {}): PendingRequest { | |
| 198 | return new PendingRequest(config) | |
| 199 | } | |
| @@ -0,0 +1,154 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { HttxFakeUnmatchedError } from '../src/fake' | |
| 3 | import { httx } from '../src/httx' | |
| 4 | ||
| 5 | /** | |
| 6 | * Faking + assertions (stacksjs/ts-http#1760). Every test tears the | |
| 7 | * fakes down in afterEach so a leak can't bleed into the next case. | |
| 8 | */ | |
| 9 | ||
| 10 | afterEach(() => { | |
| 11 | httx.restoreFetch() | |
| 12 | }) | |
| 13 | ||
| 14 | describe('httx.fake — stubbing', () => { | |
| 15 | it('stubs a matched URL with a JSON body', async () => { | |
| 16 | httx.fake({ 'api.github.com/*': { status: 200, body: { login: 'octocat' } } }) | |
| 17 | ||
| 18 | const res = await httx.get<{ login: string }>('https://api.github.com/users/octocat') | |
| 19 | expect(res.isOk).toBe(true) | |
| 20 | const response = res.unwrap() | |
| 21 | expect(response.status).toBe(200) | |
| 22 | expect(response.data).toEqual({ login: 'octocat' }) | |
| 23 | }) | |
| 24 | ||
| 25 | it('bare fake() stubs everything with an empty 200', async () => { | |
| 26 | httx.fake() | |
| 27 | const res = await httx.get('https://anything.test/x') | |
| 28 | expect(res.isOk).toBe(true) | |
| 29 | expect(res.unwrap().status).toBe(200) | |
| 30 | }) | |
| 31 | ||
| 32 | it('matches without requiring the protocol in the pattern', async () => { | |
| 33 | httx.fake({ 'api.test/users/*': { status: 200, body: { ok: true } } }) | |
| 34 | const res = await httx.get('https://api.test/users/1') | |
| 35 | expect(res.unwrap().status).toBe(200) | |
| 36 | }) | |
| 37 | ||
| 38 | it('supports a method-prefixed pattern', async () => { | |
| 39 | httx.fake({ | |
| 40 | 'POST api.test/*': { status: 201, body: { created: true } }, | |
| 41 | 'GET api.test/*': { status: 200, body: { created: false } }, | |
| 42 | }) | |
| 43 | const created = await httx.post('https://api.test/orders', { x: 1 }) | |
| 44 | expect(created.unwrap().status).toBe(201) | |
| 45 | const fetched = await httx.get('https://api.test/orders') | |
| 46 | expect(fetched.unwrap().status).toBe(200) | |
| 47 | }) | |
| 48 | ||
| 49 | it('supports a function responder that reads the request', async () => { | |
| 50 | httx.fake({ | |
| 51 | 'api.test/echo': req => ({ status: 200, body: { sawAuth: req.headers.Authorization ?? null } }), | |
| 52 | }) | |
| 53 | const res = await httx.request() | |
| 54 | .withToken('abc') | |
| 55 | .get<{ sawAuth: string | null }>('https://api.test/echo') | |
| 56 | expect(res.unwrap().data.sawAuth).toBe('Bearer abc') | |
| 57 | }) | |
| 58 | ||
| 59 | it('supports a response sequence (first call → first spec)', async () => { | |
| 60 | httx.fake({ | |
| 61 | 'api.test/flaky': httx.sequence([ | |
| 62 | { status: 500, body: { err: 'boom' } }, | |
| 63 | { status: 200, body: { ok: true } }, | |
| 64 | ]), | |
| 65 | }) | |
| 66 | ||
| 67 | const first = await httx.get('https://api.test/flaky') | |
| 68 | // 500 → the client surfaces an error Result (HttxResponseError). | |
| 69 | expect(first.isErr).toBe(true) | |
| 70 | ||
| 71 | const second = await httx.get<{ ok: boolean }>('https://api.test/flaky') | |
| 72 | expect(second.isOk).toBe(true) | |
| 73 | expect(second.unwrap().data).toEqual({ ok: true }) | |
| 74 | }) | |
| 75 | ||
| 76 | it('maps a non-2xx stub to an error Result', async () => { | |
| 77 | httx.fake({ 'api.stripe.com/*': { status: 402, body: { error: 'card_declined' } } }) | |
| 78 | const res = await httx.post('https://api.stripe.com/v1/charges', { amount: 100 }) | |
| 79 | expect(res.isErr).toBe(true) | |
| 80 | }) | |
| 81 | }) | |
| 82 | ||
| 83 | describe('httx.fake — strict vs passthrough', () => { | |
| 84 | it('throws HttxFakeUnmatchedError when strict and nothing matches', async () => { | |
| 85 | httx.fake({ 'only.this/*': { status: 200 } }) | |
| 86 | // The unmatched request throws inside executeRequest; the client's | |
| 87 | // catch turns non-HTTP throws into an error Result, so assert via | |
| 88 | // the recorded attempt + error surface. | |
| 89 | const res = await httx.get('https://other.host/x') | |
| 90 | expect(res.isErr).toBe(true) | |
| 91 | if (res.isErr) expect(res.error).toBeInstanceOf(HttxFakeUnmatchedError) | |
| 92 | }) | |
| 93 | ||
| 94 | it('passthrough lets unmatched requests flow (no throw)', async () => { | |
| 95 | // We can't assert a real network hit hermetically, but we CAN | |
| 96 | // assert that passthrough does NOT raise the unmatched error. | |
| 97 | httx.fake({ 'matches.nothing/*': { status: 200 } }, { passthrough: true }) | |
| 98 | const res = await httx.get('https://api.test/unmatched-but-passthrough').catch(() => null) | |
| 99 | // Either it resolved (real fetch) or failed with a network error — | |
| 100 | // but never HttxFakeUnmatchedError. | |
| 101 | if (res && res.isErr) { | |
| 102 | expect(res.error).not.toBeInstanceOf(HttxFakeUnmatchedError) | |
| 103 | } | |
| 104 | }) | |
| 105 | }) | |
| 106 | ||
| 107 | describe('httx assertions', () => { | |
| 108 | it('assertSent matches a recorded request', async () => { | |
| 109 | httx.fake() | |
| 110 | await httx.post('https://api.test/orders', { id: 1 }) | |
| 111 | expect(() => httx.assertSent(r => r.method === 'POST' && r.url.endsWith('/orders'))).not.toThrow() | |
| 112 | }) | |
| 113 | ||
| 114 | it('assertSent throws when nothing matches', async () => { | |
| 115 | httx.fake() | |
| 116 | await httx.get('https://api.test/a') | |
| 117 | expect(() => httx.assertSent(r => r.url.includes('/zzz'))).toThrow(/No recorded request matched/) | |
| 118 | }) | |
| 119 | ||
| 120 | it('assertSentCount reflects the number of dispatched requests', async () => { | |
| 121 | httx.fake() | |
| 122 | await httx.get('https://api.test/a') | |
| 123 | await httx.get('https://api.test/b') | |
| 124 | expect(() => httx.assertSentCount(2)).not.toThrow() | |
| 125 | expect(() => httx.assertSentCount(3)).toThrow(/Expected 3/) | |
| 126 | }) | |
| 127 | ||
| 128 | it('assertNothingSent passes when no request was made', () => { | |
| 129 | httx.fake() | |
| 130 | expect(() => httx.assertNothingSent()).not.toThrow() | |
| 131 | }) | |
| 132 | ||
| 133 | it('assertNotSent guards a forbidden request', async () => { | |
| 134 | httx.fake() | |
| 135 | await httx.get('https://api.test/safe') | |
| 136 | expect(() => httx.assertNotSent(r => r.url.includes('/admin'))).not.toThrow() | |
| 137 | expect(() => httx.assertNotSent(r => r.url.includes('/safe'))).toThrow(/should not have/) | |
| 138 | }) | |
| 139 | ||
| 140 | it('records request headers for assertion (auth token check)', async () => { | |
| 141 | httx.fake() | |
| 142 | await httx.request().withToken('secret-token').post('https://api.test/x', { a: 1 }) | |
| 143 | expect(() => httx.assertSent(r => r.headers.Authorization === 'Bearer secret-token')).not.toThrow() | |
| 144 | }) | |
| 145 | ||
| 146 | it('restoreFetch clears recorded requests', async () => { | |
| 147 | httx.fake() | |
| 148 | await httx.get('https://api.test/a') | |
| 149 | expect(httx.recorded()).toHaveLength(1) | |
| 150 | httx.restoreFetch() | |
| 151 | httx.fake() | |
| 152 | expect(httx.recorded()).toHaveLength(0) | |
| 153 | }) | |
| 154 | }) | |
| @@ -0,0 +1,101 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { httx } from '../src/httx' | |
| 3 | import { http, PendingRequest } from '../src/pending-request' | |
| 4 | ||
| 5 | /** | |
| 6 | * Fluent builder (stacksjs/ts-http#1759). All hermetic — faking | |
| 7 | * intercepts dispatch, and the function-responder echoes request | |
| 8 | * shape back so we can assert what the builder produced. | |
| 9 | */ | |
| 10 | ||
| 11 | afterEach(() => { | |
| 12 | httx.restoreFetch() | |
| 13 | }) | |
| 14 | ||
| 15 | describe('http() builder', () => { | |
| 16 | it('returns a PendingRequest', () => { | |
| 17 | expect(http()).toBeInstanceOf(PendingRequest) | |
| 18 | }) | |
| 19 | ||
| 20 | it('withToken sets a Bearer Authorization header', async () => { | |
| 21 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { auth: req.headers.Authorization } }) }) | |
| 22 | const res = await http().withToken('tok').get<{ auth: string }>('https://api.test/me') | |
| 23 | expect(res.unwrap().data.auth).toBe('Bearer tok') | |
| 24 | }) | |
| 25 | ||
| 26 | it('withToken honours a custom scheme', async () => { | |
| 27 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { auth: req.headers.Authorization } }) }) | |
| 28 | const res = await http().withToken('tok', 'Token').get<{ auth: string }>('https://api.test/me') | |
| 29 | expect(res.unwrap().data.auth).toBe('Token tok') | |
| 30 | }) | |
| 31 | ||
| 32 | it('withBasicAuth base64-encodes credentials', async () => { | |
| 33 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { auth: req.headers.Authorization } }) }) | |
| 34 | const res = await http().withBasicAuth('user', 'pass').get<{ auth: string }>('https://api.test/me') | |
| 35 | expect(res.unwrap().data.auth).toBe(`Basic ${btoa('user:pass')}`) | |
| 36 | }) | |
| 37 | ||
| 38 | it('withHeaders merges; later calls overwrite', async () => { | |
| 39 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { headers: req.headers } }) }) | |
| 40 | const res = await http() | |
| 41 | .withHeaders({ 'X-A': '1', 'X-B': '2' }) | |
| 42 | .withHeaders({ 'X-B': '3' }) | |
| 43 | .get<{ headers: Record<string, string> }>('https://api.test/h') | |
| 44 | const headers = res.unwrap().data.headers | |
| 45 | expect(headers['X-A']).toBe('1') | |
| 46 | expect(headers['X-B']).toBe('3') | |
| 47 | }) | |
| 48 | ||
| 49 | it('replaceHeaders discards previously-set headers', async () => { | |
| 50 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { headers: req.headers } }) }) | |
| 51 | const res = await http() | |
| 52 | .withHeaders({ 'X-Old': 'gone' }) | |
| 53 | .replaceHeaders({ 'X-New': 'kept' }) | |
| 54 | .get<{ headers: Record<string, string> }>('https://api.test/h') | |
| 55 | const headers = res.unwrap().data.headers | |
| 56 | expect(headers['X-Old']).toBeUndefined() | |
| 57 | expect(headers['X-New']).toBe('kept') | |
| 58 | }) | |
| 59 | ||
| 60 | it('acceptJson sets the Accept header', async () => { | |
| 61 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { accept: req.headers.Accept } }) }) | |
| 62 | const res = await http().acceptJson().get<{ accept: string }>('https://api.test/a') | |
| 63 | expect(res.unwrap().data.accept).toBe('application/json') | |
| 64 | }) | |
| 65 | ||
| 66 | it('withQueryParameters appends to the URL', async () => { | |
| 67 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { url: req.url } }) }) | |
| 68 | const res = await http() | |
| 69 | .withQueryParameters({ page: '2', per_page: '10' }) | |
| 70 | .get<{ url: string }>('https://api.test/items') | |
| 71 | const url = res.unwrap().data.url | |
| 72 | expect(url).toContain('page=2') | |
| 73 | expect(url).toContain('per_page=10') | |
| 74 | }) | |
| 75 | ||
| 76 | it('post sends a JSON body by default for objects', async () => { | |
| 77 | httx.fake({ 'api.test/*': req => ({ status: 201, body: { received: req.body, ct: req.headers['Content-Type'] } }) }) | |
| 78 | const res = await http().post<{ received: string, ct: string }>('https://api.test/x', { name: 'ada' }) | |
| 79 | const data = res.unwrap().data | |
| 80 | expect(data.ct).toBe('application/json') | |
| 81 | expect(JSON.parse(data.received)).toEqual({ name: 'ada' }) | |
| 82 | }) | |
| 83 | ||
| 84 | it('asForm switches the content-type', async () => { | |
| 85 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { ct: req.headers['Content-Type'] } }) }) | |
| 86 | const res = await http().asForm().post<{ ct: string }>('https://api.test/x', { a: '1', b: '2' }) | |
| 87 | expect(res.unwrap().data.ct).toBe('application/x-www-form-urlencoded') | |
| 88 | }) | |
| 89 | ||
| 90 | it('baseUrl resolves relative request URLs', async () => { | |
| 91 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { url: req.url } }) }) | |
| 92 | const res = await http().baseUrl('https://api.test').get<{ url: string }>('/v1/ping') | |
| 93 | expect(res.unwrap().data.url).toBe('https://api.test/v1/ping') | |
| 94 | }) | |
| 95 | ||
| 96 | it('chains through the shared httx.request() entry point', async () => { | |
| 97 | httx.fake({ 'api.test/*': req => ({ status: 200, body: { method: req.method } }) }) | |
| 98 | const res = await httx.request().acceptJson().delete<{ method: string }>('https://api.test/x') | |
| 99 | expect(res.unwrap().data.method).toBe('DELETE') | |
| 100 | }) | |
| 101 | }) | |