optimize(request): enhance request options and response handling with generic types

This commit is contained in:
Soybean 2025-07-19 02:17:42 +08:00
parent d6c8142bb4
commit 50a5cba088
7 changed files with 110 additions and 77 deletions

View File

@ -13,11 +13,12 @@ import type {
ResponseType ResponseType
} from './type'; } from './type';
function createCommonRequest<ResponseData = any>( function createCommonRequest<
axiosConfig?: CreateAxiosDefaults, ResponseData,
options?: Partial<RequestOption<ResponseData>> ApiData = ResponseData,
) { State extends Record<string, unknown> = Record<string, unknown>
const opts = createDefaultOptions<ResponseData>(options); >(axiosConfig?: CreateAxiosDefaults, options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts = createDefaultOptions<ResponseData, ApiData, State>(options);
const axiosConf = createAxiosConfig(axiosConfig); const axiosConf = createAxiosConfig(axiosConfig);
const instance = axios.create(axiosConf); const instance = axios.create(axiosConf);
@ -109,15 +110,19 @@ function createCommonRequest<ResponseData = any>(
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createRequest<ResponseData = any, State = Record<string, unknown>>( export function createRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(
axiosConfig,
options
);
const request: RequestInstance<State> = async function request<T = any, R extends ResponseType = 'json'>( const request: RequestInstance<ApiData, State> = async function request<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const response: AxiosResponse<ResponseData> = await instance(config); const response: AxiosResponse<ResponseData> = await instance(config);
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
@ -127,7 +132,7 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
} }
return response.data as MappedType<R, T>; return response.data as MappedType<R, T>;
} as RequestInstance<State>; } as RequestInstance<ApiData, State>;
request.cancelRequest = cancelRequest; request.cancelRequest = cancelRequest;
request.cancelAllRequest = cancelAllRequest; request.cancelAllRequest = cancelAllRequest;
@ -144,14 +149,17 @@ export function createRequest<ResponseData = any, State = Record<string, unknown
* @param axiosConfig axios config * @param axiosConfig axios config
* @param options request options * @param options request options
*/ */
export function createFlatRequest<ResponseData = any, State = Record<string, unknown>>( export function createFlatRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData>(axiosConfig, options); const { instance, opts, cancelRequest, cancelAllRequest } = createCommonRequest<ResponseData, ApiData, State>(
axiosConfig,
options
);
const flatRequest: FlatRequestInstance<State, ResponseData> = async function flatRequest< const flatRequest: FlatRequestInstance<ResponseData, ApiData, State> = async function flatRequest<
T = any, T extends ApiData = ApiData,
R extends ResponseType = 'json' R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) { >(config: CustomAxiosRequestConfig) {
try { try {
@ -160,20 +168,22 @@ export function createFlatRequest<ResponseData = any, State = Record<string, unk
const responseType = response.config?.responseType || 'json'; const responseType = response.config?.responseType || 'json';
if (responseType === 'json') { if (responseType === 'json') {
const data = opts.transformBackendResponse(response); const data = await opts.transformBackendResponse(response);
return { data, error: null, response }; return { data, error: null, response };
} }
return { data: response.data as MappedType<R, T>, error: null }; return { data: response.data as MappedType<R, T>, error: null, response };
} catch (error) { } catch (error) {
return { data: null, error, response: (error as AxiosError<ResponseData>).response }; return { data: null, error, response: (error as AxiosError<ResponseData>).response };
} }
} as FlatRequestInstance<State, ResponseData>; } as FlatRequestInstance<ResponseData, ApiData, State>;
flatRequest.cancelRequest = cancelRequest; flatRequest.cancelRequest = cancelRequest;
flatRequest.cancelAllRequest = cancelAllRequest; flatRequest.cancelAllRequest = cancelAllRequest;
flatRequest.state = {} as State; flatRequest.state = {
...opts.defaultState
} as State;
return flatRequest; return flatRequest;
} }

