refactor(GOV-010): 统一封装前端 Axios 请求
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
CanceledError,
|
||||
type AxiosAdapter,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { components } from '../api/generated/schema';
|
||||
import { ApiRequestError, createRequestClient } from './request';
|
||||
|
||||
type HealthResponseDto = components['schemas']['HealthResponseDto'];
|
||||
|
||||
const healthyDto: HealthResponseDto = {
|
||||
status: 'healthy',
|
||||
process: 'api',
|
||||
checkedAt: '2026-08-13T09:30:00.000Z',
|
||||
dependencies: {
|
||||
mysql: { status: 'healthy', detail: 'ready' },
|
||||
redis: { status: 'healthy', detail: 'ready' },
|
||||
rocketMq: { status: 'disabled', detail: 'disabled' },
|
||||
},
|
||||
};
|
||||
|
||||
function createResponseAdapter(
|
||||
inspectConfig?: (config: InternalAxiosRequestConfig) => void,
|
||||
): AxiosAdapter {
|
||||
return async (config) => {
|
||||
inspectConfig?.(config);
|
||||
return {
|
||||
data: healthyDto,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe('统一 Axios 请求封装', () => {
|
||||
it('使用规范化根地址和有限超时请求并只返回类型化 DTO', async () => {
|
||||
let actualConfig: InternalAxiosRequestConfig | undefined;
|
||||
const request = createRequestClient({
|
||||
baseURL: 'http://127.0.0.1:3000/',
|
||||
timeoutMs: 10_000,
|
||||
adapter: createResponseAdapter((config) => {
|
||||
actualConfig = config;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await request<HealthResponseDto>({
|
||||
url: '/api/v1/health',
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
expect(result).toEqual(healthyDto);
|
||||
expect(actualConfig).toMatchObject({
|
||||
baseURL: 'http://127.0.0.1:3000',
|
||||
timeout: 10_000,
|
||||
url: '/api/v1/health',
|
||||
method: 'get',
|
||||
});
|
||||
});
|
||||
|
||||
it('把 HTTP 错误转换为只有分类和状态码的脱敏错误', async () => {
|
||||
const request = createRequestClient({
|
||||
baseURL: 'http://127.0.0.1:3000',
|
||||
adapter: async (config) => {
|
||||
throw new AxiosError(
|
||||
'mysql://root:secret@internal',
|
||||
'ERR_BAD_RESPONSE',
|
||||
config,
|
||||
undefined,
|
||||
{
|
||||
data: { secret: 'Root@990819' },
|
||||
status: 503,
|
||||
statusText: 'Unavailable',
|
||||
headers: {},
|
||||
config,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const error = await request({ url: '/api/v1/health' }).catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'ApiRequestError',
|
||||
message: 'API 请求失败,请稍后重试',
|
||||
kind: 'http',
|
||||
status: 503,
|
||||
}),
|
||||
);
|
||||
expect(error).toBeInstanceOf(ApiRequestError);
|
||||
expect(JSON.stringify(error)).not.toMatch(/secret|internal|Root@990819/);
|
||||
});
|
||||
|
||||
it('把没有响应的连接失败转换为网络错误且不保留原始地址', async () => {
|
||||
const request = createRequestClient({
|
||||
baseURL: 'http://127.0.0.1:3000',
|
||||
adapter: async (config) => {
|
||||
throw new AxiosError(
|
||||
'connect ECONNREFUSED http://internal-api:3000',
|
||||
'ERR_NETWORK',
|
||||
config,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const error = await request({ url: '/api/v1/health' }).catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'ApiRequestError',
|
||||
message: 'API 请求失败,请稍后重试',
|
||||
kind: 'network',
|
||||
}),
|
||||
);
|
||||
expect(error).not.toHaveProperty('status');
|
||||
expect(JSON.stringify(error)).not.toMatch(/internal-api|ECONNREFUSED/);
|
||||
});
|
||||
|
||||
it('保留取消请求语义而不错误映射为服务故障', async () => {
|
||||
const controller = new AbortController();
|
||||
const adapter: AxiosAdapter = async (config) => {
|
||||
throw new CanceledError('canceled', config);
|
||||
};
|
||||
const request = createRequestClient({
|
||||
baseURL: '',
|
||||
adapter,
|
||||
});
|
||||
const config: AxiosRequestConfig = {
|
||||
url: '/api/v1/health',
|
||||
signal: controller.signal,
|
||||
};
|
||||
controller.abort();
|
||||
|
||||
const error = await request(config).catch((cause: unknown) => cause);
|
||||
|
||||
expect(axios.isCancel(error)).toBe(true);
|
||||
expect(error).not.toBeInstanceOf(ApiRequestError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import axios, { type AxiosAdapter, type AxiosRequestConfig } from 'axios';
|
||||
|
||||
export type RequestErrorKind = 'network' | 'http' | 'unknown';
|
||||
|
||||
/**
|
||||
* 对功能层暴露稳定且脱敏的错误。原始 Axios 错误可能包含内网地址、请求头或
|
||||
* 上游正文,因此不得通过 cause、config、request 或 response 继续向外传播。
|
||||
*/
|
||||
export class ApiRequestError extends Error {
|
||||
readonly kind: RequestErrorKind;
|
||||
declare readonly status?: number;
|
||||
|
||||
constructor(kind: RequestErrorKind, status?: number) {
|
||||
super('API 请求失败,请稍后重试');
|
||||
this.name = 'ApiRequestError';
|
||||
this.kind = kind;
|
||||
if (status !== undefined) this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestClientOptions {
|
||||
baseURL: string;
|
||||
timeoutMs?: number;
|
||||
adapter?: AxiosAdapter;
|
||||
}
|
||||
|
||||
export type RequestClient = <TData>(config: AxiosRequestConfig) => Promise<TData>;
|
||||
|
||||
function toApiRequestError(error: unknown): ApiRequestError {
|
||||
if (!axios.isAxiosError(error)) return new ApiRequestError('unknown');
|
||||
if (error.response) return new ApiRequestError('http', error.response.status);
|
||||
return new ApiRequestError('network');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建唯一请求边界并只返回 DTO 数据。显式包装 `client.request` 可以避免
|
||||
* `AxiosResponse` 类型渗透到业务 API,同时保留 adapter 注入以测试真实 Axios 流程。
|
||||
*/
|
||||
export function createRequestClient({
|
||||
baseURL,
|
||||
timeoutMs = 10_000,
|
||||
adapter,
|
||||
}: RequestClientOptions): RequestClient {
|
||||
const client = axios.create({
|
||||
baseURL: baseURL.replace(/\/$/, ''),
|
||||
timeout: timeoutMs,
|
||||
adapter,
|
||||
});
|
||||
|
||||
// 认证能力接入后只扩展这个拦截器,当前不提前实现 Token 或刷新流程。
|
||||
client.interceptors.request.use((config) => config);
|
||||
|
||||
return async <TData>(config: AxiosRequestConfig): Promise<TData> => {
|
||||
try {
|
||||
const response = await client.request<TData>(config);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) throw error;
|
||||
throw toApiRequestError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const request = createRequestClient({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||
});
|
||||
|
||||
export default request;
|
||||
Reference in New Issue
Block a user