Автоматическая генерация
Если у API есть OpenAPI-спецификация — клиент генерируется утилитой @gromlab/api-codegen (обёртка над swagger-typescript-api). Ручной код для таких API не пишется.
Когда схемы нет — Ручная генерация.
В примерах ниже используется условный API pet-project-api (kebab-case в путях) / petProjectApi (camelCase в коде). В реальном проекте имена выбираются по конкретному API.
Установка
npm install -D @gromlab/api-codegenСкрипт генерации в package.json — по одному на каждый API:
{
"scripts": {
"codegen:pet-project-api": "api-codegen --config src/infrastructure/pet-project-api/config/pet-project-api.config.ts"
}
}Конфиг и опции — в репозитории @gromlab/api-codegen.
Структура модуля
Клиент кладётся в слой infrastructure/ отдельным модулем по имени API (kebab-case):
src/infrastructure/
└── pet-project-api/
├── generated/ # сегмент сгенерированного кода
│ └── pet-project-api.generated.ts # сгенерировано — не править
├── types/ # расширения сгенерированных типов
│ ├── user.ts # declare module + Extended-тип
│ └── index.ts # реэкспорт расширений
├── hooks/ # SWR-хуки для клиентских компонентов
│ ├── use-user-list.hook.ts
│ ├── use-user-detail.hook.ts
│ └── index.ts # реэкспорт хуков
├── config/ # конфиги модуля
│ └── pet-project-api.config.ts # конфиг генерации клиента
├── client.ts # настройка HttpClient, инстанс Api
└── index.ts # публичный API модуля| Файл | Роль | Кто правит |
|---|---|---|
generated/{service-name}.generated.ts | Сгенерированный код: типы, class Api, class HttpClient | codegen, не править |
types/{сущность}.ts | declare module + Extended-типы по сущности | разработчик |
types/index.ts | Реэкспорт публичных расширений | разработчик |
hooks/use-{action}.hook.ts | SWR-хук поверх метода клиента | разработчик |
hooks/index.ts | Реэкспорт хуков | разработчик |
config/{service-name}.config.ts | Параметры генерации для конкретного API | разработчик |
client.ts | baseUrl из env, конфиг HttpClient, инстанс new Api(...) | разработчик |
index.ts | Публичный API: инстанс сервиса, расширенные типы, хуки | разработчик |
client.ts и index.ts — единственные корневые файлы модуля. Все остальные файлы живут в сегментах (generated/, types/, hooks/, config/).
Имя сгенерированного файла — {service-name}.generated.ts (имя сервиса в kebab-case + суффикс .generated.ts). Суффикс сигнализирует «не править руками».
client.ts
Тонкий ручной слой поверх сгенерированного кода. Делает три вещи: читает и нормализует baseUrl, конфигурирует HttpClient, создаёт именованный инстанс сервиса.
// src/infrastructure/pet-project-api/client.ts
import { Api, HttpClient } from './generated/pet-project-api.generated'
const resolvedBaseUrl = process.env.NEXT_PUBLIC_API_URL
.replace(/\/+$/, '') // убираем хвостовой слэш
.replace(/\/v1$/, '') // версия уже в путях методов — режем дубль
const httpClient = new HttpClient({
baseApiParams: {
secure: false,
headers: {
'Content-Type': 'application/json',
// кастомные заголовки API — если требуются
// 'X-App-Key': '...',
},
},
})
httpClient.baseUrl = resolvedBaseUrl
export const petProjectApi = new Api(httpClient)Имя инстанса = имя сервиса
Инстанс называется по имени API в camelCase, не унифицированно api/client. Это даёт процедурное обращение и однозначность при работе с несколькими сервисами:
import { petProjectApi } from 'infrastructure/pet-project-api'
const user = await petProjectApi.user.getUser(id)При нескольких API — каждый со своим именем:
import { petProjectApi } from 'infrastructure/pet-project-api'
import { paymentsApi } from 'infrastructure/payments-api'
const user = await petProjectApi.user.list()
const invoice = await paymentsApi.invoices.list()Нормализация baseUrl
@gromlab/api-codegen может включать версию (/v1) в baseUrl сгенерированного кода, а пути методов уже содержат её — отсюда дубль. Стандартный приём:
.replace(/\/+$/, '') // хвостовой слэш
.replace(/\/v1$/, '') // версия (если фигурирует в путях)Подгоняется под конкретный API: если версия в путях не повторяется — второй replace не нужен.
Расширения типов
Автогенерация не покрывает все реальные поля API: иногда тип object, иногда поле просто отсутствует. Расширения живут в types/, по файлу на сущность.
Две техники:
declare module — добавление полей
Дополняет существующий интерфейс из generated.ts. Сама сгенерированная декларация не трогается.
// src/infrastructure/pet-project-api/types/user.ts
import type { User } from '../generated/pet-project-api.generated'
declare module '../generated/pet-project-api.generated' {
interface User {
avatar?: {
file?: string
title?: string
url?: string
}
}
}Extended через Omit & {...} — переопределение полей
Когда автогенерация даёт object или общий тип, а реально структура известна — создаётся отдельный тип UserExtended (по имени сущности + суффикс Extended).
// src/infrastructure/pet-project-api/types/user.ts
export type UserExtended = Omit<User, 'roles' | 'tags' | 'fields'> & {
roles?: Array<{ _id?: string; id?: string; slug?: string; name?: string }>
tags?: Array<{ _id?: string; id?: string; slug?: string; name?: string }>
fields?: Record<string, unknown>
}Реэкспорт
// src/infrastructure/pet-project-api/types/index.ts
export type { UserExtended } from './user'Правила
- Расширения — только в
types/, не вclient.tsи не в сгенерированном файле. - Один файл на сущность (имя файла — kebab-case по сущности:
user.ts,order.ts,invoice.ts). - При регенерации
generated/{service-name}.generated.tsфайлы вtypes/не затрагиваются. - Если сломался
Extended-тип после regen — синхронизировать руками.
Хуки для клиентских компонентов
В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте hooks/, по файлу на операцию.
// src/infrastructure/pet-project-api/hooks/use-user-list.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petProjectApi } from '../client'
import type { User } from '../generated/pet-project-api.generated'
/**
* Получение списка пользователей.
*/
export const useUserList = (
query?: { limit?: number; offset?: number },
config?: SWRConfiguration,
) => {
return useSWR<User[]>(
['pet-project-api', 'user', 'list', query],
() => petProjectApi.user.list(query ?? {}),
config,
)
}// src/infrastructure/pet-project-api/hooks/use-user-detail.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petProjectApi } from '../client'
import type { UserExtended } from '../types'
/**
* Получение пользователя по идентификатору.
*/
export const useUserDetail = (
id: string | null,
config?: SWRConfiguration,
) => {
const key = id ? ['pet-project-api', 'user', 'detail', id] : null
const fetcher = () => petProjectApi.user.getUser(id!) as Promise<UserExtended>
return useSWR<UserExtended>(key, fetcher, config)
}// src/infrastructure/pet-project-api/hooks/index.ts
export { useUserList } from './use-user-list.hook'
export { useUserDetail } from './use-user-detail.hook'Правила хуков
- Один файл — один хук, имя файла
use-{action}.hook.ts(Именование). - Тонкая обёртка над SWR. Внутри — построение ключа, fetcher через метод клиента, возврат
useSWR(...). Никакой бизнес-логики. - Ключ начинается с имени сервиса (
['pet-project-api', ...]) — изолирует кеш между разными API. - Условный ключ для опциональных параметров:
id ? [...key, id] : null.nullприостанавливает запрос, пока параметры не готовы. - Параметр
config?: SWRConfiguration— даёт потребителю переопределить ревалидацию,fallbackData,suspenseи т.п. без обёрток.
Публичный API модуля
Из index.ts экспортируются инстанс, расширенные типы и хуки. Сырые типы из generated/ экспортируются по необходимости — точечно.
// src/infrastructure/pet-project-api/index.ts
export { petProjectApi } from './client'
export type { UserExtended } from './types'
export * from './hooks'Регенерация
При изменении OpenAPI-схемы:
npm run codegen:pet-project-apiЧто меняется:
generated/{service-name}.generated.ts— перезаписывается полностью, изменения коммитятся.client.ts,types/,config/,index.ts— не трогаются автоматически.
Поломка контракта (изменение типов в схеме) ловится TypeScript при сборке проекта. Если ломаются Extended-типы — синхронизировать вручную в соответствующих файлах types/.
Сгенерированный файл коммитится
Файл generated/{service-name}.generated.ts не добавляется в .gitignore — попадает в репозиторий вместе с остальным кодом.
Причины:
- Детерминированная сборка.
npm run buildне зависит от доступности OpenAPI-схемы (обычно она на удалённом сервере). Сервис лёг — прод собирается. - Видимость изменений в PR. Diff показывает, что именно поменялось в контракте API между версиями.
- Простой онбординг. После
git cloneIDE сразу видит типы, без предварительной генерации. - Фиксация версии контракта. Пересборка старого коммита даёт ровно тот клиент, что был тогда.
Регенерация — ручная команда при обновлении схемы, не хук predev/prebuild. Запускается осознанно.
Исключение возможно, только если OpenAPI-схема лежит в этом же репозитории и генерация быстрая, без сети — тогда допустимо добавить сегмент generated/ в .gitignore и хук prebuild, по аналогии со спрайтами. На практике встречается редко.