Базовое REST API для отправки запросов на сервер с использованием Axios

Время прочтения — 9 минут
Содержание

Предыстория

Изначально я использовал api, которое тянулось из старых проектов, я не пытался разобраться как это работает и просто использовал то, что есть. Оно было большим (130 строк) и имело в себе много лишней логики, где также не соблюдался принцип DRY. Меня это все больше напрягало — хотелось, чтобы код выглядел красиво, и его можно было просто копировать во все новые проекты и моментально получить возможность отправлять запросы на сервер.

axios.defaults.validateStatus = status => status >= 200 && status <= 399;
Я задался целью разобраться, как же все-таки правильнее это реализовать. Искал информацию, шаблоны, но потерпел фиаско: никаких статей, где было бы сформировано одно цельное api, я не нашел. Поэтому решил поделиться своим api, которое я написал для своего переиспользуемого шаблона репозитория.

Для начала

У вас проект на стадии разработки, настало время для общения с сервером. Всё начинается с установки зависимостей:
Для работы с запросами будем использовать «axios».
Для работы с куки я использую «cookies-next», но вы можете написать собственные обработчики, либо юзать любую понравившуюся вам библиотеку.
Еще я использую «react-toastify» для отображения уведомлений.
После установки всех необходимых зависимостей, можно приступить к написанию api в отдельном файле.
В начале настраиваю axios. Добавляю диапазон кодов ответа, который будет возвращать положительный ответ.

axios.defaults.validateStatus = status => status >= 200 && status <= 399;

interceptors.response

В axios существуют interceptors (перехватчики), с помощью которых мы можем «перехватить» запросы или ответы до того, как они будут обработаны. То есть, прежде чем ответ попадет в место, из которого вы воспользовались апишкой, вы можете сделать какие-либо преобразования. И до того как запрос улетит на сервер, вы можете сделать с ним что-либо, например, добавить необходимые заголовки.

axios.interceptors.response.use( response => response, error => { if (error.response) { const { status, data: { errors = null, message = null }, } = error.response; if (typeof window !== 'undefined') { message && toast.error(message); status === 401 && deleteCookie(AUTH_TOKEN); } console.error('Axios response error', error.response); return Promise.reject({ status, errors, message }); } else if (error.request) { console.error('Axios request error', error.request); const { status, statusText } = error.request; return Promise.reject({ status, message: statusText }); } else { console.error('Axios undefined error', error.message); return Promise.reject({ message: error.message }); } }
);
Когда мы обращаемся к перехватчику, у нас есть два коллбэка. Первый срабатывает в случае, если запрос прошел успешно, второй — в противоположной ситуации.
Первый коллбэк подходит, если вам необходимо добавить какой-то общий адаптер (маппер), например, для преобразования полученных серверных данных, написанных в snake_case в camelCase для всех запросов.
Вы так же можете сделать что-либо для определенного запроса, ориентируясь на responseURL, полученный в объекте. Он тоже прокидывается в данный коллбэк. Но я не рекомендую так делать: подобную логику лучше вынести в отдельные сервисы для каждого запроса.
В моем случае ничего добавлять не нужно. Соответственно, я просто возвращаю данные в том виде, в котором они пришли.
Во втором коллбэке нас интересуют response и request в объекте error. В response возвращаются данные, если запрос был сделан, и сервер ответил кодом состояния, которое выходит за пределы диапазона, установленного вначале. В request данные возвращаются, если запрос был сделан, но ответ не получен, например, если мы получили ошибку CORS. Если ни там, ни там никаких данных нет, мы понимаем, что произошла ошибка при настройке запроса.
У нас на сервере в случае ошибок, например, той же валидации, возвращаются error в виде массива ошибок и message с общим текстом ошибки. Именно эти два ключа я деструктурирую из data в первом блоке if и возвращаю в место вызова запроса. В data будут храниться данные, которые вам возвращает ВАШ сервер.
Ориентируясь на имеющийся код ответа, мы можем писать разную ситуативную логику. Например, удалять токен из куки при 401 коде (Unauthorized) или выполнить дополнительный запрос на обновление токена. Конкретно в этом случае, если сервер возвращает message, я вывожу его в toast.
В такой ситуации нужно договориться на берегу, что с сервера нам всегда будут отправлять корректное сообщение. Проверку typeof window !== 'undefined' я делаю, потому что проект написан на Next.js и большинство запросов выполняется на стороне сервера. Там у нас нет браузерного api, и мы не можем получить доступ к тем же куки. Если вы пишите проект на React, такая проверка не нужна.
В error.request мы возвращаем нужные нам данные, а именно статус, код и текстовое сообщение.
В случае, если ошибка не определена, возвращаем сообщение об ошибке, а в консоль выводим объект ошибки целиком.

interceptors.request


