Документация из кода: OpenAPI поверх той же таблицы метаданных

транспорт≈ 21 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Клиент, которого никто не писал, у вас уже есть: тип роутера даёт и вход, и ответ, и список имён процедур. Но этот клиент — для своих. Он живёт в вашем репозитории, говорит на TypeScript и при сборке исчезает без следа. А к API приходят и посторонние: мобильный разработчик, тестировщик с curl, генератор клиентов на другом языке, соседняя команда. Им нужен не тип, а текст, который читает машина: какие адреса есть, что в теле, что в ответе. Такой текст называют описанием API, а нынешний его формат — OpenAPI. Написать описание руками несложно, и ровно поэтому опасно: это вторая запись того же договора, а вторая запись расходится с первой молча. Поэтому описание здесь не напишется, а породится — обходом той же боковой таблицы, из которой каркас берёт обработчики. Потом выяснится, чего эта таблица не знает, потом то же самое проделает настоящий Nest — а под конец окажется, что в боевом API автора описание процедур собрано руками и одну процедуру уже потеряло.

Разделы темы:·

Посмотрите на документ OpenAPI не как на файл, а как на значение. Это объект, и главное в нём одно поле — paths: ключ — адрес, значение — способы запроса, у каждого способа — параметры, тело и ответы. Всё остальное в нём — заголовок, версия API, подписи и примеры — написано для человека.

А теперь сравните это с тем, что у каркаса уже записано. prefixes знает префикс контроллера, routes — способ и путь обработчика, и сборка обходит модули и контроллеры, чтобы завести по этим записям маршруты. Описание — тот же обход, только на выходе не маршрутизатор, а объект.

Одно место всё-таки придётся перевести — адрес. У вашего маршрутизатора переменный сегмент пишется с двоеточием: /notes/:id. В OpenAPI тот же адрес выглядит как /notes/{id}, и сам параметр объявляется отдельно — с именем и с местом, где он стоит. Двоеточия не поймёт ни один инструмент, читающий описание; фигурных скобок не поймёт ваш маршрутизатор.

Прогноз: сколько строк даст документ у контроллера с тремя обработчиками — и что ответит каркас на адрес, которого в документе нет?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три обработчика дали три операции, но два адреса: /notes отвечает и на GET, и на POST, и в документе это один ключ с двумя способами внутри. У byId появился параметр id, и его имя взято из того самого сегмента пути, из которого маршрутизатор берёт значение.

Последние три строки — те, ради которых рядом стоит inject. Описанные адреса отвечают, а /notes/1/history не отвечает и в документе его нет. Это не совпадение и не аккуратность автора: пока описание порождается обходом, оно не может обещать больше, чем отвечает каркас, и не может забыть то, что каркас отвечает.

Документ — не второй источник правды, а её проекция. Как только он становится файлом, который правят руками, он превращается во второй источник и начинает врать.

У проекции есть граница: таблица знает только то, что положили в неё декораторы. Всё, что отвечает мимо маршрутизатора, в ней не появляется, — а мимо отвечает многое. Звено конвейера видит только байты запроса и может ответить само, не пустив дальше: так устроены проверка здоровья, отдача файлов и всякий плагин, повешенный на транспорт.

Вторая дыра — обратная. Строку в документ можно вписать руками, и живёт она дольше маршрута: маршрут убрали, строка осталась. Ни компилятор, ни сборка об этом не скажут — документ ведь не код.

Прогноз: у трёх адресов ниже по два ответа — «есть ли он в описании» и «отвечает ли он». Какой адрес отвечает, не будучи описанным, и какой описан, не отвечая?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

/health отвечает 200, а в описании его нет: посторонний о ручке не узнает, и в сгенерированном клиенте её не будет. Это описание неполное. /notes/:id/history описан и отвечает 404: по документу сгенерируют метод, который будет звать несуществующий адрес, и виноватым окажется сервер. Это описание лживое.

