5 минут
Контракт, от которого код не может уйти: spec-first в монорепо NestJS + Nuxt + Flutter
Три моих проекта стоят на одном каркасе: Course Shelf, Coriolis Companion и burkmak. В каждом бэкенд на NestJS и два клиента: SPA на Nuxt и приложение на Flutter. Все три клиента ходят в один API, и у этого API должно быть одно описание.
Обычно описание живёт в трёх местах: в контроллере, в TypeScript-типах веба и в
Dart-моделях приложения. Держится это на дисциплине. Если переименовать поле в DTO,
бэкенд не соберётся, а оба клиента получат undefined в рантайме, и при
рефакторинге такое расхождение легко пропустить.
Почему не генерировать спеку из кода
Первое, что предлагает NestJS, — декораторы @nestjs/swagger и спека,
собранная из контроллеров. Я от этого отказался. Такая спека описывает то, что
код уже делает, и поэтому не может код отвергнуть. Мобильный клиент
тогда генерируется из того, что бэкенду случилось отдать.
gRPC и tRPC тоже отпали. Браузеру и Flutter нужен обычный HTTP с JSON, а у tRPC нет полноценной поддержки Dart.
Как устроено
Контракт — файл packages/specs/openapi/openapi.yaml в OpenAPI 3.1. Рядом
лежит AsyncAPI 3.0 для realtime-каналов Centrifugo. Любое изменение API идёт в
одном порядке:
- Правлю
openapi.yaml. pnpm spec:validate && pnpm spec:bundle && pnpm spec:codegen.- Реализую эндпоинт в
apps/backend. - Использую перегенерированный клиент в вебе и в приложении.
Оба клиента я генерирую целиком: TypeScript через @hey-api/openapi-ts, Dart
через openapi-generator-cli. Правку руками в них ловит drift guard.
Сгенерированный код я коммичу отдельным коммитом, чтобы в ревью изменения
спеки не тонули в тысячах строк кодогена.
Три замка
Каркас сверяет спеку с кодом в трёх местах.
Валидатор в рантайме
express-openapi-validator висит на /api и сверяет каждый запрос со спекой.
Если маршрута нет в спеке, бэкенд его не обслужит. Сокращённо:
app.use('/api', express.json({ limit: '1mb' }));
app.use(
'/api',
OpenApiValidator.middleware({
apiSpec,
validateRequests: true,
ajvFormats: { mode: 'full' },
validateResponses:
nodeEnv === 'production'
? false
: {
onError: (err) => {
logger.warn(`response schema mismatch: ${err.message}`);
err.status = 400;
throw err;
},
},
validateSecurity: false,
ignorePaths: /\/v1\/(?:auth(?:\/|$)|stream\/lessons\/|stream\/materials\/|...)/,
}),
);
Ответы валидатор проверяет везде, кроме production: в разработке и в тестах расхождение ответа со схемой превращается в 400 и видно сразу.
Строка ajvFormats: { mode: 'full' } появилась после конкретного случая. По
умолчанию формат date проверяется регуляркой \d{4}-\d{2}-\d{2}, и
2024-13-01 проходит. Тринадцатый месяц уехал в базу и вернулся из списочного
эндпоинта, где схема ответа тоже говорит format: date, а более строгий
читатель с ней не согласился. Режим full разбирает дату по календарю и
отбивает такой запрос на входе.
validateSecurity выключен, потому что авторизацию проверяют Better Auth и
guard-ы приложения, и они отдают локализованную ошибку в формате RFC 9457.
Валидатор вернул бы голый 401.
Drift guard в CI
Отдельная джоба в CI перегенерирует оба клиента и падает, если результат отличается от закоммиченного:
- name: Regenerate clients
run: pnpm spec:codegen
- name: Fail if generated clients drifted from committed sources
run: |
git diff --exit-code -- packages/api-client-ts packages/api-client-dart \
|| (echo '::error::Generated clients are out of date. Run `pnpm spec:codegen` locally and commit the diff.' && exit 1)
Если поправить спеку и не перегенерировать клиенты или поправить клиент руками, PR не пройдёт.
Контрактные тесты
Schemathesis гоняет по спеке четыре фазы: примеры, покрытие, фаззинг и stateful-сценарии. Он запускается в e2e-джобе против поднятого бэкенда и проверяет, что сервер отвечает то, что обещает спека, и отвергает то, что она запрещает. У этого замка есть дыры: генератор иногда отправляет валидный запрос там, где собирался проверить невалидный, и рапортует, что API принял нарушение схемы.
Что спека не покрывает
Бинарные маршруты. Видео, субтитры, материалы, постеры и архивы бэкапов
отдаются байтами. Тег <video src> не умеет слать заголовок Authorization,
поэтому эти маршруты берут короткоживущий подписанный токен из query, а
валидатор их пропускает через ignorePaths. Их покрывают тесты контроллеров.
Авторизация. Маршруты /api/v1/auth/* принадлежат Better Auth, у него свой
протокол, и схемы для него в спеке нет.
То, что спека не умеет выразить. Slug курса в Course Shelf юникодный, и его
паттерн пишется только через \p{...}. Генератор контрактных тестов строит
значения поверх питоновского re, где таких классов нет, и тесты начали падать
в зависимости от сида. Паттерн я снял с провода. Форму slug теперь
стережёт доменный value-объект, а неверный slug получает 422 вместо 400.
Соседние конфиги. AsyncAPI описывал канал scans:user:{userId}, а в
конфигах Centrifugo его namespace не был объявлен ни в одном. Centrifugo отвечал «unknown
channel» и на публикацию, и на подписку. Прогресс скана месяцами стоял на 0% в
dev, в проде и в релизной сборке, а CI оставался зелёным: бэкенд публикует
fire-and-forget, а юнит-тесты мокают Centrifugo. Теперь отдельная проверка в CI
сверяет каналы из AsyncAPI с каждым конфигом Centrifugo. Конфиг брокера лежит
вне спеки, и без такой проверки расхождение с ним видно только на живом
деплое.
Цена
- Ритуал. Один новый эндпоинт — это правка спеки, три команды и два коммита. Медленнее, чем написать контроллер. Медлительность намеренная: сначала контракт, потом код. На мелких правках она всё равно мешает.
- Java в цепочке. Dart-генератор — jar с Maven Central, который отвечает 403 достаточно часто, чтобы покраснел посторонний PR. Я кэширую jar в CI отдельным шагом.
- Размер. Спека Course Shelf — один файл на 449 КБ и 101 операцию. Ревьюить
его тяжело. Я оставил один файл: разбиение через
$refдаёт структуру каталогов, но требует сборки бандла до того, как спеку сможет прочитать что-либо ещё.
При чём тут агенты
Эти проекты я пишу вместе с AI-агентами, и каркас рассчитан на их типичные ошибки. Агент переименовывает поле в TypeScript и не трогает Dart. Добавляет маршрут в контроллер и не добавляет в спеку. Правит сгенерированный клиент, потому что так быстрее. Ревью это ловит не всегда. Drift guard и валидатор ловят на каждом PR. С людьми в команде каркас работает так же, срабатывает только реже.