Skip to content

Commit eda5886

Browse files
authored
feat: better interceptor implementation (#183)
1 parent 097c8a1 commit eda5886

File tree

6 files changed

+341
-246
lines changed

6 files changed

+341
-246
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@react-enhanced/hooks": minor
3+
---
4+
5+
feat: better interceptor implementation

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"packages/*",
1212
"packages/@react-enhanced/*"
1313
],
14-
"packageManager": "pnpm@8.1.1",
14+
"packageManager": "pnpm@8.2.0",
1515
"scripts": {
1616
"build": "run-p build:*",
1717
"build:r": "r -f cjs --esbuild {jsxFactory:'React.createElement'}",
@@ -61,7 +61,7 @@
6161
"@vitest/coverage-istanbul": "^0.30.0",
6262
"classnames": "^2.3.2",
6363
"github-markdown-css": "^5.2.0",
64-
"happy-dom": "^9.1.9",
64+
"jsdom": "^21.1.1",
6565
"lodash": "^4.17.21",
6666
"lodash-es": "^4.17.21",
6767
"prism-react-renderer": "^1.3.5",

packages/@react-enhanced/hooks/src/api.ts

Lines changed: 48 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ApiMethod, NO_CONTENT } from '@react-enhanced/shared'
1+
import { ApiMethod } from '@react-enhanced/shared'
22
import type { Nilable, URLSearchParamsOptions } from '@react-enhanced/types'
33
import {
44
CONTENT_TYPE,
@@ -8,7 +8,7 @@ import {
88
import { isPlainObject } from 'lodash'
99
import { useCallback, useState } from 'react'
1010
import type { Observable } from 'rxjs'
11-
import { NEVER, catchError, from, of, switchMap, tap, throwError } from 'rxjs'
11+
import { NEVER, from, of, switchMap, tap } from 'rxjs'
1212
import { fromFetch } from 'rxjs/fetch'
1313

1414
import { useEnhancedEffect } from './lifecycle.js'
@@ -19,53 +19,45 @@ export interface FetchApiOptions extends Omit<RequestInit, 'body' | 'method'> {
1919
query?: URLSearchParamsOptions
2020
json?: boolean
2121
type?: 'arrayBuffer' | 'blob' | 'json' | 'text' | null
22-
mock?: boolean
2322
}
2423

2524
export interface InterceptorRequest extends FetchApiOptions {
2625
url: string
2726
}
2827

29-
export type RequestInterceptor = (
28+
export type ApiInterceptor = (
3029
request: InterceptorRequest,
31-
) =>
32-
| InterceptorRequest
33-
| Observable<InterceptorRequest>
34-
| PromiseLike<InterceptorRequest>
35-
36-
export type ResponseInterceptor = (
37-
request: InterceptorRequest,
38-
response: Response,
30+
next: (request: InterceptorRequest) => Observable<Response>,
3931
) => Observable<Response> | PromiseLike<Response> | Response
4032

4133
export type ResponseError<T extends api.Error = api.Error> = T & {
4234
data?: T | null
4335
response?: Response | null
4436
}
4537

46-
export type ErrorInterceptor<T extends api.Error = api.Error> = (
47-
request: InterceptorRequest,
48-
error: ResponseError<T>,
49-
) => Observable<Response> | PromiseLike<Response> | Response
38+
export class ApiInterceptors {
39+
readonly #interceptors: ApiInterceptor[] = []
5040

51-
export interface ApiInterceptors {
52-
request: {
53-
end(): ApiInterceptors
54-
use(interceptor: RequestInterceptor): ApiInterceptors['request']
55-
eject(interceptor: RequestInterceptor): boolean
41+
get length() {
42+
return this.#interceptors.length
5643
}
57-
response: {
58-
end(): ApiInterceptors
59-
use(interceptor: ResponseInterceptor): ApiInterceptors['response']
60-
use<T extends api.Error = api.Error>(
61-
responseInterceptor: ResponseInterceptor | null,
62-
errorInterceptor: ErrorInterceptor<T>,
63-
): ApiInterceptors['response']
64-
eject(interceptor: ResponseInterceptor): boolean
65-
eject<T extends api.Error = api.Error>(
66-
responseInterceptor: ResponseInterceptor | null,
67-
errorInterceptor: ErrorInterceptor<T>,
68-
): boolean
44+
45+
at(index: number): ApiInterceptor | undefined {
46+
return this.#interceptors.at(index)
47+
}
48+
49+
use(...interceptors: ApiInterceptor[]): ApiInterceptors {
50+
this.#interceptors.push(...interceptors)
51+
return this
52+
}
53+
54+
eject(interceptor: ApiInterceptor): boolean {
55+
const index = this.#interceptors.indexOf(interceptor)
56+
if (index > -1) {
57+
this.#interceptors.splice(index, 1)
58+
return true
59+
}
60+
return false
6961
}
7062
}
7163

@@ -90,151 +82,8 @@ export interface UseApiOptions extends FetchApiOptions {
9082
url?: string
9183
}
9284

93-
const createInterceptors = () => {
94-
const requestInterceptors = new Set<RequestInterceptor>()
95-
const responseInterceptors = new Set<ResponseInterceptor>()
96-
const errorInterceptors = new Set<ErrorInterceptor>()
97-
98-
const interceptors: ApiInterceptors = {
99-
request: {
100-
end() {
101-
return interceptors
102-
},
103-
use(interceptor: RequestInterceptor) {
104-
requestInterceptors.add(interceptor)
105-
return interceptors.request
106-
},
107-
eject(interceptor: RequestInterceptor) {
108-
return requestInterceptors.delete(interceptor)
109-
},
110-
},
111-
response: {
112-
end() {
113-
return interceptors
114-
},
115-
use(
116-
responseInterceptor: ResponseInterceptor | null,
117-
errorInterceptor?: ErrorInterceptor,
118-
) {
119-
if (responseInterceptor) {
120-
responseInterceptors.add(responseInterceptor)
121-
}
122-
123-
if (errorInterceptor) {
124-
errorInterceptors.add(errorInterceptor)
125-
}
126-
127-
return interceptors.response
128-
},
129-
eject(
130-
responseInterceptor: ResponseInterceptor | null,
131-
errorInterceptor?: ErrorInterceptor,
132-
) {
133-
if (!responseInterceptor && !errorInterceptor) {
134-
return false
135-
}
136-
const resIcDeleted =
137-
!responseInterceptor ||
138-
responseInterceptors.delete(responseInterceptor)
139-
const errIcDeleted =
140-
!errorInterceptor || errorInterceptors.delete(errorInterceptor)
141-
return resIcDeleted && errIcDeleted
142-
},
143-
},
144-
}
145-
146-
return {
147-
interceptors,
148-
requestInterceptors,
149-
responseInterceptors,
150-
errorInterceptors,
151-
}
152-
}
153-
154-
const invokeRequestInterceptors = (
155-
requestInterceptors: Set<RequestInterceptor>,
156-
req: InterceptorRequest,
157-
) =>
158-
[...requestInterceptors].reduce(
159-
(acc, interceptor) =>
160-
acc.pipe(
161-
switchMap(req => {
162-
const next = interceptor(req)
163-
return isObservableLike(next) ? next : of(next)
164-
}),
165-
),
166-
of(req),
167-
)
168-
169-
const invokeResponseInterceptors = <T>(
170-
responseInterceptors: Set<ResponseInterceptor>,
171-
req: InterceptorRequest,
172-
res: Response,
173-
type: FetchApiOptions['type'],
174-
) =>
175-
[...responseInterceptors]
176-
.reduce(
177-
(acc, interceptor) =>
178-
acc.pipe(
179-
switchMap(res => {
180-
const next = interceptor(req, res)
181-
return isObservableLike(next) ? next : of(next)
182-
}),
183-
),
184-
of(res),
185-
)
186-
.pipe(
187-
switchMap(res =>
188-
from(
189-
res.status === NO_CONTENT
190-
? of(null)
191-
: type == null
192-
? of(res)
193-
: (res.clone()[type]() as Promise<T>),
194-
).pipe(
195-
catchError((err: Error) =>
196-
throwError(() =>
197-
Object.assign(new Error(err.message), { response: res }),
198-
),
199-
),
200-
switchMap(data => {
201-
if (res.ok) {
202-
return of(data)
203-
}
204-
return throwError(() =>
205-
Object.assign(new Error(res.statusText), {
206-
data,
207-
response: res,
208-
}),
209-
)
210-
}),
211-
),
212-
),
213-
)
214-
215-
const invokeErrorInterceptors = (
216-
errorInterceptors: Set<ErrorInterceptor>,
217-
req: InterceptorRequest,
218-
err: ResponseError,
219-
) =>
220-
[...errorInterceptors].reduce<Observable<Response>>(
221-
(acc, interceptor) =>
222-
acc.pipe(
223-
catchError((err: ResponseError) => {
224-
const next = interceptor(req, err)
225-
return isObservableLike(next) ? next : of(next)
226-
}),
227-
),
228-
throwError(() => err),
229-
)
230-
23185
export function createApi() {
232-
const {
233-
interceptors,
234-
requestInterceptors,
235-
responseInterceptors,
236-
errorInterceptors,
237-
} = createInterceptors()
86+
const interceptors = new ApiInterceptors()
23887

23988
function fetchApi(
24089
url: string,
@@ -273,44 +122,34 @@ export function createApi() {
273122
headers.append(CONTENT_TYPE, 'application/json')
274123
}
275124

276-
const req: InterceptorRequest = {
125+
let index = 0
126+
127+
const next = (req: InterceptorRequest) => {
128+
if (index < interceptors.length) {
129+
const res = interceptors.at(index++)!(req, next)
130+
return isObservableLike(res) ? from(res) : of(res)
131+
}
132+
const { body, url, query, ...rest } = req
133+
return fromFetch<Response>(normalizeUrl(url, query), {
134+
...rest,
135+
body: json ? JSON.stringify(body) : (body as BodyInit),
136+
selector: res => of(res),
137+
})
138+
}
139+
140+
return next({
277141
url,
278142
method,
279143
body,
280144
headers,
281145
...rest,
282-
}
283-
284-
return invokeRequestInterceptors(requestInterceptors, req).pipe(
285-
switchMap(req => {
286-
const { body, url, query, ...rest } = req
287-
return fromFetch<Response>(normalizeUrl(url, query), {
288-
...rest,
289-
body: json ? JSON.stringify(body) : (body as BodyInit),
290-
selector: res => of(res),
291-
}).pipe(
292-
catchError((err: api.Error) =>
293-
invokeErrorInterceptors(errorInterceptors, req, err),
294-
),
295-
switchMap(res =>
296-
invokeResponseInterceptors(responseInterceptors, req, res, type),
297-
),
298-
)
146+
}).pipe(
147+
switchMap(res => {
148+
if (type == null) {
149+
return of(res)
150+
}
151+
return res[type]() as Promise<T>
299152
}),
300-
catchError((err: ResponseError) =>
301-
invokeErrorInterceptors(errorInterceptors, req, err).pipe(
302-
switchMap(res => {
303-
if (type == null) {
304-
return of(res)
305-
}
306-
return from(res.clone()[type]() as Promise<T>).pipe(
307-
catchError((err: Error) =>
308-
throwError(() => Object.assign(err, { response: res })),
309-
),
310-
)
311-
}),
312-
),
313-
),
314153
)
315154
}
316155

packages/@react-enhanced/hooks/test/api.spec.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,33 @@ import { sleep } from '@react-enhanced/shared'
22
import { renderHook } from '@testing-library/react'
33
import { fetch } from 'undici'
44

5-
import { RequestInterceptor, interceptors, useApi } from '@react-enhanced/hooks'
5+
import { ApiInterceptor, interceptors, useApi } from '@react-enhanced/hooks'
66

7-
const requestInterceptor: RequestInterceptor = req => {
7+
const apiInterceptor: ApiInterceptor = (req, next) => {
88
if (!/^https?:\/\//.test(req.url)) {
99
req.url = 'https://api.github.com/' + req.url
10+
req.headers = {
11+
...req.headers,
12+
Authorization: `Bearer ${process.env.GITHUB_TOKEN!}`,
13+
}
1014
}
11-
return req
15+
return next(req)
1216
}
1317

1418
// @ts-expect-error
1519
globalThis.fetch = fetch
1620

17-
interceptors.request.use(requestInterceptor)
21+
interceptors.use(apiInterceptor)
1822

1923
afterAll(() => {
20-
interceptors.request.eject(requestInterceptor)
24+
interceptors.eject(apiInterceptor)
2125
})
2226

2327
it('should work as expected', async () => {
2428
const { result } = renderHook(() => useApi('rate_limit'))
2529
expect(result.current.data).toBeUndefined()
2630
expect(result.current.loading).toBe(true)
27-
await sleep(1000)
31+
await sleep(2 * 1000)
2832
expect(result.current.data).toBeTruthy()
2933
expect(result.current.loading).toBe(false)
3034
})

0 commit comments

Comments
 (0)