← full-stack-fastapi-template  /  frontend/src/client/core/request.ts

1
import axios from 'axios';
2
import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios';
3
4
import { ApiError } from './ApiError';
5
import type { ApiRequestOptions } from './ApiRequestOptions';
6
import type { ApiResult } from './ApiResult';
7
import { CancelablePromise } from './CancelablePromise';
8
import type { OnCancel } from './CancelablePromise';
9
import type { OpenAPIConfig } from './OpenAPI';
10
11
export const isString = (value: unknown): value is string => {
12
	return typeof value === 'string';
13
};
14
15
export const isStringWithValue = (value: unknown): value is string => {
16
	return isString(value) && value !== '';
17
};
18
19
export const isBlob = (value: any): value is Blob => {
20
	return value instanceof Blob;
21
};
22
23
export const isFormData = (value: unknown): value is FormData => {
24
	return value instanceof FormData;
25
};
26
27
export const isSuccess = (status: number): boolean => {
28
	return status >= 200 && status < 300;
29
};
30
31
export const base64 = (str: string): string => {
32
	try {
33
		return btoa(str);
34
	} catch (err) {
35
		// @ts-ignore
36
		return Buffer.from(str).toString('base64');
37
	}
38
};
39
40
export const getQueryString = (params: Record<string, unknown>): string => {
41
	const qs: string[] = [];
42
43
	const append = (key: string, value: unknown) => {
44
		qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
45
	};
46
47
	const encodePair = (key: string, value: unknown) => {
48
		if (value === undefined || value === null) {
49
			return;
50
		}
51
52
		if (value instanceof Date) {
53
			append(key, value.toISOString());
54
		} else if (Array.isArray(value)) {
55
			value.forEach(v => encodePair(key, v));
56
		} else if (typeof value === 'object') {
57
			Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v));
58
		} else {
59
			append(key, value);
60
		}
61
	};
62
63
	Object.entries(params).forEach(([key, value]) => encodePair(key, value));
64
65
	return qs.length ? `?${qs.join('&')}` : '';
