Skip to content

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

  1. Обновляем фабрику:

    ts
    // app/entities/product/api/endpoints.ts
    export const vendorProductEndpoints = {
      // ...
      archive: (productId: number) => `${VENDOR_PRODUCTS}/${productId}/archive` as const,
    } as const
  2. Добавляем unit-тест в endpoints.test.ts:

    ts
    it('archive returns /vendor/products/{id}/archive', () => {
      expect(vendorProductEndpoints.archive(42)).toBe('/vendor/products/42/archive')
    })

    И проверяем inventory — если новый метод увеличил число, подними счётчик:

    ts
    it('inventory: at least 8 endpoints', () => {
      expect(Object.keys(vendorProductEndpoints).length).toBeGreaterThanOrEqual(8)
    })
  3. Используем в feature/composable:

    ts
    import { vendorProductEndpoints } from '~/entities/product/api/endpoints'
    
    await api.post(vendorProductEndpoints.archive(productId))
  4. pnpm run final — должно пройти: typecheck, tests, lint, lint:endpoints.

Сценарий: бэк добавил новую сущность (/vendor/notifications/*)

  1. mkdir -p app/entities/notification/api
  2. Создаём endpoints.ts + endpoints.test.ts по шаблону выше.
  3. Если нужны Zod-схемы под response — entities/notification/model/notification.schema.ts.
  4. Импортируем в 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)

Алгоритм:

  1. Открой указанную строку.
  2. Найди соответствующую фабрику (Cmd+Pendpoints.ts → грепни сущность).
  3. Если метода нет — добавь (см. "Добавляем новый эндпоинт").
  4. Замени строку на вызов фабрики.
  5. Перезапусти 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.tsmockGet.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 их не сканирует.

См. также