Три моих проекта стоят на одном каркасе: 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 идёт в одном порядке:

  1. Правлю openapi.yaml.
  2. pnpm spec:validate && pnpm spec:bundle && pnpm spec:codegen.
  3. Реализую эндпоинт в apps/backend.
  4. Использую перегенерированный клиент в вебе и в приложении.

Оба клиента я генерирую целиком: 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. С людьми в команде каркас работает так же, срабатывает только реже.