66
};
67
68
const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => {
69
	const encoder = config.ENCODE_PATH || encodeURI;
70
71
	const path = options.url
72
		.replace('{api-version}', config.VERSION)
73
		.replace(/{(.*?)}/g, (substring: string, group: string) => {
74
			if (options.path?.hasOwnProperty(group)) {
75
				return encoder(String(options.path[group]));
76
			}
77
			return substring;
78
		});
79
80
	const url = config.BASE + path;
81
	return options.query ? url + getQueryString(options.query) : url;
82
};
83
84
export const getFormData = (options: ApiRequestOptions): FormData | undefined => {
85
	if (options.formData) {
86
		const formData = new FormData();
87
88
		const process = (key: string, value: unknown) => {
89
			if (isString(value) || isBlob(value)) {
90
				formData.append(key, value);
91
			} else {
92
				formData.append(key, JSON.stringify(value));
93
			}
94
		};
95
96
		Object.entries(options.formData)
97
			.filter(([, value]) => value !== undefined && value !== null)
98
			.forEach(([key, value]) => {
99
				if (Array.isArray(value)) {
100
					value.forEach(v => process(key, v));
101
				} else {
102
					process(key, value);
103
				}
104
			});
105
106
		return formData;
107
	}
108
	return undefined;
109
};
110
111
type Resolver<T> = (options: ApiRequestOptions<T>) => Promise<T>;
112
113
export const resolve = async <T>(options: ApiRequestOptions<T>, resolver?: T | Resolver<T>): Promise<T | undefined> => {
114
	if (typeof resolver === 'function') {
115
		return (resolver as Resolver<T>)(options);
116
	}
117
	return resolver;
118
};
119
120
export const getHeaders = async <T>(config: OpenAPIConfig, options: ApiRequestOptions<T>): Promise<Record<string, string>> => {
121
	const [token, username, password, additionalHeaders] = await Promise.all([
122
		// @ts-ignore
123
		resolve(options, config.TOKEN),
124
		// @ts-ignore
125
		resolve(options, config.USERNAME),
126
		// @ts-ignore
127
		resolve(options, config.PASSWORD),
128
		// @ts-ignore
129
		resolve(options, config.HEADERS),
130
	]);
131
132
	const headers = Object.entries({
133
		Accept: 'application/json',
134
		...additionalHeaders,
135
		...options.headers,
136
	})
137
	.filter(([, value]) => value !== undefined && value !== null)
138
	.reduce((headers, [key, value]) => ({
139
		...headers,
140
		[key]: String(value),
141
	}), {} as Record<string, string>);
142
143
	if (isStringWithValue(token)) {
144
		headers['Authorization'] = `Bearer ${token}`;
145
	}
146
147
	if (isStringWithValue(username) && isStringWithValue(password)) {
148
		const credentials = base64(`${username}:${password}`);
149
		headers['Authorization'] = `Basic ${credentials}`;
150
	}
151
152
	if (options.body !== undefined) {
153
		if (options.mediaType) {
154
			headers['Content-Type'] = options.mediaType;
155
		} else if (isBlob(options.body)) {
156
			headers['Content-Type'] = options.body.type || 'application/octet-stream';
157
		} else if (isString(options.body)) {
158
			headers['Content-Type'] = 'text/plain';
159
		} else if (!isFormData(options.body)) {
160
			headers['Content-Type'] = 'application/json';
161
		}
162
	} else if (options.formData !== undefined) {
163
		if (options.mediaType) {
164
			headers['Content-Type'] = options.mediaType;
165
		}
166
	}
167
168
	return headers;
169
};
170
171
export const getRequestBody = (options: ApiRequestOptions): unknown => {
172
	if (options.body) {
173
		return options.body;
174
	}
175
	return undefined;
176
};
177
178
export const sendRequest = async <T>(
179
	config: OpenAPIConfig,
180
	options: ApiRequestOptions<T>,
181
	url: string,
182
	body: unknown,
183
	formData: FormData | undefined,
184
	headers: Record<string, string>,
185
	onCancel: OnCancel,
186
	axiosClient: AxiosInstance
187
): Promise<AxiosResponse<T>> => {
188
	const controller = new AbortController();
189
190
	let requestConfig: AxiosRequestConfig = {
191
		data: body ?? formData,
192
		headers,
193
		method: options.method,
194
		signal: controller.signal,
195
		url,
196
		withCredentials: config.WITH_CREDENTIALS,
197
	};
198
199
	onCancel(() => controller.abort());
200
201
	for (const fn of config.interceptors.request._fns) {
202
		requestConfig = await fn(requestConfig);
203
	}
204
205
	try {
206
		return await axiosClient.request(requestConfig);
207
	} catch (error) {
208
		const axiosError = error as AxiosError<T>;
209
		if (axiosError.response) {
210
			return axiosError.response;
211
		}
212
		throw error;
213
	}
214
};
215
216
export const getResponseHeader = (response: AxiosResponse<unknown>, responseHeader?: string): string | undefined => {
217
	if (responseHeader) {
218
		const content = response.headers[responseHeader];
219
		if (isString(content)) {
220
			return content;
221
		}
222
	}
223
	return undefined;
224
};
225
226
export const getResponseBody = (response: AxiosResponse<unknown>): unknown => {
227
	if (response.status !== 204) {
228
		return response.data;
229
	}
230
	return undefined;
231
};
232
233
export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => {
234
	const errors: Record<number, string> = {
235
		400: 'Bad Request',
236
		401: 'Unauthorized',
237
		402: 'Payment Required',
238
		403: 'Forbidden',
239
		404: 'Not Found',
240
		405: 'Method Not Allowed',
241
		406: 'Not Acceptable',
242
		407: 'Proxy Authentication Required',
243
		408: 'Request Timeout',
244
		409: 'Conflict',
245
		410: 'Gone',
246
		411: 'Length Required',
247
		412: 'Precondition Failed',
248
		413: 'Payload Too Large',
249
		414: 'URI Too Long',
250
		415: 'Unsupported Media Type',
251
		416: 'Range Not Satisfiable',
252
		417: 'Expectation Failed',
253
		418: 'Im a teapot',
254
		421: 'Misdirected Request',
255
		422: 'Unprocessable Content',
256
		423: 'Locked',
257
		424: 'Failed Dependency',
258
		425: 'Too Early',
259
		426: 'Upgrade Required',
260
		428: 'Precondition Required',
261
		429: 'Too Many Requests',
262
		431: 'Request Header Fields Too Large',
263
		451: 'Unavailable For Legal Reasons',
264
		500: 'Internal Server Error',
265
		501: 'Not Implemented',
266
		502: 'Bad Gateway',
267
		503: 'Service Unavailable',
268
		504: 'Gateway Timeout',
269
		505: 'HTTP Version Not Supported',
270
		506: 'Variant Also Negotiates',
271
		507: 'Insufficient Storage',
272
		508: 'Loop Detected',
273
		510: 'Not Extended',
274
		511: 'Network Authentication Required',
275
		...options.errors,
276
	}
277
278
	const error = errors[result.status];
279
	if (error) {
280
		throw new ApiError(options, result, error);
281
	}
282
283
	if (!result.ok) {
284
		const errorStatus = result.status ?? 'unknown';
285
		const errorStatusText = result.statusText ?? 'unknown';
286
		const errorBody = (() => {
287
			try {
288
				return JSON.stringify(result.body, null, 2);
289
			} catch (e) {
290
				return undefined;
291
			}
292
		})();
293
294
		throw new ApiError(options, result,
295
			`Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`
296
		);
297
	}
298
};
299
300
/**
301
 * Request method
302
 * @param config The OpenAPI configuration object
303
 * @param options The request options from the service
304
 * @param axiosClient The axios client instance to use
305
 * @returns CancelablePromise<T>
306
 * @throws ApiError
307
 */
308
export const request = <T>(config: OpenAPIConfig, options: ApiRequestOptions<T>, axiosClient: AxiosInstance = axios): CancelablePromise<T> => {
309
	return new CancelablePromise(async (resolve, reject, onCancel) => {
310
		try {
311
			const url = getUrl(config, options);
312
			const formData = getFormData(options);
313
			const body = getRequestBody(options);
314
			const headers = await getHeaders(config, options);
315
316
			if (!onCancel.isCancelled) {
317
				let response = await sendRequest<T>(config, options, url, body, formData, headers, onCancel, axiosClient);
318
319
				for (const fn of config.interceptors.response._fns) {
320
					response = await fn(response);
321
				}
322
323
				const responseBody = getResponseBody(response);
324
				const responseHeader = getResponseHeader(response, options.responseHeader);
325
326
				let transformedBody = responseBody;
327
				if (options.responseTransformer && isSuccess(response.status)) {
328
					transformedBody = await options.responseTransformer(responseBody)
329
				}
330
331
				const result: ApiResult = {
332
					url,
333
					ok: isSuccess(response.status),
334
					status: response.status,
335
					statusText: response.statusText,
336
					body: responseHeader ?? transformedBody,
337
				};
338
339
				catchErrorCodes(options, result);
340
341
				resolve(result.body);
342
			}
343
		} catch (error) {
344
			reject(error);
345
		}
346
	});
347
};