feat: refactor request

This commit is contained in:
vben
2024-06-02 20:50:51 +08:00
parent ce0c3834ed
commit f95cc80895
29 changed files with 1096 additions and 265 deletions

View File

@@ -1,5 +1,5 @@
{
"name": "@vben/request",
"name": "@vben-core/request",
"version": "1.0.0",
"type": "module",
"license": "MIT",
@@ -7,7 +7,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/request"
"directory": "packages/@vben-core/forward/request"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
@@ -41,6 +41,11 @@
}
},
"dependencies": {
"@vben-core/toolkit": "workspace:*",
"axios": "^1.7.2",
"vue-request": "^2.0.4"
},
"devDependencies": {
"axios-mock-adapter": "^1.22.0"
}
}

View File

@@ -0,0 +1,3 @@
export * from './request-client';
export * from './use-request';
export * from 'axios';

View File

@@ -0,0 +1,3 @@
export * from './request-client';
export type * from './types';
export * from './util';

View File

@@ -0,0 +1,127 @@
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AxiosCanceler } from './canceler';
describe('axiosCanceler', () => {
let axiosCanceler: AxiosCanceler;
beforeEach(() => {
axiosCanceler = new AxiosCanceler();
});
it('should generate a unique request key', () => {
const config: AxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
};
const requestKey = axiosCanceler.getRequestKey(config);
expect(requestKey).toBe('get:/test:{"id":1}:{"name":"test"}');
});
it('should add a request and create an AbortController', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
const updatedConfig = axiosCanceler.addRequest(config);
expect(updatedConfig.signal).toBeInstanceOf(AbortSignal);
});
it('should cancel an existing request if a duplicate is added', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
axiosCanceler.addRequest(config);
const controller = axiosCanceler.pending.get(
'get:/test:{"id":1}:{"name":"test"}',
);
expect(controller).toBeDefined();
if (controller) {
const spy = vi.spyOn(controller, 'abort');
axiosCanceler.addRequest(config);
expect(spy).toHaveBeenCalled();
}
});
it('should remove a request', () => {
const config: AxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
};
axiosCanceler.addRequest(config as InternalAxiosRequestConfig);
axiosCanceler.removeRequest(config);
expect(axiosCanceler.pending.size).toBe(0);
});
it('should remove all pending requests', () => {
const config1: InternalAxiosRequestConfig = {
data: { name: 'test1' },
method: 'get',
params: { id: 1 },
url: '/test1',
} as InternalAxiosRequestConfig;
const config2: InternalAxiosRequestConfig = {
data: { name: 'test2' },
method: 'get',
params: { id: 2 },
url: '/test2',
} as InternalAxiosRequestConfig;
axiosCanceler.addRequest(config1);
axiosCanceler.addRequest(config2);
axiosCanceler.removeAllPending();
expect(axiosCanceler.pending.size).toBe(0);
});
it('should handle empty config gracefully', () => {
const config = {} as InternalAxiosRequestConfig;
const updatedConfig = axiosCanceler.addRequest(config);
expect(updatedConfig.signal).toBeInstanceOf(AbortSignal);
});
it('should handle undefined params and data gracefully', () => {
const config: InternalAxiosRequestConfig = {
method: 'get',
url: '/test',
} as InternalAxiosRequestConfig;
const requestKey = axiosCanceler.getRequestKey(config);
expect(requestKey).toBe('get:/test:{}:{}');
});
it('should not abort if no controller exists for the request key', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
const requestKey = axiosCanceler.getRequestKey(config);
const spy = vi.spyOn(AbortController.prototype, 'abort');
axiosCanceler.addRequest(config);
axiosCanceler.pending.delete(requestKey);
axiosCanceler.addRequest(config);
expect(spy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import type {
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
class AxiosCanceler {
public pending: Map<string, AbortController> = new Map();
// 添加请求
public addRequest(
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig {
const requestKey = this.getRequestKey(config);
if (this.pending.has(requestKey)) {
// 如果存在相同的请求,取消前一个请求
const controller = this.pending.get(requestKey);
controller?.abort();
}
// 创建新的AbortController并添加到pending中
const controller = new AbortController();
config.signal = controller.signal;
this.pending.set(requestKey, controller);
return config;
}
// 生成请求的唯一标识
public getRequestKey(config: AxiosRequestConfig): string {
const { data = {}, method, params = {}, url } = config;
return `${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)}`;
}
/**
* 清除所有等待中的请求
*/
public removeAllPending(): void {
for (const [, abortController] of this.pending) {
abortController?.abort();
}
this.pending.clear();
}
// 移除请求
public removeRequest(config: AxiosRequestConfig | AxiosResponse): void {
const requestKey = this.getRequestKey(config);
this.pending.delete(requestKey);
}
}
export { AxiosCanceler };

View File

@@ -0,0 +1,84 @@
import type { AxiosRequestConfig } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileDownloader } from './downloader';
describe('fileDownloader', () => {
let fileDownloader: FileDownloader;
const mockAxiosInstance = {
get: vi.fn(),
} as any;
beforeEach(() => {
fileDownloader = new FileDownloader(mockAxiosInstance);
});
it('should create an instance of FileDownloader', () => {
expect(fileDownloader).toBeInstanceOf(FileDownloader);
});
it('should download a file and return a Blob', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const result = await fileDownloader.download(url);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
responseType: 'blob',
});
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileDownloader.download(url, customConfig);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig,
responseType: 'blob',
});
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/file';
mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network Error'));
await expect(fileDownloader.download(url)).rejects.toThrow('Network Error');
});
it('should handle empty URL gracefully', async () => {
const url = '';
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,30 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
class FileDownloader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async download(
url: string,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<Blob>> {
const finalConfig: AxiosRequestConfig = {
...config,
responseType: 'blob',
};
const response = await this.client.get<AxiosResponse<Blob>>(
url,
finalConfig,
);
return response;
}
}
export { FileDownloader };

View File

@@ -0,0 +1,33 @@
import {
AxiosInstance,
AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
class InterceptorManager {
private axiosInstance: AxiosInstance;
constructor(instance: AxiosInstance) {
this.axiosInstance = instance;
}
addRequestInterceptor(
fulfilled: (
config: InternalAxiosRequestConfig,
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.request.use(fulfilled, rejected);
}
addResponseInterceptor(
fulfilled: (
response: AxiosResponse,
) => AxiosResponse | Promise<AxiosResponse>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.response.use(fulfilled, rejected);
}
}
export { InterceptorManager };

View File

@@ -0,0 +1,118 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileUploader } from './uploader';
describe('fileUploader', () => {
let fileUploader: FileUploader;
// Mock the AxiosInstance
const mockAxiosInstance = {
post: vi.fn(),
} as any;
beforeEach(() => {
fileUploader = new FileUploader(mockAxiosInstance);
});
it('should create an instance of FileUploader', () => {
expect(fileUploader).toBeInstanceOf(FileUploader);
});
it('should upload a file and return the response', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const result = await fileUploader.upload(url, file);
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileUploader.upload(url, file, customConfig);
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
'Custom-Header': 'value',
},
},
);
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Network Error'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Network Error',
);
});
it('should handle empty URL gracefully', async () => {
const url = '';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,32 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
class FileUploader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async upload(
url: string,
file: Blob | File,
config?: AxiosRequestConfig,
): Promise<AxiosResponse> {
const formData = new FormData();
formData.append('file', file);
const finalConfig: AxiosRequestConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config?.headers,
},
};
return this.client.post(url, formData, finalConfig);
}
}
export { FileUploader };

