Skip to content

Commit 2850f53

Browse files
authored
Response type (#244)
* v1.0.3 * v1.0.4 * 1 test failing, first go at `responseType` * updating gh docs * cleanup, tests, finished feature * rename ResponseTypes -> ResponseType * cleaning up docs
1 parent c207229 commit 2850f53

File tree

10 files changed

+113
-46
lines changed

10 files changed

+113
-46
lines changed

README.md

+13-8
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Features
7272

7373
- SSR (server side rendering) support
7474
- TypeScript support
75-
- 1 dependency ([use-ssr](https://github.com/alex-cory/use-ssr))
75+
- 2 dependencies ([use-ssr](https://github.com/alex-cory/use-ssr), [urs](https://github.com/alex-cory/urs))
7676
- GraphQL support (queries + mutations)
7777
- Provider to set default `url` and `options`
7878
- Request/response interceptors <!--https://github.com/ava/use-http#user-content-interceptors-->
@@ -855,6 +855,7 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr
855855
| `path` | When using a global `url` set in the `Provider`, this is useful for adding onto it | `''` |
856856
| `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` |
857857
| `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` |
858+
| `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` |
858859
| `retries` | When a request fails or times out, retry the request this many times. By default it will not retry. | `0` |
859860
| `retryDelay` | You can retry with certain intervals i.e. 30 seconds `30000` or with custom logic (i.e. to increase retry intervals). | `1000` |
860861
| `retryOn` | You can retry on certain http status codes or have custom logic to decide whether to retry or not via a function. Make sure `retries > 0` otherwise it won't retry. | `[]` |
@@ -911,6 +912,16 @@ const options = {
911912
// Allows caching to persist after page refresh. Only supported in the Browser currently.
912913
persist: false,
913914

915+
// this would basically call `await response.json()`
916+
// and set the `data` and `response.data` field to the output
917+
responseType: 'json',
918+
// OR can be an array. It's an array by default.
919+
// We will try to get the `data` by attempting to extract
920+
// it via these body interface methods, one by one in
921+
// this order. We skip `formData` because it's mostly used
922+
// for service workers.
923+
responseType: ['json', 'text', 'blob', 'arrayBuffer'],
924+
914925
// amount of times it should retry before erroring out
915926
retries: 3,
916927

@@ -1008,7 +1019,7 @@ Todos
10081019
- [ ] if calling `response.json()` and there is no response yet
10091020
- [ ] tests
10101021
- [ ] tests for SSR
1011-
- [ ] tests for FormData (can also do it for react-native at same time. [see here](https://stackoverflow.com/questions/45842088/react-native-mocking-formdata-in-unit-tests))
1022+
- [ ] tests for react native [see here](https://stackoverflow.com/questions/45842088/react-native-mocking-formdata-in-unit-tests)
10121023
- [ ] tests for GraphQL hooks `useMutation` + `useQuery`
10131024
- [ ] tests for stale `response` see this [PR](https://github.com/ava/use-http/pull/119/files)
10141025
- [ ] tests to make sure `response.formData()` and some of the other http `response methods` work properly
@@ -1034,12 +1045,6 @@ Todos
10341045
// to overwrite those of `useFetch` for
10351046
// `useMutation` and `useQuery`
10361047
},
1037-
responseType: 'json', // similar to axios
1038-
// OR can be an array. We will try to get the `data`
1039-
// by attempting to extract it via these body interface
1040-
// methods, one by one in this order
1041-
// we skip `formData` because it's mostly used for service workers
1042-
responseType: ['json', 'text', 'blob', 'arrayBuffer'],
10431048
// by default this is true, but if set to false
10441049
// then we default to the responseType array of trying 'json' first, then 'text', etc.
10451050
// hopefully I get some answers on here: https://bit.ly/3afPlJS

docs/README.md

+12-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Features
5252

5353
- SSR (server side rendering) support
5454
- TypeScript support
55-
- 1 dependency ([use-ssr](https://github.com/alex-cory/use-ssr))
55+
- 2 dependencies ([use-ssr](https://github.com/alex-cory/use-ssr), [urs](https://github.com/alex-cory/urs))
5656
- GraphQL support (queries + mutations)
5757
- Provider to set default `url` and `options`
5858
- Request/response interceptors <!--https://github.com/alex-cory/use-http#user-content-interceptors-->
@@ -805,6 +805,7 @@ This is exactly what you would pass to the normal js `fetch`, with a little extr
805805
| `onTimeout` | Called when the request times out. | empty function |
806806
| `path` | When using a global `url` set in the `Provider`, this is useful for adding onto it | `''` |
807807
| `persist` | Persists data for the duration of `cacheLife`. If `cacheLife` is not set it defaults to 24h. Currently only available in Browser. | `false` |
808+
| `responseType` | This will determine how the `data` field is set. If you put `json` then it will try to parse it as JSON. If you set it as an array, it will attempt to parse the `response` in the order of the types you put in the array. Read about why we don't put `formData` in the defaults [in the yellow Note part here](https://developer.mozilla.org/en-US/docs/Web/API/Body/formData). | `['json', 'text', 'blob', 'readableStream']` |
808809
| `perPage` | Stops making more requests if there is no more data to fetch. (i.e. if we have 25 todos, and the perPage is 10, after fetching 2 times, we will have 20 todos. The last 5 tells us we don't have any more to fetch because it's less than 10) For pagination. | `0` |
809810
| `retries` | When a request fails or times out, retry the request this many times. By default it will not retry. | `0` |
810811
| `retryDelay` | You can retry with certain intervals i.e. 30 seconds `30000` or with custom logic (i.e. to increase retry intervals). | `1000` |
@@ -862,6 +863,16 @@ const options = {
862863
// Allows caching to persist after page refresh. Only supported in the Browser currently.
863864
persist: false,
864865

866+
// this would basically call `await response.json()`
867+
// and set the `data` and `response.data` field to the output
868+
responseType: 'json',
869+
// OR can be an array. It's an array by default.
870+
// We will try to get the `data` by attempting to extract
871+
// it via these body interface methods, one by one in
872+
// this order. We skip `formData` because it's mostly used
873+
// for service workers.
874+
responseType: ['json', 'text', 'blob', 'arrayBuffer'],
875+
865876
// amount of times it should retry before erroring out
866877
retries: 3,
867878

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "use-http",
3-
"version": "1.0.3",
3+
"version": "1.0.4",
44
"homepage": "https://use-http.com",
55
"main": "dist/index.js",
66
"license": "MIT",
@@ -9,6 +9,7 @@
99
"url": "https://github.com/alex-cory/use-http.git"
1010
},
1111
"dependencies": {
12+
"urs": "^0.0.4",
1213
"use-ssr": "^1.0.22"
1314
},
1415
"peerDependencies": {

src/__tests__/useFetch.test.tsx

+37-1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,42 @@ describe('useFetch - BROWSER - basic functionality', (): void => {
132132
})
133133
})
134134

135+
describe('useFetch - responseType', (): void => {
136+
afterEach((): void => {
137+
cleanup()
138+
fetch.resetMocks()
139+
})
140+
141+
it('should fail to process a text response with responseType: `json`', async (): Promise<void> => {
142+
cleanup()
143+
fetch.resetMocks()
144+
fetch.mockResponseOnce('Alex Cory')
145+
const { result, waitForNextUpdate } = renderHook(
146+
() => useFetch('a-fake-url', { data: '', responseType: 'json' }, []), // onMount === true
147+
)
148+
expect(result.current.data).toEqual('')
149+
expect(result.current.loading).toBe(true)
150+
await waitForNextUpdate()
151+
expect(result.current.loading).toBe(false)
152+
expect(result.current.error.name).toBe('FetchError')
153+
})
154+
155+
it('should process .text() with default array responseType', async (): Promise<void> => {
156+
cleanup()
157+
fetch.resetMocks()
158+
const expectedString = 'Alex Cory'
159+
fetch.mockResponseOnce(JSON.stringify(expectedString))
160+
const { result, waitForNextUpdate } = renderHook(
161+
() => useFetch('a-fake-url', { data: '' }, []), // onMount === true
162+
)
163+
expect(result.current.data).toEqual('')
164+
expect(result.current.loading).toBe(true)
165+
await waitForNextUpdate()
166+
expect(result.current.loading).toBe(false)
167+
expect(result.current.data).toBe(expectedString)
168+
})
169+
})
170+
135171
describe('useFetch - BROWSER - with <Provider />', (): void => {
136172
const expected = {
137173
name: 'Alex Cory',
@@ -151,7 +187,7 @@ describe('useFetch - BROWSER - with <Provider />', (): void => {
151187
fetch.mockResponseOnce(JSON.stringify(expected))
152188
})
153189

154-
it('should work correctly: useFetch({ onMount: true, data: [] })', async (): Promise<void> => {
190+
it('should work correctly: useFetch({ data: [] }, [])', async (): Promise<void> => {
155191
const { result, waitForNextUpdate } = renderHook(
156192
() => useFetch({ data: {} }, []), // onMount === true
157193
{ wrapper }

src/defaults.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const useFetchArgsDefaults: UseFetchArgsReturn = {
1313
path: '',
1414
perPage: 0,
1515
persist: false,
16+
responseType: ['json', 'text', 'blob', 'arrayBuffer'],
1617
retries: 0,
1718
retryDelay: 1000,
1819
retryOn: [],

src/types.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ReactNode } from 'react'
2+
import { FunctionKeys } from 'utility-types'
23

34
export enum HTTPMethod {
45
DELETE = 'DELETE',
@@ -95,7 +96,7 @@ export type RouteAndBodyOnly = (
9596
) => Promise<any>
9697

9798
export type RouteOrBody = string | BodyInit | object
98-
export type Body = BodyInit | object
99+
export type UFBody = BodyInit | object
99100
export type RetryOpts = { attempt: number, error?: Error, response?: Response }
100101

101102
export type NoArgs = () => Promise<any>
@@ -184,6 +185,7 @@ export interface CustomOptions {
184185
path?: string
185186
persist?: boolean
186187
perPage?: number
188+
responseType?: ResponseType
187189
retries?: number
188190
retryOn?: RetryOn
189191
retryDelay?: RetryDelay
@@ -206,6 +208,9 @@ export type OverwriteGlobalOptions = (options: Options) => Options
206208
export type RetryOn = (<TData = any>({ attempt, error, response }: RetryOpts) => Promise<boolean>) | number[]
207209
export type RetryDelay = (<TData = any>({ attempt, error, response }: RetryOpts) => number) | number
208210

211+
export type BodyInterfaceMethods = Exclude<FunctionKeys<Body>, 'body' | 'bodyUsed' | 'formData'>
212+
export type ResponseType = BodyInterfaceMethods | BodyInterfaceMethods[]
213+
209214
export type UseFetchArgsReturn = {
210215
customOptions: {
211216
cacheLife: number
@@ -217,6 +222,7 @@ export type UseFetchArgsReturn = {
217222
path: string
218223
perPage: number
219224
persist: boolean
225+
responseType: ResponseType
220226
retries: number
221227
retryDelay: RetryDelay
222228
retryOn: RetryOn | undefined

src/useFetch.ts

+9-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useEffect, useState, useCallback, useRef, useReducer, useMemo } from 'react'
1+
import { useEffect, useCallback, useRef, useReducer, useMemo } from 'react'
22
import useSSR from 'use-ssr'
3+
import useRefState from 'urs'
34
import {
45
HTTPMethod,
56
UseFetch,
@@ -13,7 +14,7 @@ import {
1314
FetchData,
1415
NoArgs,
1516
RouteOrBody,
16-
Body,
17+
UFBody,
1718
RetryOpts
1819
} from './types'
1920
import useFetchArgs from './useFetchArgs'
@@ -36,6 +37,7 @@ function useFetch<TData = any>(...args: UseFetchArgs): UseFetch<TData> {
3637
path,
3738
perPage,
3839
persist,
40+
responseType,
3941
retries,
4042
retryDelay,
4143
retryOn,
@@ -59,18 +61,12 @@ function useFetch<TData = any>(...args: UseFetchArgs): UseFetch<TData> {
5961
const suspender = useRef<Promise<any>>()
6062
const mounted = useRef(false)
6163

62-
const loading = useRef(defaults.loading)
63-
const setLoadingState = useState(defaults.loading)[1]
64-
const setLoading = (v: boolean) => {
65-
if (!mounted.current) return
66-
loading.current = v
67-
setLoadingState(v)
68-
}
64+
const [loading, setLoading] = useRefState(defaults.loading)
6965
const forceUpdate = useReducer(() => ({}), [])[1]
7066

7167
const makeFetch = useDeepCallback((method: HTTPMethod): FetchData => {
7268

73-
const doFetch = async (routeOrBody?: RouteOrBody, body?: Body): Promise<any> => {
69+
const doFetch = async (routeOrBody?: RouteOrBody, body?: UFBody): Promise<any> => {
7470
if (isServer) return // for now, we don't do anything on the server
7571
controller.current = new AbortController()
7672
controller.current.signal.onabort = onAbort
@@ -94,7 +90,7 @@ function useFetch<TData = any>(...args: UseFetchArgs): UseFetch<TData> {
9490
if (response.isCached && cachePolicy === CACHE_FIRST) {
9591
try {
9692
res.current = response.cached as Res<TData>
97-
const theData = await tryGetData(response.cached, defaults.data)
93+
const theData = await tryGetData(response.cached, defaults.data, responseType)
9894
res.current.data = theData
9995
res.current = interceptors.response ? await interceptors.response(res.current) : res.current
10096
invariant('data' in res.current, 'You must have `data` field on the Response returned from your `interceptors.response`')
@@ -125,7 +121,7 @@ function useFetch<TData = any>(...args: UseFetchArgs): UseFetch<TData> {
125121
newRes = await fetch(url, options)
126122
res.current = newRes.clone()
127123

128-
newData = await tryGetData(newRes, defaults.data)
124+
newData = await tryGetData(newRes, defaults.data, responseType)
129125
res.current.data = onNewData(data.current, newData)
130126

131127
res.current = interceptors.response ? await interceptors.response(res.current) : res.current
@@ -182,7 +178,7 @@ function useFetch<TData = any>(...args: UseFetchArgs): UseFetch<TData> {
182178
return data.current
183179
} // end of doFetch()
184180

185-
const retry = async (opts: RetryOpts, routeOrBody?: RouteOrBody, body?: Body) => {
181+
const retry = async (opts: RetryOpts, routeOrBody?: RouteOrBody, body?: UFBody) => {
186182
const delay = (isFunction(retryDelay) ? (retryDelay as Function)(opts) : retryDelay) as number
187183
if (!(Number.isInteger(delay) && delay >= 0)) {
188184
console.error('retryDelay must be a number >= 0! If you\'re using it as a function, it must also return a number >= 0.')

src/useFetchArgs.ts

+13-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OptionsMaybeURL, NoUrlOptions, CachePolicies, Interceptors, OverwriteGlobalOptions, Options, RetryOn, RetryDelay, UseFetchArgsReturn } from './types'
1+
import { OptionsMaybeURL, NoUrlOptions, CachePolicies, Interceptors, OverwriteGlobalOptions, Options, RetryOn, RetryDelay, UseFetchArgsReturn, ResponseType } from './types'
22
import { isString, isObject, invariant, pullOutRequestInit, isFunction, isPositiveNumber } from './utils'
33
import { useContext, useMemo } from 'react'
44
import FetchContext from './FetchContext'
@@ -80,6 +80,7 @@ export default function useFetchArgs(
8080
invariant(isValidRetryOn, '`retryOn` must be an array of positive numbers or a function returning a boolean.')
8181
const retryDelay = useField<RetryDelay>('retryDelay', urlOrOptions, optionsNoURLs)
8282
invariant(isFunction(retryDelay) || Number.isInteger(retryDelay as number) && retryDelay >= 0, '`retryDelay` must be a positive number or a function returning a positive number.')
83+
const responseType = useField<ResponseType>('responseType', urlOrOptions, optionsNoURLs)
8384

8485
const loading = useMemo((): boolean => {
8586
if (isObject(urlOrOptions)) return !!urlOrOptions.loading || Array.isArray(dependencies)
@@ -125,21 +126,22 @@ export default function useFetchArgs(
125126

126127
return {
127128
customOptions: {
128-
url,
129-
path,
129+
cacheLife,
130+
cachePolicy,
130131
interceptors,
131-
timeout,
132-
retries,
133-
persist,
134132
onAbort,
135-
onTimeout,
136133
onNewData,
134+
onTimeout,
135+
path,
136+
persist,
137137
perPage,
138-
cachePolicy,
139-
cacheLife,
140-
suspense,
138+
responseType,
139+
retries,
140+
retryDelay,
141141
retryOn,
142-
retryDelay
142+
suspense,
143+
timeout,
144+
url,
143145
},
144146
requestInit,
145147
defaults: {

src/utils.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useMemo, useEffect, MutableRefObject, useRef, useCallback, DependencyList } from 'react'
22
import useSSR from 'use-ssr'
3-
import { RequestInitJSON, OptionsMaybeURL, Res, HTTPMethod } from './types'
3+
import { RequestInitJSON, OptionsMaybeURL, Res, HTTPMethod, ResponseType } from './types'
44
import { FunctionKeys, NonFunctionKeys } from 'utility-types'
55

66
/**
@@ -144,18 +144,22 @@ export const isBrowser = device === Browser
144144
export const isServer = device === Server
145145
export const isNative = device === Native
146146

147-
export const tryGetData = async (res: Response | undefined, defaultData: any) => {
147+
export const tryGetData = async (res: Response | undefined, defaultData: any, responseType: ResponseType) => {
148148
if (typeof res === 'undefined') throw Error('Response cannot be undefined... 😵')
149-
const response = res.clone()
150-
let data
149+
if (typeof responseType === 'undefined') throw Error('responseType cannot be undefined... 😵')
150+
const types = (Array.isArray(responseType) ? responseType : [responseType]) as ResponseType
151+
if (types[0] == null) throw Error('could not parse data from response 😵')
152+
const data = res.ok ? await tryRetry(res, types) : undefined
153+
return !isEmpty(defaultData) && isEmpty(data) ? defaultData : data
154+
}
155+
156+
const tryRetry = async <T = any>(res: Response, types: ResponseType): Promise<T> => {
151157
try {
152-
data = await response.json()
153-
} catch (er) {
154-
try {
155-
data = (await response.text()) as any // FIXME: should not be `any` type
156-
} catch (er) {}
158+
return (res.clone() as any)[types[0]]()
159+
} catch (error) {
160+
if (types.length === 1) throw error
161+
return tryRetry(res.clone(), (types as any).slice(1))
157162
}
158-
return !isEmpty(defaultData) && isEmpty(data) ? defaultData : data
159163
}
160164

161165
/**

yarn.lock

+5
Original file line numberDiff line numberDiff line change
@@ -4561,6 +4561,11 @@ urix@^0.1.0:
45614561
resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
45624562
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
45634563

4564+
urs@^0.0.4:
4565+
version "0.0.4"
4566+
resolved "https://registry.yarnpkg.com/urs/-/urs-0.0.4.tgz#d559d660f2a468e0bb116e0b7b505af57cb59ae4"
4567+
integrity sha512-+QflFOKa9DmjWclPB2audGCV83uWUnTXHOxLPQyu7XXcaY9yQ4+Tb3UEm8m4N7abJ0kJUCUAQBpFlq6mx80j9g==
4568+
45644569
use-ssr@^1.0.22:
45654570
version "1.0.22"
45664571
resolved "https://registry.yarnpkg.com/use-ssr/-/use-ssr-1.0.22.tgz#a43c2587b1907fabda61c6542b80542c619228fe"

0 commit comments

Comments
 (0)