Appearance
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)Проблемы
- Drift от OpenAPI — бэк переименовал путь → фронт тихо сломался в рантайме, не в билде.
- Невозможен codegen — ручное размножение строк блокирует переход на SDD (генерация типов из
openapi.yaml). - Тестируемость — тесты дублируют те же литералы → нет защиты от опечаток, drift между prod-кодом и assertions.
- Разделение админки (ADR-010, ADR-011) — нужна чёткая граница "какие пути использует какой слой", захардкоженные строки её размывают.
- Поиск использования — нельзя найти "где ещё дёргается
/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}(...) запрещены вне этих файлов.
Принципы
- Один файл на сущность —
entities/<entity>/api/endpoints.ts. Может экспортировать несколько фабрик по scope (storeCategoryEndpoints,adminCategoryEndpoints). - Naming convention:
vendor*Endpointsдля/vendor/*(seller self-service)store*Endpointsдля/store/*(публичные)admin*Endpointsдля/admin/*(модерация, админка)authEndpoints— без префикса (единственный scope)
- Method signatures — каждый метод возвращает
as consttemplate literal:tsbyId: (userId: number) => `${ADMIN_USERS}/${userId}` as const - Resource-style naming:
list,create,byId(id),subresource(parentId). Множественное действие — глагол:approve(id),publish(id). - Цель-источник: 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. Три блока:
- Correctness — каждый метод возвращает ожидаемый путь:ts
it('byId substitutes user id', () => { expect(adminUserEndpoints.byId(42)).toBe('/admin/users/42') }) - Parameterization — одни и те же параметры дают одинаковый результат (stateless), разные — разный:ts
expect(vendorUserEndpoints.phoneById(1)).toBe('/vendor/me/phones/1') expect(vendorUserEndpoints.phoneById(999)).toBe('/vendor/me/phones/999') - 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 |
Связанные решения
- ADR-002: REST API Structure — бэковые пути, за которыми следим
- ADR-010: Admin Service Separation — почему важно физическое разделение admin/vendor/store
- ADR-011: Monorepo Evolution — будущее вынесение админки, endpoint-фабрики готовят срез
См. также
- Guide: Frontend endpoint factories — как писать фабрики, как добавить новый эндпоинт, примеры
app/entities/*/api/endpoints.ts— текущий набор фабрикscripts/check-no-hardcoded-endpoints.mjs— lint-скрипт