View File

@@ -0,0 +1,97 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RequestClient } from './request-client';
describe('requestClient', () => {
let mock: MockAdapter;
let requestClient: RequestClient;
beforeEach(() => {
mock = new MockAdapter(axios);
requestClient = new RequestClient();
});
afterEach(() => {
mock.reset();
});
it('should successfully make a GET request', async () => {
mock.onGet('test/url').reply(200, { data: 'response' });
const response = await requestClient.get('test/url');
expect(response.data).toEqual({ data: 'response' });
});
it('should successfully make a POST request', async () => {
const postData = { key: 'value' };
const mockData = { data: 'response' };
mock.onPost('/test/post', postData).reply(200, mockData);
const response = await requestClient.post('/test/post', postData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a PUT request', async () => {
const putData = { key: 'updatedValue' };
const mockData = { data: 'updated response' };
mock.onPut('/test/put', putData).reply(200, mockData);
const response = await requestClient.put('/test/put', putData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a DELETE request', async () => {
const mockData = { data: 'delete response' };
mock.onDelete('/test/delete').reply(200, mockData);
const response = await requestClient.delete('/test/delete');
expect(response.data).toEqual(mockData);
});
it('should handle network errors', async () => {
mock.onGet('/test/error').networkError();
try {
await requestClient.get('/test/error');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.message).toBe('Network Error');
}
});
it('should handle timeout', async () => {
mock.onGet('/test/timeout').timeout();
try {
await requestClient.get('/test/timeout');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.code).toBe('ECONNABORTED');
}
});
it('should successfully upload a file', async () => {
const fileData = new Blob(['file contents'], { type: 'text/plain' });
mock.onPost('/test/upload').reply((config) => {
return config.data instanceof FormData && config.data.has('file')
? [200, { data: 'file uploaded' }]
: [400, { error: 'Bad Request' }];
});
const response = await requestClient.upload('/test/upload', fileData);
expect(response.data).toEqual({ data: 'file uploaded' });
});
it('should successfully download a file as a blob', async () => {
const mockFileContent = new Blob(['mock file content'], {
type: 'text/plain',
});
mock.onGet('/test/download').reply(200, mockFileContent);
const res = await requestClient.download('/test/download');
expect(res.data).toBeInstanceOf(Blob);
});
});