View File

@ -4,12 +4,16 @@ import { stringify } from 'qs';
import { isHttpSuccess } from './shared'; import { isHttpSuccess } from './shared';
import type { RequestOption } from './type'; import type { RequestOption } from './type';
export function createDefaultOptions<ResponseData = any>(options?: Partial<RequestOption<ResponseData>>) { export function createDefaultOptions<
const opts: RequestOption<ResponseData> = { ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
>(options?: Partial<RequestOption<ResponseData, ApiData, State>>) {
const opts: RequestOption<ResponseData, ApiData, State> = {
onRequest: async config => config, onRequest: async config => config,
isBackendSuccess: _response => true, isBackendSuccess: _response => true,
onBackendFail: async () => {}, onBackendFail: async () => {},
transformBackendResponse: async response => response.data, transformBackendResponse: async response => response.data as unknown as ApiData,
onError: async () => {} onError: async () => {}
}; };

View File

@ -8,7 +8,23 @@ export type ContentType =
| 'application/x-www-form-urlencoded' | 'application/x-www-form-urlencoded'
| 'application/octet-stream'; | 'application/octet-stream';
export interface RequestOption<ResponseData = any> { export type ResponseTransform<Input = any, Output = any> = (input: Input) => Output | Promise<Output>;
export interface RequestOption<
ResponseData,
ApiData = ResponseData,
State extends Record<string, unknown> = Record<string, unknown>
> {
/**
* The default state
*/
defaultState?: State;
/**
* transform the response data to the api data
*
* @param response Axios response
*/
transformBackendResponse: ResponseTransform<AxiosResponse<ResponseData>, ApiData>;
/** /**
* The hook before request * The hook before request
* *
@ -35,12 +51,6 @@ export interface RequestOption<ResponseData = any> {
response: AxiosResponse<ResponseData>, response: AxiosResponse<ResponseData>,
instance: AxiosInstance instance: AxiosInstance
) => Promise<AxiosResponse | null> | Promise<void>; ) => Promise<AxiosResponse | null> | Promise<void>;
/**
* transform backend response when the responseType is json
*
* @param response Axios response
*/
transformBackendResponse(response: AxiosResponse<ResponseData>): any | Promise<any>;
/** /**
* The hook to handle error * The hook to handle error
* *
@ -68,7 +78,7 @@ export type CustomAxiosRequestConfig<R extends ResponseType = 'json'> = Omit<Axi
responseType?: R; responseType?: R;
}; };
export interface RequestInstanceCommon<T> { export interface RequestInstanceCommon<State extends Record<string, unknown>> {
/** /**
* cancel the request by request id * cancel the request by request id
* *
@ -84,32 +94,35 @@ export interface RequestInstanceCommon<T> {
*/ */
cancelAllRequest: () => void; cancelAllRequest: () => void;
/** you can set custom state in the request instance */ /** you can set custom state in the request instance */
state: T; state: State;
} }
/** The request instance */ /** The request instance */
export interface RequestInstance<S = Record<string, unknown>> extends RequestInstanceCommon<S> { export interface RequestInstance<ApiData, State extends Record<string, unknown>> extends RequestInstanceCommon<State> {
<T = any, R extends ResponseType = 'json'>(config: CustomAxiosRequestConfig<R>): Promise<MappedType<R, T>>; <T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R>
): Promise<MappedType<R, T>>;
} }
export type FlatResponseSuccessData<T = any, ResponseData = any> = { export type FlatResponseSuccessData<ResponseData, ApiData> = {
data: T; data: ApiData;
error: null; error: null;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseFailData<ResponseData = any> = { export type FlatResponseFailData<ResponseData> = {
data: null; data: null;
error: AxiosError<ResponseData>; error: AxiosError<ResponseData>;
response: AxiosResponse<ResponseData>; response: AxiosResponse<ResponseData>;
}; };
export type FlatResponseData<T = any, ResponseData = any> = export type FlatResponseData<ResponseData, ApiData> =
| FlatResponseSuccessData<T, ResponseData> | FlatResponseSuccessData<ResponseData, ApiData>
| FlatResponseFailData<ResponseData>; | FlatResponseFailData<ResponseData>;
export interface FlatRequestInstance<S = Record<string, unknown>, ResponseData = any> extends RequestInstanceCommon<S> { export interface FlatRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig<R> config: CustomAxiosRequestConfig<R>
): Promise<FlatResponseData<MappedType<R, T>, ResponseData>>; ): Promise<FlatResponseData<ResponseData, MappedType<R, T>>>;
} }

