Перейти к основному содержимому

Требования к HTTP-запросам

Цель

Слой API отвечает исключительно за HTTP-запросы и транспорт.
Он не управляет состоянием и не знает о UI.


Общие принципы

MUST

  • Вся логика сетевого взаимодействия находится исключительно в слое API: src/shared/api/*.
  • В проекте обязан быть единый HTTP-клиент (singleton / factory), который:
    • Выполняет HTTP-запросы
    • Централизованно конфигурируется (baseURL, timeout, headers)
    • Использует middleware/interceptors
    • Перехватывает все неуспешные запросы (4xx/5xx)
    • Выполняет логирование сетевых ошибок
    • Поддерживает отмену запросов (AbortController)
  • Все запросы строго типизированы.
  • Контракт API и типизация:
    • Все запросы строго типизированы
    • Допустимые источники типов:
      • Сгенерированные DTO (OpenAPI)
      • Вручную описанные DTO (shared/api/dto или packages/api/dto

SHOULD

  • API слой предоставляет тонкие сервисы по доменам (например usersApi, ordersApi) без бизнес-логики.
  • Ошибки приводятся к единому формату (ApiError) для удобной обработки выше по стеку.
  • Принимать AbortSignal во всех API методах:
    • Tanstack Query предоставляет автоматическую отмену, достаточно встроенного механизма
    • API методы просто пробрасывают signal в HTTP-клиент

FORBIDDEN

  • Использование HTTP-клиента вне shared/api
  • Реализация бизнес-логики внутри shared/api
  • Импорт UI-зависимостей в API слое (toast, router, store, components)
  • Использование any, unknown, object, {} в типах DTO
  • Inline-типы в сигнатурах запросов

Допустимые библиотеки HTTP-клиента

MUST

В проекте должна быть выбрана одна библиотека и один единый клиент.

Допустимые библиотеки


Стиль экспорта API методов

Функциональный стиль (именованные экспорты) разрешён и часто удобнее в больших проектах, если соблюдать правила слоя shared/api.

✅ Хорошо (functions + строгая типизация + DTO)

import type { UserDto, UpdateUserDto } from '@/shared/api/dto';

export function getUserById(id: string, signal?: AbortSignal) {
return http.get<UserDto>(`/users/${id}`, { signal });
}

export function updateUser(
id: string,
dto: UpdateUserDto,
signal?: AbortSignal
) {
return http.put<UserDto>(`/users/${id}`, { json: dto, signal });
}

Почему это хорошо:

  • Все типы вынесены в DTO (UserDto, UpdateUserDto)
  • Нет any/unknown/object/{} в DTO
  • Есть поддержка отмены (AbortSignal)
  • Нет бизнес-логики, только транспортный вызов

✅ Хорошо (functions + единый контекст запроса)

import type { UserDto } from '@/shared/api/dto';

export function searchUsers(
params: { q: string; limit?: number },
signal?: AbortSignal
) {
return http.get<UserDto[]>('/users/search', {
searchParams: params,
signal,
});
}

❌ Плохо (inline types + any)

export const getUserById = (id: string) => http.get<any>(`/users/${id}`);

export const updateUser = (id: string, dto: { name: string }) =>
http.put(`/users/${id}`, { json: dto });

Почему плохо:

  • any ломает типобезопасность API и отключает проверки выше по стеку
  • Inline DTO ({ name: string }) разъезжается между файлами и усложняет рефакторинг
  • Нет AbortSignal, поэтому труднее правильно управлять отменой запросов

❌ Плохо (бизнес-логика внутри shared/api)

import type { UserDto } from '@/shared/api/dto';

export async function getUserAndRedirectIfMissing(id: string) {
const user = await http.get<UserDto>(`/users/${id}`);

if (!user) {
router.push('/404'); // ❌ UI/Router зависимость в shared/api
}

return user;
}

Почему плохо:

  • shared/api не должен зависеть от UI/Router/Store
  • Логика "что делать в UI" должна находиться выше (server state / feature / page)

Почему функции часто лучше объектов

Преимущества функционального стиля

  1. Лучше для tree-shaking и бандла
  • Именованные экспорты проще оптимизируются сборщиком, чем большой объект, который импортируют целиком
  1. Чище API и проще импорты

    // ✅ Функции
    import { getUserById, updateUser } from '@/shared/api/users/users.api';

    // VS объект
    import { usersApi } from '@/shared/api/users';
  2. Проще тестирование и моки

  • Функции проще мокать точечно (jest.mock/vi.mock по именам)
  • Меньше риска "случайно замокали весь объект"

Рекомендация для большого проекта

MUST

  • Функции в shared/api/* строго типизированы и используют DTO
  • Нет any/unknown/object/{} в DTO
  • Нет UI/Router/Store зависимостей в shared/api

SHOULD

  • Один файл = один домен (users.api.ts, orders.api.ts, billing.api.ts)
  • Экспорт только именованных функций (или дополнительно index.ts как public API домена)
  • Принимать AbortSignal во всех чтениях/модификациях

MAY

  • Использовать объект-стиль, если команде так удобнее, при сохранении всех ограничений

Архитектурные ограничения (границы)

MUST

shared/api не зависит от:

  • UI (components/pages/widgets)
  • Доменных модулей (features/entities)
  • State management (redux/zustand)
  • Роутера (next/router, react-router)

shared/api зависит только от:

  • types/dto
  • shared/lib (helpers)
  • env/config (baseURL, headers) — если есть

Структура потока зависимостей

✅ Хорошо (поток зависимостей)
UI/Features → Server State (Tanstack Query) → shared/api → HTTP client → network

❌ Плохо
shared/api → store/router/components

Request cancellation (AbortController)

MUST

  • API методы принимают AbortSignal (optional)
  • Server State слой отвечает за отмену/жизненный цикл

Tanstack Query и автоматическая отмена

Tanstack Query предоставляет автоматическую отмену запросов при размонтировании компонента или смене ключа запроса.
Достаточно встроенного механизма — он передаёт signal в queryFn автоматически.

Пример

// ✅ Хорошо
// shared/api/contacts/contacts.api.ts
export function getContactById(id: string, signal?: AbortSignal) {
return http.get<UserDto>(`/users/${id}`, { signal });
}

// Использование в Tanstack Query
// signal передаётся автоматически из Tanstack Query
const { data } = useQuery({
queryKey: ['user', id],
queryFn: ({ signal }) => getContactById(id, signal),
});

Почему это важно:

  • Tanstack Query автоматически отменяет запросы при:
    • Размонтировании компонента
    • Смене ключа запроса (queryKey)
    • Ручной инвалидации
  • API методы просто принимают signal и пробрасывают его в HTTP-клиент
  • Нет необходимости в ручном управлении AbortController

Временные отклонения (MVP / small projects)

Допускается (временно)

  • Упрощенная конфигурация HTTP-клиента
  • Отсутствие централизованного логирования
  • Минимальная типизация ответов (но без any)

Обязательные ограничения (всё равно MUST)

  • Все запросы проходят через единый HTTP-клиент, который содержит настройку перехватчиков для базового логирования сетевых ошибок
  • UI не выполняет HTTP-запросы напрямую
  • Бизнес-логика не размещается рядом с HTTP-клиентом

Условия возврата к основным правилам регламента

  • Появление нескольких доменных модулей
  • Повторное использование API-кода
  • Рост количества запросов и интеграций

Процедуры контроля (Code Review)

Ревьюер обязан проверить

1. Изоляция HTTP-клиента

  • ✅ HTTP-клиент используется только внутри слоя shared/api
  • ❌ Отсутствуют импорты HTTP-клиентов (fetch, axios и аналогов) вне shared/api

2. Отсутствие бизнес-логики

  • ❌ Бизнес-логика не реализуется внутри shared/api
  • ❌ Нет UI-зависимостей (router, store, toast, components)

3. Соблюдение границ слоев

  • ✅ Слой shared/api не зависит от UI и доменных модулей
  • ✅ Поток зависимостей: UI → Server State → shared/api → HTTP client

4. Типизация

  • ✅ Все запросы строго типизированы
  • ❌ Нет использования any, unknown, object, {} в DTO
  • ❌ Нет inline-типов в сигнатурах запросов

5. Единый HTTP-клиент

  • ✅ Присутствует единый конфигурируемый HTTP-клиент
  • ✅ Настроены interceptors/middleware
  • ✅ Есть логирование сетевых ошибок
  • ✅ Поддерживается отмена запросов (AbortController)

Автоматизация (lint / boundary checks)

MUST

В проекте настроены проверки, запрещающие:

  • Прямое использование fetch/axios/_ вне src/shared/api/_
  • Импорт HTTP-клиента вне src/shared/api/*
  • Нарушения границ модулей (например shared/api импортирует features/*)

Пример ESLint правила

В проекте должно быть настроено ESLint правило для запрета использования fetch, axios и аналогов вне API-сегментов */api/* и shared/api:

Пример: no-http-client-outside-api

Инструменты для boundary checks

  • ESLint custom rules
  • eslint-plugin-boundaries
  • Контроль направлений импортов между слоями

Примерная структура shared/api

shared/api/
dto/ # DTO types (или генерируются из OpenAPI)
user.dto.ts
order.dto.ts
lib/
http-client.ts # Единый HTTP-клиент
interceptors.ts # Interceptors/middleware
errors.ts # ApiError, error mapping
users/
users.api.ts # API методы для users
orders/
orders.api.ts # API методы для orders
index.ts # Public API (опционально)

Пример http-client.ts

import ky, { KyInstance } from 'ky';
import { getAuthToken } from '@/shared/lib/auth';
import { ApiError } from '@/shared/api/lib/errors';

export const http: KyInstance = ky.create({
prefixUrl: process.env.NEXT_PUBLIC_API_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
hooks: {
beforeRequest: [
(request) => {
const token = getAuthToken();
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
},
],
afterResponse: [
async (_request, _options, response) => {
if (!response.ok) {
const data = await response.json().catch(() => undefined);
throw new ApiError(response.status, response.statusText, data);
}
return response;
},
],
},
});

Пример errors.ts

export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: Record<string, any>
) {
super(`API Error: ${status} ${statusText}`);
this.name = 'ApiError';
}
}

export function handleApiError(error: unknown): never {
// Optional: send to monitoring
// logError(error);

if (error instanceof ApiError) throw error;

if (error instanceof Error) {
throw new ApiError(500, 'Internal Error', { message: error.message });
}

throw new ApiError(500, 'Unknown Error', { raw: error });
}

export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}