View File

@@ -0,0 +1,179 @@
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
import { merge } from '@vben-core/toolkit';
import axios from 'axios';
import { AxiosCanceler } from './modules/canceler';
import { FileDownloader } from './modules/downloader';
import { InterceptorManager } from './modules/interceptor';
import { FileUploader } from './modules/uploader';
import type { MakeAuthorizationFn, RequestClientOptions } from './types';
class RequestClient {
private instance: AxiosInstance;
private makeAuthorization: MakeAuthorizationFn | undefined;
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public download: FileDownloader['download'];
public upload: FileUploader['upload'];
/**
* 构造函数用于创建Axios实例
* @param {AxiosRequestConfig} config - Axios请求配置可选
*/
constructor(options: RequestClientOptions = {}) {
// 合并默认配置和传入的配置
const defaultConfig: CreateAxiosDefaults = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
// 默认超时时间
timeout: 10_000,
withCredentials: true,
};
const { makeAuthorization, ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);
this.instance = axios.create(requestConfig);
this.makeAuthorization = makeAuthorization;
// 实例化拦截器管理器
const interceptorManager = new InterceptorManager(this.instance);
this.addRequestInterceptor =
interceptorManager.addRequestInterceptor.bind(interceptorManager);
this.addResponseInterceptor =
interceptorManager.addResponseInterceptor.bind(interceptorManager);
// 实例化文件上传器
const fileUploader = new FileUploader(this);
this.upload = fileUploader.upload.bind(fileUploader);
// 实例化文件下载器
const fileDownloader = new FileDownloader(this);
this.download = fileDownloader.download.bind(fileDownloader);
// 设置默认的拦截器
this.setupInterceptors();
}
private errorHandler(error: any) {
return Promise.reject(error);
}
private setupAuthorizationInterceptor() {
this.addRequestInterceptor((config: InternalAxiosRequestConfig) => {
const authorization = this.makeAuthorization?.(config);
if (authorization) {
config.headers[authorization.key || 'Authorization'] =
authorization.handle?.();
}
return config;
}, this.errorHandler);
}
private setupInterceptors() {
// 默认拦截器
this.setupAuthorizationInterceptor();
// 设置取消请求的拦截器
this.setupCancelerInterceptor();
}
/**
* DELETE请求方法
* @param {string} url - 请求的URL
* @param {AxiosRequestConfig} config - 请求配置(可选)
* @returns 返回Promise
*/
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
/**
* GET请求方法
* @param {string} url - 请求URL
* @param {AxiosRequestConfig} config - 请求配置,可选
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET' });
}
/**
* POST请求方法
* @param {string} url - 请求URL
* @param {any} data - 请求体数据
* @param {AxiosRequestConfig} config - 请求配置,可选
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'POST' });
}
/**
* PUT请求方法
* @param {string} url - 请求的URL
* @param {any} data - 请求体数据
* @param {AxiosRequestConfig} config - 请求配置(可选)
* @returns 返回Promise
*/
public put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'PUT' });
}
/**
* 通用的请求方法
* @param {string} url - 请求的URL
* @param {AxiosRequestConfig} config - 请求配置对象
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public async request<T>(url: string, config: AxiosRequestConfig): Promise<T> {
try {
const response: AxiosResponse<T> = await this.instance({
url,
...config,
});
return response as T;
} catch (error: any) {
throw error.response ? error.response.data : error;
}
}
public setupCancelerInterceptor() {
const axiosCanceler = new AxiosCanceler();
// 注册取消重复请求的请求拦截器
this.addRequestInterceptor((config: InternalAxiosRequestConfig) => {
return axiosCanceler.addRequest(config);
}, this.errorHandler);
// 注册移除请求的响应拦截器
this.addResponseInterceptor(
(response: AxiosResponse) => {
axiosCanceler.removeRequest(response);
return response;
},
(error) => {
if (error.config) {
axiosCanceler.removeRequest(error.config);
}
return Promise.reject(error);
},
);
}
}
export { RequestClient };

View File

@@ -0,0 +1,24 @@
import type { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
type RequestContentType =
| 'application/json;charset=utf-8'
| 'application/octet-stream;charset=utf-8'
| 'application/x-www-form-urlencoded;charset=utf-8'
| 'multipart/form-data;charset=utf-8';
interface MakeAuthorization {
handle: () => null | string;
key?: string;
}
type MakeAuthorizationFn = (
config?: InternalAxiosRequestConfig,
) => MakeAuthorization;
interface RequestClientOptions extends CreateAxiosDefaults {
/**
* 用于生成Authorization
*/
makeAuthorization?: MakeAuthorizationFn;
}
export type { MakeAuthorizationFn, RequestClientOptions, RequestContentType };