Лечатся дыры по-разному. Первую закрывают, дописав документ руками, — и заводят ровно ту вторую запись, от которой мы уходим; иначе никак, звено в таблицу не попадёт. Зато вторая дыра лечится дёшево: спросить приложение по каждому описанному адресу и посмотреть, не 404 ли в ответе. Это третья задача статьи, и в боевом проекте такая сверка стоит одного теста.

Описание читают не глазами. По нему генерируют клиентов (для Kotlin, Swift, Go — чем угодно из семейства openapi-generator), и обещанный адрес появляется в чужом коде готовым методом. Автор того кода в ваш репозиторий не заглядывает: он читает документ, как вы читаете типы.

Свою же страницу Swagger UI рисует по тому же документу и на каждый адрес добавляет кнопку «попробовать». Кнопка, которая отвечает 404, учит тому же, чему учила кнопка Run у неисполнимого примера: что здесь всё сломано и верить ничему нельзя.

Описание бывает неполным и бывает лживым, и это разные беды. Неполное стоит посторонним времени, лживое — доверия ко всему документу сразу.

Адреса — половина описания. Вторая половина — что в теле и что в ответе, и здесь описанию нужно от схемы то, чего она не умеет. Схема умеет одно: проверить значение. Спросить её «какие у тебя поля и какие пределы» нельзя — поля лежат в замыкании, а замыкание снаружи не читается. Один раз вы это уже видели: обход роутера напечатал про вход {}.

Прогноз: что напечатает JSON.stringify схемы — и что скажет схема на 0, если рядом с ней написано «целое, не меньше единицы»?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
Схема как значение — {} и единственный ключ check. Приписанная рядом форма — обычная вторая запись, и разъехалась она сразу: описание обещает минимум единицу, проверка принимает ноль; описание обещает двести символов, проверка принимает триста. Никто не халтурил — просто правило написано дважды, а правку внесли в одно место. Последняя строка про объект: формы у него нет вовсе, её никто не приписал, и тело в описании выйдет пустым.

Лечение то же, что было у клиента: копий не должно быть. Форма не приписывается схеме рядом, а лежит в ней — таким же полем, как check, — и обе половины строятся из одного и того же аргумента.

Прогноз: что окажется в описании входа — и совпадут ли пределы в нём с тем, на чём схема отказывает?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Предел 20 попал и в форму, и в проверку из одного числа: поменяйте его — поедут обе половины сразу, и разойтись им теперь нечем. Объект собирает свою форму из форм полей там же, где берёт проверку у подложки, — то есть описание составное ровно настолько, насколько составная схема.

Именно так устроен zod: форма у его схемы хранится значением, и поэтому её умеют переводить в JSON Schema — язык, на котором в OpenAPI описывают тело и ответ. Следующий прогон этот перевод и показывает.

Правило простое: если форму можно спросить у самой схемы, описание порождается. Если форму приходится приписывать рядом, у вас уже две записи одного правила.

У zod перевод встроен: z.toJSONSchema(схема). Звучит как «одна строка и готово», но в боевом коде у этого вызова стоят три параметра, и каждый куплен ошибкой в документе.

Первый — target. JSON Schema не один язык, а семейство диалектов: у современного (draft 2020-12) и у того, на котором говорит OpenAPI 3.0, часть ключей пишется по-разному. Выбор не вкусовой: документ, который строит @nestjs/swagger, объявляет себя версией 3.0.0, и схемы в нём ждут второго диалекта.

Второй — io. У поля бывает значение по умолчанию: клиент его не присылает, а в разобранном значении оно есть. Значит, на входе поле необязательное, а на выходе обязательное — одна схема, два разных описания, и перепутать их значит потребовать от клиента то, что сервер подставит сам.

Третий — что делать с тем, чего в JSON нет вовсе. Дата — не тип JSON: по проводу она едет строкой, и, если схему не предупредить, перевод упрётся в это на первом же поле.

Прогноз: девять строк. Чем документ по умолчанию отличается от openapi-3.0, в каком из двух io поле со значением по умолчанию окажется обязательным — и что zod сделает с z.date(), когда его об этом не попросили?

