diff --git a/.changeset/swift-kings-kneel.md b/.changeset/swift-kings-kneel.md new file mode 100644 index 0000000..5b4e697 --- /dev/null +++ b/.changeset/swift-kings-kneel.md @@ -0,0 +1,5 @@ +--- +'@fetchkit/ffetch': patch +--- + +Allow fetchHandler to be overriden on a per-request basis diff --git a/docs/api.md b/docs/api.md index 5bc7893..e73df90 100644 --- a/docs/api.md +++ b/docs/api.md @@ -209,6 +209,14 @@ const client = createClient({ // Or use node-fetch/undici in Node.js import nodeFetch from 'node-fetch' const clientNode = createClient({ fetchHandler: nodeFetch }) + +// Per-request fetchHandler override (useful for testing) +const client = createClient({ retries: 0 }) +const mockFetch = () => Promise.resolve( + new Response(JSON.stringify({ test: 'data' }), { status: 200 }) +) +await client('https://example.com', { fetchHandler: mockFetch }) // Uses mockFetch +await client('https://example.com') // Uses global fetch ``` ## Circuit Breaker Hooks diff --git a/docs/examples.md b/docs/examples.md index cbbd5dd..d10391a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -127,6 +127,8 @@ const data = await response.json() ### Injecting a Mock Fetch for Unit Tests +You can provide a mock fetch handler at the client level or override it per-request: + ```typescript import createClient from '@fetchkit/ffetch' @@ -136,10 +138,28 @@ function mockFetch(url, options) { ) } +// Option 1: Client-level fetchHandler (all requests use this) const client = createClient({ fetchHandler: mockFetch }) const response = await client('https://api.example.com/test') const data = await response.json() // data: { ok: true, url: 'https://api.example.com/test' } + +// Option 2: Per-request fetchHandler (useful for different mocks per test) +const client2 = createClient({ retries: 0 }) + +// First request with specific mock +const mockUser = () => Promise.resolve( + new Response(JSON.stringify({ id: 1, name: 'Alice' }), { status: 200 }) +) +const userResponse = await client2('/api/user', { fetchHandler: mockUser }) +// Returns: { id: 1, name: 'Alice' } + +// Second request with different mock +const mockPosts = () => Promise.resolve( + new Response(JSON.stringify([{ id: 1, title: 'Hello' }]), { status: 200 }) +) +const postsResponse = await client2('/api/posts', { fetchHandler: mockPosts }) +// Returns: [{ id: 1, title: 'Hello' }] ``` ## Advanced Patterns diff --git a/package-lock.json b/package-lock.json index 1527646..ebc8276 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fetchkit/ffetch", - "version": "0.1.3", + "version": "4.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fetchkit/ffetch", - "version": "0.1.3", + "version": "4.1.0", "license": "MIT", "devDependencies": { "@changesets/cli": "^2.29.5", diff --git a/src/client.ts b/src/client.ts index 8a7ce39..b68434b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -232,7 +232,7 @@ export function createClient(opts: FFetchOptions = {}): FFetch { signal: combinedSignal, }) try { - const handler = fetchHandler ?? fetch + const handler = init.fetchHandler ?? fetchHandler ?? fetch const response = await handler(reqWithSignal) lastResponse = response if ( diff --git a/test/client.fetchHandler.test.ts b/test/client.fetchHandler.test.ts index 85b9e29..34dc4c7 100644 --- a/test/client.fetchHandler.test.ts +++ b/test/client.fetchHandler.test.ts @@ -73,4 +73,77 @@ describe('ffetch fetchHandler option', () => { expect(global.fetch).toHaveBeenCalled() global.fetch = originalFetch }) + + it('allows per-request fetchHandler override', async () => { + const clientFetchSpy = vi.fn(mockFetch) + const requestFetchSpy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ from: 'request' }), { status: 200 }) + ) + ) + + const client = createClient({ fetchHandler: clientFetchSpy }) + const res = await client('https://example.com', { + fetchHandler: requestFetchSpy, + }) + const json = await res.json() + + expect(clientFetchSpy).not.toHaveBeenCalled() + expect(requestFetchSpy).toHaveBeenCalled() + expect(json.from).toBe('request') + }) + + it('uses client fetchHandler when no per-request override is provided', async () => { + const clientFetchSpy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ from: 'client' }), { status: 200 }) + ) + ) + + const client = createClient({ fetchHandler: clientFetchSpy }) + const res = await client('https://example.com') + const json = await res.json() + + expect(clientFetchSpy).toHaveBeenCalled() + expect(json.from).toBe('client') + }) + + it('supports per-request fetchHandler without client-level handler', async () => { + const requestFetchSpy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ from: 'request-only' }), { status: 200 }) + ) + ) + + const client = createClient({ retries: 0 }) + const res = await client('https://example.com', { + fetchHandler: requestFetchSpy, + }) + const json = await res.json() + + expect(requestFetchSpy).toHaveBeenCalled() + expect(json.from).toBe('request-only') + }) + + it('allows different fetchHandlers for different requests on same client', async () => { + const fetch1 = vi.fn(() => + Promise.resolve(new Response(JSON.stringify({ id: 1 }), { status: 200 })) + ) + const fetch2 = vi.fn(() => + Promise.resolve(new Response(JSON.stringify({ id: 2 }), { status: 200 })) + ) + + const client = createClient({ retries: 0 }) + + const res1 = await client('https://test1.com', { fetchHandler: fetch1 }) + const json1 = await res1.json() + + const res2 = await client('https://test2.com', { fetchHandler: fetch2 }) + const json2 = await res2.json() + + expect(fetch1).toHaveBeenCalledTimes(1) + expect(fetch2).toHaveBeenCalledTimes(1) + expect(json1.id).toBe(1) + expect(json2.id).toBe(2) + }) })