Продвинутый HTTP клиент на TypeScript: от базовых запросов к промышленным решениям
При разработке современных веб-приложений на TypeScript работа с HTTP-запросами выходит далеко за рамки простого вызова fetch. На продвинутом уровне требуется не только отправлять запросы, но и грамотно обрабатывать ошибки, управлять временем ожидания, типизировать ответы, внедрять интерсепторы и даже кэшировать данные. В этой статье мы разберём, как построить надёжный и гибкий HTTP-клиент на TypeScript, который подойдёт для production-проектов.
1. Создание типизированного HTTP-клиента с использованием обобщений (Generics)
Одно из главных преимуществ TypeScript — строгая типизация. Создадим базовый класс HttpClient, который будет принимать тип ответа через дженерики. Это позволит избежать ошибок на этапе компиляции.
// types.ts
export interface ApiResponse<T> {
data: T;
status: number;
ok: boolean;
}
export interface RequestConfig {
headers?: Record<string, string>;
params?: Record<string, string>;
signal?: AbortSignal;
}
// http-client.ts
import { ApiResponse, RequestConfig } from './types';
class HttpClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
const url = new URL(`${this.baseUrl}${endpoint}`);
if (config?.params) {
Object.entries(config.params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...config?.headers,
},
signal: config?.signal,
});
const data: T = await response.json();
return {
data,
status: response.status,
ok: response.ok,
};
}
async post<T, U>(endpoint: string, body: U, config?: RequestConfig): Promise<ApiResponse<T>> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...config?.headers,
},
body: JSON.stringify(body),
signal: config?.signal,
});
const data: T = await response.json();
return { data, status: response.status, ok: response.ok };
}
}
export default HttpClient;
2. Обработка ошибок и таймаутов
Продвинутый клиент должен корректно обрабатывать как сетевые ошибки, так и ошибки бизнес-логики. Добавим механизм таймаута с помощью AbortController и кастомную обработку ошибок.
// errors.ts
export class HttpError extends Error {
status: number;
data?: unknown;
constructor(message: string, status: number, data?: unknown) {
super(message);
this.name = 'HttpError';
this.status = status;
this.data = data;
}
}
export class TimeoutError extends Error {
constructor(timeout: number) {
super(`Request timed out after ${timeout}ms`);
this.name = 'TimeoutError';
}
}
// http-client.ts (дополнение)
async requestWithTimeout<T>(
endpoint: string,
options: RequestInit,
timeout: number = 5000
): Promise<ApiResponse<T>> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
signal: controller.signal,
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new HttpError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errorData
);
}
const data: T = await response.json();
return { data, status: response.status, ok: true };
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new TimeoutError(timeout);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
3. Интерсепторы: перехват запросов и ответов
Интерсепторы — мощный паттерн, позволяющий модифицировать запросы (добавлять токены, логирование) или обрабатывать ответы централизованно. Реализуем очередь интерсепторов.
// interceptors.ts
export interface Interceptor<T> {
onFulfilled?: (value: T) => T | Promise<T>;
onRejected?: (error: unknown) => unknown;
}
class InterceptorManager<T> {
private interceptors: Interceptor<T>[] = [];
use(onFulfilled?: (value: T) => T | Promise<T>, onRejected?: (error: unknown) => unknown): number {
this.interceptors.push({ onFulfilled, onRejected });
return this.interceptors.length - 1;
}
eject(id: number): void {
if (this.interceptors[id]) {
this.interceptors[id] = { onFulfilled: undefined, onRejected: undefined };
}
}
async execute(initialValue: T): Promise<T> {
let value = initialValue;
for (const interceptor of this.interceptors) {
if (interceptor.onFulfilled) {
value = await interceptor.onFulfilled(value);
}
}
return value;
}
}
// Использование в HttpClient
class HttpClient {
public requestInterceptors = new InterceptorManager<RequestInit>();
public responseInterceptors = new InterceptorManager<ApiResponse<unknown>>();
async get<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
let options: RequestInit = {
method: 'GET',
headers: { 'Content-Type': 'application/json', ...config?.headers },
signal: config?.signal,
};
// Применяем интерсепторы запроса
options = await this.requestInterceptors.execute(options);
let response = await fetch(`${this.baseUrl}${endpoint}`, options);
let data: T = await response.json();
let apiResponse: ApiResponse<T> = { data, status: response.status, ok: response.ok };
// Применяем интерсепторы ответа
apiResponse = await this.responseInterceptors.execute(apiResponse) as ApiResponse<T>;
return apiResponse;
}
}
Пример использования интерсептора для добавления токена авторизации:
const client = new HttpClient('https://api.example.com');
client.requestInterceptors.use(async (config) => {
const token = await getAuthToken();
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`,
},
};
});