прогон node 26.5.0 · 28 сентября 2026
Одна и та же схема zod, переведённая в JSON Schema с разными параметрами
import { z } from 'zod';

// Схема входа процедуры. Форма у неё лежит значением, поэтому её можно не только
// позвать на проверку, но и прочитать снаружи
const createNote = z.object({
  title: z.string().min(1).max(200),
  status: z.enum(['draft', 'published']).default('draft'),
});

const short = (value: unknown) => JSON.stringify(value);

console.log('по умолчанию:   ', short(z.toJSONSchema(createNote)));
console.log('openapi-3.0:    ', short(z.toJSONSchema(createNote, { target: 'openapi-3.0' })));

// io говорит, с какой стороны границы смотреть на поле с .default()
for (const io of ['input', 'output'] as const) {
  const schema = z.toJSONSchema(createNote, { target: 'openapi-3.0', io });
  console.log(`io "${io}": обязательны`, short(schema.required), '| лишние поля:', short(schema.additionalProperties ?? 'ничего не сказано'));
}

// В ответе процедуры дата есть, а в JSON её нет
const note = z.object({ id: z.number().int().positive(), createdAt: z.date() });
const field = (options: Parameters<typeof z.toJSONSchema>[1], name = 'createdAt') => short(z.toJSONSchema(note, options).properties?.[name]);

try {
  z.toJSONSchema(note);
} catch (err) {
  console.log('дата без параметров →', (err as Error).message);
}
console.log('unrepresentable "any" →', field({ target: 'openapi-3.0', unrepresentable: 'any' }));
console.log(
  'плюс override →',
  field({
    target: 'openapi-3.0',
    unrepresentable: 'any',
    override: (ctx) => {
      if (ctx.zodSchema._zod.def.type === 'date') {
        ctx.jsonSchema.type = 'string';
        ctx.jsonSchema.format = 'date-time';
      }
    },
  }),
);
// Один и тот же предел в двух диалектах: у OpenAPI 3.0 и у draft 2020-12 он пишется по-разному
console.log('целое openapi-3.0:   ', field({ target: 'openapi-3.0', unrepresentable: 'any' }, 'id'));
console.log('целое draft 2020-12: ', field({ unrepresentable: 'any' }, 'id'));
node describe.ts

Две первые строки отличаются одним ключом: по умолчанию zod ставит в документ $schema с адресом диалекта, а в OpenAPI 3.0 такому ключу места нет. Разница глубже видна на двух последних строках: «строго больше нуля» в диалекте OpenAPI 3.0 пишется двумя ключами — minimum: 0 и exclusiveMinimum: true, — а в draft 2020-12 одним: exclusiveMinimum: 0. Смысл тот же, слова разные, и инструмент, который ждёт одно, второго не поймёт.

Строки про io — про то самое поле status со значением по умолчанию. На входе обязателен один title, на выходе обязательны оба: сервер подставил draft, и в ответе поле есть всегда. Заодно видно, что additionalProperties: false zod ставит только выходу: про лишние поля на входе он в описании ничего не обещает.

И три строки про дату. Без параметров перевод не молчит, а отказывает: «Date cannot be represented in JSON Schema». Разрешение unrepresentable: "any" превращает отказ в {} — «любое значение», то есть в описании поле есть, а что в нём, не сказано. И только override даёт описанию правду: строка с форматом date-time, ровно то, что едет по проводу.

Это наибольшее целое, которое число с плавающей точкой держит без потери точности, — то же Number.MAX_SAFE_INTEGER. Схема z.number().int() проверяет не «целое вообще», а «целое, которое JavaScript ещё различает», и в описание попадает именно то, что она проверяет. Ключ выглядит странно, но врать он не начал: если клиент пришлёт число больше, схема его отвергнет.

Перевод схемы в описание — не одна строка, а место, где две системы правил договариваются. Параметры такого вызова стоит читать целиком: каждый из них решает, чья правда попадёт в документ.

В Nest всё это уже написано, и берёт он данные оттуда же, откуда вы. Таблица, из которой его маршрутизатор достаёт способ и путь, — это reflect-metadata, и @nestjs/swagger ходит в неё же.

