@lark-apaas/client-toolkit 1.2.73 → 1.2.74-alpha.20261010220931
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -73,4 +73,177 @@ function Nav() {
|
|
|
73
73
|
</nav>
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
|
-
```
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Axios 403 合同与 1.x → 2.x 迁移
|
|
79
|
+
|
|
80
|
+
`axiosForBackend` / `getAxiosForBackend()` 在 **1.x** 有一项历史兼容行为:当
|
|
81
|
+
Axios 按默认 `validateStatus` 收到带响应的真实 HTTP 403 时,SDK 的响应拦截器
|
|
82
|
+
会返回 `error.response`,因此 Promise 是 fulfilled,返回值仍可读取
|
|
83
|
+
`status`、`data`、`headers` 和 `config`。
|
|
84
|
+
|
|
85
|
+
这不是推荐给新代码的安全默认。公开类型仍是普通 `AxiosInstance`:例如
|
|
86
|
+
`get<SuccessDto>()` 的 TypeScript 类型会把 `response.data` 表示成
|
|
87
|
+
`SuccessDto`,但 1.x 的 403 在运行时可能携带权限错误体。类型系统不会自动把
|
|
88
|
+
两种 body 表示成联合类型,调用方必须先检查 status,再把 data 当成功 DTO。
|
|
89
|
+
|
|
90
|
+
边界如下:
|
|
91
|
+
|
|
92
|
+
- 1.x 默认配置下,2xx fulfilled;带响应的 403 也因兼容逻辑 fulfilled。
|
|
93
|
+
- 400、401、404、500 等其它 HTTP 错误 rejected;网络错误同样 rejected,且
|
|
94
|
+
`error.response` 通常为 `undefined`。
|
|
95
|
+
- 调用方若用 `validateStatus` 显式接受 403,Axios 会在进入 SDK 的 rejection
|
|
96
|
+
interceptor 之前正常 fulfilled;这在 1.x 和目标 2.x 合同中都不变。
|
|
97
|
+
- 采用新合同的 2.x 目标是恢复 Axios 默认语义:默认真实 403 rejected,并保留
|
|
98
|
+
原始 `AxiosError` 的 `response`、`config` 等字段。**本文描述迁移目标,不表示
|
|
99
|
+
2.x 已经发布或可通过 `latest` 获取;升级前应另行确认可用的精确 2.x 版本。**
|
|
100
|
+
|
|
101
|
+
以下示例中的错误判断使用 Axios 官方类型守卫。只有
|
|
102
|
+
`axios.isAxiosError(error)` 为真时,才把错误当作 `AxiosError`;HTTP 错误可从
|
|
103
|
+
`error.response?.status` / `error.response?.data` 读取响应,而网络错误通常没有
|
|
104
|
+
`response`。
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import axios from 'axios';
|
|
108
|
+
import { axiosForBackend } from '@lark-apaas/client-toolkit';
|
|
109
|
+
|
|
110
|
+
type ItemList = { items: Array<{ id: string }> };
|
|
111
|
+
type ApiErrorBody = { code?: string; message?: string };
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 直接读取 `response.data` 的调用方
|
|
115
|
+
|
|
116
|
+
1.x 不要直接返回 data;先在统一 API 层检查 fulfilled response 的 status:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
async function listItems1x(): Promise<ItemList> {
|
|
120
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
121
|
+
|
|
122
|
+
if (response.status < 200 || response.status >= 300) {
|
|
123
|
+
// 1.x 默认配置下,此分支主要承接历史 fulfilled 403。
|
|
124
|
+
throw new Error(`Request failed with status ${response.status}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return response.data;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
迁移到采用新合同的 2.x 后,成功路径可以继续读取 data,但默认 403 在 catch 中
|
|
132
|
+
处理:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
async function listItems2x(): Promise<ItemList> {
|
|
136
|
+
try {
|
|
137
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
138
|
+
return response.data;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (axios.isAxiosError<ApiErrorBody>(error)) {
|
|
141
|
+
const status = error.response?.status;
|
|
142
|
+
const body = error.response?.data;
|
|
143
|
+
throw new Error(`Request failed with status ${status}: ${body?.message ?? ''}`);
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 已按 fulfilled status 分支处理的调用方
|
|
151
|
+
|
|
152
|
+
1.x 保留 status 分支,并确保成功 DTO 只在 2xx 后交给业务组件:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
async function listItemsByStatus1x() {
|
|
156
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
157
|
+
|
|
158
|
+
if (response.status === 403) {
|
|
159
|
+
return { ok: false as const, reason: 'forbidden' as const };
|
|
160
|
+
}
|
|
161
|
+
if (response.status < 200 || response.status >= 300) {
|
|
162
|
+
return { ok: false as const, reason: 'http-error' as const };
|
|
163
|
+
}
|
|
164
|
+
return { ok: true as const, data: response.data };
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
迁移到采用新合同的 2.x 后,把 403 分支移到结构化 `AxiosError` 的 catch 中:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
async function listItemsByStatus2x() {
|
|
172
|
+
try {
|
|
173
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
174
|
+
return { ok: true as const, data: response.data };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (axios.isAxiosError<ApiErrorBody>(error) && error.response?.status === 403) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false as const,
|
|
179
|
+
reason: 'forbidden' as const,
|
|
180
|
+
detail: error.response.data,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 已有 catch 的调用方
|
|
189
|
+
|
|
190
|
+
1.x 的 catch 仍负责 400、401、404、500 和网络错误,但默认真实 403 必须在
|
|
191
|
+
fulfilled 分支检查:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
async function loadItems1x() {
|
|
195
|
+
try {
|
|
196
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
197
|
+
if (response.status === 403) {
|
|
198
|
+
return { ok: false as const, reason: 'forbidden' as const };
|
|
199
|
+
}
|
|
200
|
+
return { ok: true as const, data: response.data };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (axios.isAxiosError<ApiErrorBody>(error)) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false as const,
|
|
205
|
+
reason: error.response ? 'http-error' : 'network-error',
|
|
206
|
+
status: error.response?.status,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
采用新合同的 2.x 中,catch 可以统一处理默认 403 和其它 rejected 错误:
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
async function loadItems2x() {
|
|
218
|
+
try {
|
|
219
|
+
const response = await axiosForBackend.get<ItemList>('/api/items');
|
|
220
|
+
return { ok: true as const, data: response.data };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (axios.isAxiosError<ApiErrorBody>(error)) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false as const,
|
|
225
|
+
reason: error.response?.status === 403 ? 'forbidden' : 'request-error',
|
|
226
|
+
status: error.response?.status,
|
|
227
|
+
detail: error.response?.data,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### 显式接受 403
|
|
236
|
+
|
|
237
|
+
如果业务有意把 403 当普通响应,必须显式声明并继续检查 status;1.x 与目标
|
|
238
|
+
2.x 都会 fulfilled:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const response = await axiosForBackend.get<ItemList | ApiErrorBody>('/api/items', {
|
|
242
|
+
validateStatus: status => (status >= 200 && status < 300) || status === 403,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (response.status === 403) {
|
|
246
|
+
const errorBody = response.data as ApiErrorBody;
|
|
247
|
+
// 显式处理权限错误体,不把它当 ItemList。
|
|
248
|
+
}
|
|
249
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { initAxiosConfig } from "../axiosConfig.js";
|
|
5
|
+
const { loggerLogMock, sendEventMock, captureExceptionMock } = vi.hoisted(()=>({
|
|
6
|
+
loggerLogMock: vi.fn(),
|
|
7
|
+
sendEventMock: vi.fn(),
|
|
8
|
+
captureExceptionMock: vi.fn()
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@lark-apaas/observable-web', ()=>({
|
|
11
|
+
observable: {
|
|
12
|
+
startSpan: vi.fn(()=>void 0),
|
|
13
|
+
log: vi.fn()
|
|
14
|
+
}
|
|
15
|
+
}));
|
|
16
|
+
vi.mock('@lark-apaas/internal-slardar', ()=>({
|
|
17
|
+
slardar: {
|
|
18
|
+
sendEvent: sendEventMock,
|
|
19
|
+
captureException: captureExceptionMock
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('../../apis/logger', ()=>({
|
|
23
|
+
logger: {
|
|
24
|
+
log: loggerLogMock
|
|
25
|
+
}
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('../../logger/selected-logs', ()=>({
|
|
28
|
+
getStacktrace: vi.fn(async ()=>[])
|
|
29
|
+
}));
|
|
30
|
+
vi.mock('../../locales', ()=>({
|
|
31
|
+
t: vi.fn((key)=>key)
|
|
32
|
+
}));
|
|
33
|
+
const responseBody = {
|
|
34
|
+
code: 'FORBIDDEN',
|
|
35
|
+
message: 'permission denied'
|
|
36
|
+
};
|
|
37
|
+
let server;
|
|
38
|
+
let baseURL;
|
|
39
|
+
const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
40
|
+
beforeAll(async ()=>{
|
|
41
|
+
Object.defineProperty(globalThis, 'window', {
|
|
42
|
+
configurable: true,
|
|
43
|
+
value: {
|
|
44
|
+
csrfToken: void 0,
|
|
45
|
+
location: {
|
|
46
|
+
pathname: '/contract-test'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server = createServer((request, response)=>{
|
|
51
|
+
if ('/network-error' === request.url) return void request.socket.destroy();
|
|
52
|
+
const status = Number(request.url?.split('/').pop());
|
|
53
|
+
const body = 403 === status ? responseBody : {
|
|
54
|
+
status
|
|
55
|
+
};
|
|
56
|
+
response.writeHead(status, {
|
|
57
|
+
'content-type': 'application/json',
|
|
58
|
+
'x-test-status': String(status)
|
|
59
|
+
});
|
|
60
|
+
response.end(204 === status ? void 0 : JSON.stringify(body));
|
|
61
|
+
});
|
|
62
|
+
await new Promise((resolve)=>{
|
|
63
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
64
|
+
});
|
|
65
|
+
const address = server.address();
|
|
66
|
+
baseURL = `http://127.0.0.1:${address.port}`;
|
|
67
|
+
});
|
|
68
|
+
afterAll(async ()=>{
|
|
69
|
+
await new Promise((resolve, reject)=>{
|
|
70
|
+
server.close((error)=>error ? reject(error) : resolve());
|
|
71
|
+
});
|
|
72
|
+
if (originalWindowDescriptor) Object.defineProperty(globalThis, 'window', originalWindowDescriptor);
|
|
73
|
+
else Reflect.deleteProperty(globalThis, 'window');
|
|
74
|
+
});
|
|
75
|
+
beforeEach(()=>{
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
function client() {
|
|
79
|
+
const instance = axios.create({
|
|
80
|
+
baseURL,
|
|
81
|
+
adapter: 'http'
|
|
82
|
+
});
|
|
83
|
+
initAxiosConfig(instance);
|
|
84
|
+
return instance;
|
|
85
|
+
}
|
|
86
|
+
describe('initAxiosConfig 1.x legacy response contract', ()=>{
|
|
87
|
+
it('fulfills a default real HTTP 403 with its readable response structure', async ()=>{
|
|
88
|
+
const response = await client().get('/status/403');
|
|
89
|
+
expect(response).toMatchObject({
|
|
90
|
+
status: 403,
|
|
91
|
+
data: responseBody,
|
|
92
|
+
config: {
|
|
93
|
+
method: 'get',
|
|
94
|
+
url: '/status/403'
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
expect(response.headers['x-test-status']).toBe('403');
|
|
98
|
+
expect(sendEventMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
99
|
+
name: 'toolkit_axios_403_downgrade'
|
|
100
|
+
}));
|
|
101
|
+
});
|
|
102
|
+
it.each([
|
|
103
|
+
200,
|
|
104
|
+
201,
|
|
105
|
+
204
|
|
106
|
+
])('keeps real HTTP %s responses fulfilled', async (status)=>{
|
|
107
|
+
const response = await client().get(`/status/${status}`);
|
|
108
|
+
expect(response.status).toBe(status);
|
|
109
|
+
expect(response.data).toEqual(204 === status ? '' : {
|
|
110
|
+
status
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
it.each([
|
|
114
|
+
400,
|
|
115
|
+
401,
|
|
116
|
+
404,
|
|
117
|
+
500
|
|
118
|
+
])('keeps real HTTP %s errors rejected as AxiosError', async (status)=>{
|
|
119
|
+
const error = await client().get(`/status/${status}`).catch((reason)=>reason);
|
|
120
|
+
expect(axios.isAxiosError(error)).toBe(true);
|
|
121
|
+
expect(error).toMatchObject({
|
|
122
|
+
config: {
|
|
123
|
+
method: 'get',
|
|
124
|
+
url: `/status/${status}`
|
|
125
|
+
},
|
|
126
|
+
response: {
|
|
127
|
+
status,
|
|
128
|
+
data: {
|
|
129
|
+
status
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
it('keeps a real network failure rejected without a response', async ()=>{
|
|
135
|
+
const error = await client().get('/network-error').catch((reason)=>reason);
|
|
136
|
+
expect(axios.isAxiosError(error)).toBe(true);
|
|
137
|
+
expect(error).toMatchObject({
|
|
138
|
+
config: {
|
|
139
|
+
method: 'get',
|
|
140
|
+
url: '/network-error'
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
expect(error.response).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
it('keeps a real HTTP 403 fulfilled when validateStatus explicitly accepts it', async ()=>{
|
|
146
|
+
const validateStatus = vi.fn((status)=>403 === status);
|
|
147
|
+
const response = await client().get('/status/403', {
|
|
148
|
+
validateStatus
|
|
149
|
+
});
|
|
150
|
+
expect(validateStatus).toHaveBeenCalledWith(403);
|
|
151
|
+
expect(response).toMatchObject({
|
|
152
|
+
status: 403,
|
|
153
|
+
data: responseBody
|
|
154
|
+
});
|
|
155
|
+
expect(sendEventMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
|
156
|
+
name: 'toolkit_axios_403_downgrade'
|
|
157
|
+
}));
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -9,4 +9,20 @@ declare module 'axios' {
|
|
|
9
9
|
_requestUUID?: string;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* 为 Axios 实例安装 client-toolkit 的请求与响应拦截器。
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* 1.x 为兼容存量调用,会把带 `error.response` 的真实 403 从 rejected 恢复为
|
|
17
|
+
* fulfilled `AxiosResponse`。`AxiosInstance` 的泛型仍只描述调用方声明的成功
|
|
18
|
+
* DTO,无法表达 403 错误体,因此直接读取 `response.data` 前必须先检查
|
|
19
|
+
* `response.status`。fulfilled-status 调用方应在统一 API 层处理 403;仅依赖
|
|
20
|
+
* `catch` 的调用方在 1.x 默认配置下不会在 catch 中收到真实 403。
|
|
21
|
+
*
|
|
22
|
+
* 其它 4xx/5xx 和网络错误仍以 `AxiosError` rejected;调用方显式通过
|
|
23
|
+
* `validateStatus` 接受 403 时,Axios 也会正常 fulfilled。上述 403 recovery
|
|
24
|
+
* 是 1.x 历史兼容合同,不是推荐给新代码的安全默认。迁移到采用新合同的
|
|
25
|
+
* 2.x 时,默认 403 将在 catch 中以原始 `AxiosError` 提供
|
|
26
|
+
* `error.response?.status` 和 `error.response?.data`。完整迁移示例见包 README。
|
|
27
|
+
*/
|
|
12
28
|
export declare function initAxiosConfig(axiosInstance?: AxiosInstance): void;
|
|
@@ -6,6 +6,20 @@ declare module 'axios' {
|
|
|
6
6
|
};
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* 获取供前端调用后端 API 的共享 Axios 实例。
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* 1.x 保留历史兼容语义:默认真实 403 会 fulfilled。直接读取
|
|
14
|
+
* `response.data` 的调用方必须先检查 status;已有 fulfilled-status 调用方应
|
|
15
|
+
* 继续在统一 API 层归一化 403;只在 catch 中判断 403 的调用方不能假设
|
|
16
|
+
* 1.x 默认请求会进入 catch。其它 HTTP 错误和网络错误仍 rejected,通常可用
|
|
17
|
+
* `axios.isAxiosError(error)` 后读取 `error.response`。
|
|
18
|
+
*
|
|
19
|
+
* 迁移到采用新合同的 2.x 后,默认真实 403 会 rejected 为原始
|
|
20
|
+
* `AxiosError`,fulfilled-status 分支应迁到 catch;显式 `validateStatus`
|
|
21
|
+
* 接受 403 时仍会 fulfilled。该 1.x 行为仅用于兼容,不是推荐的新合同。
|
|
22
|
+
* 完整的 direct-data、fulfilled-status 和 catch 迁移示例见包 README。
|
|
23
|
+
*/
|
|
10
24
|
export declare function getAxiosForBackend(): AxiosInstance;
|
|
11
25
|
export declare const axiosForBackend: AxiosInstance;
|