Skip to content
Merged
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/little-hornets-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-enhanced/hooks": minor
---

feat: better interceptor implementation
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"packages/*",
"packages/@react-enhanced/*"
],
"packageManager": "pnpm@8.1.1",
"packageManager": "pnpm@8.2.0",
"scripts": {
"build": "run-p build:*",
"build:r": "r -f cjs --esbuild {jsxFactory:'React.createElement'}",
Expand Down Expand Up @@ -61,7 +61,7 @@
"@vitest/coverage-istanbul": "^0.30.0",
"classnames": "^2.3.2",
"github-markdown-css": "^5.2.0",
"happy-dom": "^9.1.9",
"jsdom": "^21.1.1",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"prism-react-renderer": "^1.3.5",
Expand Down
257 changes: 48 additions & 209 deletions packages/@react-enhanced/hooks/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ApiMethod, NO_CONTENT } from '@react-enhanced/shared'
import { ApiMethod } from '@react-enhanced/shared'
import type { Nilable, URLSearchParamsOptions } from '@react-enhanced/types'
import {
CONTENT_TYPE,
Expand All @@ -8,7 +8,7 @@ import {
import { isPlainObject } from 'lodash'
import { useCallback, useState } from 'react'
import type { Observable } from 'rxjs'
import { NEVER, catchError, from, of, switchMap, tap, throwError } from 'rxjs'
import { NEVER, from, of, switchMap, tap } from 'rxjs'
import { fromFetch } from 'rxjs/fetch'

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

export interface InterceptorRequest extends FetchApiOptions {
url: string
}

export type RequestInterceptor = (
export type ApiInterceptor = (
request: InterceptorRequest,
) =>
| InterceptorRequest
| Observable<InterceptorRequest>
| PromiseLike<InterceptorRequest>

export type ResponseInterceptor = (
request: InterceptorRequest,
response: Response,
next: (request: InterceptorRequest) => Observable<Response>,
) => Observable<Response> | PromiseLike<Response> | Response

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

export type ErrorInterceptor<T extends api.Error = api.Error> = (
request: InterceptorRequest,
error: ResponseError<T>,
) => Observable<Response> | PromiseLike<Response> | Response
export class ApiInterceptors {
readonly #interceptors: ApiInterceptor[] = []

export interface ApiInterceptors {
request: {
end(): ApiInterceptors
use(interceptor: RequestInterceptor): ApiInterceptors['request']
eject(interceptor: RequestInterceptor): boolean
get length() {
return this.#interceptors.length
}
response: {
end(): ApiInterceptors
use(interceptor: ResponseInterceptor): ApiInterceptors['response']
use<T extends api.Error = api.Error>(
responseInterceptor: ResponseInterceptor | null,
errorInterceptor: ErrorInterceptor<T>,
): ApiInterceptors['response']
eject(interceptor: ResponseInterceptor): boolean
eject<T extends api.Error = api.Error>(
responseInterceptor: ResponseInterceptor | null,
errorInterceptor: ErrorInterceptor<T>,
): boolean

at(index: number): ApiInterceptor | undefined {
return this.#interceptors.at(index)
}

use(...interceptors: ApiInterceptor[]): ApiInterceptors {
this.#interceptors.push(...interceptors)
return this
}

eject(interceptor: ApiInterceptor): boolean {
const index = this.#interceptors.indexOf(interceptor)
if (index > -1) {
this.#interceptors.splice(index, 1)
return true
}
return false
}
}

Expand All @@ -90,151 +82,8 @@ export interface UseApiOptions extends FetchApiOptions {
url?: string
}

const createInterceptors = () => {
const requestInterceptors = new Set<RequestInterceptor>()
const responseInterceptors = new Set<ResponseInterceptor>()
const errorInterceptors = new Set<ErrorInterceptor>()

const interceptors: ApiInterceptors = {
request: {
end() {
return interceptors
},
use(interceptor: RequestInterceptor) {
requestInterceptors.add(interceptor)
return interceptors.request
},
eject(interceptor: RequestInterceptor) {
return requestInterceptors.delete(interceptor)
},
},
response: {
end() {
return interceptors
},
use(
responseInterceptor: ResponseInterceptor | null,
errorInterceptor?: ErrorInterceptor,
) {
if (responseInterceptor) {
responseInterceptors.add(responseInterceptor)
}

if (errorInterceptor) {
errorInterceptors.add(errorInterceptor)
}

return interceptors.response
},
eject(
responseInterceptor: ResponseInterceptor | null,
errorInterceptor?: ErrorInterceptor,
) {
if (!responseInterceptor && !errorInterceptor) {
return false
}
const resIcDeleted =
!responseInterceptor ||
responseInterceptors.delete(responseInterceptor)
const errIcDeleted =
!errorInterceptor || errorInterceptors.delete(errorInterceptor)
return resIcDeleted && errIcDeleted
},
},
}

return {
interceptors,
requestInterceptors,
responseInterceptors,
errorInterceptors,
}
}

const invokeRequestInterceptors = (
requestInterceptors: Set<RequestInterceptor>,
req: InterceptorRequest,
) =>
[...requestInterceptors].reduce(
(acc, interceptor) =>
acc.pipe(
switchMap(req => {
const next = interceptor(req)
return isObservableLike(next) ? next : of(next)
}),
),
of(req),
)

const invokeResponseInterceptors = <T>(
responseInterceptors: Set<ResponseInterceptor>,
req: InterceptorRequest,
res: Response,
type: FetchApiOptions['type'],
) =>
[...responseInterceptors]
.reduce(
(acc, interceptor) =>
acc.pipe(
switchMap(res => {
const next = interceptor(req, res)
return isObservableLike(next) ? next : of(next)
}),
),
of(res),
)
.pipe(
switchMap(res =>
from(
res.status === NO_CONTENT
? of(null)
: type == null
? of(res)
: (res.clone()[type]() as Promise<T>),
).pipe(
catchError((err: Error) =>
throwError(() =>
Object.assign(new Error(err.message), { response: res }),
),
),
switchMap(data => {
if (res.ok) {
return of(data)
}
return throwError(() =>
Object.assign(new Error(res.statusText), {
data,
response: res,
}),
)
}),
),
),
)

const invokeErrorInterceptors = (
errorInterceptors: Set<ErrorInterceptor>,
req: InterceptorRequest,
err: ResponseError,
) =>
[...errorInterceptors].reduce<Observable<Response>>(
(acc, interceptor) =>
acc.pipe(
catchError((err: ResponseError) => {
const next = interceptor(req, err)
return isObservableLike(next) ? next : of(next)
}),
),
throwError(() => err),
)

export function createApi() {
const {
interceptors,
requestInterceptors,
responseInterceptors,
errorInterceptors,
} = createInterceptors()
const interceptors = new ApiInterceptors()

function fetchApi(
url: string,
Expand Down Expand Up @@ -273,44 +122,34 @@ export function createApi() {
headers.append(CONTENT_TYPE, 'application/json')
}

const req: InterceptorRequest = {
let index = 0

const next = (req: InterceptorRequest) => {
if (index < interceptors.length) {
const res = interceptors.at(index++)!(req, next)
return isObservableLike(res) ? from(res) : of(res)
}
const { body, url, query, ...rest } = req
return fromFetch<Response>(normalizeUrl(url, query), {
...rest,
body: json ? JSON.stringify(body) : (body as BodyInit),
selector: res => of(res),
})
}

return next({
url,
method,
body,
headers,
...rest,
}

return invokeRequestInterceptors(requestInterceptors, req).pipe(
switchMap(req => {
const { body, url, query, ...rest } = req
return fromFetch<Response>(normalizeUrl(url, query), {
...rest,
body: json ? JSON.stringify(body) : (body as BodyInit),
selector: res => of(res),
}).pipe(
catchError((err: api.Error) =>
invokeErrorInterceptors(errorInterceptors, req, err),
),
switchMap(res =>
invokeResponseInterceptors(responseInterceptors, req, res, type),
),
)
}).pipe(
switchMap(res => {
if (type == null) {
return of(res)
}
return res[type]() as Promise<T>
}),
catchError((err: ResponseError) =>
invokeErrorInterceptors(errorInterceptors, req, err).pipe(
switchMap(res => {
if (type == null) {
return of(res)
}
return from(res.clone()[type]() as Promise<T>).pipe(
catchError((err: Error) =>
throwError(() => Object.assign(err, { response: res })),
),
)
}),
),
),
)
}

Expand Down
16 changes: 10 additions & 6 deletions packages/@react-enhanced/hooks/test/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,33 @@ import { sleep } from '@react-enhanced/shared'
import { renderHook } from '@testing-library/react'
import { fetch } from 'undici'

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

const requestInterceptor: RequestInterceptor = req => {
const apiInterceptor: ApiInterceptor = (req, next) => {
if (!/^https?:\/\//.test(req.url)) {
req.url = 'https://api.github.com/' + req.url
req.headers = {
...req.headers,
Authorization: `Bearer ${process.env.GITHUB_TOKEN!}`,
}
}
return req
return next(req)
}

// @ts-expect-error
globalThis.fetch = fetch

interceptors.request.use(requestInterceptor)
interceptors.use(apiInterceptor)

afterAll(() => {
interceptors.request.eject(requestInterceptor)
interceptors.eject(apiInterceptor)
})

it('should work as expected', async () => {
const { result } = renderHook(() => useApi('rate_limit'))
expect(result.current.data).toBeUndefined()
expect(result.current.loading).toBe(true)
await sleep(1000)
await sleep(2 * 1000)
expect(result.current.data).toBeTruthy()
expect(result.current.loading).toBe(false)
})
Loading