Две записи из этой таблицы он читает чужие. Первая — путь и способ, их положили @Controller и @Get. Вторая — тип тела: его пишет компилятор в design:paramtypes, тот самый, из которого контейнер берёт список зависимостей. Ни одна из них для описания не предназначалась.

А своя запись у Swagger одна, и она отдельная: @ApiProperty над полем DTO. Без неё Swagger о поле не узнает — класс он читает не по типам, а по этой записи. Вот тут и появляется вторая запись договора, от которой мы прятались весь раздел про схемы.

Прогноз: какие ключи лежат в таблице у контроллера и у его метода, что окажется в схеме тела — и что ответит сервер на поле, которого в этой схеме нет?

прогон node 26.5.0 · 28 сентября 2026
Ключи таблицы, документ, собранный без запуска сервера, и ответ на поле, которого в документе нет
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { CreateNoteDto } from './create-note.dto.js';

@Controller('notes')
export class NotesController {
  @Get()
  list() {
    return [{ id: 1, title: 'купить хлеб', status: 'published' }];
  }

  @Get(':id')
  byId(@Param('id') id: string) {
    return { id: Number(id), title: 'купить хлеб', status: 'published' };
  }

  @Post()
  create(@Body() dto: CreateNoteDto) {
    return { id: 2, title: dto.title, status: dto.status ?? 'draft' };
  }
}
nest start

Первые строки — таблица. У класса пять ключей, у метода два: path и method. Это ровно то же, что лежит в prefixes и routes вашего каркаса, только имена длиннее и хранилище общее. Тип тела CreateNoteDto приехал из design:paramtypes — из записи, которую компилятор сделал для внедрения зависимостей.

Своя запись Swagger содержит одно поле, title, хотя в классе их два. Дальше это видно в схеме: CreateNoteDto описан одним заголовком. А последняя строка показывает цену: сервер принял status, обработчик его прочитал и вернул в ответе. Поле работает, в документе его нет, и узнать об этом посторонний не может никак.

Два наблюдения помельче, но полезных. Шаблон :id Nest перевёл в {id} сам, и параметр описал строкой — про Number(id) внутри обработчика документ не знает. А весь документ собран до app.init(): createDocument читает таблицу, и ни порт, ни запрос ему не нужны. Отсюда приём: описание выгружают в файл отдельной командой и сверяют на сборке, не поднимая сервер.

У @nestjs/swagger есть плагин для сборки — он включается в nest-cli.json и читает типы полей сам. Проверено на этом же примере: с плагином в схеме появляется и status, причём необязательным, потому что в классе он объявлен с вопросительным знаком. Массив @ApiProperty при этом не меняется: плагин дописывает классу отдельный статический метод _OPENAPI_METADATA_FACTORY, который Swagger и спрашивает.

Цена — в том же: описание начинает зависеть от шага сборки. Тот же исходник, собранный без плагина, даёт другой документ, и объяснить это по коду нельзя — придётся знать про nest-cli.json. В прогоне плагина нет намеренно: без него видно, что запись у Swagger своя и отдельная.

Свою запись Swagger читает только там, где её поставили. Всё, что он получает даром, сделано для другого: маршрут — для маршрутизатора, тип тела — для контейнера.

tRPC внутри Nest живёт рядом, а не внутри: плагин регистрируется на самом Fastify, и конвейер Nest до процедур не доезжает. Таблица маршрутов — та же история: процедуры в неё не попадают, а значит, Swagger о них не узнает ничего. Половина API — а в боевом проекте автора почти всё, кроме здоровья и загрузки, — оказывается за границей документа.

Зато у tRPC есть своя таблица, и вы её уже видели: _def.procedures — плоский список «полное имя → процедура». У каждой процедуры есть _def.type (вид) и _def.inputs (схемы входа). Описание из неё порождается тем же обходом, что из вашей, и по дороге вскрывается разница между описанием и типизированным клиентом — в одном поле.

