Модуль 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-типов
bashnpx 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 решает все три задачи одним объектом:
tsximport { 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 | Где используется |
|---|---|
| Валидация ответов API | fetchMentions() проверяет ответ через 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+ 4useStateпревращаются в 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 Практика
- Установите
zodиmsw(zod уже в deps). - Создайте
app/shared/api/{openapi.yaml, schemas.ts, client.ts, msw-handlers.ts}. - Обновите Route Handler
/api/mentionsс переключением mock/real. - Реализуйте
MentionInputс прямым fetch. - Добавьте MSW-хендлер, проверьте в Vitest и Storybook.
bashnpm 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.
🧩 Проверь себя
🔗 Мостик к Модулю 8
Контракт есть: OpenAPI → Zod → MSW → Route Handler. Но MentionInput делает прямой fetch с 4 useState и ручным AbortController. Теперь отрефакторим его на Effector: декларативный граф связей вместо императивных эффектов.