Создавая приложение сложнее ToDo-листа, чаще всего нам требуется взаимодействовать с какими-то данными, хранящимися на сервере. Это могут быть как прогнозы погоды, обрабатываемые сторонним API, так и данные наших клиентов, будь то их логин и пароль или список покупок в магазине. Работая с SPA (Single Page Application) приложением, нам нужно эти самые данные получать, модифицировать и отправлять со стороны клиента. Следовательно, нужно иметь какую-то прослойку, отвечающую за взаимодействие с сервером. В этой статье рассмотрим использование API-клиента с библиотекой React, хотя ей можно смело пользоваться на том же Vue, Svelte и так далее.
Почему не прописать все запросы в компонентах, где они используются?
Все просто: если у вас поменяется интерфейс API, с которым вы работаете, вам придется пройтись по всему коду и найти все точки изменений, которые это затронуло. Можно попробовать вынести эту логику в React-хуки, раз уж сейчас речь о нем, но это решение не получится использовать в других проектах c другими фреймворками.
Реализация на Typescript
Для начала вынесем домены, где находятся API, в своего рода конфиг, работающий с .env
-файлом:
REACT_APP_API_BASE_URL=http://localhost:8083
export default {
get apiBaseUrl(): string {
return process.env.REACT_APP_API_BASE_URL || "";
},
}
Затем напишем сам абстрактный клиент, не привязанный к данному домену. Для его работы потребуются библиотеки axios и axios-extensions.
Код клиента:
import axios, {AxiosInstance, AxiosRequestConfig} from "axios";
import {
Forbidden,
HttpError,
Unauthorized
} from '../errors';
import {Headers} from "../types";
export class ApiClient {
constructor(
private readonly baseUrl: string,
private readonly headers: Headers,
private readonly authToken: string = ""
) {}
public async get(endpoint: string = "", params?: any, signal?: AbortSignal): Promise<any> {
try {
const client = this.createClient(params);
const response = await client.get(endpoint, { signal });
return response.data;
} catch (error: any) {
this.handleError(error);
}
}
public async post(endpoint: string = "", data?: any, signal?: AbortSignal): Promise<any> {
try {
const client = this.createClient();
const response = await client.post(endpoint, data, { signal });
return response.data;
} catch (error) {
this.handleError(error);
}
}
public async uploadFile(endpoint: string = "", formData: FormData): Promise<any> {
try {
const client = this.createClient();
const response = await client.post(endpoint, formData, {
headers: {
"Content-Type": "multipart/form-data",
}
})
return response.data;
} catch (error) {
this.handleError(error);
}
}
private createClient(params: object = {}): AxiosInstance {
const config: AxiosRequestConfig = {
baseURL: this.baseUrl,
headers: this.headers,
params: params
}
if (this.authToken) {
config.headers = {
Authorization: `Bearer ${this.authToken}`,
}
}
return axios.create(config);
}
private handleError(error: any): never {
if (!error.response) {
throw new HttpError(error.message)
} else if (error.response.status === 401) {
throw new Unauthorized(error.response.data);
} else if (error.response.status === 403) {
throw new Forbidden(error.response.data);
} else {
throw error
}
}
}
В клиенте используются пользовательские типы, такие как Headers
, который, по сути, является просто словарем [key: string]: string, и различные ошибки, которые наследуют глобальный класс Error
(Unauthorized, Forbidden, HttpError), чтобы в дальнейшем было проще понять, что послужило их причиной.
У класса всего три публичных метода, которые при каждом использовании генерируют axios-клиент. Этот клиент может работать как с публичными эндпоинтами API, так и с защищенными, путем добавления заголовка с Bearer-токеном. Как клиент получает этот самый токен, будет рассмотрено позже. Как get-, так и post-методы используют необязательный параметр abortSignal
, который позволяет прервать отправку запроса в зависимости от действий пользователя.
В случае с отправкой каких-либо файлов на сервер клиент использует метод uploadFile()
, отправляя на сервер запрос с заголовком Content-Type: multipart/form-data.
Для инкапсуляции логики создания этих клиентов, напишем фабрику.
Код фабрики:
import {Headers} from "../../types";
import {ApiClient} from "../../clients";
export class ApiClientFactory {
constructor(
private readonly baseUrl: string,
private readonly headers: Headers = {}
) {}
public createClient(): ApiClient {
return new ApiClient(this.baseUrl, this.headers);
}
public createAuthorizedClient(authToken: string): ApiClient {
return new ApiClient(this.baseUrl, this.headers, authToken);
}
}
Ничего сложного она не делает: просто создает либо обычный клиент, либо авторизованный, передавая в конструктор токен.
Конкретная реализация
Теперь нам нужно адаптировать этот абстрактный клиент под какой-то конкретный эндпоинт. Например, создадим менеджер, получающий с сервера последнее состояние профиля пользователя:
import {ApiClientInterface} from "./clients";
import {Profile} from "./models";
export class ProfileManager {
constructor(private readonly apiClient: ApiClientInterface) {}
public async get(): Promise<Profile> {
return this.apiClient.get("");
}
}
В данном примере нам не важна модель, которую мы используем для профиля. Будем просто считать, что она совместима с передаваемым с сервера значением.
Сам класс менеджера использует композицию и хранит в своем состоянии объект клиента, чтобы переадресовывать все API-запросы к нему, а если надо, он сможет добавить какую-то свою логику к полученному значению (провести валидацию, создать свой эндпоинт и так далее).
Чаще всего, API группируют доменную логику, добавляя к своим эндпоинтам определенный префикс. Также бывают случаи миграции API с одной версии на более новую. Чтобы все это предусмотреть, создадим фабрику для этого конкретного менеджера.
Код фабрики:
import {ApiClientFactory} from "./clients";
import {Headers} from "../types";
import {ProfileManager} from "../ProfileManager";
export class ProfileManagerFactory {
private readonly apiClientFactory: ApiClientFactory;
constructor(baseUrl: string, headers: Headers) {
this.apiClientFactory = new ApiClientFactory(
`${baseUrl}/api/v1/profile`,
headers
);
}
public createProfileManager(authToken: string): ProfileManager {
return new ProfileManager(
this.apiClientFactory.createAuthorizedClient(authToken)
);
}
}
При создании этой фабрики, в конструктор передается URL домена и заголовки для запроса. Затем эти параметры передаются в конструктор фабрики API клиентов, дописывая после переданного URL версию API и тот самый префикс, обозначающий часть доменной логики. При создании менеджера профилей пользователей, требуется авторизация, так что в метод передается токен, на основе которого создается клиент с заголовком авторизации.
Dependency injection
Теперь осталось только написать функцию, которая будет отвечать за предоставление рабочего менеджера профилей в любой части кода, будь то React-компонент или независимый Typescript-класс. Выглядеть она будет примерно так:
export async function createProfileManager(): Promise<apiClient.ProfileManager> {
const factory = new apiClient.ProfileManagerFactory(apiClientConfig.apiBaseUrl, getBaseHeaders());
return factory.createProfileManager(await getAuthToken());
}
Вначале внутри создается фабрика этих самых менеджеров, в которую передается домен сервера и базовые заголовки, которые выглядят так:
function getBaseHeaders(): apiClient.Headers {
return {
"Accept-Language": "ru"
}
}
При желании можно добавить на уровне функции создания менеджера любые свои заголовки.
Способ получения API-токена и работы функции getAuthToken()
я не буду рассматривать в этой статье, потому что эта тема заслуживает отдельной публикации.
async function getAuthToken(): Promise<string> {
// Здесь был бы код получения токена, но пока что просто...
return localStorage.getItem("auth-token");
}
Использование в компонентах.
Пример работы менеджера профилей представлен ниже:
useEffect(() => {
(async () => {
try {
await initProfile();
} catch (error: any) {
await handleError(error);
} finally {
setLoading(false);
}
})()
}, []);
const initProfile = async () => {
const manager = await createProfileManager();
const profile = await manager.get();
await dispatch(set(profile));
}
При запуске функции в хуке useEffect
, асинхронно создается менеджер профилей, который затем запрашивает с сервера текущее состояние профиля пользователей. В данном примере мы просто записываем полученное состояние в хранилище Redux, чтобы затем работать с этим профилем, не перезапрашивая каждый раз его с сервера. В случае ошибки работы клиента, запускается функция handleError()
, которая в зависимости от рода ошибки, о чем я говорил ранее, выполняет те или иные действия.
Итоги
Данная реализация независима от фреймворка, с которым вы работаете, ее можно использовать даже на нативном JS (TS). В ней можно еще много чего доработать, например добавить паттерн «Строитель» для создания API-клиента и передачи в него параметров, abortSignal-ов и прочего, или сделать вариативную систему аутентификации через JWT-токен. Все на ваше усмотрение). В следующей статье расскажу вам про способ получения и работу с API-токенами на клиенте.
Комментарии
Интересная статья. Один момент, в useEffect, можно было бы прописать let controller = new AbortController(); let signal = controller.signal и передать в initProfile.
Очень хорошая статья, надеюсь пригодится в будущем проекте!