Вход у процедуры есть значением: схема лежит в таблице, её можно прочитать и перевести. А выход клиенту достаётся из типа обработчика, и описанию от этого пользы нет: ему нужно значение. Значение появляется только у той процедуры, которая объявила .output() — то есть там, где автор сделал лишнюю работу, которой типизированный клиент не требовал.

Прогноз: что Swagger найдёт сам, что появится после обхода таблицы процедур — и у какой из трёх процедур описанию будет откуда взять ответ?

прогон node 26.5.0 · 28 сентября 2026
Документ Swagger, тот же документ после обхода таблицы процедур и три запроса к описанным адресам
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

type Note = { id: number; title: string; status: 'draft' | 'published' };
const rows = new Map<number, Note>([[1, { id: 1, title: 'купить хлеб', status: 'published' }]]);
let nextId = 2;

const noteOut = z.object({ id: z.number(), title: z.string(), status: z.enum(['draft', 'published']) });

const t = initTRPC.create();

export const appRouter = t.router({
  notes: t.router({
    byId: t.procedure.input(z.object({ id: z.int().positive() })).query(({ input }) => {
      const note = rows.get(input.id);
      if (!note) throw new TRPCError({ code: 'NOT_FOUND', message: `заметки ${input.id} нет` });
      return note;
    }),
    create: t.procedure
      .input(z.object({ title: z.string().min(1).max(200), status: z.enum(['draft', 'published']).default('draft') }))
      .output(noteOut)
      .mutation(({ input }) => {
        const note: Note = { id: nextId++, title: input.title, status: input.status };
        rows.set(note.id, note);
        return note;
      }),
    published: t.procedure.query(() => [...rows.values()].filter((row) => row.status === 'published')),
  }),
});

export type AppRouter = typeof appRouter;
nest start

Первые две строки — приговор автомату: Swagger нашёл /health, и после регистрации плагина у него по-прежнему один путь. Плагин стоит на транспорте, в таблице Nest его нет, и документ о нём не узнаёт — ровно так же, как не узнавал о запросах к процедурам страж приложения.

Обход таблицы дал три операции. Вид процедуры решил способ запроса: query стал GET, mutation — POST. Вход запроса описан одним параметром input, в котором лежит весь JSON целиком, а не разложен по полям — не ?id=1, а ?input={"id":1}: tRPC ждёт именно так, и описание обязано повторять транспорт, а не придумывать удобный.

А теперь главное. Схема ответа нашлась только у notes.create — у той процедуры, где стоит .output(). У двух других в таблице пусто, и в документе про их ответ сказать нечего, хотя типизированный клиент знает форму ответа точно, до последнего поля. Вот и вся разница между описанием и клиентом: клиент читает типы, описание читает значения, и то, что осталось только типом, для постороннего не существует.

Последние три строки — сверка того же рода, о которой шла речь в разделе про дыры: описанные адреса спросили и получили 200. В прогоне это три строчки; в проекте — тест, который ходит по описанию и падает, когда документ начинает врать.

Вход и выход в API несимметричны. Вход обязан быть значением, потому что его проверяют в рантайме. Выход может остаться типом — и тогда его нет ни в одном описании.

В apps/api автора описание собрано из двух половин. REST-часть — две ручки, здоровье и загрузка фото, — устроена как в прогоне: документ строит SwaggerModule, а подписи, коды ответов и тело multipart дописаны рядом, в том же файле настройки. В контроллерах их нет намеренно: иначе каждый домен тянул бы зависимость от @nestjs/swagger. Тело загрузки, кстати, Swagger не видит вовсе — контроллер читает запрос сырым, и это отдельный разговор.

Вторая половина — двенадцать процедур tRPC, и вот они описаны реестром руками. Выглядит он так — из записей реестра здесь оставлена одна.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Схемы здесь не переписаны — это те же самые объекты zod из пакета контракта, что стоят в декораторах роутеров; руками заданы только список процедур, требование токена и подпись. Параметры перевода те самые, из прогона, вместе с override для даты — и комментарий рядом объясняет, чем он куплен: без него поле с датой выродилось бы в {}, как в прогоне.