View File

@@ -0,0 +1,25 @@
import axios from 'axios';
import { describe, expect, it } from 'vitest';
import { isCancelError } from './util';
describe('isCancelError', () => {
const source = axios.CancelToken.source();
source.cancel('Operation canceled by the user.');
it('should detect cancellation', () => {
const error = new axios.Cancel('Operation canceled by the user.');
const result = isCancelError(error);
expect(result).toBe(true);
});
it('should not detect cancellation on regular errors', () => {
const error = new Error('Regular error');
const result = isCancelError(error);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import axios from 'axios';
function isCancelError(error: any) {
return axios.isCancel(error);
}
export { isCancelError };

View File

@@ -1 +0,0 @@
export * from './use-request';

View File

@@ -0,0 +1,51 @@
$max-child: 5;
@for $i from 1 through $max-child {
* > .enter-x:nth-child(#{$i}) {
transform: translateX(50px);
}
* > .-enter-x:nth-child(#{$i}) {
transform: translateX(-50px);
}
* > .enter-x:nth-child(#{$i}),
* > .-enter-x:nth-child(#{$i}) {
// z-index: 10 - $i;
opacity: 0;
animation: enter-x-animation 0.3s ease-in-out 0.2s;
animation-delay: 0.1s * $i;
animation-fill-mode: forwards;
}
* > .enter-y:nth-child(#{$i}) {
transform: translateY(50px);
}
* > .-enter-y:nth-child(#{$i}) {
transform: translateY(-50px);
}
* > .enter-y:nth-child(#{$i}),
* > .-enter-y:nth-child(#{$i}) {
// z-index: 10 - $i;
opacity: 0;
animation: enter-y-animation 0.3s ease-in-out 0.2s;
animation-delay: 0.1s * $i;
animation-fill-mode: forwards;
}
}
@keyframes enter-x-animation {
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes enter-y-animation {
to {
opacity: 1;
transform: translateY(0);
}
}