@stdex/request 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/client.d.ts +48 -0
- package/dist/client.js +236 -0
- package/dist/error.d.ts +58 -0
- package/dist/error.js +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/instance.d.ts +4 -0
- package/dist/instance.js +4 -0
- package/dist/options.d.ts +33 -0
- package/dist/options.js +1 -0
- package/package.json +44 -3
- package/src/client.ts +314 -0
- package/src/error.ts +78 -0
- package/src/index.ts +7 -0
- package/src/instance.ts +5 -0
- package/src/options.ts +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 安坚实
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 简洁、灵活的日志模块
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- 支持浏览器、Node.js 环境
|
|
4
|
+
- 可扩展性强(可继承子类或添加自定义 handler)
|
|
5
|
+
- 代码简洁,结构清晰
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 实现一个功能完善、异常处理逻辑全面的请求发起器。
|
|
3
|
+
* 可通过继承子类来扩展其功能。
|
|
4
|
+
*/
|
|
5
|
+
import { type Logger } from '@stdex/logging';
|
|
6
|
+
import { type Result, type Success, type Failure } from '@stdex/utils/result.js';
|
|
7
|
+
import { type RequestFailure } from './error.js';
|
|
8
|
+
import type { Options, PredefinedOptions, FormattedOptions } from './options.js';
|
|
9
|
+
/** 此基类不可直接使用,因其未对错误格式进行具体约定 */
|
|
10
|
+
export declare abstract class BaseRequestClient<FailureT extends Failure> {
|
|
11
|
+
logger: Logger;
|
|
12
|
+
prefefinedOptions: PredefinedOptions;
|
|
13
|
+
constructor(options?: PredefinedOptions & {
|
|
14
|
+
logger?: string | Logger;
|
|
15
|
+
});
|
|
16
|
+
/** 生成一个快捷方式函数,调用它相当于调用 client.request() */
|
|
17
|
+
asFunction(): <T>(inputUrl: string, inputOptions?: Options<T>) => Promise<Result<T, FailureT>>;
|
|
18
|
+
request<T>(inputUrl: string, inputOptions?: Options<T>): Promise<Result<T, FailureT>>;
|
|
19
|
+
protected realRequest<T>(options: FormattedOptions): Promise<Result<T, FailureT>>;
|
|
20
|
+
formatOptions<T>(input: Options<T>): Promise<FormattedOptions<T>>;
|
|
21
|
+
/** 请求发起前调用此方法补充 Headers 内容 */
|
|
22
|
+
protected getHeaders(options: FormattedOptions, inputOptions: Options): Record<string, string> | undefined | Promise<Record<string, string> | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* 解析响应内容
|
|
25
|
+
* - 若 options.binary 为 true,返回二进制结果
|
|
26
|
+
* - 若 response Content-Type 为 'text/' 开头,返回文本结果
|
|
27
|
+
* - 尝试解析 JSON,若成功,返回 JSON 结果;
|
|
28
|
+
* - 若 JSON 解析失败:
|
|
29
|
+
* - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
|
|
30
|
+
* - 其他情况,返回纯文本结果
|
|
31
|
+
*/
|
|
32
|
+
protected parseResponse(options: FormattedOptions, response: Response): Promise<Result<unknown, FailureT>>;
|
|
33
|
+
protected handleError(result: RequestFailure): FailureT;
|
|
34
|
+
/** 生成符合 FailureT 约定的失败结果 */
|
|
35
|
+
protected abstract makeFailureResult(result: RequestFailure): FailureT;
|
|
36
|
+
/**
|
|
37
|
+
* 对请求成功得到的数据进行格式化。
|
|
38
|
+
*
|
|
39
|
+
* - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
|
|
40
|
+
* - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
|
|
41
|
+
* - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
|
|
42
|
+
*/
|
|
43
|
+
protected formatSuccessResult(result: Success<unknown>, options: FormattedOptions): Result<unknown, FailureT>;
|
|
44
|
+
}
|
|
45
|
+
/** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
|
|
46
|
+
export declare class RequestClient extends BaseRequestClient<RequestFailure> {
|
|
47
|
+
protected makeFailureResult(result: RequestFailure): RequestFailure;
|
|
48
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 实现一个功能完善、异常处理逻辑全面的请求发起器。
|
|
3
|
+
* 可通过继承子类来扩展其功能。
|
|
4
|
+
*/
|
|
5
|
+
import { getLogger } from '@stdex/logging';
|
|
6
|
+
import { success, failure, formatFailure, exceptionToFailure, } from '@stdex/utils/result.js';
|
|
7
|
+
import { combineUrl } from '@stdex/utils/url.js';
|
|
8
|
+
import isPlainObject from 'lodash/isPlainObject.js';
|
|
9
|
+
import pick from 'lodash/pick.js';
|
|
10
|
+
/** 此基类不可直接使用,因其未对错误格式进行具体约定 */
|
|
11
|
+
export class BaseRequestClient {
|
|
12
|
+
logger;
|
|
13
|
+
prefefinedOptions;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.logger = !options.logger
|
|
16
|
+
? getLogger('request')
|
|
17
|
+
: typeof options.logger === 'string'
|
|
18
|
+
? getLogger(options.logger)
|
|
19
|
+
: options.logger;
|
|
20
|
+
this.prefefinedOptions = options;
|
|
21
|
+
}
|
|
22
|
+
/** 生成一个快捷方式函数,调用它相当于调用 client.request() */
|
|
23
|
+
asFunction() {
|
|
24
|
+
return async (inputUrl, inputOptions) => this.request(inputUrl, inputOptions);
|
|
25
|
+
}
|
|
26
|
+
// -------------------------------
|
|
27
|
+
// 发起请求
|
|
28
|
+
// -------------------------------
|
|
29
|
+
async request(inputUrl, inputOptions) {
|
|
30
|
+
const options = await this.formatOptions({
|
|
31
|
+
url: inputUrl,
|
|
32
|
+
...(inputOptions ?? {}),
|
|
33
|
+
});
|
|
34
|
+
let result;
|
|
35
|
+
let tryCount = 0;
|
|
36
|
+
do {
|
|
37
|
+
tryCount += 1;
|
|
38
|
+
if (tryCount > 1) {
|
|
39
|
+
this.logger.warn('Retrying Request', { ...options, tryCount });
|
|
40
|
+
}
|
|
41
|
+
result = await this.realRequest(options);
|
|
42
|
+
} while (!result.success && tryCount < options.retry + 1);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
async realRequest(options) {
|
|
46
|
+
const { url, method, headers, body, timeout, signal: manualSignal, format } = options;
|
|
47
|
+
const timeoutSignal = timeout ? AbortSignal.timeout(timeout) : undefined;
|
|
48
|
+
const signal = manualSignal || timeoutSignal
|
|
49
|
+
? AbortSignal.any([manualSignal, timeoutSignal].filter(Boolean))
|
|
50
|
+
: undefined;
|
|
51
|
+
// 发起请求
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, { method, headers, body, signal });
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// 失败情形“手动取消”
|
|
58
|
+
if (manualSignal?.aborted && manualSignal.reason === error) {
|
|
59
|
+
const reason = error instanceof DOMException && error.name === 'AbortError' ? null : error;
|
|
60
|
+
return this.handleError(failure('Request Was Canceled', 'RequestAborted', { options, reason }));
|
|
61
|
+
}
|
|
62
|
+
// 失败情形“请求超时”
|
|
63
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
64
|
+
return this.handleError(failure('Request Timed Out', 'RequestTimeout', { options }));
|
|
65
|
+
}
|
|
66
|
+
// 失败情形“请求发起失败”
|
|
67
|
+
return this.handleError(failure("Couldn't Send Request", 'RequestSendFailed', { options, originalError: error }));
|
|
68
|
+
}
|
|
69
|
+
// 失败情形“失败状态码”
|
|
70
|
+
if (!response.status.toString().startsWith('2')) {
|
|
71
|
+
// 此时服务端仍可能输出一些内容,试着解析出来
|
|
72
|
+
// 但因此情况下后端输出数据往往和成功时不同,因此不调用用户提供的 format() 函数来格式化
|
|
73
|
+
const responseDataRes = await this.parseResponse(options, response);
|
|
74
|
+
const responseData = responseDataRes.success ? responseDataRes.data : undefined;
|
|
75
|
+
return this.handleError(failure('Unexpected Response HTTP Status', 'UnexpectedHttpStatus', {
|
|
76
|
+
options,
|
|
77
|
+
status: response.status,
|
|
78
|
+
response,
|
|
79
|
+
responseData,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
// 解析响应内容
|
|
83
|
+
let result = await this.parseResponse(options, response);
|
|
84
|
+
// 格式化响应数据
|
|
85
|
+
if (result.success) {
|
|
86
|
+
try {
|
|
87
|
+
result = this.formatSuccessResult(result, options);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
return this.handleError(failure('Failed To Format Response Data', 'ResponseDataFormattingFailed', {
|
|
91
|
+
options,
|
|
92
|
+
originalError: error,
|
|
93
|
+
response,
|
|
94
|
+
responseData: result.data,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (result.success && format) {
|
|
99
|
+
try {
|
|
100
|
+
const formattedData = format(result.data);
|
|
101
|
+
result = success(formattedData);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
return this.handleError(failure('Failed To Format Response Data Using options.format', 'ResponseDataFormattingFailed', {
|
|
105
|
+
options,
|
|
106
|
+
originalError: error,
|
|
107
|
+
response,
|
|
108
|
+
responseData: result.data,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
// -------------------------------
|
|
115
|
+
// 请求预处理
|
|
116
|
+
// -------------------------------
|
|
117
|
+
async formatOptions(input) {
|
|
118
|
+
const predefined = this.prefefinedOptions;
|
|
119
|
+
const { urlPrefix = predefined.urlPrefix ?? '', url: rawUrl, query = {}, method = predefined.method ?? 'GET', headers: rawHeaders = {}, body: rawBody = null, data, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
|
|
120
|
+
binary = false, timeout = predefined.timeout ?? 0, signal, retry = predefined.retry ?? 0, format, } = input;
|
|
121
|
+
const headers = {
|
|
122
|
+
...(predefined.headers ?? {}),
|
|
123
|
+
...rawHeaders,
|
|
124
|
+
};
|
|
125
|
+
let body = rawBody;
|
|
126
|
+
if (data !== undefined) {
|
|
127
|
+
if (method === 'GET') {
|
|
128
|
+
if (isPlainObject(data)) {
|
|
129
|
+
Object.assign(query, data);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
body = data instanceof FormData ? data : JSON.stringify(data);
|
|
134
|
+
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const url = combineUrl(urlPrefix + (rawUrl ?? ''), query);
|
|
138
|
+
const options = {
|
|
139
|
+
method,
|
|
140
|
+
url,
|
|
141
|
+
headers,
|
|
142
|
+
body,
|
|
143
|
+
timeout,
|
|
144
|
+
binary,
|
|
145
|
+
signal,
|
|
146
|
+
format,
|
|
147
|
+
retry,
|
|
148
|
+
};
|
|
149
|
+
Object.assign(options.headers, await this.getHeaders(options, input));
|
|
150
|
+
return options;
|
|
151
|
+
}
|
|
152
|
+
/** 请求发起前调用此方法补充 Headers 内容 */
|
|
153
|
+
getHeaders(
|
|
154
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
155
|
+
options,
|
|
156
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
157
|
+
inputOptions) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
// -------------------------------
|
|
161
|
+
// 请求结果、错误处理
|
|
162
|
+
// -------------------------------
|
|
163
|
+
/**
|
|
164
|
+
* 解析响应内容
|
|
165
|
+
* - 若 options.binary 为 true,返回二进制结果
|
|
166
|
+
* - 若 response Content-Type 为 'text/' 开头,返回文本结果
|
|
167
|
+
* - 尝试解析 JSON,若成功,返回 JSON 结果;
|
|
168
|
+
* - 若 JSON 解析失败:
|
|
169
|
+
* - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
|
|
170
|
+
* - 其他情况,返回纯文本结果
|
|
171
|
+
*/
|
|
172
|
+
async parseResponse(options, response) {
|
|
173
|
+
const contentType = (response.headers.get('Content-Type') ?? '').toLowerCase().trim();
|
|
174
|
+
if (options.binary) {
|
|
175
|
+
const blobResult = await exceptionToFailure(response.blob());
|
|
176
|
+
return formatFailure(blobResult, result => this.handleError(failure('Blob Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
177
|
+
options,
|
|
178
|
+
status: response.status,
|
|
179
|
+
originalError: result.data,
|
|
180
|
+
response,
|
|
181
|
+
})));
|
|
182
|
+
}
|
|
183
|
+
const textResult = await exceptionToFailure(response.text());
|
|
184
|
+
if (contentType.startsWith('text/') || !textResult.success) {
|
|
185
|
+
return formatFailure(textResult, result => this.handleError(failure('Text Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
186
|
+
options,
|
|
187
|
+
status: response.status,
|
|
188
|
+
originalError: result.data,
|
|
189
|
+
response,
|
|
190
|
+
})));
|
|
191
|
+
}
|
|
192
|
+
let jsonParseError;
|
|
193
|
+
try {
|
|
194
|
+
return success(JSON.parse(textResult.data));
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
jsonParseError = error;
|
|
198
|
+
}
|
|
199
|
+
if (contentType.startsWith('application/json')) {
|
|
200
|
+
return this.handleError(failure('JSON Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
201
|
+
options,
|
|
202
|
+
status: response.status,
|
|
203
|
+
originalError: jsonParseError,
|
|
204
|
+
response,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
return textResult;
|
|
208
|
+
}
|
|
209
|
+
handleError(result) {
|
|
210
|
+
const info = pick(result.data.options, ['url', 'method']);
|
|
211
|
+
for (const key of ['originalError', 'status', 'responseData']) {
|
|
212
|
+
if (key in result.data)
|
|
213
|
+
Object.assign(info, pick(result.data, [key]));
|
|
214
|
+
}
|
|
215
|
+
this.logger.error(result.code, info);
|
|
216
|
+
return this.makeFailureResult(result);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* 对请求成功得到的数据进行格式化。
|
|
220
|
+
*
|
|
221
|
+
* - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
|
|
222
|
+
* - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
|
|
223
|
+
* - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
|
|
224
|
+
*/
|
|
225
|
+
formatSuccessResult(result,
|
|
226
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
227
|
+
options) {
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
|
|
232
|
+
export class RequestClient extends BaseRequestClient {
|
|
233
|
+
makeFailureResult(result) {
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
}
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定义请求失败类型。
|
|
3
|
+
*
|
|
4
|
+
* 有两类失败情形不在处理范围内:
|
|
5
|
+
* 1. client 或子类自身代码报错,例如 formatOptions() 方法意外报错
|
|
6
|
+
* 2. 请求本身成功,但相应内容表达了业务上的失败,例如登录接口返回密码错误。
|
|
7
|
+
* 如有需要,可继承子类来处理这些失败情形。
|
|
8
|
+
*/
|
|
9
|
+
import { type Failure } from '@stdex/utils/result.js';
|
|
10
|
+
import { type FormattedOptions } from './options.js';
|
|
11
|
+
export type RequestFailure = RequestSendFailed | RequestAborted | RequestTimeout | UnexpectedHttpStatus | ResponseBodyParseFailed | ResponseDataFormattingFailed;
|
|
12
|
+
export interface RequestFailureInfo {
|
|
13
|
+
options: FormattedOptions;
|
|
14
|
+
}
|
|
15
|
+
/** 请求发起失败(如网络异常) */
|
|
16
|
+
export type RequestSendFailed = Failure<'RequestSendFailed', RequestSendFailedInfo>;
|
|
17
|
+
export interface RequestSendFailedInfo extends RequestFailureInfo {
|
|
18
|
+
/** 原始错误对象 */
|
|
19
|
+
originalError: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** 手动取消 */
|
|
22
|
+
export type RequestAborted = Failure<'RequestAborted', RequestAbortedInfo>;
|
|
23
|
+
export interface RequestAbortedInfo extends RequestFailureInfo {
|
|
24
|
+
/** 取消原因,即调用 abortController.abort() 时传入的值,若未传值则为 null */
|
|
25
|
+
reason: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** 请求超时 */
|
|
28
|
+
export type RequestTimeout = Failure<'RequestTimeout', RequestFailureInfo>;
|
|
29
|
+
/** 失败状态码(服务端响应了代表失败的状态码,如 500) */
|
|
30
|
+
export type UnexpectedHttpStatus = Failure<'UnexpectedHttpStatus', UnexpectedHttpStatusInfo>;
|
|
31
|
+
export interface UnexpectedHttpStatusInfo extends RequestFailureInfo {
|
|
32
|
+
/** HTTP Status */
|
|
33
|
+
status: number;
|
|
34
|
+
/** Response 对象 */
|
|
35
|
+
response: Response;
|
|
36
|
+
/** 尝试着从 response body 中解析出的数据 */
|
|
37
|
+
responseData: unknown;
|
|
38
|
+
}
|
|
39
|
+
/** 响应体解析失败 */
|
|
40
|
+
export type ResponseBodyParseFailed = Failure<'ResponseBodyParseFailed', ResponseBodyParseFailedInfo>;
|
|
41
|
+
export interface ResponseBodyParseFailedInfo extends RequestFailureInfo {
|
|
42
|
+
/** HTTP Status */
|
|
43
|
+
status: number;
|
|
44
|
+
/** 原始错误对象 */
|
|
45
|
+
originalError: unknown;
|
|
46
|
+
/** Response 对象 */
|
|
47
|
+
response: Response;
|
|
48
|
+
}
|
|
49
|
+
/** 响应体数据格式化失败 */
|
|
50
|
+
export type ResponseDataFormattingFailed = Failure<'ResponseDataFormattingFailed', ResponseDataFormattingFailedInfo>;
|
|
51
|
+
export interface ResponseDataFormattingFailedInfo extends RequestFailureInfo {
|
|
52
|
+
/** 原始错误对象 */
|
|
53
|
+
originalError: unknown;
|
|
54
|
+
/** Response 对象 */
|
|
55
|
+
response: Response;
|
|
56
|
+
/** 未经格式化的响应数据 */
|
|
57
|
+
responseData: unknown;
|
|
58
|
+
}
|
package/dist/error.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { RequestClient } from './client.js';
|
|
2
|
+
/** 提供一个即取即用的 RequestClient 实例及其函数版本 */
|
|
3
|
+
export declare const client: RequestClient;
|
|
4
|
+
export declare const request: <T>(inputUrl: string, inputOptions?: import("./options.js").Options<T> | undefined) => Promise<import("@stdex/utils/result.js").Result<T, import("./error.js").RequestFailure>>;
|
package/dist/instance.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求参数
|
|
3
|
+
*/
|
|
4
|
+
export interface Options<T = unknown> {
|
|
5
|
+
urlPrefix?: string;
|
|
6
|
+
url?: string;
|
|
7
|
+
query?: Record<string, string | number | undefined>;
|
|
8
|
+
method?: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
body?: string | FormData | null;
|
|
11
|
+
/**
|
|
12
|
+
* 向后端传递的数据。
|
|
13
|
+
* 1. 对于 GET 请求:若 data 为 plain object 会合并到 query 中,否则会被忽略。
|
|
14
|
+
* 2. 对于 POST 请求:会作为 POST body,代替 body 参数。
|
|
15
|
+
* 其中若为 FormData,会原样交给 fetch();否则进行 JSON 化。
|
|
16
|
+
* 3. 为了支持传入 interface 类型的值,Record 只能定义成 Record<string, any>
|
|
17
|
+
*/
|
|
18
|
+
data?: any;
|
|
19
|
+
/** 是否把响应内容作为二进制处理(结果是 blob) */
|
|
20
|
+
binary?: boolean;
|
|
21
|
+
/** 超时时间,不指定或设为 0 代表不限 */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
/** 可通过此信号手动终止请求 */
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
/** 请求结果为失败(无论原因为何)时的重试次数,默认值为 0,表示不重试 */
|
|
26
|
+
retry?: number;
|
|
27
|
+
/** 对请求成功时得到的数据进行最终格式化 */
|
|
28
|
+
format?: (responseData: any) => T;
|
|
29
|
+
}
|
|
30
|
+
/** 可预指定的请求参数 */
|
|
31
|
+
export type PredefinedOptions = Pick<Options, 'urlPrefix' | 'method' | 'headers' | 'timeout' | 'retry'>;
|
|
32
|
+
/** 经过整理的请求参数(client 内部使用) */
|
|
33
|
+
export type FormattedOptions<T = unknown> = Required<Pick<Options, 'url' | 'method' | 'headers' | 'body' | 'binary' | 'timeout' | 'retry'>> & Pick<Options<T>, 'signal' | 'format'>;
|
package/dist/options.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stdex/request",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "可靠、简洁的网络请求模块",
|
|
5
|
+
"homepage": "https://github.com/anjianshi/stdex-request",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/anjianshi/stdex-request/issues",
|
|
8
|
+
"email": "anjianshi@gmail.com"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "anjianshi <anjianshi@gmail.com>",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/anjianshi/stdex-request.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"registry": "https://registry.npmjs.org/",
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"types": "dist/index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"dist/"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@stdex/logging": "^1.1.0",
|
|
29
|
+
"@stdex/utils": "^1.0.2",
|
|
30
|
+
"lodash": "^4.18.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@stdex/presets-eslint-typescript": "^1.0.2",
|
|
34
|
+
"@stdex/presets-prettier": "^1.0.2",
|
|
35
|
+
"@stdex/presets-typescript": "^1.0.2",
|
|
36
|
+
"@types/lodash": "^4.17.27",
|
|
37
|
+
"eslint": "^10.6.0",
|
|
38
|
+
"typescript": "^6.0.3"
|
|
39
|
+
},
|
|
40
|
+
"prettier": "@stdex/presets-prettier/prettierrc",
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "npm run clear && tsc",
|
|
43
|
+
"watch": "npm run clear && tsc --watch",
|
|
44
|
+
"clear": "rm -rf dist",
|
|
45
|
+
"lint": "tsc --noEmit && eslint './src/'"
|
|
46
|
+
}
|
|
6
47
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 实现一个功能完善、异常处理逻辑全面的请求发起器。
|
|
3
|
+
* 可通过继承子类来扩展其功能。
|
|
4
|
+
*/
|
|
5
|
+
import { getLogger, type Logger } from '@stdex/logging'
|
|
6
|
+
import {
|
|
7
|
+
success,
|
|
8
|
+
failure,
|
|
9
|
+
formatFailure,
|
|
10
|
+
exceptionToFailure,
|
|
11
|
+
type Result,
|
|
12
|
+
type Success,
|
|
13
|
+
type Failure,
|
|
14
|
+
} from '@stdex/utils/result.js'
|
|
15
|
+
import { combineUrl } from '@stdex/utils/url.js'
|
|
16
|
+
import isPlainObject from 'lodash/isPlainObject.js'
|
|
17
|
+
import pick from 'lodash/pick.js'
|
|
18
|
+
import { type RequestFailure } from './error.js'
|
|
19
|
+
import type { Options, PredefinedOptions, FormattedOptions } from './options.js'
|
|
20
|
+
|
|
21
|
+
/** 此基类不可直接使用,因其未对错误格式进行具体约定 */
|
|
22
|
+
export abstract class BaseRequestClient<FailureT extends Failure> {
|
|
23
|
+
logger: Logger
|
|
24
|
+
prefefinedOptions: PredefinedOptions
|
|
25
|
+
|
|
26
|
+
constructor(options: PredefinedOptions & { logger?: string | Logger } = {}) {
|
|
27
|
+
this.logger = !options.logger
|
|
28
|
+
? getLogger('request')
|
|
29
|
+
: typeof options.logger === 'string'
|
|
30
|
+
? getLogger(options.logger)
|
|
31
|
+
: options.logger
|
|
32
|
+
this.prefefinedOptions = options
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 生成一个快捷方式函数,调用它相当于调用 client.request() */
|
|
36
|
+
asFunction() {
|
|
37
|
+
return async <T>(inputUrl: string, inputOptions?: Options<T>) =>
|
|
38
|
+
this.request<T>(inputUrl, inputOptions)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// -------------------------------
|
|
42
|
+
// 发起请求
|
|
43
|
+
// -------------------------------
|
|
44
|
+
|
|
45
|
+
async request<T>(inputUrl: string, inputOptions?: Options<T>): Promise<Result<T, FailureT>> {
|
|
46
|
+
const options = await this.formatOptions<T>({
|
|
47
|
+
url: inputUrl,
|
|
48
|
+
...(inputOptions ?? {}),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
let result!: Result<T, FailureT>
|
|
52
|
+
let tryCount = 0
|
|
53
|
+
do {
|
|
54
|
+
tryCount += 1
|
|
55
|
+
if (tryCount > 1) {
|
|
56
|
+
this.logger.warn('Retrying Request', { ...options, tryCount })
|
|
57
|
+
}
|
|
58
|
+
result = await this.realRequest<T>(options)
|
|
59
|
+
} while (!result.success && tryCount < options.retry + 1)
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected async realRequest<T>(options: FormattedOptions): Promise<Result<T, FailureT>> {
|
|
64
|
+
const { url, method, headers, body, timeout, signal: manualSignal, format } = options
|
|
65
|
+
|
|
66
|
+
const timeoutSignal = timeout ? AbortSignal.timeout(timeout) : undefined
|
|
67
|
+
const signal =
|
|
68
|
+
manualSignal || timeoutSignal
|
|
69
|
+
? AbortSignal.any([manualSignal, timeoutSignal].filter(Boolean) as AbortSignal[])
|
|
70
|
+
: undefined
|
|
71
|
+
|
|
72
|
+
// 发起请求
|
|
73
|
+
let response: Response | undefined
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(url, { method, headers, body, signal })
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// 失败情形“手动取消”
|
|
78
|
+
if (manualSignal?.aborted && manualSignal.reason === error) {
|
|
79
|
+
const reason = error instanceof DOMException && error.name === 'AbortError' ? null : error
|
|
80
|
+
return this.handleError(
|
|
81
|
+
failure('Request Was Canceled', 'RequestAborted', { options, reason }),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
// 失败情形“请求超时”
|
|
85
|
+
if (error instanceof DOMException && error.name === 'TimeoutError') {
|
|
86
|
+
return this.handleError(failure('Request Timed Out', 'RequestTimeout', { options }))
|
|
87
|
+
}
|
|
88
|
+
// 失败情形“请求发起失败”
|
|
89
|
+
return this.handleError(
|
|
90
|
+
failure("Couldn't Send Request", 'RequestSendFailed', { options, originalError: error }),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 失败情形“失败状态码”
|
|
95
|
+
if (!response.status.toString().startsWith('2')) {
|
|
96
|
+
// 此时服务端仍可能输出一些内容,试着解析出来
|
|
97
|
+
// 但因此情况下后端输出数据往往和成功时不同,因此不调用用户提供的 format() 函数来格式化
|
|
98
|
+
const responseDataRes = await this.parseResponse(options, response)
|
|
99
|
+
const responseData = responseDataRes.success ? responseDataRes.data : undefined
|
|
100
|
+
return this.handleError(
|
|
101
|
+
failure('Unexpected Response HTTP Status', 'UnexpectedHttpStatus', {
|
|
102
|
+
options,
|
|
103
|
+
status: response.status,
|
|
104
|
+
response,
|
|
105
|
+
responseData,
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 解析响应内容
|
|
111
|
+
let result = await this.parseResponse(options, response)
|
|
112
|
+
|
|
113
|
+
// 格式化响应数据
|
|
114
|
+
if (result.success) {
|
|
115
|
+
try {
|
|
116
|
+
result = this.formatSuccessResult(result, options)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return this.handleError(
|
|
119
|
+
failure('Failed To Format Response Data', 'ResponseDataFormattingFailed', {
|
|
120
|
+
options,
|
|
121
|
+
originalError: error,
|
|
122
|
+
response,
|
|
123
|
+
responseData: result.data,
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (result.success && format) {
|
|
129
|
+
try {
|
|
130
|
+
const formattedData = format(result.data)
|
|
131
|
+
result = success(formattedData)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return this.handleError(
|
|
134
|
+
failure(
|
|
135
|
+
'Failed To Format Response Data Using options.format',
|
|
136
|
+
'ResponseDataFormattingFailed',
|
|
137
|
+
{
|
|
138
|
+
options,
|
|
139
|
+
originalError: error,
|
|
140
|
+
response,
|
|
141
|
+
responseData: result.data,
|
|
142
|
+
},
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return result as Result<T, FailureT>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// -------------------------------
|
|
151
|
+
// 请求预处理
|
|
152
|
+
// -------------------------------
|
|
153
|
+
|
|
154
|
+
async formatOptions<T>(input: Options<T>): Promise<FormattedOptions<T>> {
|
|
155
|
+
const predefined = this.prefefinedOptions
|
|
156
|
+
const {
|
|
157
|
+
urlPrefix = predefined.urlPrefix ?? '',
|
|
158
|
+
url: rawUrl,
|
|
159
|
+
query = {},
|
|
160
|
+
method = predefined.method ?? 'GET',
|
|
161
|
+
headers: rawHeaders = {},
|
|
162
|
+
body: rawBody = null,
|
|
163
|
+
data, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
|
|
164
|
+
binary = false,
|
|
165
|
+
timeout = predefined.timeout ?? 0,
|
|
166
|
+
signal,
|
|
167
|
+
retry = predefined.retry ?? 0,
|
|
168
|
+
format,
|
|
169
|
+
} = input
|
|
170
|
+
|
|
171
|
+
const headers = {
|
|
172
|
+
...(predefined.headers ?? {}),
|
|
173
|
+
...rawHeaders,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let body: string | FormData | null = rawBody
|
|
177
|
+
if (data !== undefined) {
|
|
178
|
+
if (method === 'GET') {
|
|
179
|
+
if (isPlainObject(data)) {
|
|
180
|
+
Object.assign(query, data)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
body = data instanceof FormData ? data : JSON.stringify(data)
|
|
184
|
+
headers['Content-Type'] = 'application/json; charset=utf-8'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const url = combineUrl(urlPrefix + (rawUrl ?? ''), query)
|
|
189
|
+
|
|
190
|
+
const options: FormattedOptions<T> = {
|
|
191
|
+
method,
|
|
192
|
+
url,
|
|
193
|
+
headers,
|
|
194
|
+
body,
|
|
195
|
+
timeout,
|
|
196
|
+
binary,
|
|
197
|
+
signal,
|
|
198
|
+
format,
|
|
199
|
+
retry,
|
|
200
|
+
}
|
|
201
|
+
Object.assign(options.headers, await this.getHeaders(options, input))
|
|
202
|
+
return options
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 请求发起前调用此方法补充 Headers 内容 */
|
|
206
|
+
protected getHeaders(
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
208
|
+
options: FormattedOptions,
|
|
209
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
210
|
+
inputOptions: Options,
|
|
211
|
+
): Record<string, string> | undefined | Promise<Record<string, string> | undefined> {
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -------------------------------
|
|
216
|
+
// 请求结果、错误处理
|
|
217
|
+
// -------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 解析响应内容
|
|
221
|
+
* - 若 options.binary 为 true,返回二进制结果
|
|
222
|
+
* - 若 response Content-Type 为 'text/' 开头,返回文本结果
|
|
223
|
+
* - 尝试解析 JSON,若成功,返回 JSON 结果;
|
|
224
|
+
* - 若 JSON 解析失败:
|
|
225
|
+
* - 若 response Content-Type 为 'application/json' 开头,返回解析失败信息
|
|
226
|
+
* - 其他情况,返回纯文本结果
|
|
227
|
+
*/
|
|
228
|
+
protected async parseResponse(
|
|
229
|
+
options: FormattedOptions,
|
|
230
|
+
response: Response,
|
|
231
|
+
): Promise<Result<unknown, FailureT>> {
|
|
232
|
+
const contentType = (response.headers.get('Content-Type') ?? '').toLowerCase().trim()
|
|
233
|
+
|
|
234
|
+
if (options.binary) {
|
|
235
|
+
const blobResult = await exceptionToFailure(response.blob())
|
|
236
|
+
return formatFailure(blobResult, result =>
|
|
237
|
+
this.handleError(
|
|
238
|
+
failure('Blob Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
239
|
+
options,
|
|
240
|
+
status: response.status,
|
|
241
|
+
originalError: result.data,
|
|
242
|
+
response,
|
|
243
|
+
}),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const textResult = await exceptionToFailure(response.text())
|
|
249
|
+
if (contentType.startsWith('text/') || !textResult.success) {
|
|
250
|
+
return formatFailure(textResult, result =>
|
|
251
|
+
this.handleError(
|
|
252
|
+
failure('Text Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
253
|
+
options,
|
|
254
|
+
status: response.status,
|
|
255
|
+
originalError: result.data,
|
|
256
|
+
response,
|
|
257
|
+
}),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let jsonParseError: unknown
|
|
263
|
+
try {
|
|
264
|
+
return success(JSON.parse(textResult.data))
|
|
265
|
+
} catch (error) {
|
|
266
|
+
jsonParseError = error
|
|
267
|
+
}
|
|
268
|
+
if (contentType.startsWith('application/json')) {
|
|
269
|
+
return this.handleError(
|
|
270
|
+
failure('JSON Body Parsing Failed', 'ResponseBodyParseFailed', {
|
|
271
|
+
options,
|
|
272
|
+
status: response.status,
|
|
273
|
+
originalError: jsonParseError,
|
|
274
|
+
response,
|
|
275
|
+
}),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
return textResult
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
protected handleError(result: RequestFailure) {
|
|
282
|
+
const info: Record<string, unknown> = pick(result.data.options, ['url', 'method'])
|
|
283
|
+
for (const key of ['originalError', 'status', 'responseData'] as const) {
|
|
284
|
+
if (key in result.data) Object.assign(info, pick(result.data, [key]))
|
|
285
|
+
}
|
|
286
|
+
this.logger.error(result.code, info)
|
|
287
|
+
return this.makeFailureResult(result)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** 生成符合 FailureT 约定的失败结果 */
|
|
291
|
+
protected abstract makeFailureResult(result: RequestFailure): FailureT
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 对请求成功得到的数据进行格式化。
|
|
295
|
+
*
|
|
296
|
+
* - 此步骤在 applyInputFormat() 之前运行,子类可在此进行一些通用的格式化工作,
|
|
297
|
+
* - 输出 Success 或 Failure 结果均可,但输出的 Failure 结果不会交给 makeFailureResult() 进行格式化,需自行保证符合 FailureT 约定。
|
|
298
|
+
* - 若此方法抛出异常,会生成 ResponseDataFormattingFailed,此错误会照常交给 makeFailureResult() 处理。
|
|
299
|
+
*/
|
|
300
|
+
protected formatSuccessResult(
|
|
301
|
+
result: Success<unknown>,
|
|
302
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
303
|
+
options: FormattedOptions,
|
|
304
|
+
): Result<unknown, FailureT> {
|
|
305
|
+
return result
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** 默认的 RequestClient 实现。出现错误时,会把错误对象原样放入错误信息中。 */
|
|
310
|
+
export class RequestClient extends BaseRequestClient<RequestFailure> {
|
|
311
|
+
protected makeFailureResult(result: RequestFailure) {
|
|
312
|
+
return result
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定义请求失败类型。
|
|
3
|
+
*
|
|
4
|
+
* 有两类失败情形不在处理范围内:
|
|
5
|
+
* 1. client 或子类自身代码报错,例如 formatOptions() 方法意外报错
|
|
6
|
+
* 2. 请求本身成功,但相应内容表达了业务上的失败,例如登录接口返回密码错误。
|
|
7
|
+
* 如有需要,可继承子类来处理这些失败情形。
|
|
8
|
+
*/
|
|
9
|
+
import { type Failure } from '@stdex/utils/result.js'
|
|
10
|
+
import { type FormattedOptions } from './options.js'
|
|
11
|
+
|
|
12
|
+
export type RequestFailure =
|
|
13
|
+
| RequestSendFailed
|
|
14
|
+
| RequestAborted
|
|
15
|
+
| RequestTimeout
|
|
16
|
+
| UnexpectedHttpStatus
|
|
17
|
+
| ResponseBodyParseFailed
|
|
18
|
+
| ResponseDataFormattingFailed
|
|
19
|
+
|
|
20
|
+
export interface RequestFailureInfo {
|
|
21
|
+
options: FormattedOptions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 请求发起失败(如网络异常) */
|
|
25
|
+
export type RequestSendFailed = Failure<'RequestSendFailed', RequestSendFailedInfo>
|
|
26
|
+
export interface RequestSendFailedInfo extends RequestFailureInfo {
|
|
27
|
+
/** 原始错误对象 */
|
|
28
|
+
originalError: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 手动取消 */
|
|
32
|
+
export type RequestAborted = Failure<'RequestAborted', RequestAbortedInfo>
|
|
33
|
+
export interface RequestAbortedInfo extends RequestFailureInfo {
|
|
34
|
+
/** 取消原因,即调用 abortController.abort() 时传入的值,若未传值则为 null */
|
|
35
|
+
reason: unknown
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 请求超时 */
|
|
39
|
+
export type RequestTimeout = Failure<'RequestTimeout', RequestFailureInfo>
|
|
40
|
+
|
|
41
|
+
/** 失败状态码(服务端响应了代表失败的状态码,如 500) */
|
|
42
|
+
export type UnexpectedHttpStatus = Failure<'UnexpectedHttpStatus', UnexpectedHttpStatusInfo>
|
|
43
|
+
export interface UnexpectedHttpStatusInfo extends RequestFailureInfo {
|
|
44
|
+
/** HTTP Status */
|
|
45
|
+
status: number
|
|
46
|
+
/** Response 对象 */
|
|
47
|
+
response: Response
|
|
48
|
+
/** 尝试着从 response body 中解析出的数据 */
|
|
49
|
+
responseData: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 响应体解析失败 */
|
|
53
|
+
export type ResponseBodyParseFailed = Failure<
|
|
54
|
+
'ResponseBodyParseFailed',
|
|
55
|
+
ResponseBodyParseFailedInfo
|
|
56
|
+
>
|
|
57
|
+
export interface ResponseBodyParseFailedInfo extends RequestFailureInfo {
|
|
58
|
+
/** HTTP Status */
|
|
59
|
+
status: number
|
|
60
|
+
/** 原始错误对象 */
|
|
61
|
+
originalError: unknown
|
|
62
|
+
/** Response 对象 */
|
|
63
|
+
response: Response
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 响应体数据格式化失败 */
|
|
67
|
+
export type ResponseDataFormattingFailed = Failure<
|
|
68
|
+
'ResponseDataFormattingFailed',
|
|
69
|
+
ResponseDataFormattingFailedInfo
|
|
70
|
+
>
|
|
71
|
+
export interface ResponseDataFormattingFailedInfo extends RequestFailureInfo {
|
|
72
|
+
/** 原始错误对象 */
|
|
73
|
+
originalError: unknown
|
|
74
|
+
/** Response 对象 */
|
|
75
|
+
response: Response
|
|
76
|
+
/** 未经格式化的响应数据 */
|
|
77
|
+
responseData: unknown
|
|
78
|
+
}
|
package/src/index.ts
ADDED
package/src/instance.ts
ADDED
package/src/options.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求参数
|
|
3
|
+
*/
|
|
4
|
+
export interface Options<T = unknown> {
|
|
5
|
+
urlPrefix?: string
|
|
6
|
+
url?: string
|
|
7
|
+
query?: Record<string, string | number | undefined>
|
|
8
|
+
method?: string
|
|
9
|
+
headers?: Record<string, string>
|
|
10
|
+
body?: string | FormData | null
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 向后端传递的数据。
|
|
14
|
+
* 1. 对于 GET 请求:若 data 为 plain object 会合并到 query 中,否则会被忽略。
|
|
15
|
+
* 2. 对于 POST 请求:会作为 POST body,代替 body 参数。
|
|
16
|
+
* 其中若为 FormData,会原样交给 fetch();否则进行 JSON 化。
|
|
17
|
+
* 3. 为了支持传入 interface 类型的值,Record 只能定义成 Record<string, any>
|
|
18
|
+
*/
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
|
+
data?: any
|
|
21
|
+
|
|
22
|
+
/** 是否把响应内容作为二进制处理(结果是 blob) */
|
|
23
|
+
binary?: boolean
|
|
24
|
+
|
|
25
|
+
/** 超时时间,不指定或设为 0 代表不限 */
|
|
26
|
+
timeout?: number
|
|
27
|
+
|
|
28
|
+
/** 可通过此信号手动终止请求 */
|
|
29
|
+
signal?: AbortSignal
|
|
30
|
+
|
|
31
|
+
/** 请求结果为失败(无论原因为何)时的重试次数,默认值为 0,表示不重试 */
|
|
32
|
+
retry?: number
|
|
33
|
+
|
|
34
|
+
/** 对请求成功时得到的数据进行最终格式化 */
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
format?: (responseData: any) => T
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 可预指定的请求参数 */
|
|
40
|
+
export type PredefinedOptions = Pick<
|
|
41
|
+
Options,
|
|
42
|
+
'urlPrefix' | 'method' | 'headers' | 'timeout' | 'retry'
|
|
43
|
+
>
|
|
44
|
+
|
|
45
|
+
/** 经过整理的请求参数(client 内部使用) */
|
|
46
|
+
export type FormattedOptions<T = unknown> = Required<
|
|
47
|
+
Pick<Options, 'url' | 'method' | 'headers' | 'body' | 'binary' | 'timeout' | 'retry'>
|
|
48
|
+
> &
|
|
49
|
+
Pick<Options<T>, 'signal' | 'format'>
|