Почему руками, а не автоматом. nestjs-trpc держит метаданные процедур в приватных Symbol и Swagger их не получает: роутеры для него — обычные провайдеры, контроллеров у них нет. trpc-to-openapi потребовал бы переписать каждую процедуру на .meta({ openapi }) со своим методом и путём, то есть отказаться от декораторов. Готовый обозреватель trpc-panel на этом стеке просто не запускается: ему нужны tRPC 10 и zod 3. Автомат для tRPC 11 существует — trpc-to-openapi держит peer на ^11.1.0, — но в стек он не взят, и строка про него ждёт на полке сервера.

Описывать приходится и конверт. У боевого API между входом и схемой стоит superjson, поэтому в теле запроса лежит не сам вход, а {"json": вход}, а в ответе — {"result":{"data":{"json": ответ}}}. Описание, забывшее конверт, честно опишет поля и всё равно не даст сделать запрос.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь про цену. Реестр статически типизирован, и одну беду это ловит: переименуйте схему в контракте — и сборка станет красной, а не документ тихо потеряет поле. А вот коды ответов рядом с ними — обычные строки описания: те же доменные коды, что отдаёт сервер, но набранные заново, и их не проверяет уже ничто.

А вторую беду тип не ловит вовсе: процедуру, которую в реестр не вписали. Пока писалась эта статья, в реестре боевого API было одиннадцать записей при двенадцати процедурах в роутерах: не вписали notes.mine, «мои заметки». Она объявлена в роутере @Query со своим middleware, она есть в сгенерированном типе клиента — а в описании её не было ни строкой: ни на странице Swagger, ни в документе. Комментарий в том же файле считал, что процедур одиннадцать, и рядом же обещал, что состав реестра «один в один» со сгенерированным файлом клиента, где их двенадцать. Отстало и то и другое: и реестр, и обещание про него — при зелёной сборке и верном типе клиента.

