Skip to content

Commit

Permalink
feat(useFetch): allow configure lifecycle handler behavior (#2333)
Browse files Browse the repository at this point in the history
* feat(useFetch): allow configurate lifecycle handler behavior

* fix: types mismatch

* style: linting

* fix: node@14 syntax error

* fix: typo

* chore: typo
  • Loading branch information
climba03003 committed Oct 27, 2022
1 parent 24747b6 commit a849634
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 16 deletions.
40 changes: 40 additions & 0 deletions packages/core/useFetch/index.md
Expand Up @@ -166,6 +166,46 @@ const useMyFetch = createFetch({
const { isFetching, error, data } = useMyFetch('users')
```

If you want to control the behavior of `beforeFetch`, `afterFetch`, `onFetchError` between the pre-configured instance and newly spawned instance. You can provide a `combination` option to toggle between `overwrite` or `chaining`.

```ts
const useMyFetch = createFetch({
baseUrl: 'https://my-api.com',
combination: 'overwrite',
options: {
// beforeFetch in pre-configured instance will only run when the newly spawned instance do not pass beforeFetch
async beforeFetch({ options }) {
const myToken = await getMyToken()
options.headers.Authorization = `Bearer ${myToken}`

return { options }
},
},
})

// use useMyFetch beforeFetch
const { isFetching, error, data } = useMyFetch('users')

// use custom beforeFetch
const { isFetching, error, data } = useMyFetch('users', {
async beforeFetch({ url, options, cancel }) {
const myToken = await getMyToken()

if (!myToken)
cancel()

options.headers = {
...options.headers,
Authorization: `Bearer ${myToken}`,
}

return {
options,
}
},
})
```

### Events

The `onFetchResponse` and `onFetchError` will fire on fetch request responses and errors respectively.
Expand Down
155 changes: 153 additions & 2 deletions packages/core/useFetch/index.test.ts
@@ -1,8 +1,8 @@
import { ref } from 'vue-demi'
import { until } from '@vueuse/shared'
import { ref } from 'vue-demi'
import { retry } from '../../.test'
import '../../.test/mockServer'
import { createFetch, useFetch } from '.'
import '../../.test/mockServer'

const jsonMessage = { hello: 'world' }
const jsonUrl = `https://example.com?json=${encodeURI(JSON.stringify(jsonMessage))}`
Expand Down Expand Up @@ -286,6 +286,157 @@ describe('useFetch', () => {
})
})

test('should overwrite beforeFetch function when using a factory instance', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
beforeFetch({ options }) {
options.headers = { ...options.headers, Global: 'foo' }
return { options }
},
},
})
useMyFetch('test', {
beforeFetch({ options }) {
options.headers = { ...options.headers, Local: 'foo' }
return { options }
},
})

await retry(() => {
expect(fetchSpyHeaders()).toMatchObject({ Local: 'foo' })
})
})

test('should overwrite afterFetch function when using a factory instance', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
afterFetch(ctx) {
ctx.data.global = 'Global'
return ctx
},
},
})
const { data } = useMyFetch('test?json', {
afterFetch(ctx) {
ctx.data.local = 'Local'
return ctx
},
}).json()

await retry(() => {
expect(data.value).toEqual(expect.objectContaining({ local: 'Local' }))
expect(data.value).toEqual(expect.not.objectContaining({ global: 'Global' }))
})
})

test('should overwrite onFetchError function when using a factory instance', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
onFetchError(ctx) {
ctx.data.global = 'Global'
return ctx
},
},
})
const { data } = useMyFetch('test?status=400&json', {
onFetchError(ctx) {
ctx.data.local = 'Local'
return ctx
},
}).json()

await retry(() => {
expect(data.value).toEqual(expect.objectContaining({ local: 'Local' }))
expect(data.value).toEqual(expect.not.objectContaining({ global: 'Global' }))
})
})

test('should overwrite beforeFetch function when using a factory instance and the options object in useMyFetch', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
beforeFetch({ options }) {
options.headers = { ...options.headers, Global: 'foo' }
return { options }
},
},
})
useMyFetch(
'test',
{ method: 'GET' },
{
beforeFetch({ options }) {
options.headers = { ...options.headers, Local: 'foo' }
return { options }
},
})

await retry(() => {
expect(fetchSpyHeaders()).toMatchObject({ Local: 'foo' })
})
})

test('should overwrite afterFetch function when using a factory instance and the options object in useMyFetch', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
afterFetch(ctx) {
ctx.data.global = 'Global'
return ctx
},
},
})
const { data } = useMyFetch(
'test?json',
{ method: 'GET' },
{
afterFetch(ctx) {
ctx.data.local = 'Local'
return ctx
},
}).json()

await retry(() => {
expect(data.value).toEqual(expect.objectContaining({ local: 'Local' }))
expect(data.value).toEqual(expect.not.objectContaining({ global: 'Global' }))
})
})

