ADR 0033 — Справочник API выводится из кода, а табличный тест требует метаданных
Контекст
api предоставляет 118 маршрутов в 23 controllers, и до сих пор единственной документацией
контракта был docs/security-surface.md. Он отвечает, кто может вызывать каждый
маршрут, — и ничего о том, что маршрут делает, что принимает и что
возвращает. Тот, кто интегрировался, читал controller.
7.1 открыла исключение: два controller auth родились с
@ApiTags/@ApiOperation/@ApiProperty, а заголовок auth/dto/auth.dto.ts
зафиксировал намерение — «os decorators entram AGORA, junto com as rotas, e não
numa varredura retroativa». Эта поставка и есть ретроактивная проверка, которая, как обещал
тот комментарий, больше не понадобится, и механизм, делающий её последней.
@nestjs/swagger был зависимостью ещё с 7.1, но SwaggerModule нигде не был
подключён: документа не существовало.
Три находки исследования перепроектировали поставку ещё до того, как была написана хоть одна строка:
@nestjs/swaggerСИНТЕЗИРУЕТ ответ, когда нет ни одного decorator.api-response.explorer.jsвозвращает{ '<status>': { description: '' } }для каждого handler без@ApiResponse. До этой фазы 111 из 118 маршрутов находились ровно в этом состоянии. Очевидное утверждение — «у каждого маршрута есть ответ 2xx» — родилось бы зелёным, ничего не проверяя.@HttpCodeигнорируется, как только существует любой@ApiResponse. Документированный статус начинает браться только из decorator. Дефект уже существовал в репозитории: уPOST /auth/registerиPOST /auth/request-password-resetбыл@HttpCode(202)с@ApiOkResponse, и документ утверждал 200.- Вне auth ни один handler не объявляет тип возврата. Шаблон —
return this.useCase.execute(...), который разрешается в интерфейсы и type aliases изsrc/domain/**, — из них@nestjs/swaggerне выводит схему и для них выдаёт{}без предупреждения.
Решение
Справочник генерируется из OpenAPI и никогда не пишется вручную, а табличный тест Фазы 5 начинает требовать метаданных, которые его питают.
DTO ответов отражают сущность по ТИПУ
Существует ~55 различных форм ответа (не 118: ProposedAction обслуживает 6
маршрутов, Session — 5). Каждая стала классом в
interfaces/http/<domínio>/dto/*.response.dto.ts. Ничто в src/domain/** не было
затронуто — ставить @ApiProperty на сущность нарушает чистоту домена.
Написать DTO — лёгкая часть. Риск — день, когда сущность получит поле, а DTO нет: справочник начинает лгать молча. Против этого — две типовые защёлки, и обе необходимы:
export class SessionResponseDto implements Wire<Session> { … }
export const _chavesSession: MesmasChaves<SessionResponseDto, Session> = true;
Прямой implements Session не годится: сущность говорит createdAt: Date, а
тело JSON говорит string. Wire<T> — это сущность такой, какой она уходит в провод, и тогда
implements честен. Но implements однонаправлен — он слеп к лишнему
полю, и DTO, описывающий уже удалённое поле, компилировался бы вечно.
MesmasChaves закрывает эту сторону.
Четыре режима отказа были проверены запуском до того, как поверх этого был написан хоть один DTO:
| ошибка | ловится |
|---|---|
| сущность получила поле | implements — TS2420 |
DTO типизировал Date, где в проводе string | implements — TS2416 |
| у DTO есть поле, которого у сущности больше нет | MesmasChaves — TS2322, и только им |
| корректный DTO | компилируется чисто |
Защёлки выполняет tsc, а не vitest, — тот транспилирует через SWC и не
проверяет никаких типов. Отсюда новый pnpm --filter api typecheck в CI: без него
доказательство выполнялось бы только в job образов, на двадцать минут позже.
Табличный тест требует ДОКУМЕНТ, а не decorators
route-surface.spec.ts получил семь утверждений, все о документе, собранном
SwaggerModule.createDocument, — том же, что идёт на сайт. Отражать
DECORATORS.API_RESPONSE handler за handler значило бы тестировать промежуточный шаг:
type:, указывающий на интерфейс, прошёл бы проверку decorator и
дал бы {} в документе.
Утверждение об ответе требует разрешённого содержимого или непустого описания, а
не просто наличия ключа 2xx — к этому обязывает находка 1 выше.
Утверждение о статусе пересчитывает реальное значение из HTTP_CODE_METADATA, что
закрывает находку 2. А последнее привязывает документ к настоящим guards: маршрут
@Public() не может объявлять security, аутентифицированный маршрут обязан объявлять. Без
него справочник мог бы утверждать, что маршрут открыт, когда guard его закрывает, —
ошибаться именно там, где ошибка дорога.
Исключение маршрута из справочника требует записи в EXCLUIDAS_DA_REFERENCIA,
а маршрут без JSON-тела требует записи в SEM_CORPO_JSON с собственной
обязанностью (SSE объявляет text/event-stream, redirect объявляет заголовок
Location, 204 объявляет 204). Без этого @ApiExcludeEndpoint() был бы лёгким
выходом, чтобы уйти от всего, а «это stream» стало бы лицензией ничего не
документировать.
--check использует манифест, а не перегенерацию
pnpm docs:check обещает не писать. Запуск gen-api-docs для сравнения
нарушил бы обещание. Защёлка — манифест с sha256 каждого сгенерированного
файла плюс openapi.json, который их произвёл, записываемый тем же
escrever(), что и все остальные генерируемые, — а значит, с тем же поведением
в check.
Он ловит четыре значимых дрейфа: MDX, отредактированный вручную, старый MDX для новой spec, отсутствующий сгенерированный файл и осиротевший файл. Три были проверены запуском.
Порядок openapi.json фиксирован (paths, глаголы, schemas, tags, статусы),
потому что порядок, который отдаёт Nest, следует из порядка регистрации модулей: без
нормализации перемещение строки import в AppModule дало бы тысячи
строк diff, и следующим предсказуемым шагом кто-нибудь отключил бы check.
Swagger UI вне production
/docs и /docs-json существуют только при NODE_ENV !== 'production'. Справочник
production — это сайт документации, сгенерированный из того же документа; отдавать
всю поверхность в реальном окружении ничего не добавляет и бесплатно даёт карту тому, кто станет зондировать.
Последствия
В справочнике 118 страниц, по одной на маршрут, сгруппированных по тегам. Обзор —
аутентификация, соглашение об ошибках, rate limit — берётся из info.description, поэтому
он генерируется из единого источника, а не пишется в .md, который бы разошёлся.
Новый маршрут без метаданных не проходит. Это механизм против дрейфа, которого у docmap нет: docmap срабатывает при изменении файла, но не видит нового маршрута, который родился без документации.
Что проверка исправила по пути
Это была не только документация:
PUT /projects/:id/agent-autonomyиDELETE /projects/:id/members/:userIdвозвращали 200 с пустым телом.api-client.tsв web считает «без тела» только 204 и попадал вres.json(), бросаяSyntaxError. Оба стали@HttpCode(204).UpdateWorkspaceDtoиUpdateProjectDtoиспользовалиPartialTypeиз@nestjs/mapped-types, который копирует только валидацию: оба вышли бы без единого свойства в документе.- Классовый
@ApiBearerAuthвGitControllerпротекал в callback OAuth, который@Public(), — справочник утверждал, что браузеру нужен токен, чтобы вернуться от provider. Ни один decorator@nestjs/swaggerне очищает унаследованное требование, поэтому объявление стало по маршруту. - Два
@HttpCode(202)в auth, задокументированные как 200.
Принятые издержки
- 2,7 МБ версионируемого сгенерированного (117 MDX плюс 352 JSON, которые они
require()). Без этого у docmap было бы мёртвое правило, аgit ls-filesничего бы не видел. - Без сниппетов по языкам.
postman-code-generatorsзапускает вложенныйnpm installв postinstall — сеть посреди нашей установки, а это то, от чего отказывается дисциплина Фазы 5. Он вошёл вallowBuildsкакfalse. outputDirудаляется целиком командойclean-api-docs, поэтому spec живёт уровнем выше. Генератор, удаляющий собственный вход, работает при первом запуске и падает при втором.- Id сгенерированного sidebar оказываются на уровень глубже, потому что плагин предполагает,
что
outputDirнаходится внутри сайта. Исправление живёт в написанном вручнуюsidebars.ts, а не в переписывании сгенерированного файла — его изменение заставило быdocs:checkобвинять в дрейфе на каждом раунде.
Что остаётся открытым
engine_api_client.ex по-прежнему без автоматической проверки соответствия
маршрутам api. Справочник даёт обеим сторонам один источник для сверки, и
TODO(humano) в internal-api.md остаётся в силе: по-настоящему это закрыло бы
генерирование клиента Elixir из openapi.json или контрактный тест
между сторонами.
Также вне охвата: сгенерированный TS-клиент для web (api-client.ts остаётся ручным),
версионирование api и удаление GET / из scaffold — который получил
@ApiExcludeEndpoint() с обоснованием, вместо того чтобы исчезнуть, потому что его удаление —
продуктовое решение.
Ссылается на ADR 0027, создавший табличный тест, и ADR 0031, где появились первые decorators.