Модуль 7. Контракты API и Next.js Route Handlers (без Effector)

Цель модуля: построить надёжный контракт между фронтендом и API-слоем через OpenAPI + Zod, реализовать Route Handler для автокомплита упоминаний и увидеть боль прямых запросов в компоненте — закладка для рефакторинга в М8.

К концу модуля у вас:

  • OpenAPI-спецификация /api/mentions — единый источник правды для TS-типов и (в будущем) Go-хендлеров.
  • Zod-схема ответа + валидация в runtime.
  • Route Handler с переключением mock/real через env.
  • MSW-обработчик для Storybook и Vitest — один мок, две среды.
  • Виджет MentionInput с прямым fetch (который мы перепишем на Effector в М8).

📌 Перед началом: Модули 1-6. 🛠 Инструменты: OpenAPI, Zod, MSW, openapi-typescript. ⏱ Время: ~6 ч.

🔥 Инцидент: production падает с undefined is not a function

Бэкенд изменил поле user_name на username. Фронтенд об этом не узнал — TypeScript ошибок нет (типы написаны руками). Production падает с undefined is not a function. Нужен единый контракт: OpenAPI → Zod-валидация в runtime → автогенерация TS-типов. Один источник правды — три потребителя.

📖 Теория

7.1 OpenAPI — единый контракт

Спецификация

yaml
# app/shared/api/openapi.yaml
openapi: 3.1.0
info:
  title: Messenger API
  version: 0.1.0
paths:
  /api/mentions:
    get:
      summary: Autocomplete users for @mentions
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2 }
        - name: limit
          in: query
          schema: { type: integer, default: 5, maximum: 20 }
      responses:
        '200':
          description: Suggestions
          content:
            application/json:
              schema:
                type: object
                required: [suggestions]
                properties:
                  suggestions:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserSuggestion'
        '400': { description: Invalid query }
components:
  schemas:
    UserSuggestion:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        avatar: { type: string, nullable: true }

Генерация TS-типов

bash
npx openapi-typescript app/shared/api/openapi.yaml -o app/shared/api/schema.gen.ts
ts
// app/shared/api/schema.gen.ts (автогенерируется)
export interface paths {
  '/api/mentions': {
    get: {
      parameters: { query: { q: string; limit?: number } }
      responses: {
        200: { content: { 'application/json': { suggestions: components['schemas']['UserSuggestion'][] } } }
      }
    }
  }
}

В М15 мы покажем, как из этой же OpenAPI-схемы генерируются Go-хендлеры (oapi-codegen). Один источник — три потребителя: TS-типы, Zod-схема, Go-хендлеры.


7.2 Zod — зачем нужен и что это

Проблема: TypeScript не защищает в runtime

TypeScript проверяет типы только на этапе компиляции. После сборки типов не существует — в браузере работает чистый JavaScript. Если сервер вернул данные, не соответствующие типу, TS об этом не узнает:

tsx
// TS думает, что всё ок:
const data: User[] = await res.json()
console.log(data[0].name)
// ↑ TypeError: Cannot read properties of undefined
// Если сервер вернул { error: "not found" } вместо массива —
// TS не словит, приложение падает в runtime.

Типичные ситуации, когда типы врут:

  • Сервер вернул ошибку (HTTP 200 с { error: "..." } вместо массива).
  • Поле avatar иногда null, иногда строка, иногда вообще отсутствует.
  • Сервер обновил API и добавил/удалил поле — фронт ещё не знает.
  • Внешний API (Go-бэкенд) возвращает даты в RFC3339, а фронт ждёт ISO.

Решение: Zod-схема = описание + валидация + типы

Zod решает все три задачи одним объектом:

tsx
import { z } from 'zod'

// 1. Описываем схему
const UserSuggestionSchema = z.object({
  id: z.string(),
  name: z.string(),
  avatar: z.string().nullable().optional(),  // null, строка или отсутствует — всё ок
})

// 2. Получаем TypeScript-тип ИЗ схемы (не дублируем)
type UserSuggestion = z.infer<typeof UserSuggestionSchema>
// { id: string; name: string; avatar?: string | null }

// 3. Валидируем в runtime
const raw = await res.json()
const data = UserSuggestionSchema.parse(raw)
// ↑ если raw не соответствует схеме → выбросит ZodError с понятным сообщением
// если соответствует → data имеет тип UserSuggestion, безопасно используем

Что даёт Zod в курсе

