Skip to content

ADR-013: Frontend API Endpoint Factories

Дата: 2026-04-09 Статус: Принято Авторы: Frontend Team Связано: DEV-326 (backend OpenAPI 100%), DEV-346 (frontend sync), DEV-337 (tests + lint guard)


Контекст

Фронтенд (Nuxt 4) вызывает backend API напрямую через useApi() / useApiClient(). Пути эндпоинтов исторически задавались как строковые литералы прямо в местах использования — composables, features, pages, stores:

ts
// До: хардкод в 29+ местах
const response = await api.get<{ data: Category[] }>('/store/categories')
await api.put(`/admin/cars/makes/${id}`, data)

Проблемы

  1. Drift от OpenAPI — бэк переименовал путь → фронт тихо сломался в рантайме, не в билде.
  2. Невозможен codegen — ручное размножение строк блокирует переход на SDD (генерация типов из openapi.yaml).
  3. Тестируемость — тесты дублируют те же литералы → нет защиты от опечаток, drift между prod-кодом и assertions.
  4. Разделение админки (ADR-010, ADR-011) — нужна чёткая граница "какие пути использует какой слой", захардкоженные строки её размывают.
  5. Поиск использования — нельзя найти "где ещё дёргается /admin/users/{id}" через IDE "Find usages".

Рассмотренные альтернативы

ПодходЗаПротив
Хардкод как раньшеПростотаВсе проблемы выше, не масштабируется
Endpoint factories (принято)Типобезопасность, рефакторимость, единый источник правды, работает сегодня без codegenТребует дисциплины, lint-скрипт для контроля
Codegen из OpenAPIПолная автоматизацияБлокируется DEV-326, инфраструктура ещё не готова
Хелпер-функции поверх API-клиента (getCategories(), updateUser(id, data))Полный wrappingСлишком много boilerplate, теряется гибкость api.get<T>() с произвольными query

Решение

Все пути backend API, вызываемые из фронта, объявляются в endpoint-фабриках — файлах app/entities/<entity>/api/endpoints.ts по правилам FSD. Прямые строковые литералы вида '/auth/|'/store/|'/vendor/|'/admin/ в качестве первого аргумента api.{get,post,put,patch,delete,upload}(...) запрещены вне этих файлов.

