From 85b7ab2c474e49703af5374dc828b89879da1fda Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 18 Jan 2024 16:15:47 +0800 Subject: [PATCH 1/3] feat: add extractDataFromResponse util for better data from response --- package.json | 4 +-- src/index.ts | 71 +++++++++++------------------------------ src/types.ts | 62 +++++++++++++++++++++++++++++++++--- src/utils.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 156 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 8d78c0e..06fc2fc 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "license": "MIT", "packageManager": "yarn@4.0.2", "engines": { - "node": ">=4.0.0" + "node": ">=14.0.0" }, "main": "./lib/index.cjs", "module": "./lib/index.js", @@ -24,7 +24,7 @@ "!**/*.tsbuildinfo" ], "scripts": { - "build": "yarn test && concurrently 'yarn:build:*'", + "build": "yarn test && concurrently -r 'yarn:build:*'", "build:r": "r -f cjs", "build:tsc": "tsc -p src", "dev": "vitest", diff --git a/src/index.ts b/src/index.ts index 4cc0cc5..eb03cb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,20 @@ -import type { URLSearchParametersOptions, ValueOf } from './types.js' -import { CONTENT_TYPE, isPlainObject, normalizeUrl } from './utils.js' - -export type * from './types.js' +import { + ApiMethod, + FetchApiOptions, + InterceptorRequest, + type ApiInterceptor, + type FetchApiBaseOptions, +} from './types.js' +import { + CONTENT_TYPE, + extractDataFromResponse, + isPlainObject, + normalizeUrl, +} from './utils.js' + +export * from './types.js' export * from './utils.js' -export const ApiMethod = { - GET: 'GET', - POST: 'POST', - PATCH: 'PATCH', - PUT: 'PUT', - DELETE: 'DELETE', -} as const - -export type ApiMethod = ValueOf - -export interface FetchApiBaseOptions - extends Omit { - method?: ApiMethod - body?: BodyInit | object - query?: URLSearchParametersOptions - json?: boolean -} - -export interface FetchApiOptions extends FetchApiBaseOptions { - type?: 'arrayBuffer' | 'blob' | 'json' | 'text' | null -} - -export interface InterceptorRequest extends FetchApiOptions { - url: string -} - -export type ApiInterceptor = ( - request: InterceptorRequest, - next: (request: InterceptorRequest) => PromiseLike, -) => PromiseLike | Response - -export interface ResponseError extends Error { - data?: T | null - response?: Response | null -} - export class ApiInterceptors { readonly #interceptors: ApiInterceptor[] = [] @@ -66,7 +41,7 @@ export class ApiInterceptors { } } -export const createFetchApi = () => { +export const createFetchApi = (fetch = globalThis.fetch) => { const interceptors = new ApiInterceptors() function fetchApi( @@ -90,7 +65,6 @@ export const createFetchApi = () => { url: string, options?: FetchApiBaseOptions & { type?: 'json' }, ): Promise - // eslint-disable-next-line sonarjs/cognitive-complexity async function fetchApi( url: string, { @@ -122,16 +96,8 @@ export const createFetchApi = () => { if (response.ok) { return response } - let data: unknown = null - if (type != null) { - try { - data = await response.clone()[type]() - } catch { - data = await response.clone().text() - } - } throw Object.assign(new Error(response.statusText), { - data, + data: extractDataFromResponse(response, type), response, }) } @@ -143,7 +109,8 @@ export const createFetchApi = () => { headers, ...rest, }) - return type == null ? response : response.clone()[type]() + + return type == null ? response : extractDataFromResponse(response, type) } return { interceptors, fetchApi } diff --git a/src/types.ts b/src/types.ts index 5e56569..25d3daa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,13 +1,65 @@ -export type Nullable = T | null | undefined +export type Nil = null | undefined | void + +export type Nilable = Nil | T + +export type Readonlyable = Readonly | T + +export type AnyArray = Readonlyable + +export type Arrayable = [R] extends [never] + ? T | T[] + : R extends true + ? Readonly | readonly T[] + : R extends false + ? AnyArray | Readonlyable + : never export type ValueOf = T[keyof T] -export type URLSearchParametersInit = ConstructorParameters< +export type URLSearchParamsInit = ConstructorParameters< typeof URLSearchParams // eslint-disable-next-line @typescript-eslint/no-magic-numbers >[0] -export type URLSearchParametersOptions = - | Record> - | URLSearchParametersInit +export type URLSearchParamsOptions = + | Record>> + | URLSearchParamsInit | object + +export const ApiMethod = { + GET: 'GET', + POST: 'POST', + PATCH: 'PATCH', + PUT: 'PUT', + DELETE: 'DELETE', +} as const + +export type ApiMethod = ValueOf + +export interface FetchApiBaseOptions + extends Omit { + method?: ApiMethod + body?: BodyInit | object + query?: URLSearchParamsOptions + json?: boolean +} + +export type ResponseType = 'arrayBuffer' | 'blob' | 'json' | 'text' | null + +export interface FetchApiOptions extends FetchApiBaseOptions { + type?: ResponseType +} + +export interface InterceptorRequest extends FetchApiOptions { + url: string +} + +export type ApiInterceptor = ( + request: InterceptorRequest, + next: (request: InterceptorRequest) => PromiseLike, +) => PromiseLike | Response + +export interface ResponseError extends Error { + data?: T | null + response?: Response | null +} diff --git a/src/utils.ts b/src/utils.ts index 6e8e70f..b79a078 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,14 +1,14 @@ import { - Nullable, - URLSearchParametersInit, - URLSearchParametersOptions, + Nilable, + ResponseType, + URLSearchParamsOptions, ValueOf, } from './types.js' export const CONTENT_TYPE = 'Content-Type' // eslint-disable-next-line @typescript-eslint/unbound-method -const { toString } = Object.prototype // type-coverage:ignore-line - TODO: report bug +const { toString } = Object.prototype // type-coverage:ignore-line -- https://github.com/plantain-00/type-coverage/issues/133 const objectTag = '[object Object]' @@ -22,7 +22,7 @@ export const cleanNilValues = (input: T, empty?: boolean): T => { for (const _key of Object.keys(input)) { const key = _key as keyof T - const value = input[key] as Nullable> + const value = input[key] as Nilable> if (empty ? !value : value == null) { delete input[key] } else { @@ -33,12 +33,78 @@ export const cleanNilValues = (input: T, empty?: boolean): T => { return input } -export const normalizeUrl = ( - url: string, - query?: URLSearchParametersOptions, -) => { - const search = new URLSearchParams( - cleanNilValues(query, true) as URLSearchParametersInit, - ).toString() +export const normalizeUrl = (url: string, query?: URLSearchParamsOptions) => { + const cleanedQuery = cleanNilValues(query, true) + const searchParams = new URLSearchParams() + if (isPlainObject(cleanedQuery)) { + for (const [ + key, + // type-coverage:ignore-next-line -- cannot control + _value, + ] of Object.entries(cleanedQuery)) { + const value = _value as unknown + if (Array.isArray(value)) { + const items = value as unknown[] + for (const item of items) { + searchParams.append(key, String(item)) + } + } else { + searchParams.set(key, String(value)) + } + } + } + const search = searchParams.toString() return search ? url + (url.includes('?') ? '&' : '?') + search : url } + +export async function extractDataFromResponse( + res: Response, + type: null, +): Promise +export async function extractDataFromResponse( + res: Response, + type: 'arrayBuffer', +): Promise +export async function extractDataFromResponse( + res: Response, + type: 'blob', +): Promise +export async function extractDataFromResponse( + res: Response, + type: 'json', +): Promise +export async function extractDataFromResponse( + res: Response, + type: 'text', +): Promise +export async function extractDataFromResponse( + res: Response, + type: ResponseType, +): Promise +// eslint-disable-next-line sonarjs/cognitive-complexity +export async function extractDataFromResponse( + res: Response, + type: ResponseType, +) { + let data: unknown + if (type != null) { + if (type === 'json' || type === 'text') { + try { + // data could be empty text + data = await res.clone().text() + } catch {} + if (type === 'json' && (data = (data as string).trim())) { + try { + data = JSON.parse(data as string) + } catch {} + } + } else { + try { + data = await res.clone()[type]() + } catch { + data = await res.clone().text() + } + } + } + return data +} From 1eb67b104deb701c8f45987359eaac04a6b3976e Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 18 Jan 2024 19:45:07 +0800 Subject: [PATCH 2/3] test: add cases for utils --- test/utils.spec.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/utils.spec.ts diff --git a/test/utils.spec.ts b/test/utils.spec.ts new file mode 100644 index 0000000..89126db --- /dev/null +++ b/test/utils.spec.ts @@ -0,0 +1,54 @@ +/* eslint-disable unicorn/no-await-expression-member */ + +import { + cleanNilValues, + extractDataFromResponse, + isPlainObject, + normalizeUrl, +} from 'x-fetch' + +test('isPlainObject', () => { + expect(isPlainObject({})).toBe(true) + expect(isPlainObject([])).toBe(false) + expect(isPlainObject(0)).toBe(false) + expect(isPlainObject(true)).toBe(false) +}) + +test('cleanNilValues', () => { + expect(cleanNilValues('')).toBe('') + expect(cleanNilValues(true)).toBe(true) + expect(cleanNilValues({ foo: 'bar' })).toEqual({ foo: 'bar' }) + expect(cleanNilValues({ foo: 'bar', test: null })).toEqual({ foo: 'bar' }) + expect(cleanNilValues({ foo: 'bar', test: '' })).toEqual({ + foo: 'bar', + test: '', + }) + expect(cleanNilValues({ foo: 'bar', test: '' }, true)).toEqual({ foo: 'bar' }) +}) + +test('normalizeUrl', () => { + expect(normalizeUrl('')).toBe('') + expect(normalizeUrl('', { foo: 'bar' })).toBe('?foo=bar') + expect(normalizeUrl('', { foo: ['bar', 'baz'] })).toBe('?foo=bar&foo=baz') + expect(normalizeUrl('test?x=y')).toBe('test?x=y') + expect(normalizeUrl('test?x=y', { foo: 'bar' })).toBe('test?x=y&foo=bar') + expect(normalizeUrl('test?x=y', { foo: ['bar', 'baz'] })).toBe( + 'test?x=y&foo=bar&foo=baz', + ) +}) + +test('extractDataFromResponse', async () => { + expect(await extractDataFromResponse(new Response(''), null)).toBe(undefined) + expect(await extractDataFromResponse(new Response(), null)).toBe(undefined) + expect(await extractDataFromResponse(new Response(), 'text')).toBe('') + expect(await extractDataFromResponse(new Response(null), 'text')).toBe('') + expect(await extractDataFromResponse(new Response('foo'), 'text')).toBe('foo') + expect(await extractDataFromResponse(new Response('foo'), 'json')).toBe('foo') + expect( + await extractDataFromResponse(new Response('{"foo":"bar"}'), 'json'), + ).toEqual({ foo: 'bar' }) + expect( + (await extractDataFromResponse(new Response('foo'), 'arrayBuffer')) + .byteLength, + ).toBe(3) +}) From 7d4b3c3066f9a6608858bade9d4a1987d820219d Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 18 Jan 2024 19:46:01 +0800 Subject: [PATCH 3/3] Create quiet-lizards-itch.md --- .changeset/quiet-lizards-itch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-lizards-itch.md diff --git a/.changeset/quiet-lizards-itch.md b/.changeset/quiet-lizards-itch.md new file mode 100644 index 0000000..17a1388 --- /dev/null +++ b/.changeset/quiet-lizards-itch.md @@ -0,0 +1,5 @@ +--- +"x-fetch": minor +--- + +feat: add extractDataFromResponse util for better data from response