Skip to content

Ручная генерация

Если у API нет OpenAPI-спецификации — клиент пишется и поддерживается вручную. Цель та же, что и у автогенерации: единая точка работы с API, без прямых fetch в коде приложения.

Когда схема есть — Автоматическая генерация.

В примерах ниже используется условный API pet-project-api / petProjectApi. В реальном проекте имена выбираются по конкретному API.

Структура модуля

Клиент живёт в слое infrastructure/ отдельным модулем по имени API (kebab-case):

text
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.tsSWR-хук поверх метода клиента
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 коротким и не смешивает декларации типов с реализацией класса.

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 коротким и не плодит «бога-класс».

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 указывается явно, на конкретных методах (загрузка файлов, отправка форм).
  • Нормализация слэшей в buildUrlbaseUrl без хвостового /, path без ведущего /.
  • async/await, не .then() — линейное чтение, простая обработка ошибок.
  • Поддержка next.revalidate — клиент знает о Next.js App Router и пробрасывает кеш-флаги.

Доменная ошибка

Сетевая ошибка превращается в класс ошибки модуля. Наружу не выходит сырой Response.

ts
// 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.

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[]
}
ts
// 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)).

ts
// 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),
  }
}
ts
// 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(...).

ts
// 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/, по файлу на операцию.

ts
// 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)
}
ts
// 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,
  )
}
ts
// 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 запрещены. Все запросы идут через клиент.

Исключение допускается точечно — например, разовая отладочная проверка эндпоинта в скрипте — и требует обоснования в коде (комментарий с причиной).

Использование

ts
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 или сгенерирован.