Принципы

  1. Один файл на сущностьentities/<entity>/api/endpoints.ts. Может экспортировать несколько фабрик по scope (storeCategoryEndpoints, adminCategoryEndpoints).
  2. Naming convention:
    • vendor*Endpoints для /vendor/* (seller self-service)
    • store*Endpoints для /store/* (публичные)
    • admin*Endpoints для /admin/* (модерация, админка)
    • authEndpoints — без префикса (единственный scope)
  3. Method signatures — каждый метод возвращает as const template literal:
    ts
    byId: (userId: number) => `${ADMIN_USERS}/${userId}` as const
  4. Resource-style naming: list, create, byId(id), subresource(parentId). Множественное действие — глагол: approve(id), publish(id).
  5. Цель-источник: spec ../partizap-docs/specs/openapi.yaml. Backend переименовал путь → правится только файл фабрики → TypeScript сразу находит все точки использования.

Структура

app/entities/
├── admin/api/endpoints.ts        # adminStatsEndpoints
├── auth/api/endpoints.ts         # authEndpoints
├── car/api/endpoints.ts          # storeCarEndpoints + adminCarEndpoints
├── category/api/endpoints.ts     # storeCategoryEndpoints + adminCategoryEndpoints
├── chat/api/endpoints.ts         # vendorChatEndpoints (global search + presence)
├── conversation/api/endpoints.ts # vendorConversationEndpoints
├── geo/api/endpoints.ts          # storeGeoEndpoints + adminGeoEndpoints
├── product/api/endpoints.ts      # vendor* + store* + admin* ProductEndpoints
└── user/api/endpoints.ts         # vendor* + session* + favorite* + seller* + adminUserEndpoints

Пример фабрики

ts
// app/entities/user/api/endpoints.ts
const ADMIN_USERS = '/admin/users' as const

export const adminUserEndpoints = {
  list: () => ADMIN_USERS,
  byId: (userId: number) => `${ADMIN_USERS}/${userId}` as const,
} as const

Пример потребителя

ts
// app/features/admin-users/composables/useAdminUsers.ts
import { adminUserEndpoints } from '~/entities/user/api/endpoints'

const response = await api.get<ApiListResponse<User>>(adminUserEndpoints.list(), query)
await api.put(adminUserEndpoints.byId(id), { is_active: isActive })

Контроль: lint:endpoints

Zero-dependency Node-скрипт scripts/check-no-hardcoded-endpoints.mjs сканирует app/ на вызовы api.{get,post,put,patch,delete,upload}(...) с первым аргументом-строкой /auth/|/store/|/vendor/|/admin/. Endpoint-фабрики и их тесты исключены.

Что НЕ ловит (интенционально):

  • navigateTo('/auth/login'), router.push('/admin/products') — Vue-router, не API.
  • <NuxtLink to="/admin/users">, { to: '/admin/products' } в nav-конфигах — UI-навигация.
  • tests/e2e/** — E2E-тесты по определению бьют реальные пути.

Подключение:

json
// package.json
"scripts": {
  "lint:endpoints": "node scripts/check-no-hardcoded-endpoints.mjs",
  "final": "pnpm run typecheck && pnpm run test:run && pnpm run lint && pnpm run lint:endpoints"
}

Скрипт в pnpm run final → запускается перед каждым коммитом → нарушение блокирует merge.


Тестирование фабрик

Каждая фабрика имеет endpoints.test.ts рядом с endpoints.ts. Три блока:

  1. Correctness — каждый метод возвращает ожидаемый путь:
    ts
    it('byId substitutes user id', () => {
      expect(adminUserEndpoints.byId(42)).toBe('/admin/users/42')
    })
  2. Parameterization — одни и те же параметры дают одинаковый результат (stateless), разные — разный:
    ts
    expect(vendorUserEndpoints.phoneById(1)).toBe('/vendor/me/phones/1')
    expect(vendorUserEndpoints.phoneById(999)).toBe('/vendor/me/phones/999')
  3. Inventory — защита от случайного удаления метода при рефакторинге:
    ts
    it('inventory: exposes at least 10 endpoints', () => {
      expect(Object.keys(authEndpoints).length).toBeGreaterThanOrEqual(10)
    })

Покрытие обязательно для всех endpoint-файлов (DoD lint:endpoints + unit-тесты).


Последствия

Положительные

  • Единый источник правды для путей API — переименование на бэке ловится одним grep'ом + TypeScript.
  • Дисциплина через автоматизациюlint:endpoints блокирует регрессию без ревью-боли.
  • Готовность к codegen — когда SDD созреет, фабрики меняются на сгенерированные клиенты точечно, без touch-а consumer-кода.
  • Find Usages работает — IDE находит все места использования adminUserEndpoints.byId мгновенно.
  • Готовность к разделению админки (ADR-010, ADR-011) — admin*Endpoints физически отделены от vendor* / store*, при выносе админки в отдельный пакет это срез по фабрикам.

Отрицательные

  • Boilerplate — для каждого нового эндпоинта строка в фабрике + (если новый entity) новый файл.
  • Дисциплина — новички должны знать про lint:endpoints (описано в dev/frontend/endpoint-factories.md).
  • Не универсально — E2E-тесты фабрики не используют (по дизайну).

Митигация

ПроблемаРешение
Разработчик забыл про фабрикуlint:endpoints в pnpm run final → блок коммита
Drift между фабрикой и spec'ойUnit-тесты + ручная сверка при merge DEV-326-like задач
Дубли по scope (одна сущность в vendor/store/admin)Несколько экспортов из одного файла: storeCategoryEndpoints + adminCategoryEndpoints

Связанные решения

См. также