axios.interceptors.request.use( config => { config.headers.Accept = 'application/json'; if (typeof window !== 'undefined') { config.headers.Authorization = `Bearer ${getCookie(AUTH_TOKEN)}`; } return config; }, error => { console.error(error); return Promise.reject(error); }
);
Здесь все просто. Я добавляю необходимые заголовки ко всем запросам. Токен авторизации использую в случае, если запрос выполняется на клиенте. Как я уже писал ранее, на сервере мы не можем получить доступ к куки таким образом. При выполнения запроса на сервере, токен будет передаваться в headers из функции инициализации страницы. В случае ошибки — возвращаем ошибку.
Затем пишем базовую функцию для выполнения запросов и функции запросов, которые будут ее использовать.

const makeBaseRequest = (method: REQUEST_METHODS): BaseRequestReturnType => async (config: BaseRequestParams) => { return axios({ method, ...config, }); };
Функцию makeBaseRequest можно написать разными способами, я же показал самый лаконичный. Однако он может быть не для всех очевидным, поэтому покажу ещё два варианта.

const makeBaseRequest = (method: REQUEST_METHODS): BaseRequestReturnType => async ({ url, data, headers, params }: BaseRequestParams) => { return axios({ url, method, data, headers, params, }); };

const makeBaseRequest = (method: REQUEST_METHODS): BaseRequestReturnType => async ({ url, data, headers, params }: BaseRequestParams) => { switch (method) { case REQUEST_METHODS.GET: return axios.get(url, { headers, params }); case REQUEST_METHODS.POST: return axios.post(url, data, { headers, params }); case REQUEST_METHODS.PUT: return axios.put(url, data, { headers, params }); case REQUEST_METHODS.PATCH: return axios.patch(url, data, { headers, params }); case REQUEST_METHODS.DELETE: return axios.delete(url, { headers, params }); default: throw new Error(`Invalid request method ${method}.`); } };
Наше базовое api готово. Оно состоит всего из 66 строк и полностью типизировано. Вы можете расширять его исходя из потребностей проекта и собственных предпочтений. Я же привык писать отдельный сервис для каждого запроса. Выглядеть это может так:

interface Params { id: number; userName: string; tasksAmount: number;
}
const getTodosData = async ({ id, userName, tasksAmount }: Params): Promise<DataResponse<TodoType | CustomError>> => { const payload = { id, user_name: userName, tasks_amount: tasksAmount, }; try { const { data: json } = await post<TodoJson>({ url: TODOS_ENDPOINT, data: payload }); const data = mapJsonToTodoType(json); return { ok: true, data }; } catch (error) { return Promise.reject(error); }
};
export default getTodosData;
В сервисе вы можете писать любую логику, связанную с вашим запросом. Различные преобразования, адаптеры, работа с FormData и так далее.

Типизация api

Конфигурацию запроса, передаваемую в axios, я описал следующим образом:

import type { AxiosRequestConfig, RawAxiosRequestHeaders } from 'axios';
type AxiosDataType = Record<string, unknown> | FormData;
export type BaseRequestParams = { url: string; data?: AxiosDataType; headers?: RawAxiosRequestHeaders; params?: AxiosRequestConfig<AxiosDataType>['params'];
};
Здесь особо нечего комментировать, чего не скажешь про тип возвращаемого значения makeBaseRequest функции.

export type BaseRequestReturnType = <T>(params: BaseRequestParams) => Promise<AxiosResponse<T>>;
Данный тип построен с помощью дженерика, что позволяет нам динамически определить возвращаемое значение.
Таким образом, в своём сервисе я указываю тип, который мне вернет мой запрос в случае успешного выполнения.
 const { data: json } = await get<TodoJson>({ url: TODOS_ENDPOINT });

Собственный возвращаемый ключ со статусом запроса

Действительно ли его нужно использовать, и с какой целью вообще это делается?
На старых проектах я заметил, что разработчики с сервера в каждом запросе присылают в ответе помимо прочего - { status: ’success' | ‘failed’ } или { success: boolean }.
Фронтенд-разработчик может ориентироваться по данному ключу, понимая успешно прошел запрос или нет.
Данный код копировать не надо, поэтому оставляю скрин для примера
Как по мне, — это просто лишняя логика, которая раздувает ваш код. Мы можем получать код ответа и определять по нему, насколько успешно прошел запрос. Более того, axios автоматически возвращает ошибку после наших настроек, если код ответа выходит за установленные пределы.
Соответственно дальнейшая логика уходит в блок catch. Если запрос прошел успешно, код продолжит выполняться в блоке try. Таким образом мы уверены, что запрос выполнился успешно.
Зачем тогда присылать дополнительный статус с сервера и зачем использовать его в коде? Вы можете создать собственный ключ в своем сервисе, который будет сообщать, что все прошло, как ожидалось, а не запрашивать его с сервера — обратите внимание на мой сервис getTodosData выше.
Если у вас есть советы и предложения по оптимизации api или у вы придумали более интересный вариант, буду рад вашим комментариям.