Документация из кода: OpenAPI поверх той же таблицы метаданных
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Клиент, которого никто не писал, у вас уже есть: тип роутера даёт и вход, и ответ, и список имён процедур. Но этот клиент — для своих. Он живёт в вашем репозитории, говорит на TypeScript и при сборке исчезает без следа. А к API приходят и посторонние: мобильный разработчик, тестировщик с curl, генератор клиентов на другом языке, соседняя команда. Им нужен не тип, а текст, который читает машина: какие адреса есть, что в теле, что в ответе. Такой текст называют описанием API, а нынешний его формат — OpenAPI. Написать описание руками несложно, и ровно поэтому опасно: это вторая запись того же договора, а вторая запись расходится с первой молча. Поэтому описание здесь не напишется, а породится — обходом той же боковой таблицы, из которой каркас берёт обработчики. Потом выяснится, чего эта таблица не знает, потом то же самое проделает настоящий Nest — а под конец окажется, что в боевом API автора описание процедур собрано руками и одну процедуру уже потеряло.
Посмотрите на документ OpenAPI не как на файл, а как на значение. Это объект, и главное в нём одно поле — paths: ключ — адрес, значение — способы запроса, у каждого способа — параметры, тело и ответы. Всё остальное в нём — заголовок, версия API, подписи и примеры — написано для человека.
А теперь сравните это с тем, что у каркаса уже записано. prefixes знает префикс контроллера, routes — способ и путь обработчика, и сборка обходит модули и контроллеры, чтобы завести по этим записям маршруты. Описание — тот же обход, только на выходе не маршрутизатор, а объект.
Одно место всё-таки придётся перевести — адрес. У вашего маршрутизатора переменный сегмент пишется с двоеточием: /notes/:id. В OpenAPI тот же адрес выглядит как /notes/{id}, и сам параметр объявляется отдельно — с именем и с местом, где он стоит. Двоеточия не поймёт ни один инструмент, читающий описание; фигурных скобок не поймёт ваш маршрутизатор.
Прогноз: сколько строк даст документ у контроллера с тремя обработчиками — и что ответит каркас на адрес, которого в документе нет?
Три обработчика дали три операции, но два адреса: /notes отвечает и на GET, и на POST, и в документе это один ключ с двумя способами внутри. У byId появился параметр id, и его имя взято из того самого сегмента пути, из которого маршрутизатор берёт значение.
Последние три строки — те, ради которых рядом стоит inject. Описанные адреса отвечают, а /notes/1/history не отвечает и в документе его нет. Это не совпадение и не аккуратность автора: пока описание порождается обходом, оно не может обещать больше, чем отвечает каркас, и не может забыть то, что каркас отвечает.
Документ — не второй источник правды, а её проекция. Как только он становится файлом, который правят руками, он превращается во второй источник и начинает врать.
У проекции есть граница: таблица знает только то, что положили в неё декораторы. Всё, что отвечает мимо маршрутизатора, в ней не появляется, — а мимо отвечает многое. Звено конвейера видит только байты запроса и может ответить само, не пустив дальше: так устроены проверка здоровья, отдача файлов и всякий плагин, повешенный на транспорт.
Вторая дыра — обратная. Строку в документ можно вписать руками, и живёт она дольше маршрута: маршрут убрали, строка осталась. Ни компилятор, ни сборка об этом не скажут — документ ведь не код.
Прогноз: у трёх адресов ниже по два ответа — «есть ли он в описании» и «отвечает ли он». Какой адрес отвечает, не будучи описанным, и какой описан, не отвечая?
/health отвечает 200, а в описании его нет: посторонний о ручке не узнает, и в сгенерированном клиенте её не будет. Это описание неполное. /notes/:id/history описан и отвечает 404: по документу сгенерируют метод, который будет звать несуществующий адрес, и виноватым окажется сервер. Это описание лживое.
Лечатся дыры по-разному. Первую закрывают, дописав документ руками, — и заводят ровно ту вторую запись, от которой мы уходим; иначе никак, звено в таблицу не попадёт. Зато вторая дыра лечится дёшево: спросить приложение по каждому описанному адресу и посмотреть, не 404 ли в ответе. Это третья задача статьи, и в боевом проекте такая сверка стоит одного теста.
Описание читают не глазами. По нему генерируют клиентов (для Kotlin, Swift, Go — чем угодно из семейства openapi-generator), и обещанный адрес появляется в чужом коде готовым методом. Автор того кода в ваш репозиторий не заглядывает: он читает документ, как вы читаете типы.
Свою же страницу Swagger UI рисует по тому же документу и на каждый адрес добавляет кнопку «попробовать». Кнопка, которая отвечает 404, учит тому же, чему учила кнопка Run у неисполнимого примера: что здесь всё сломано и верить ничему нельзя.
Описание бывает неполным и бывает лживым, и это разные беды. Неполное стоит посторонним времени, лживое — доверия ко всему документу сразу.
Адреса — половина описания. Вторая половина — что в теле и что в ответе, и здесь описанию нужно от схемы то, чего она не умеет. Схема умеет одно: проверить значение. Спросить её «какие у тебя поля и какие пределы» нельзя — поля лежат в замыкании, а замыкание снаружи не читается. Один раз вы это уже видели: обход роутера напечатал про вход {}.
Прогноз: что напечатает JSON.stringify схемы — и что скажет схема на 0, если рядом с ней написано «целое, не меньше единицы»?
{} и единственный ключ check. Приписанная рядом форма — обычная вторая запись, и разъехалась она сразу: описание обещает минимум единицу, проверка принимает ноль; описание обещает двести символов, проверка принимает триста. Никто не халтурил — просто правило написано дважды, а правку внесли в одно место. Последняя строка про объект: формы у него нет вовсе, её никто не приписал, и тело в описании выйдет пустым.Лечение то же, что было у клиента: копий не должно быть. Форма не приписывается схеме рядом, а лежит в ней — таким же полем, как check, — и обе половины строятся из одного и того же аргумента.
Прогноз: что окажется в описании входа — и совпадут ли пределы в нём с тем, на чём схема отказывает?
Предел 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(), когда его об этом не попросили?
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'));
по умолчанию: {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"status":{"default":"draft","type":"string","enum":["draft","published"]}},"required":["title","status"],"additionalProperties":false}
openapi-3.0: {"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"status":{"default":"draft","type":"string","enum":["draft","published"]}},"required":["title","status"],"additionalProperties":false}
io "input": обязательны ["title"] | лишние поля: "ничего не сказано"
io "output": обязательны ["title","status"] | лишние поля: false
дата без параметров → Date cannot be represented in JSON Schema
unrepresentable "any" → {}
плюс override → {"type":"string","format":"date-time"}
целое openapi-3.0: {"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":9007199254740991}
целое draft 2020-12: {"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}Две первые строки отличаются одним ключом: по умолчанию 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 о поле не узнает — класс он читает не по типам, а по этой записи. Вот тут и появляется вторая запись договора, от которой мы прятались весь раздел про схемы.
Прогноз: какие ключи лежат в таблице у контроллера и у его метода, что окажется в схеме тела — и что ответит сервер на поле, которого в этой схеме нет?
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' }; } }
у класса: __controller__, path, host, scope:options, __version__
у create: path, method
тип тела: CreateNoteDto
@ApiProperty: [":title"]
GET /notes: параметры []
POST /notes: тело {"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateNoteDto"}}}}
GET /notes/{id}: параметры [{"name":"id","required":true,"in":"path","schema":{"type":"string"}}]
схемы: CreateNoteDto
CreateNoteDto: {"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"купить хлеб"}},"required":["title"]}
POST /notes {"title":"молоко","status":"published"} → 201 {"id":2,"title":"молоко","status":"published"}Первые строки — таблица. У класса пять ключей, у метода два: 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 найдёт сам, что появится после обхода таблицы процедур — и у какой из трёх процедур описанию будет откуда взять ответ?
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;
Swagger сам нашёл: /health
после плагина: /health
notes.byId query вход: {"type":"object","properties":{"id":{"type":"integer","minimum":0,"exclusiveMinimum":true,"maximum":9007199254740991}},"required":["id"]}
выход: в таблице нет
notes.create mutation вход: {"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"status":{"default":"draft","type":"string","enum":["draft","published"]}},"required":["title"]}
выход: {"type":"object","properties":{"id":{"type":"number"},"title":{"type":"string"},"status":{"type":"string","enum":["draft","published"]}},"required":["id","title","status"],"additionalProperties":false}
notes.published query вход: не объявлен
выход: в таблице нет
после обхода: /health, /trpc/notes.byId, /trpc/notes.create, /trpc/notes.published
GET /trpc/notes.byId?input={"id":1} → 200 {"result":{"data":{"id":1,"title":"купить хлеб","status":"published"}}}
POST /trpc/notes.create → 200 {"result":{"data":{"id":2,"title":"молоко","status":"draft"}}}
GET /trpc/notes.published → 200 {"result":{"data":[{"id":1,"title":"купить хлеб","status":"published"}]}}Первые две строки — приговор автомату: 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, и вот они описаны реестром руками. Выглядит он так — из записей реестра здесь оставлена одна.
Схемы здесь не переписаны — это те же самые объекты 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": ответ}}}. Описание, забывшее конверт, честно опишет поля и всё равно не даст сделать запрос.
Теперь про цену. Реестр статически типизирован, и одну беду это ловит: переименуйте схему в контракте — и сборка станет красной, а не документ тихо потеряет поле. А вот коды ответов рядом с ними — обычные строки описания: те же доменные коды, что отдаёт сервер, но набранные заново, и их не проверяет уже ничто.
А вторую беду тип не ловит вовсе: процедуру, которую в реестр не вписали. Пока писалась эта статья, в реестре боевого 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}, а параметров — два. Подложку и декораторы не трогайте.
Переменных сегментов в пути бывает сколько угодно, поэтому искать надо не первый, а все. Путь уже разбит на сегменты — по ним и идём дважды: один раз собирая адрес, другой раз собирая имена.
После правки печатается GET /notes/{id}/comments/{cid} параметры: id, cid. Замена через replace здесь плоха не только числом: replace ищет подстроку, и путь /notes/:id/:idx он испортил бы, подставив скобки в середину второго имени. Разбор по сегментам этого класса ошибок не знает — сегмент либо целиком параметр, либо нет.
Необязательное поле в описании и в проверке
У схемы ниже форма лежит внутри, как и положено, но необязательных полей она не умеет: в required попадают все поля, и тело без status схема отвергает. Добавьте обёртку optional так, чтобы одна пометка меняла обе половины: поле уходит из required в описании и перестаёт быть обязательным в проверке. Ожидаемый вывод: ["title"] в первой строке и «принято» в обеих остальных.
Пометка нужна там, где её увидят оба потребителя, — в самой схеме. Обёртка возвращает копию с полем optional и с проверкой, которая пропускает undefined:
А объект перестаёт объявлять обязательными все поля подряд:
Поле optional придётся объявить и в Described — optional?: true. Обратите внимание на последнюю строку вывода: разобранное значение печатается как {"title":"молоко"}, хотя ключ status в нём есть — со значением undefined. JSON.stringify такие ключи выбрасывает, и разница «поля нет» против «поле пустое» на проводе исчезает; в части это уже встречалось, когда речь шла о частичном обновлении.
Сверка описания с тем, что отвечает
Документ в репозитории (described) частью порождён, частью вписан руками, и проверить его можно только запросами. Напишите compare: по каждому адресу из probes спросите приложение и разложите находки на два списка — что описано, но отвечает 404, и что отвечает, но не описано. Это тот самый тест, которого не хватало боевому реестру.
Вся сверка — один проход по списку проб с двумя вопросами на каждой:
Выходит: лишние в описании — GET /notes/:id/history и GET /health (первый когда-то был, второй не появился никогда), незамеченный — GET /metrics, на который отвечает звено. Список проб здесь написан руками, и это честное ограничение такой сверки: она проверяет описание, а не ищет неизвестные адреса. Зато шаблоны для неё можно взять прямо из документа, а живые адреса — подставить в параметры по одному примеру на параметр; в боевом проекте этого хватает, чтобы забытая процедура падала в тестах, а не обнаруживалась через год.