Skip to content

Commit

Permalink
feat: add extractDataFromResponse util for better data from response
Browse files Browse the repository at this point in the history
  • Loading branch information
JounQin committed Jan 18, 2024
1 parent 65831ce commit 85b7ab2
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 71 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"license": "MIT",
"packageManager": "[email protected]",
"engines": {
"node": ">=4.0.0"
"node": ">=14.0.0"
},
"main": "./lib/index.cjs",
"module": "./lib/index.js",
Expand All @@ -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",
Expand Down
71 changes: 19 additions & 52 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ApiMethod>

export interface FetchApiBaseOptions
extends Omit<RequestInit, 'body' | 'method'> {
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<Response>,
) => PromiseLike<Response> | Response

export interface ResponseError<T = never> extends Error {
data?: T | null
response?: Response | null
}

export class ApiInterceptors {
readonly #interceptors: ApiInterceptor[] = []

Expand All @@ -66,7 +41,7 @@ export class ApiInterceptors {
}
}

export const createFetchApi = () => {
export const createFetchApi = (fetch = globalThis.fetch) => {
const interceptors = new ApiInterceptors()

function fetchApi(
Expand All @@ -90,7 +65,6 @@ export const createFetchApi = () => {
url: string,
options?: FetchApiBaseOptions & { type?: 'json' },
): Promise<T>
// eslint-disable-next-line sonarjs/cognitive-complexity
async function fetchApi(
url: string,
{
Expand Down Expand Up @@ -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,
})
}
Expand All @@ -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 }
Expand Down
62 changes: 57 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,65 @@
export type Nullable<T> = T | null | undefined
export type Nil = null | undefined | void

export type Nilable<T> = Nil | T

export type Readonlyable<T> = Readonly<T> | T

export type AnyArray<T = unknown> = Readonlyable<T[]>

export type Arrayable<T, R extends boolean = false> = [R] extends [never]
? T | T[]
: R extends true
? Readonly<T> | readonly T[]
: R extends false
? AnyArray<T> | Readonlyable<T>
: never

export type ValueOf<T> = 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<string, Nullable<number | string>>
| URLSearchParametersInit
export type URLSearchParamsOptions =
| Record<string, Nilable<Arrayable<number | string>>>
| URLSearchParamsInit
| object

export const ApiMethod = {
GET: 'GET',
POST: 'POST',
PATCH: 'PATCH',
PUT: 'PUT',
DELETE: 'DELETE',
} as const

export type ApiMethod = ValueOf<typeof ApiMethod>

export interface FetchApiBaseOptions
extends Omit<RequestInit, 'body' | 'method'> {
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<Response>,
) => PromiseLike<Response> | Response

export interface ResponseError<T = never> extends Error {
data?: T | null
response?: Response | null
}
90 changes: 78 additions & 12 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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]'

Expand All @@ -22,7 +22,7 @@ export const cleanNilValues = <T = unknown>(input: T, empty?: boolean): T => {

for (const _key of Object.keys(input)) {
const key = _key as keyof T
const value = input[key] as Nullable<ValueOf<T>>
const value = input[key] as Nilable<ValueOf<T>>

Check warning on line 25 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L25

Added line #L25 was not covered by tests
if (empty ? !value : value == null) {
delete input[key]
} else {
Expand All @@ -33,12 +33,78 @@ export const cleanNilValues = <T = unknown>(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 [

Check warning on line 40 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L40

Added line #L40 was not covered by tests
key,
// type-coverage:ignore-next-line -- cannot control
_value,
] of Object.entries(cleanedQuery)) {
const value = _value as unknown

Check warning on line 45 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L45

Added line #L45 was not covered by tests
if (Array.isArray(value)) {
const items = value as unknown[]
for (const item of items) {
searchParams.append(key, String(item))

Check warning on line 49 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L47-L49

Added lines #L47 - L49 were not covered by tests
}
} else {
searchParams.set(key, String(value))

Check warning on line 52 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L51-L52

Added lines #L51 - L52 were not covered by tests
}
}
}
const search = searchParams.toString()
return search ? url + (url.includes('?') ? '&' : '?') + search : url
}

export async function extractDataFromResponse(
res: Response,
type: null,
): Promise<Response>
export async function extractDataFromResponse(
res: Response,
type: 'arrayBuffer',
): Promise<ArrayBuffer>
export async function extractDataFromResponse(
res: Response,
type: 'blob',
): Promise<Blob>
export async function extractDataFromResponse<T>(
res: Response,
type: 'json',
): Promise<T>
export async function extractDataFromResponse(
res: Response,
type: 'text',
): Promise<string>
export async function extractDataFromResponse(
res: Response,
type: ResponseType,
): Promise<unknown>
// 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]()

Check warning on line 103 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L101-L103

Added lines #L101 - L103 were not covered by tests
} catch {
data = await res.clone().text()

Check warning on line 105 in src/utils.ts

View check run for this annotation

Codecov / codecov/patch

src/utils.ts#L105

Added line #L105 was not covered by tests
}
}
}
return data
}

0 comments on commit 85b7ab2

Please sign in to comment.