Loghorrean 03 февраля 2023

📜 Как написать правильный API-клиент на Typescript

В этой статье я подробно расскажу о реализации API-клиента на языке TypeScript для работы как со сторонними API, так и со своими собственными. Клиент может работать с публичными и защищенными эндпойнтами и не привязан к конкретному фреймворку, что делает его пригодным для использования в React, Vue, Svelte и других фреймворках.
📜 Как написать правильный API-клиент на Typescript

Создавая приложение сложнее 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-токенами на клиенте.

***

Материалы по теме

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
DevOps
от 350000 RUB
AppSec Business Partner
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