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

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.

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,
packages/httx/src/fake.tsadded+246-0
Changes to packages/httx/src/fake.ts
@@ -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). */
19export 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. */
27export 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. */
36export 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 */
44export 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
58export type FakeStub = FakeResponder | FakeSequence
59
60/** Map of URL glob (or `'METHOD url-glob'`) → stub. */
61export type FakeMap = Record<string, FakeStub>
62
63export 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
72interface FakeState {
73 stubs: FakeMap | null // null → fake everything with an empty 200
74 passthrough: boolean
75}
76
77let state: FakeState | null = null
78const recorded: FakeRequest[] = []
79
80/** Error thrown when faking is strict and a request matches no stub. */
81export 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 */
98function 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 */
110function 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. */
127export 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. */
133export function uninstallFakes(): void {
134 state = null
135 recorded.length = 0
136}
137
138export function isFaking(): boolean {
139 return state !== null
140}
141
142export 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 */
153export 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. */
177export 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. */
207export function recordedRequests(): readonly FakeRequest[] {
208 return recorded
209}
210
211export function clearRecorded(): void {
212 recorded.length = 0
213}
214
215export 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
224export 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
231export 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
237export 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
243function summarize(reqs: readonly FakeRequest[]): string {
244 if (reqs.length === 0) return '(none)'
245 return reqs.map(r => `${r.method} ${r.url}`).join(', ')
246}
packages/httx/src/httx.tsadded+92-0
Changes to packages/httx/src/httx.ts
@@ -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
21import type { Result } from 'ts-error-handling'
22import type { FakeMap, FakeOptions, FakeRequest, FakeResponseSpec } from './fake'
23import type { HttxConfig, HttxResponse, RequestOptions } from './types'
24import { HttxClient } from './client'
25import {
26 assertNothingSent,
27 assertNotSent,
28 assertSent,
29 assertSentCount,
30 clearRecorded,
31 FakeSequence,
32 installFakes,
33 recordedRequests,
34 uninstallFakes,
35} from './fake'
36import { http, PendingRequest } from './pending-request'
37
38type VerbResult<T> = Promise<Result<HttxResponse<T>, Error>>
39type BodylessOptions = Omit<RequestOptions, 'method'>
40type BodyOptions = Omit<RequestOptions, 'method' | 'body'>
41
42/** Public shape of the {@link httx} facade. Annotated explicitly so
43 * the package can build under `--isolatedDeclarations`. */
44export 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
65const defaultClient = new HttxClient()
66
67export 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}
packages/httx/src/index.tsmodified+3-0
Changes to packages/httx/src/index.ts
@@ -2,5 +2,8 @@ export * from './circuit-breaker'
22export * from './client'
33export * from './config'
44export * from './errors'
5export * from './fake'
6export * from './httx'
7export * from './pending-request'
58export * from './types'
69export * from './utils'
packages/httx/src/pending-request.tsadded+199-0
Changes to packages/httx/src/pending-request.ts
@@ -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
23import type { Result } from 'ts-error-handling'
24import type { HttpMethod, HttxConfig, HttxResponse, RequestOptions, RetryOptions } from './types'
25import { HttxClient } from './client'
26
27export 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 */
197export function http(config: Partial<HttxConfig> = {}): PendingRequest {
198 return new PendingRequest(config)
199}
packages/httx/test/fake.test.tsadded+154-0
Changes to packages/httx/test/fake.test.ts
@@ -0,0 +1,154 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { HttxFakeUnmatchedError } from '../src/fake'
3import { 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
10afterEach(() => {
11 httx.restoreFetch()
12})
13
14describe('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
83describe('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
107describe('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})
packages/httx/test/pending-request.test.tsadded+101-0
Changes to packages/httx/test/pending-request.test.ts
@@ -0,0 +1,101 @@
1import { afterEach, describe, expect, it } from 'bun:test'
2import { httx } from '../src/httx'
3import { 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
11afterEach(() => {
12 httx.restoreFetch()
13})
14
15describe('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})