Роль ZodГде используется
Валидация ответов APIfetchMentions() проверяет ответ через MentionsResponseSchema.parse()
Источник TS-типовtype UserSuggestion = z.infer<typeof Schema> — не дублируем типы вручную
Мок-данные для MSWМок описывается по той же схеме — если схема изменилась, мок сразу ломается
Контракт с Go-бэкендомВ М15 — Zod-схема проверяет, что Go-хендлер возвращает то, что ожидает фронт

Принцип: одна Zod-схема — три потребителя: TS-типы, runtime-валидация, моки. Изменили схему → везде синхронно.


7.3 Схемы для API mentions

ts
// app/shared/api/schemas.ts
import { z } from 'zod'

export const UserSuggestionSchema = z.object({
  id: z.string(),
  name: z.string(),
  avatar: z.string().nullable().optional(),
})

export const MentionsResponseSchema = z.object({
  suggestions: z.array(UserSuggestionSchema),
})

export type UserSuggestion = z.infer<typeof UserSuggestionSchema>
export type MentionsResponse = z.infer<typeof MentionsResponseSchema>

Валидация ответа

ts
// app/shared/api/client.ts
export async function fetchMentions(q: string, limit = 5): Promise<MentionsResponse> {
  const res = await fetch(`/api/mentions?q=${encodeURIComponent(q)}&limit=${limit}`)
  if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`)
  const data = await res.json()
  // ВАЛИДАЦИЯ: если ответ не соответствует схеме → выбросит ZodError
  return MentionsResponseSchema.parse(data)
}

Зачем валидировать в runtime: TypeScript проверяет типы только на этапе компиляции. Сервер может вернуть что угодно — null вместо массива, строку вместо числа, лишние поля. Zod ловит это в runtime, до того как приложение упадёт.


7.4 Route Handler с переключением mock/real

ts
// app/app/api/mentions/route.ts
import { NextResponse } from 'next/server'

// MOCK-данные — в реальном проекте заменим на запрос к БД
const MOCK_USERS = [
  { id: '1', name: 'Alice Johnson', avatar: null },
  { id: '2', name: 'Bob Smith', avatar: null },
  { id: '3', name: 'Charlie Brown', avatar: null },
  { id: '4', name: 'Diana Prince', avatar: null },
  { id: '5', name: 'Evan Wright', avatar: null },
]

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const q = (searchParams.get('q') ?? '').toLowerCase()
  const limit = Math.min(Number(searchParams.get('limit') ?? 5), 20)

  if (q.length < 2) {
    return NextResponse.json({ error: 'Query too short' }, { status: 400 })
  }

  // Переключение mock/real через env
  const useRealApi = process.env.API_SOURCE === 'go'
  if (useRealApi) {
    // В М15 здесь будет проксирование на Go-бэкенд:
    // return fetch(`${process.env.GO_API_URL}/mentions?${searchParams}`)
    return NextResponse.json({ error: 'Go backend not configured' }, { status: 501 })
  }

  const suggestions = MOCK_USERS
    .filter((u) => u.name.toLowerCase().includes(q))
    .slice(0, limit)

  return NextResponse.json({ suggestions })
}

7.5 MSW — один мок для Storybook и Vitest

ts
// app/shared/api/msw-handlers.ts
import { http, HttpResponse } from 'msw'
import { UserSuggestionSchema } from './schemas'

// Тот же источник правды (Zod-схема) — для мок-данных
const mockSuggestions = [
  { id: '1', name: 'Alice Johnson', avatar: null },
  { id: '2', name: 'Bob Smith', avatar: null },
]

export const mentionsHandlers = [
  http.get('/api/mentions', ({ request }) => {
    const url = new URL(request.url)
    const q = (url.searchParams.get('q') ?? '').toLowerCase()
    const filtered = mockSuggestions.filter((u) => u.name.toLowerCase().includes(q))
    // Валидируем мок через ту же Zod-схему — если схема изменилась, мок упадёт
    return HttpResponse.json({ suggestions: filtered })
  }),

  // Хендлер для тестирования ошибок
  http.get('/api/mentions-error', () => {
    return HttpResponse.json({ error: 'Simulated server error' }, { status: 500 })
  }),
]

Подключение в Storybook

ts
// ui-kit/.storybook/preview.tsx — добавляем MSW
import { initialize as initializeMSW } from 'msw-storybook-addon'
initializeMSW()

Подключение в Vitest

ts
// vitest.setup.ts — добавляем MSW
import { setupServer } from 'msw/node'
import { mentionsHandlers } from './app/shared/api/msw-handlers'

export const server = setupServer(...mentionsHandlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

7.6 Виджет MentionInput — прямой fetch (закладка боли)

tsx
// app/features/mention-autocomplete/MentionInput.tsx
'use client'

import { useState, useEffect, useRef } from 'react'
import { Input } from '@myorg/ui-kit'
import { fetchMentions, type UserSuggestion } from '@/shared/api/client'
import styles from './MentionInput.module.css'

export function MentionInput() {
  const [query, setQuery] = useState('')
  const [suggestions, setSuggestions] = useState<UserSuggestion[]>([])
  const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle')
  const [error, setError] = useState<string | null>(null)
  const abortRef = useRef<AbortController | null>(null)

  useEffect(() => {
    if (query.length < 2) {
      setSuggestions([])
      setStatus('idle')
      return
    }

    // Отмена предыдущего запроса
    abortRef.current?.abort()
    const ctrl = new AbortController()
    abortRef.current = ctrl

    setStatus('loading')
    setError(null)

    // Debounce 300ms
    const timer = setTimeout(async () => {
      try {
        const data = await fetchMentions(query)
        if (!ctrl.signal.aborted) {
          setSuggestions(data.suggestions)
          setStatus('done')
        }
      } catch (e) {
        if (!ctrl.signal.aborted) {
          setError(e instanceof Error ? e.message : 'Unknown error')
          setStatus('error')
        }
      }
    }, 300)

    return () => { clearTimeout(timer); ctrl.abort() }
  }, [query])

  return (
    <div className={styles.wrapper}>
      <Input
        id="mention"
        placeholder="Type @ to mention..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        loading={status === 'loading'}
        error={status === 'error' ? error ?? undefined : undefined}
      />
      {status === 'done' && suggestions.length > 0 && (
        <ul className={styles.dropdown} role="listbox">
          {suggestions.map((s) => (
            <li key={s.id} role="option" aria-selected="false">
              {s.name}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Запомните этот код. В М8 мы перепишем его на Effector — и вы увидите, как useEffect + setTimeout + AbortController + 4 useState превращаются в 5 строк декларативного графа связей.


7.7 Антипаттерны

АнтипаттернКак правильно
Вызов API без ZodВсегда Schema.parse(data) на ответе
Игнорирование .pending/.errorПоказывать Skeleton/Loader, обрабатывать ошибку
Дублирование ответа между MSW и реальным хендлеромИсточник — единая Zod-схема
fetch в Server Component для клиентских данныхТолько в Client Component или Route Handler
4 useState для одного запросаЭто сигнал — нужен Effector (М8)

Компромиссы (Trade-offs)

РешениеПлюсыМинусы
OpenAPI (backend-first)Единый контракт с Go, кодогенНужно поддерживать схему
Zod-валидация в runtimeЗащита от contract driftЛишняя зависимость + оверхед
MSW (общий мок)Storybook и Vitest на одном источникеНастройка infra
Прямой fetch в компоненте (пока)Быстрый стартБоль — рефакторим в М8

💻 7.8 Практика

  1. Установите zod и msw (zod уже в deps).
  2. Создайте app/shared/api/{openapi.yaml, schemas.ts, client.ts, msw-handlers.ts}.
  3. Обновите Route Handler /api/mentions с переключением mock/real.
  4. Реализуйте MentionInput с прямым fetch.
  5. Добавьте MSW-хендлер, проверьте в Vitest и Storybook.
bash
npm run verify

7.9 Артефакты сдачи М7

АртефактКритерий
verify зелёныйКонтракт-тест (MSW + Zod) проходит
MentionInput работаетНа :3000/ui-gallery — вводите «al» → видите Alice
Ошибка обрабатываетсяcurl /api/mentions?q=a → 400 «Query too short»

Вперёд → Модуль 8: рефакторинг на Effector.


🧩 Проверь себя

1. Если backend меняет поле без обновления контракта (contract drift)...
2. zod-scheme (runtime-валидация) vs только TS-типы — в чём ценность?
3. Contract testing (Pact) vs runtime-валидация (zod) — разница?
4. Контракт API (OpenAPI + zod) — зачем?
5. Кодогенерация типов из OpenAPI (openapi-typescript) — источник правды?

🔗 Мостик к Модулю 8

Контракт есть: OpenAPI → Zod → MSW → Route Handler. Но MentionInput делает прямой fetch с 4 useState и ручным AbortController. Теперь отрефакторим его на Effector: декларативный граф связей вместо императивных эффектов.