Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swift-kings-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fetchkit/ffetch': patch
---

Allow fetchHandler to be overriden on a per-request basis
8 changes: 8 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
73 changes: 73 additions & 0 deletions test/client.fetchHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})