api-layer-frontend.md — vim

Слой API: как перестать писать try/catch в каждом компоненте

В любом проекте старше года живёт три способа сходить на бэк. Показываю, как я это привожу к одному виду и что складываю на каждый уровень.


Открываешь проект старше года и находишь три способа сходить на сервер: где-то fetch, где-то axios, где-то самописный request, который писал человек, уже сменивший работу. И в каждом компоненте свой try/catch со своим текстом ошибки.

Разгребается это в четыре слоя. Ниже - как выглядит у меня.

Транспорт

Один клиент на приложение. Здесь всё общее: базовый адрес, заголовки, токен, реакция на 401.

// shared/api/client.ts
export const client = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15_000,
});

client.interceptors.request.use((config) => {
  const token = useAuthStore().token;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

client.interceptors.response.use(
  (res) => res,
  async (error) => {
    if (error.response?.status === 401) await useAuthStore().logout();
    return Promise.reject(normalizeError(error));
  },
);

Про normalizeError отдельно. У нас был бэк, который отдавал ошибки тремя способами в зависимости от того, какой сервис отвечал: {message}, {detail} и {errors: []}. Я не хочу, чтобы это знание расползлось по тридцати компонентам:

export interface AppError {
  code: string;
  message: string;
  fields?: Record<string, string>;
}

function normalizeError(e: unknown): AppError {
  if (axios.isAxiosError(e)) {
    const data = e.response?.data as any;
    return {
      code: data?.code ?? String(e.response?.status ?? 'network'),
      message: data?.message ?? data?.detail ?? 'Что-то пошло не так',
      fields: data?.errors,
    };
  }
  return { code: 'unknown', message: 'Неизвестная ошибка' };
}

Дальше по коду ошибка всегда одного вида. Это экономит по десятку строк в каждой форме.

Методы домена

Компонент не знает URL. Он знает функцию.

// entities/user/api.ts
export const userApi = {
  list: (params: UserListParams) =>
    client.get<Paginated<User>>('/users', { params }).then(r => r.data),

  byId: (id: string) =>
    client.get<User>(`/users/${id}`).then(r => r.data),

  update: (id: string, dto: UpdateUserDto) =>
    client.patch<User>(`/users/${id}`, dto).then(r => r.data),
};

Скучно, зато однажды у нас /users переехал на /v2/accounts, и правка заняла две минуты вместо получаса поиска по строкам в проекте.

Кэш и состояние запроса

Вот тут я категорически против рукописного. loading/error/data в каждом компоненте - это самодельный кэш, и получается он всегда хуже готового.

export function useUsers(params: Ref<UserListParams>) {
  return useQuery({
    queryKey: ['users', params],       // ключ реактивный - параметры сменились, запрос ушёл заново
    queryFn: () => userApi.list(toValue(params)),
    staleTime: 60_000,
    placeholderData: (prev) => prev,   // при смене страницы не мигаем пустотой
  });
}

Бесплатно приезжают: дедупликация одинаковых запросов, ретраи, обновление при возврате на вкладку, отмена устаревших ответов и общий кэш между компонентами. Всё это руками писать неделю, а поддерживать - вечно.

Мутации так же, с инвалидацией:

const { mutateAsync, isPending } = useMutation({
  mutationFn: (dto: UpdateUserDto) => userApi.update(id, dto),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});

Типы не пишем руками

Если у бэка есть OpenAPI - типы генерируются:

npx openapi-typescript http://api.local/openapi.json -o src/shared/api/schema.d.ts

Написанный руками interface User расходится с реальностью спринта через два, и узнаёшь ты об этом от пользователя. Мы ставим генерацию отдельным шагом в CI: сгенерированный файл отличается от закоммиченного - сборка красная, идём выяснять, кто поменял контракт.

Если схемы нет, с бэкендом обычно работает такой заход: генерация превращает контракт в исполняемый документ, любое несогласованное изменение поля становится ошибкой компиляции у нас, а не багом у пользователя. Против этого сложно возразить.

Что в итоге в компоненте

<script setup lang="ts">
const params = ref({ page: 1, search: '' });
const { data, isPending, error } = useUsers(params);
</script>

<template>
  <UiSpinner v-if="isPending" />
  <UiError v-else-if="error" :message="error.message" />
  <UserTable v-else :items="data.items" />
</template>

Ни одного try/catch, ни одного URL, ни одного any.

Смысл слоёв не в красоте, а в стоимости изменений. Меняем библиотеку запросов - трогаем первый уровень. Переехал эндпоинт - второй. Поменяли стратегию кэша - третий. Компоненты не трогаем вообще.

Ссылки