Appearance
Frontend: API Endpoint Factories
Последнее обновление: 2026-04-09 (DEV-337) ADR: ADR-013
Практическое руководство: как добавлять/менять пути backend API во фронте, как тестировать, как проходить lint:endpoints.
TL;DR
- Любой API-путь живёт в
app/entities/<entity>/api/endpoints.ts. - Потребитель (composable/store/page) импортирует фабрику и вызывает метод:
api.get(adminUserEndpoints.byId(id)). - Хардкод
'/auth/|/store/|/vendor/|/admin/'в первом аргументеapi.*(...)запрещён — ловитсяpnpm run lint:endpointsвpnpm run final. - Тесты фабрик — обязательны (
endpoints.test.tsрядом сendpoints.ts).
Структура фабрики
ts
// app/entities/user/api/endpoints.ts
/**
* URL factories for user-related endpoints (vendor self, seller view, admin users).
* Backed by openapi.yaml — /vendor/me/*, /vendor/sessions*, /admin/users*.
*/
const VENDOR_ME = '/vendor/me' as const
export const vendorUserEndpoints = {
profile: () => VENDOR_ME,
avatar: () => `${VENDOR_ME}/avatar` as const,
phones: () => `${VENDOR_ME}/phones` as const,
phoneById: (phoneId: number) => `${VENDOR_ME}/phones/${phoneId}` as const,
} as const
const ADMIN_USERS = '/admin/users' as const
export const adminUserEndpoints = {
list: () => ADMIN_USERS,
byId: (userId: number) => `${ADMIN_USERS}/${userId}` as const,
} as constПравила оформления
- Префиксная константа
const ADMIN_USERS = '/admin/users' as const— дедуплицирует базовый путь, всё остальное строится template-literal'ом. as constна всех возвратах — template literal сохраняется как литеральный тип, TypeScript видит точное значение.as constна всём объекте — методы и экспортированный объект immutable.- Noun-based keys:
list,byId(id),phones(),phoneById(id),subresource(parentId). - Verb-based only для действий:
approve(id),reject(id),publish(id),logoutAll(). - Несколько scope'ов в одном файле — если сущность живёт в
/store/*и/admin/*, экспортируй две константы (storeCategoryEndpoints+adminCategoryEndpoints) из одногоendpoints.ts.
Naming convention
| Префикс | Pattern | Пример |
|---|---|---|
vendor*Endpoints | /vendor/* | vendorProductEndpoints, vendorUserEndpoints |
store*Endpoints | /store/* | storeCategoryEndpoints, storeCarEndpoints |
admin*Endpoints | /admin/* | adminStatsEndpoints, adminGeoEndpoints |
authEndpoints | /auth/* | (один scope — без префикса) |
Одна сущность может экспортировать несколько — например entities/category/api/endpoints.ts даёт и storeCategoryEndpoints (/store/categories/*), и adminCategoryEndpoints (/admin/categories/*).
Добавляем новый эндпоинт
Сценарий: бэк добавил POST /vendor/products/{id}/archive
Обновляем фабрику:
ts// app/entities/product/api/endpoints.ts export const vendorProductEndpoints = { // ... archive: (productId: number) => `${VENDOR_PRODUCTS}/${productId}/archive` as const, } as constДобавляем unit-тест в
endpoints.test.ts:tsit('archive returns /vendor/products/{id}/archive', () => { expect(vendorProductEndpoints.archive(42)).toBe('/vendor/products/42/archive') })И проверяем
inventory— если новый метод увеличил число, подними счётчик:tsit('inventory: at least 8 endpoints', () => { expect(Object.keys(vendorProductEndpoints).length).toBeGreaterThanOrEqual(8) })Используем в feature/composable:
tsimport { vendorProductEndpoints } from '~/entities/product/api/endpoints' await api.post(vendorProductEndpoints.archive(productId))pnpm run final— должно пройти: typecheck, tests, lint, lint:endpoints.
Сценарий: бэк добавил новую сущность (/vendor/notifications/*)
mkdir -p app/entities/notification/api- Создаём
endpoints.ts+endpoints.test.tsпо шаблону выше. - Если нужны Zod-схемы под response —
entities/notification/model/notification.schema.ts. - Импортируем в consumer.
lint:endpoints
Zero-dep Node-скрипт scripts/check-no-hardcoded-endpoints.mjs. Запуск:
bash
pnpm run lint:endpoints # отдельно
pnpm run final # в составе pipelineЧто ловит
Регулярка:
\.(?:get|post|put|patch|delete|upload)\s*(?:<[^(]*>)?\s*\(\s*(['"`])\/(auth|store|vendor|admin)\/То есть: api.get('/store/...'), api.post<T>('/vendor/...', body), api.upload(/admin/...${id}) — включая multi-line вызовы с type-args.
Что НЕ ловит (интенционально)
navigateTo('/auth/login'),router.push('/admin/products')— Vue-router.<NuxtLink to="/admin/users">,{ to: '/admin/products' }в layout-конфигах.tests/e2e/**— E2E по определению бьют реальные пути, фабрики там опциональны.- Строки в тестах
mockGet.toHaveBeenCalledWith('/vendor/me/phones')— тесты не вызывают реальный api-клиент. (Рекомендация: в новых тестах импортируй фабрику — защита от drift.)
Когда скрипт ругается
Вывод:
✗ check-no-hardcoded-endpoints: hardcoded API paths detected
Replace these with calls into app/entities/<entity>/api/endpoints.ts
app/features/foo/composables/useFoo.ts:42 '/vendor/foo/bar'
1 violation(s) in 1 file(s)Алгоритм:
- Открой указанную строку.
- Найди соответствующую фабрику (
Cmd+P→endpoints.ts→ грепни сущность). - Если метода нет — добавь (см. "Добавляем новый эндпоинт").
- Замени строку на вызов фабрики.
- Перезапусти
pnpm run lint:endpoints.
Тестирование
Каждая фабрика = один endpoints.test.ts рядом. Три блока:
ts
import { describe, it, expect } from 'vitest'
import { adminUserEndpoints } from './endpoints'
describe('adminUserEndpoints', () => {
// 1. Correctness
it('list returns /admin/users', () => {
expect(adminUserEndpoints.list()).toBe('/admin/users')
})
it('byId substitutes user id', () => {
expect(adminUserEndpoints.byId(42)).toBe('/admin/users/42')
})
// 2. Parameterization — разные id → разные пути (stateless)
it('byId handles different ids independently', () => {
expect(adminUserEndpoints.byId(1)).toBe('/admin/users/1')
expect(adminUserEndpoints.byId(999)).toBe('/admin/users/999')
})
// 3. Inventory — защита от случайного удаления при рефакторинге
it('inventory: exposes at least 2 endpoints', () => {
expect(Object.keys(adminUserEndpoints).length).toBeGreaterThanOrEqual(2)
})
})inventory-чек — "якорь": если кто-то случайно удалит метод, тест упадёт. Увеличивай пороги при добавлении новых методов.
Типичные грабли
| Грабли | Решение |
|---|---|
Забыл as const на возврате — тип стал string, теряется литеральность | Всегда ... as const в шаблонах |
Разбросал хардкод по api.upload — lint:endpoints ловит только get/post/put/patch/delete? | upload тоже в списке методов скрипта, ловит |
Фабрика не видна в тесте *.test.ts — mockGet.toHaveBeenCalledWith принимает строку, а не функцию | В тесте импортируй фабрику и вызови её: .toHaveBeenCalledWith(vendorUserEndpoints.phones()) |
router.push('/admin/users') падает в lint:endpoints? | Не должен — скрипт ловит только вызовы api.*. Если ругается — сообщи, возможно ложный позитив |
| Новый entity, но ESLint FSD-правила ругаются на импорт | Проверь FSD boundaries: features → entities, pages → entities. Фабрика на слое entities — импорты должны проходить |
FAQ
Q: Почему не сгенерировать клиент из openapi.yaml? A: Это конечная цель (SDD), но до 100% покрытия спеки (DEV-326) codegen невозможен. Endpoint factories — мост: когда SDD созреет, фабрики заменяются на сгенерированный код точечно, consumer-код не трогаем.
Q: Можно ли ставить в entities/admin/ что-то кроме stats? A: Admin — не классический FSD entity, а "служебный слой" для stats и прочего, что не принадлежит конкретной доменной сущности. Пользователи admin'а — это entities/user (adminUserEndpoints), товары — entities/product (adminProductEndpoints) и т.д. entities/admin только для кросс-сущных admin-концептов (stats, dashboard).
Q: Нужно ли обновлять тесты-потребители (useAdminUsers.test.ts), чтобы assertions использовали фабрику? A: Необязательно для зелёного билда (строки совпадают с тем, что возвращает фабрика), но желательно для защиты от drift. Делай, если трогаешь файл для других целей.
Q: E2E под tests/e2e/** нужно переводить на фабрики? A: Нет — E2E живёт отдельно, бьёт реальные пути по дизайну. Скрипт lint:endpoints их не сканирует.
См. также
- ADR-013: Frontend API Endpoint Factories — архитектурное решение и контекст
- ADR-002: REST API Structure — бэковая структура, за которой следим
../contracts/api-reference.md— текущий список эндпоинтов