Ручная генерация
Если у API нет OpenAPI-спецификации — клиент пишется и поддерживается вручную. Цель та же, что и у автогенерации: единая точка работы с API, без прямых fetch в коде приложения.
Когда схема есть — Автоматическая генерация.
В примерах ниже используется условный API pet-project-api / petProjectApi. В реальном проекте имена выбираются по конкретному API.
Структура модуля
Клиент живёт в слое infrastructure/ отдельным модулем по имени API (kebab-case):
src/infrastructure/
└── pet-project-api/
├── methods/ # методы по сущностям API
│ ├── pages.ts
│ ├── posts.ts
│ └── forms.ts
├── hooks/ # SWR-хуки для клиентских компонентов
│ ├── use-post-detail.hook.ts
│ ├── use-post-filter.hook.ts
│ └── index.ts
├── types/ # типы клиента и доменные типы
│ ├── client.ts # типы клиента: RequestOptions, ParamValue
│ ├── post.ts # доменные типы сущности post
│ ├── form.ts # доменные типы сущности form
│ └── index.ts # реэкспорт публичных типов
├── errors/ # доменные ошибки API
│ └── pet-project-api.error.ts
├── client.ts # класс клиента: baseUrl, headers, get/post
└── index.ts # публичный API модуля| Файл | Роль |
|---|---|
client.ts | Класс PetProjectApiClient: baseUrl, общие заголовки, buildUrl, базовые get/post |
methods/{entity}.ts | Методы по сущности, экспортируются фабрикой {entity}Methods(client) |
hooks/use-{action}.hook.ts | SWR-хук поверх метода клиента |
hooks/index.ts | Реэкспорт хуков |
types/client.ts | Типы инфраструктуры клиента: RequestOptions, PostOptions, ParamValue |
types/{entity}.ts | Доменные типы: запросы, ответы, фильтры по сущности |
types/index.ts | Реэкспорт публичных типов |
errors/{service-name}.error.ts | Доменный класс ошибок API |
index.ts | Публичный API: инстанс клиента, хуки, доменные ошибки, типы |
methods/, hooks/, types/, errors/ — сегменты модуля по канону SLM. client.ts и index.ts — единственные корневые файлы.
Типы клиента
Типы, описывающие саму инфраструктуру запросов (опции, параметры) — выносятся в types/client.ts. Это держит client.ts коротким и не смешивает декларации типов с реализацией класса.
// src/infrastructure/pet-project-api/types/client.ts
export type ParamValue = string | number | (string | number)[]
export type RequestOptions = {
params?: Record<string, ParamValue>
headers?: Record<string, string>
revalidate?: number | false
}
export type PostOptions = RequestOptions & {
type?: 'json' | 'formdata'
}Базовый клиент
Класс с конфигурацией (baseUrl, общие заголовки) и базовыми методами get / post. Конкретные методы API размещаются в сегменте methods/, а не на самом классе — это держит client.ts коротким и не плодит «бога-класс».
// src/infrastructure/pet-project-api/client.ts
import { PetProjectApiError } from './errors/pet-project-api.error'
import type { ParamValue, RequestOptions, PostOptions } from './types/client'
export class PetProjectApiClient {
constructor(
private readonly baseUrl: string,
private readonly defaultHeaders: Record<string, string> = {},
) {
this.defaultHeaders = {
Accept: 'application/json',
...defaultHeaders,
}
}
buildUrl(path: string, params?: Record<string, ParamValue>): string {
const base = this.baseUrl.replace(/\/+$/, '')
const tail = path.replace(/^\/+/, '')
const url = `${base}/${tail}`
if (!params) {
return url
}
const search = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (Array.isArray(value)) {
value.forEach((v) => search.append(key, String(v)))
} else {
search.set(key, String(value))
}
}
return `${url}?${search}`
}
async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { params, headers, revalidate } = options
const response = await fetch(this.buildUrl(path, params), {
headers: { ...this.defaultHeaders, ...headers },
...(revalidate !== undefined && { next: { revalidate } }),
})
if (!response.ok) {
throw await PetProjectApiError.fromResponse(response)
}
return response.json() as Promise<T>
}
async post<T>(path: string, body: unknown, options: PostOptions = {}): Promise<T> {
const { params, headers, revalidate, type = 'json' } = options
const isJson = type === 'json'
const response = await fetch(this.buildUrl(path, params), {
method: 'POST',
headers: {
...this.defaultHeaders,
...(isJson && { 'Content-Type': 'application/json' }),
...headers,
},
body: isJson ? JSON.stringify(body) : (body as BodyInit),
...(revalidate !== undefined && { next: { revalidate } }),
})
if (!response.ok) {
throw await PetProjectApiError.fromResponse(response)
}
return response.json() as Promise<T>
}
}Ключевые требования к клиенту
- Класс с приватным состоянием (
baseUrl,defaultHeaders) — конфигурация инкапсулирована. - Типы клиента — в
types/client.ts, не вclient.ts. Реализация и контракты разделены. - Базовые методы дженерик
<T>без дефолта. Вызов без типа невозможен — потребитель обязан указать форму ответа. - Доменная ошибка вместо
null. При не-okбросаетсяPetProjectApiError. Возвратnullглотает причины (404 vs 500 vs 401) — не использовать. - Дефолт POST —
json.formdataуказывается явно, на конкретных методах (загрузка файлов, отправка форм). - Нормализация слэшей в
buildUrl—baseUrlбез хвостового/,pathбез ведущего/. async/await, не.then()— линейное чтение, простая обработка ошибок.- Поддержка
next.revalidate— клиент знает о Next.js App Router и пробрасывает кеш-флаги.
Доменная ошибка
Сетевая ошибка превращается в класс ошибки модуля. Наружу не выходит сырой Response.
// src/infrastructure/pet-project-api/errors/pet-project-api.error.ts
export class PetProjectApiError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
) {
super(`PetProjectApi ${status}: ${body.slice(0, 200)}`)
this.name = 'PetProjectApiError'
}
static async fromResponse(response: Response): Promise<PetProjectApiError> {
const body = await response.text().catch(() => '')
return new PetProjectApiError(response.status, body)
}
}Дополнительные подклассы по необходимости: PetProjectApiValidationError (400), PetProjectApiAuthError (401/403), PetProjectApiNotFoundError (404). Вводятся когда у потребителя есть разная реакция на разные коды; иначе хватает базового класса.
Доменные типы
Типы запросов, ответов и фильтров — по файлу на сущность. Типы должны лежать рядом по смыслу: всё, что относится к posts, — в types/post.ts.
// src/infrastructure/pet-project-api/types/post.ts
export type Post = {
id: string
slug: string
title: string
content: string
publishedAt: string
}
export type PostFilter = {
limit?: number
categories?: number[]
}// src/infrastructure/pet-project-api/types/index.ts
export type * from './post'
export type * from './form'
// типы клиента — внутренние, наружу не реэкспортируютсяТипы клиента (RequestOptions, PostOptions, ParamValue) не реэкспортируются через types/index.ts — они нужны только внутри модуля.
Методы
Методы группируются по сущностям в сегменте methods/, экспортируются фабрикой, принимающей клиент. Это даёт процедурное обращение в стиле автогенерированного клиента (petProjectApi.posts.get(slug)), а не плоский список (petProjectApi.getPost(slug)).
// src/infrastructure/pet-project-api/methods/posts.ts
import type { PetProjectApiClient } from '../client'
import type { Post, PostFilter } from '../types/post'
export function postsMethods(client: PetProjectApiClient) {
return {
/** GET /posts/{slug} */
get: (slug: string, options?: { revalidate?: number | false }) =>
client.get<Post>(`posts/${slug}`, options),
/** POST /posts/filter */
filter: (body: PostFilter) =>
client.post<Post[]>('posts/filter', body),
}
}// src/infrastructure/pet-project-api/methods/forms.ts
import type { PetProjectApiClient } from '../client'
import type { Form, FormSubmissionResult } from '../types/form'
export function formsMethods(client: PetProjectApiClient) {
return {
/** GET /forms/{id} */
get: (id: string) => client.get<Form>(`forms/${id}`),
/** POST /forms/{id} — multipart/form-data */
submit: (id: string, data: FormData) =>
client.post<FormSubmissionResult>(`forms/${id}`, data, { type: 'formdata' }),
}
}Правила методов
- Группировка по сущности (
pages,posts,forms), не плоский список. - Имя метода — глагол действия:
get,list,filter,create,update,delete,submit. НеgetPost/getPosts— сущность уже в имени группы. - Типы запросов и ответов — в
types/{entity}.ts, импортируются в файл методов. Вmethods/лежит только композиция вызовов клиента, без объявлений типов. - Фабрика принимает клиент — это даёт тестируемость (моковый клиент в юнит-тестах) и единый источник конфигурации.
- Никаких знаний об UI. Клиент не знает про React, SWR, тосты — только данные и ошибки.
Сборка инстанса
Группы методов соединяются в один объект на уровне index.ts. Это даёт процедурный доступ petProjectApi.posts.get(...).
// src/infrastructure/pet-project-api/index.ts
import { PetProjectApiClient } from './client'
import { pagesMethods } from './methods/pages'
import { postsMethods } from './methods/posts'
import { formsMethods } from './methods/forms'
const client = new PetProjectApiClient(process.env.NEXT_PUBLIC_API_URL, {
'X-App-Key': process.env.NEXT_PUBLIC_APP_KEY,
})
export const petProjectApi = {
pages: pagesMethods(client),
posts: postsMethods(client),
forms: formsMethods(client),
}
export { PetProjectApiError } from './errors/pet-project-api.error'
export type { Post, PostFilter, Page, Form } from './types'
export * from './hooks'Хуки для клиентских компонентов
В клиентских компонентах вызовы клиента не делаются напрямую — компонент получает готовый хук, который инкапсулирует SWR + метод клиента. Хуки живут в сегменте hooks/, по файлу на операцию.
// src/infrastructure/pet-project-api/hooks/use-post-detail.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petProjectApi } from '..'
import type { Post } from '../types/post'
/**
* Получение поста по slug.
*/
export const usePostDetail = (
slug: string | null,
config?: SWRConfiguration,
) => {
const key = slug ? ['pet-project-api', 'post', 'detail', slug] : null
const fetcher = () => petProjectApi.posts.get(slug!)
return useSWR<Post>(key, fetcher, config)
}// src/infrastructure/pet-project-api/hooks/use-post-filter.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petProjectApi } from '..'
import type { Post, PostFilter } from '../types/post'
/**
* Получение списка постов по фильтру.
*/
export const usePostFilter = (
filter: PostFilter,
config?: SWRConfiguration,
) => {
return useSWR<Post[]>(
['pet-project-api', 'post', 'filter', filter],
() => petProjectApi.posts.filter(filter),
config,
)
}// src/infrastructure/pet-project-api/hooks/index.ts
export { usePostDetail } from './use-post-detail.hook'
export { usePostFilter } from './use-post-filter.hook'Правила хуков
- Один файл — один хук, имя файла
use-{action}.hook.ts(Именование). - Тонкая обёртка над SWR. Внутри — построение ключа, fetcher через метод клиента, возврат
useSWR(...). Никакой бизнес-логики. - Ключ начинается с имени сервиса (
['pet-project-api', ...]) — изолирует кеш между разными API. - Условный ключ для опциональных параметров:
id ? [...key, id] : null.nullприостанавливает запрос, пока параметры не готовы. - Параметр
config?: SWRConfiguration— даёт потребителю переопределить ревалидацию,fallbackData,suspenseи т.п. без обёрток.
Запрет прямого fetch
В коде приложения (слои выше infrastructure) прямые вызовы fetch к API запрещены. Все запросы идут через клиент.
Исключение допускается точечно — например, разовая отладочная проверка эндпоинта в скрипте — и требует обоснования в коде (комментарий с причиной).
Использование
import { petProjectApi } from 'infrastructure/pet-project-api'
const post = await petProjectApi.posts.get('my-post')
const list = await petProjectApi.posts.filter({ limit: 10, categories: [1, 2] })
const form = await petProjectApi.forms.get('contact')Стиль вызовов совпадает с автогенерированным клиентом — потребитель не различает, ручной API или сгенерирован.