test('should overwrite onFetchError function when using a factory instance and the options object in useMyFetch', async () => {
const useMyFetch = createFetch({
baseUrl: 'https://example.com',
combination: 'overwrite',
options: {
onFetchError(ctx) {
ctx.data.global = 'Global'
return ctx
},
},
})
const { data } = useMyFetch(
'test?status=400&json',
{ method: 'GET' },
{
onFetchError(ctx) {
ctx.data.local = 'Local'
return ctx
},
}).json()

await retry(() => {
expect(data.value).toEqual(expect.objectContaining({ local: 'Local' }))
expect(data.value).toEqual(expect.not.objectContaining({ global: 'Global' }))
})
})

test('should run the beforeFetch function and add headers to the request', async () => {
useFetch('https://example.com', { headers: { 'Accept-Language': 'en-US' } }, {
beforeFetch({ options }) {
Expand Down
48 changes: 34 additions & 14 deletions packages/core/useFetch/index.ts
@@ -1,6 +1,6 @@
import type { ComputedRef, Ref } from 'vue-demi'
import type { EventHookOn, Fn, MaybeComputedRef, Stoppable } from '@vueuse/shared'
import { containsProp, createEventHook, resolveRef, resolveUnref, until, useTimeoutFn } from '@vueuse/shared'
import type { ComputedRef, Ref } from 'vue-demi'
import { computed, isRef, ref, shallowRef, watch } from 'vue-demi'
import { defaultWindow } from '../_configurable'

Expand Down Expand Up @@ -90,6 +90,7 @@ export interface UseFetchReturn<T> {

type DataType = 'text' | 'json' | 'blob' | 'arrayBuffer' | 'formData'
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'
type Combination = 'overwrite' | 'chain'

const payloadMapping: Record<string, string> = {
json: 'application/json',
Expand Down Expand Up @@ -187,6 +188,12 @@ export interface CreateFetchOptions {
*/
baseUrl?: MaybeComputedRef<string>

/**
* Determine the inherit behavior for beforeFetch, afterFetch, onFetchError
* @default 'chain'
*/
combination?: Combination

/**
* Default Options for the useFetch function
*/
Expand Down Expand Up @@ -214,17 +221,30 @@ function headersToObject(headers: HeadersInit | undefined) {
return headers
}

function chainCallbacks<T = any>(...callbacks: (((ctx: T) => void | Partial<T> | Promise<void | Partial<T>>) | undefined)[]) {
return async (ctx: T) => {
await callbacks.reduce((prevCallback, callback) => prevCallback.then(async () => {
if (callback)
ctx = { ...ctx, ...(await callback(ctx)) }
}), Promise.resolve())
return ctx
function combineCallbacks<T = any>(combination: Combination, ...callbacks: (((ctx: T) => void | Partial<T> | Promise<void | Partial<T>>) | undefined)[]) {
if (combination === 'overwrite') {
// use last callback
return async (ctx: T) => {
const callback = callbacks[callbacks.length - 1]
if (callback !== undefined)
await callback(ctx)
return ctx
}
}
else {
// chaining and combine result
return async (ctx: T) => {
await callbacks.reduce((prevCallback, callback) => prevCallback.then(async () => {
if (callback)
ctx = { ...ctx, ...(await callback(ctx)) }
}), Promise.resolve())
return ctx
}
}
}

export function createFetch(config: CreateFetchOptions = {}) {
const _combination = config.combination || 'chain' as Combination
const _options = config.options || {}
const _fetchOptions = config.fetchOptions || {}

Expand All @@ -243,9 +263,9 @@ export function createFetch(config: CreateFetchOptions = {}) {
options = {
...options,
...args[0],
beforeFetch: chainCallbacks(_options.beforeFetch, args[0].beforeFetch),
afterFetch: chainCallbacks(_options.afterFetch, args[0].afterFetch),
onFetchError: chainCallbacks(_options.onFetchError, args[0].onFetchError),
beforeFetch: combineCallbacks(_combination, _options.beforeFetch, args[0].beforeFetch),
afterFetch: combineCallbacks(_combination, _options.afterFetch, args[0].afterFetch),
onFetchError: combineCallbacks(_combination, _options.onFetchError, args[0].onFetchError),
}
}
else {
Expand All @@ -264,9 +284,9 @@ export function createFetch(config: CreateFetchOptions = {}) {
options = {
...options,
...args[1],
beforeFetch: chainCallbacks(_options.beforeFetch, args[1].beforeFetch),
afterFetch: chainCallbacks(_options.afterFetch, args[1].afterFetch),
onFetchError: chainCallbacks(_options.onFetchError, args[1].onFetchError),
beforeFetch: combineCallbacks(_combination, _options.beforeFetch, args[1].beforeFetch),
afterFetch: combineCallbacks(_combination, _options.afterFetch, args[1].afterFetch),
onFetchError: combineCallbacks(_combination, _options.onFetchError, args[1].onFetchError),
}
}

Expand Down

0 comments on commit a849634

Please sign in to comment.