Создавая приложение сложнее 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-токенами на клиенте.
***
Материалы по теме
☕ Самоучитель для начинающих: как освоить TypeScript с нуля за 30 минут?
⚛️ 12 советов по внедрению TypeScript в React-приложениях