Это и есть та вторая запись, о которой шла речь с первой страницы, — в самом мягком её виде: типизированная, аккуратная, с объяснением в комментарии. И всё равно отставшая; аккуратность тут не помогает, помогает сверка. Сейчас запись на месте, а рядом с реестром лежит тест: он собирает документ, вынимает из него пути /trpc/* и сравнивает со списком процедур из сгенерированного файла — состав, порядок и способ запроса. Уберите запись — тест упадёт и назовёт пропавшую по имени; раньше на этом месте была тишина. Есть у сверки и своя проверка: разбор сгенерированного файла сначала доказывает, что увидел все процедуры до единой, — иначе смена формата у генератора превратила бы сверку в вечное «всё сошлось». Та же мысль, что в третьей задаче, только там описание спрашивают запросами.

Реестр, написанный руками, стареет в двух местах: когда меняют то, что он описывает, и когда добавляют то, чего он не знает. Второе не ловит ни один компилятор.

  • Описание API — это текст для посторонних: у них нет ни ваших типов, ни вашего репозитория. Типизированный клиент им не поможет, потому что тип живёт до сборки, а описание читает машина на другом языке.
  • Порождённое описание — проекция той же боковой таблицы, из которой каркас берёт обработчики: модули, контроллеры, функции прототипа. Оно не может обещать адрес, которого нет, и не может забыть про маршрут. Разница одна — переменный сегмент в OpenAPI пишется {id}, а не :id, и параметр объявляется отдельно.
  • Всё, что отвечает мимо маршрутизатора — звено с проверкой здоровья, статика, плагин транспорта, — в таблицу не попадает, и описание о нём не узнает. Обратная дыра опаснее: строка, вписанная в документ руками, живёт дольше маршрута и делает документ лживым. Лечит только сверка запросами.
  • Чтобы описать тело, схему надо прочитать, а не позвать. У схемы-замыкания формы нет, приписанная рядом форма — вторая запись правила и разъезжается с проверкой. У zod форма хранится значением, поэтому z.toJSONSchema существует; его параметры target, io и unrepresentable решают, будет ли описание верным.
  • @nestjs/swagger берёт из reflect-metadata путь, способ и тип тела — записи, сделанные для маршрутизатора и контейнера. Своя запись у него одна, @ApiProperty, и поле без неё пропадает из документа молча, продолжая работать. Сам документ собирается без порта и запросов.
  • У процедур tRPC своя таблица — _def.procedures, — и Swagger в неё не заглядывает: плагин стоит на транспорте. Вход в этой таблице есть значением, а выход появляется только с .output(): то, что осталось типом, для описания не существует. Поэтому в боевом API процедуры описаны реестром руками — и одну процедуру он уже терял: такую потерю не ловит компилятор, только сверка реестра со сгенерированным файлом в тестах.

1/4Почему описание API порождают обходом таблицы, а не пишут файлом рядом с кодом?

2/4Звено конвейера отвечает на /health само и до маршрутизатора запрос не пускает. Что попадёт про этот адрес в описание, порождённое обходом таблицы маршрутов?

3/4У процедуры tRPC объявлена схема входа, а .output() не объявлен. Что можно написать про неё в описании API?

4/4В DTO два поля, @ApiProperty стоит над одним. Плагина компилятора у проекта нет. Что будет со вторым полем?

Два параметра в одном пути

Обход таблицы ниже переводит в шаблон OpenAPI только первый переменный сегмент: у /notes/:id/comments/:cid адрес получился полу-переведённым, а в списке параметров одно имя из двух. Почините обход: адрес должен стать /notes/{id}/comments/{cid}, а параметров — два. Подложку и декораторы не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Переменных сегментов в пути бывает сколько угодно, поэтому искать надо не первый, а все. Путь уже разбит на сегменты — по ним и идём дважды: один раз собирая адрес, другой раз собирая имена.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

После правки печатается GET /notes/{id}/comments/{cid} параметры: id, cid. Замена через replace здесь плоха не только числом: replace ищет подстроку, и путь /notes/:id/:idx он испортил бы, подставив скобки в середину второго имени. Разбор по сегментам этого класса ошибок не знает — сегмент либо целиком параметр, либо нет.

Необязательное поле в описании и в проверке

У схемы ниже форма лежит внутри, как и положено, но необязательных полей она не умеет: в required попадают все поля, и тело без status схема отвергает. Добавьте обёртку optional так, чтобы одна пометка меняла обе половины: поле уходит из required в описании и перестаёт быть обязательным в проверке. Ожидаемый вывод: ["title"] в первой строке и «принято» в обеих остальных.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пометка нужна там, где её увидят оба потребителя, — в самой схеме. Обёртка возвращает копию с полем optional и с проверкой, которая пропускает undefined:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А объект перестаёт объявлять обязательными все поля подряд:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Поле optional придётся объявить и в Described — optional?: true. Обратите внимание на последнюю строку вывода: разобранное значение печатается как {"title":"молоко"}, хотя ключ status в нём есть — со значением undefined. JSON.stringify такие ключи выбрасывает, и разница «поля нет» против «поле пустое» на проводе исчезает; в части это уже встречалось, когда речь шла о частичном обновлении.

Сверка описания с тем, что отвечает

Документ в репозитории (described) частью порождён, частью вписан руками, и проверить его можно только запросами. Напишите compare: по каждому адресу из probes спросите приложение и разложите находки на два списка — что описано, но отвечает 404, и что отвечает, но не описано. Это тот самый тест, которого не хватало боевому реестру.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Вся сверка — один проход по списку проб с двумя вопросами на каждой:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Выходит: лишние в описании — GET /notes/:id/history и GET /health (первый когда-то был, второй не появился никогда), незамеченный — GET /metrics, на который отвечает звено. Список проб здесь написан руками, и это честное ограничение такой сверки: она проверяет описание, а не ищет неизвестные адреса. Зато шаблоны для неё можно взять прямо из документа, а живые адреса — подставить в параметры по одному примеру на параметр; в боевом проекте этого хватает, чтобы забытая процедура падала в тестах, а не обнаруживалась через год.