View File

@ -6,31 +6,31 @@ import type {
CreateAxiosDefaults, CreateAxiosDefaults,
CustomAxiosRequestConfig, CustomAxiosRequestConfig,
MappedType, MappedType,
RequestInstanceCommon,
RequestOption, RequestOption,
ResponseType ResponseType
} from '@sa/axios'; } from '@sa/axios';
import useLoading from './use-loading'; import useLoading from './use-loading';
export type HookRequestInstanceResponseSuccessData<T = any> = { export type HookRequestInstanceResponseSuccessData<ApiData> = {
data: Ref<T>; data: Ref<ApiData>;
error: Ref<null>; error: Ref<null>;
}; };
export type HookRequestInstanceResponseFailData<ResponseData = any> = { export type HookRequestInstanceResponseFailData<ResponseData> = {
data: Ref<null>; data: Ref<null>;
error: Ref<AxiosError<ResponseData>>; error: Ref<AxiosError<ResponseData>>;
}; };
export type HookRequestInstanceResponseData<T = any, ResponseData = any> = { export type HookRequestInstanceResponseData<ResponseData, ApiData> = {
loading: Ref<boolean>; loading: Ref<boolean>;
} & (HookRequestInstanceResponseSuccessData<T> | HookRequestInstanceResponseFailData<ResponseData>); } & (HookRequestInstanceResponseSuccessData<ApiData> | HookRequestInstanceResponseFailData<ResponseData>);
export interface HookRequestInstance<ResponseData = any> { export interface HookRequestInstance<ResponseData, ApiData, State extends Record<string, unknown>>
<T = any, R extends ResponseType = 'json'>( extends RequestInstanceCommon<State> {
<T extends ApiData = ApiData, R extends ResponseType = 'json'>(
config: CustomAxiosRequestConfig config: CustomAxiosRequestConfig
): HookRequestInstanceResponseData<MappedType<R, T>, ResponseData>; ): HookRequestInstanceResponseData<ResponseData, MappedType<R, T>>;
cancelRequest: (requestId: string) => void;
cancelAllRequest: () => void;
} }
/** /**
@ -39,25 +39,26 @@ export interface HookRequestInstance<ResponseData = any> {
* @param axiosConfig * @param axiosConfig
* @param options * @param options
*/ */
export default function createHookRequest<ResponseData = any>( export default function createHookRequest<ResponseData, ApiData, State extends Record<string, unknown>>(
axiosConfig?: CreateAxiosDefaults, axiosConfig?: CreateAxiosDefaults,
options?: Partial<RequestOption<ResponseData>> options?: Partial<RequestOption<ResponseData, ApiData, State>>
) { ) {
const request = createFlatRequest<ResponseData>(axiosConfig, options); const request = createFlatRequest<ResponseData, ApiData, State>(axiosConfig, options);
const hookRequest: HookRequestInstance<ResponseData> = function hookRequest<T = any, R extends ResponseType = 'json'>( const hookRequest: HookRequestInstance<ResponseData, ApiData, State> = function hookRequest<
config: CustomAxiosRequestConfig T extends ApiData = ApiData,
) { R extends ResponseType = 'json'
>(config: CustomAxiosRequestConfig) {
const { loading, startLoading, endLoading } = useLoading(); const { loading, startLoading, endLoading } = useLoading();
const data = ref<MappedType<R, T> | null>(null) as Ref<MappedType<R, T>>; const data = ref(null) as Ref<MappedType<R, T>>;
const error = ref<AxiosError<ResponseData> | null>(null) as Ref<AxiosError<ResponseData> | null>; const error = ref(null) as Ref<AxiosError<ResponseData> | null>;
startLoading(); startLoading();
request(config).then(res => { request(config).then(res => {
if (res.data) { if (res.data) {
data.value = res.data; data.value = res.data as MappedType<R, T>;
} else { } else {
error.value = res.error; error.value = res.error;
} }
@ -70,7 +71,7 @@ export default function createHookRequest<ResponseData = any>(
data, data,
error error
}; };
} as HookRequestInstance<ResponseData>; } as HookRequestInstance<ResponseData, ApiData, State>;
hookRequest.cancelRequest = request.cancelRequest; hookRequest.cancelRequest = request.cancelRequest;
hookRequest.cancelAllRequest = request.cancelAllRequest; hookRequest.cancelAllRequest = request.cancelAllRequest;

View File

@ -10,7 +10,7 @@ import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y'; const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy); const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest<App.Service.Response, RequestInstanceState>( export const request = createFlatRequest(
{ {
baseURL, baseURL,
headers: { headers: {
@ -18,6 +18,13 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
} }
}, },
{ {
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transformBackendResponse(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) { async onRequest(config) {
const Authorization = getAuthorization(); const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization }); Object.assign(config.headers, { Authorization });
@ -91,9 +98,6 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
return null; return null;
}, },
transformBackendResponse(response) {
return response.data.data;
},
onError(error) { onError(error) {
// when the request is fail, you can show error message // when the request is fail, you can show error message
@ -123,11 +127,14 @@ export const request = createFlatRequest<App.Service.Response, RequestInstanceSt
} }
); );
export const demoRequest = createRequest<App.Service.DemoResponse>( export const demoRequest = createRequest(
{ {
baseURL: otherBaseURL.demo baseURL: otherBaseURL.demo
}, },
{ {
transformBackendResponse(response: AxiosResponse<App.Service.DemoResponse>) {
return response.data.result;
},
async onRequest(config) { async onRequest(config) {
const { headers } = config; const { headers } = config;
@ -147,9 +154,6 @@ export const demoRequest = createRequest<App.Service.DemoResponse>(
// when the backend response code is not "200", it means the request is fail // when the backend response code is not "200", it means the request is fail
// for example: the token is expired, refresh token and retry request // for example: the token is expired, refresh token and retry request
}, },
transformBackendResponse(response) {
return response.data.result;
},
onError(error) { onError(error) {
// when the request is fail, you can show error message // when the request is fail, you can show error message

View File

@ -28,14 +28,14 @@ async function handleRefreshToken() {
} }
export async function handleExpiredRequest(state: RequestInstanceState) { export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) { if (!state.refreshTokenPromise) {
state.refreshTokenFn = handleRefreshToken(); state.refreshTokenPromise = handleRefreshToken();
} }
const success = await state.refreshTokenFn; const success = await state.refreshTokenPromise;
setTimeout(() => { setTimeout(() => {
state.refreshTokenFn = null; state.refreshTokenPromise = null;
}, 1000); }, 1000);
return success; return success;

View File

@ -1,6 +1,7 @@
export interface RequestInstanceState { export interface RequestInstanceState {
/** whether the request is refreshing token */ /** the promise of refreshing token */
refreshTokenFn: Promise<boolean> | null; refreshTokenPromise: Promise<boolean> | null;
/** the request error message stack */ /** the request error message stack */
errMsgStack: string[]; errMsgStack: string[];
[key: string]: unknown;
} }