В прошлой статье я рассказывал, как лень довела меня до кодогенератора для биндинга и валидации. Закончил я обещанием: теги оказались не приватным входом одного инструмента, а объявлением, которое может прочитать кто угодно. Вот второй читатель.
Две одинаково безнадёжные крайности
С документацией к API у меня было два опыта, и оба плохие.
Написанная руками. Красивый openapi.yaml, в нём примеры, описания семантики, разложенные по полочкам коды ошибок. Ровно до первого релиза. Потом кто-то добавил поле, кто-то переименовал параметр, кто-то выпилил ручку — и через полгода спецификация описывает систему, которой нет. Хуже, чем её отсутствие: отсутствие честно, а эта врёт с уверенным лицом. Клиент читает, пишет по ней интеграцию, приходит с багом, и оказывается, что баг в документации.
Сгенерированная целиком. Инструмент строит yaml из кода, всё всегда актуально. Ровно до того момента, как кто-то написал в описании поля что-то осмысленное — «идентификатор из внешней системы, у старых записей пустой» — и следующий прогон это стёр. Раз стёр, два стёр, и люди перестают писать. Остаётся формально верная, но нечитаемая простыня из type: string без единого слова о том, что эта строка означает.
Обе стороны абсолютно правы насчёт проблем другой.
Третья дорога, которую я не осилил
Знающий читатель здесь скажет: обе беды от того, что документацию делают после кода. Пишите спецификацию первой, генерируйте из неё типы — и врать станет нечему.
В первой статье я отговорился тем, что проект уже был и переписывать HTTP-слой никто бы не дал. Это правда, но не вся: я пробовал spec first и на новом проекте — и не смог.
Причина не в инструментах, ogen и oapi-codegen хорошие. Причина в том, что spec first требует решить форму до того, как код существует, а я так не умею. Я прихожу к форме перебором: собираю работающий вариант, смотрю, как он живёт на настоящих данных, и переделываю — обычно выбрасывая свою же первую выдумку. Когда впереди лежит yaml, каждая такая переделка становится правкой в двух местах, то есть лишним поводом её не делать. А переделки — единственный известный мне способ получить нормальный интерфейс.
С TDD, кстати, у меня та же история и ровно по той же причине. Написать тест на поведение, которое я ещё не придумал, у меня не выходит: я придумываю его, пока пишу код. Это про меня, а не про метод: люди, у которых получается, существуют, и я им завидую.
Так что документация у меня всё равно появляется после кода. И вот тут вопрос обычно ставят неверно.
Вопрос поставлен неверно
Спецификация — не один документ. Это два разных документа, слипшихся в один файл.
Есть структура: какие есть пути, какие у них параметры, какого типа поля, какие обязательны, что может прийти null. Всё это уже написано в коде, причём формально, и человек, который переписывает это в yaml руками, работает транслятором. Плохо работает: он забывает, устаёт и уезжает в отпуск.
И есть проза: что эта ручка делает, почему поле называется так, что бывает, если прислать отрицательное число, какой пример осмысленный. Ничего этого в коде нет и быть не может. Это знание живёт в голове человека и попадает в файл единственным способом — он его туда напишет.
Отсюда решение, и оно не в том, чтобы найти хитрый способ слить два документа, а в разделённой заботе обоих родителей: у каждого своя половина, и в чужую он не лезет.
Структура принадлежит коду и синхронизируется на каждом прогоне, без спроса. Проза принадлежит файлу и не трогается никогда. Не «мержится аккуратно», не «трогается только при конфликте» — не трогается совсем. Генератор физически не пишет в те ключи, которые принадлежат человеку.
Есть и другой ответ на тот же вопрос
Сразу скажу про huma, потому что она про то же самое и её обязательно вспомнят. Там тоже code first и тоже спецификация выводится из Go-типов.
Одна линия у нас общая: спецификация в обоих случаях строится в рантайме, обходом типов. Тут у меня никакого превосходства.
А вот путь запроса различается принципиально. huma разбирает и проверяет каждый входящий запрос рефлексией — по той же схеме, которую сама и вывела. Здесь этим занимается сгенерированный прямолинейный код, тот самый из первой статьи, а рефлексия просыпается ровно один раз и только когда у неё попросили спецификацию. Разница не в том, есть ли рефлексия, а в том, живёт ли она на горячем пути.
Но для этой статьи важнее другое — где живёт проза. В huma описания и примеры задаются в Go: рядом с регистрацией операции, аргументами и тегами. Файла, который правит человек, там (насколько я знаю) просто нет — документ генерируется целиком.
Это законный выбор, и в нём есть плюс: одна точка правды, рассинхронизировать нечего по определению. Но следствий у него два, и оба меня остановили.
Первое: это тяжело читать. Описание, enum и пример, втиснутые в теги одного поля, дают примерно вот такую строку:
Status string `json:"status" enum:"active,archived" example:"active" doc:"Состояние договора; архивный только читается и не продлевается"`
Тег — одна строка, в ней нет ни абзацев, ни переносов, ни возможности отступить. Два предложения семантики превращаются в двести символов, уезжающих за край экрана. А главное — объявление типа перестаёт отвечать на вопрос, за которым в него пришли: какие есть поля и какого они типа. На одном поле это заметно, на структуре из пятнадцати читать уже нечего.
Второе: проза оказывается не там, где нужна. Структуру открывают, когда меняют код. Документацию читают, когда пишут интеграцию, и это обычно другие люди — часто вообще не открывающие Go. Значит и править описания сможет только тот, кто пишет на Go, а семантику ручек лучше всех знают не программисты. Им нужен файл, в который можно залезть, ничего не собирая.
Плюс huma владеет сигнатурой хендлера и интеграцией с роутером. Здесь роутер — обычный chi, а сервер про OpenAPI не знает вообще: спецификация выводится по таблице маршрутов, которую он и так ведёт для себя. Тот же приём с разделением владения, только на уровень выше.
Откуда генератор знает про типы
Тут обычно начинается самое неприятное в таких инструментах — аннотации. Комменты вида // @Success 200 {object} Contract над каждым хендлером, отдельный парсер, отдельный шаг сборки и отдельный класс ошибок «забыл поправить коммент».
Аннотации нужны затем, что генератору неоткуда взять типы. Я пошёл проверять, так ли это, и первым делом написал статический анализатор: обход AST, всё как положено.
Типы он находит — тип это объявление, оно лежит в исходнике целиком. Но пока я его писал, стало видно кое-что получше: типы уже собраны в одном месте, и не мной. Типизированный хендлер объявляет их сам:
mux.Get("/contracts", server.Bind( func(ctx context.Context, req *model.ListReq) (*server.Response[ListRes], error) { ... }, ))
server.Bind — обобщённая функция, и в момент оборачивания она знает и ListReq, и ListRes. Она их запоминает, а мультиплексор складывает вместе с методом и шаблоном пути:
type RouteMeta struct { Method string Path string Req reflect.Type Res reflect.Type Handler uintptr }
Всё. Таблица маршрутов уже содержит то, за чем другие генераторы ходят в комментарии: метод, шаблон пути и оба типа. Аннотации не нужны не потому, что я их не люблю, а потому, что данные и так есть.
Обратите внимание, что здесь произошло. Пакет с моделями про OpenAPI не знает. Генератор биндинга про OpenAPI не знает — я даже на всякий случай проверял это грепом в прошлый раз. Про OpenAPI знает только пакет, который его строит, и он никого не просил подготовиться.
Анализом их не достать
И тут анализатор кончился. Каталог типов он собирает, а таблицу — нет: таблица нигде не объявлена, она собирается исполнением кода. Вот из чего складывается один путь:
a := mux.Group("/admin") a.Get("/users/{userId}", r.admin("user:read"), http.Bind(r.usersHandler.GetByAdmin))
Итогового /admin/users/{userId} нет нигде: он получается сложением префикса группы, которая лежит в переменной, с путём метода. Это анализатором осилить ещё реально — придётся тащить группы через переменные и вызовы функций, но реально.
А вот это уже нельзя:
if os.Getenv("PROJECT_NAME") == "gws" { r.setupPacking(mux) }
Какие ручки вообще зарегистрируются, решает переменная окружения. У нас на одной кодовой базе живут две площадки, и наборы ручек у них разные — причём условие сидит не только в регистрации маршрутов, но и глубже, в том, какие части сервиса собираются при старте.
Анализатору пришлось бы вычислить os.Getenv — или выдать спецификацию для всех комбинаций флагов сразу, включая конфигурации, которых никогда не бывает.
У запущенного процесса этой проблемы нет, потому что он уже одна конкретная конфигурация. Спецификация получается для того сервиса, который вы собрали и запустили, а не для объединения всех возможных.
Так что анализатор упёрся в границу: он читает объявления, а таблица — не объявление, а результат работы программы. Запуск здесь не компромисс, а единственный известный мне способ спросить у сервиса, что у него на самом деле зарегистрировано.
Аннотации, к слову, эту границу не переходят тоже. Они описывают то, что программист думает про свои маршруты, а не то, что получилось.
Запущенный процесс отдаёт таблицу, а дальше рефлексия читает поля перечисленных в ней типов — те самые теги scan, validate и json из первой статьи.
Как типы вообще доезжают до роутера
Сразу оговорка, потому что дальше будут слова «глобальный буфер» и «мьютекс», а в разделе про HTTP-сервер они читаются как приговор. Ни буфер, ни мьютекс, ни чтение типов не участвуют в обработке запроса. Всё это происходит один раз, пока роутер собирается, — то есть при старте процесса, до того как сервер начал слушать. Дальше таблица маршрутов готова, цепочка обработчиков — обычные функции, и запрос не встречает на своём пути ни одной блокировки из этого раздела.
Теперь сам трюк. Он мне до сих пор не нравится, хотя работает.
Проблема в том, что Layer — это func(next Handler) Handler, обычный функциональный тип. Дженерик-параметры Bind в нём не выживают: наружу выходит функция, а роутер видит только её. Приделать к ней поля нельзя — тип функциональный, не структурный.
Поэтому в пакете лежит буфер:
var ( pendingMu sync.Mutex pendingReq, pendingRes reflect.Type pendingPC uintptr )
Типы объявляет не сам Bind, а слой, который он вернул, — в момент, когда register применяет его к цепочке; register их тут же забирает и очищает буфер. Метаданные проезжают в обход системы типов: напрямую через неё их никак не протащить. Почему именно так, а не проще, — следующий раздел.
Про цену на старте стоит сказать точно, раз уж речь о ней. reflect.TypeFor[Req]() — не обход структуры: он отдаёт готовый указатель на дескриптор типа, который компилятор уже положил в бинарник. На старте не происходит ни разбора полей, ни чтения тегов, ни построения дерева OpenAPI: приложение перекладывает несколько указателей и складывает плоский массив RouteMeta. Тяжёлый обход случается один раз и только когда вы явно попросили спецификацию.
Буфер при этом общий на пакет, поэтому отрезок от объявления до сбора закрыт мьютексом. Не ради продакшена — маршруты там объявляются последовательно при старте, — а ради тестов: два роутера, собираемые в параллельных подтестах, иначе разбирали бы типы друг друга. А вот регистрировать в один роутер из нескольких горутин по-прежнему нельзя, и мьютекс тут ничего не изменит: вставка в дерево маршрутов у chi сама по себе не потокобезопасна.
А вот здесь я налетел по-настоящему
Потому что первую версию этого раздела я написал проще: типы кладёт Bind, забирает register. Так оно и было. А потом я решил проверить, что бывает, если между этими двумя событиями вклинится что-то третье.
Бывает вот что:
bound := server.Bind(handler) // типы легли в буфер mux.Get("/plain", someMiddleware) // ...и достались этому маршруту mux.Get("/typed", bound) // а этот остался без типов
/plain получил типы чужой ручки, /typed не получил своих. В спецификации это означает одну неверно описанную ручку и одну пропавшую — молча, без единого предупреждения.
Причём случай не выдуманный. Вот совершенно нормальный код:
bound := server.Bind(handler) mux.Get("/a", bound) mux.Post("/a", bound)
Один обработчик на два метода. Типы получал только GET, POST уходил в документацию пустым.
Корень в том, что буфер наполнялся в момент вызова Bind, а забирался в момент регистрации, и между ними могло произойти что угодно. Починка оказалась маленькой — та самая, что описана выше: объявляет типы не Bind, а возвращённый им слой, когда его применяют, то есть внутри той самой регистрации, к которой они относятся. Держите слой в переменной, переиспользуйте на пяти маршрутах — каждый получит своё.
Тесты на это я написал уже после того, как поймал, и с формулировками вроде «типы не должны достаться маршруту, который их не объявлял». Раньше их не было, потому что мне не приходило в голову, что так бывает.
Что в итоге получается
Возьмём модель списка с миксином пагинации из первой статьи, дописав ему границы:
type Paginated struct { Page int64 `scan:"query=page" validate:"gte=1"` Count int64 `scan:"query=count" validate:"lte=100"` Sort []string `scan:"query=sort[],repeated"` } type ListReq struct { Paginated Paginated `scan:"embed"` Status string `scan:"query=status" validate:"oneof=active archived"` Code string `scan:"query=code" validate:"startswith=ORD-"` } type Contract struct { ID uuid.UUID `json:"id"` Email string `json:"email" validate:"required,email"` Note *string `json:"note"` Status string `json:"status" validate:"required,oneof=active archived"` } type ListRes struct { Items []Contract `json:"items"` }
И вот что выпадает из Emit — не пересказ, а вывод, я его сгенерировал перед тем, как писать этот абзац:
paths: /contracts: get: parameters: - $ref: '#/components/parameters/page' - $ref: '#/components/parameters/count' - $ref: '#/components/parameters/sort' - $ref: '#/components/parameters/status' - $ref: '#/components/parameters/code' responses: "200": description: OK content: application/json: schema: $ref: '#/components/schemas/openapi.ListRes' components: schemas: openapi.Contract: type: object properties: email: type: string format: email id: type: string format: uuid note: type: [string, "null"] status: type: string enum: - active - archived required: - email - status openapi.ListRes: type: object properties: items: type: array items: $ref: '#/components/schemas/openapi.Contract' parameters: code: name: code in: query required: false schema: type: string x-constraints: - startswith=ORD- count: name: count in: query required: false schema: type: integer format: int64 maximum: 100 page: name: page in: query required: false schema: type: integer format: int64 minimum: 1 sort: name: sort[] in: query required: false schema: type: array items: type: string status: name: status in: query required: false schema: type: string enum: - active - archived
Разберём, что здесь интересного, потому что почти каждая строчка — чьё-то решение.
Миксин растворился. В операции все пять параметров лежат на одном уровне: scan:"embed" поднял page, count и sort из вложенного типа в родителя, к его собственным status и code. Никакого Paginated в спецификации нет, и правильно — клиент про наш способ переиспользования кода знать не должен.
Правила валидации переехали в схему. validate:"email" стал format: email, oneof — списком enum, uuid.UUID — строкой с format: uuid. Не потому, что генератор знает про uuid, а потому, что это известный тип с известным представлением.
Границы тоже. gte=1 стал minimum: 1, lte=100 — maximum: 100. На строке те же правила дают minLength с maxLength, на массиве — minItems с maxItems: ограничивается то, что у этого типа вообще можно ограничить.
А startswith=ORD- не переехал — такого ключевого слова у OpenAPI нет. Правило не выбрасывается: оно уезжает в x-constraints и остаётся видимым тому, кто читает спецификацию.
*string без required стал type: [string, "null"]. Это форма 3.1, а не устаревший nullable: true. Логика простая: обязательность в этих моделях означает «биндер проверил, что значение пришло», поэтому необязательный указатель — это ровно «может не прийти».
Обратите внимание, что у параметров такого нет: page остался type: integer с required: false. Это не недосмотр. "null" в типе означает «значение может быть литеральным null», а в {"note": null} это настоящее присутствующее значение. Query-строка такого выразить не умеет: ?page= — пустая строка, а не null. Отсутствие параметра описывается required: false, и добавить туда "null" было бы обещанием, которое клиент не сможет исполнить.
Параметры вынесены в компоненты и подключены через $ref. Параметр page, который встречается в двадцати ручках, описан один раз.
И маленькое, но приятное: ключ компонента sort, а name внутри — sort[]. Клиент присылает sort[]=name, потому что так делает его фреймворк, и спецификация обязана называть параметр именно так. А в ключ компонента квадратные скобки не годятся. Два разных имени для двух разных задач, и склеивать их нельзя.
Разногласие лучше тишины
С общими параметрами есть ловушка, в которую легко провалиться.
Пусть в одной ручке status — строка с enum, а в другой кто-то объявил status целым числом. Соблазн — слить в один компонент: имя же одно. Результат: в спецификации один контракт, а в системе два, и клиент узнает об этом от пятисотки.
Поэтому одноимённые параметры сравниваются — по расположению, структуре типа и обязательности. Совпали — сливаются в один компонент. Разошлись — разъезжаются на pkg.ReqType.status и попадают в предупреждения.
Молчаливое слияние выглядит опрятнее, а разъезд честнее. Опрятность здесь стоит дороже: неверная спецификация не выглядит неверной.
Второй читатель видит то, чего не видел первый
В первой статье я признал границу: опечатку в scan генератор биндинга не ловит. Тег называет источник, а модель сознательно ни к какому транспорту не привязана — сверять не с чем.
Здесь есть с чем. Документ строится по таблице маршрутов, то есть по паре «модель и сервер, который её обслуживает», — и обе половины наконец сходятся в одном месте. Причём словарь тут не предположение: мультиплексор сам создаёт Source для каждого запроса, так что список scope’ов известен точно, и неизвестный означает поле, которое не наполнится никогда.
Поэтому не предупреждение, а отказ:
1 field(s) nothing can fill: openapi.Invite.Token: unknown scan scope "heder" — nothing fills this field, and it is not part of the request body either
Раньше такое поле уезжало в схему тела: документ просил у клиента то, чего тот присылать не должен. Это хуже молчания — пропущенную строчку замечают, а неверную читают и верят. А предупреждение было бы молчанием потише: их читают ровно до третьего.
Проверка частичная, и это стоит сказать прямо: только HTTP и только ручки, прошедшие через Bind. Модель, которую наполняет очередь, по-прежнему не сверяется ни с чем. Но случай ровно тот, ради которого всё и затевалось: тег читает второй инструмент, и он отказывается делать то, чего первый даже не мог заметить.
operationId, который лечится сам
Ещё одна мелочь, которая портит жизнь в сгенерированных спецификациях, — operationId. Клиентские генераторы делают из него имена методов, так что значение важное, а вписывать его руками мучительно.
Здесь он выводится из самой функции-хендлера, и эскалирует ровно настолько, насколько требует уникальность: сначала имя метода, потом пакет плюс имя, потом добавляется путь, потом HTTP-метод. Пока Create в проекте один — он Create. Появился второй — оба уточняются, а не получают номерки.
И, что важнее, operationId принадлежит генератору и перезаписывается на каждом прогоне. Переименовали хендлер — спецификация вылечилась, а не осталась с устаревшим идентификатором, который никто не заметит.
Анонимное замыкание осмысленного имени не имеет и не получает id вовсе. Тоже решение: лучше без id, чем func1.
Что происходит при слиянии
Самая содержательная часть — Reconcile, согласование двух частей. Для каждой схемы, параметра и операции код может оказаться в одном из трёх состояний.
Этого нет в файле. Добавляется, и с комментарием, который говорит человеку, что он ещё должен:
openapi.Contract: # TODO(openapi): fill semantics (format/enum/example/description)
Не «TODO», а перечень того, чего не хватает. Не надо вспоминать, за что отвечаешь.
Это в файле есть. Сравнивается структурно, а потом переписывается всё, что выведено из кода: обязательность, nullability, operationId, ссылка на схему тела и ответа, и ключевые слова, полученные из validate — format, enum, границы. Ваши summary, description, примеры, дополнительные коды ответов не трогаются: они не в зоне генератора.
Граница тут одна, и она строже, чем «структура против прозы»: что генератор написал, то он обязан уметь переписать. Значение, выданное однажды и потом неисправимое, тихо стареет — а golden-проверка из предыдущего раздела подтвердит такой файл как актуальный, потому что байты не менялись. Поэтому правило, ужатое с min=2 до min=8, доезжает до документа, а не остаётся в нём прежним.
Обратная сторона: ключевое слово, за которым правила нет, остаётся вашим. Уточнили format руками на поле без тега — это законно, и генератору нечего об этом сказать. Отличить это от удалённого правила он не может, поэтому не пытается: и уточнение, и удаление выглядят в файле одинаково, а гадать хуже, чем оставить.
Само же структурное сравнение к формату слепо и остаётся таким: расхождение в format — не конфликт типов, и ошибкой оно не будет никогда.
Это несовместимо. Поле, у которого тип отличается от кода; поле, которого в коде больше нет; параметр, у которого разошёлся in — это ошибка, а не молчаливая правка.
И отдельное правило, о котором стоит сказать вслух: записи в файле, которых код не порождает, — предупреждение, но никогда не удаление. Они бывают законными: ручка могла быть зарегистрирована без типизированного хендлера. А могут быть следом переименования — и тогда это хорошая подсказка. В обоих случаях решение за человеком, потому что удалить чужой текст генератор права не имеет.
Заодно Reconcile пишет объединённый документ до того, как вернуть ошибку. Упавший прогон всё равно оставляет файл, в который можно посмотреть, — иначе отладка структурного расхождения превращается в угадайку.
Один формат, и это решение, а не недоделка
Медиа-тип в документе один — application/json, и настройки для этого нет. Я успел сделать её и убрать, поэтому расскажу, обо что она сломалась: это хороший пример того, как «сделать настраиваемым» оказывается дороже, чем выглядит.
Рендерер ошибок собирает тело как открытую мапу — payload у типизированной ошибки несёт произвольные ключи, это его смысл. А encoding/xml мапы не умеет вообще:
xml: unsupported type: map[string]string
Не «не реализовано», а нет верного представления: у открытой карты в xml нет формы без выдуманного соглашения вроде <entry key="…">, которое сразу стало бы публичным API. Значит документ, объявивший xml, не смог бы честно описать ответы с ошибками той же ручки — а они там же, на той же операции.
Второе, помельче: формат успешного ответа выбирает хендлер, server.OK против server.XML. Генератор читает типы и маршруты, тело функции он не читает — и по критерию из последней статьи серии читать не должен. То есть даже про успешный ответ объявленный формат был бы обещанием, а не выводом.
Так что документ описывает json-API, и это записано как решение. Ручка, которая отдаёт что-то другое, правит свой content руками: Reconcile добавляет только application/json, а остальные медиа-типы не трогает — они в человеческой половине файла, как и проза.
Починить, кстати, нетрудно: формат — свойство маршрута, объявляется там же, где маршрут, и уезжает в RouteMeta, куда генератор и так смотрит. Дорого не объявить, а проверить — а без проверки я бы только передвинул ложь на шаг ближе к автору: вместо документа, который догадывается, получился бы автор, который объявил и забыл.
Документация как golden-файл
Раз запускать всё равно приходится — пусть запускает тест.
Живым этот запуск должен быть меньше, чем кажется. «Запущенное приложение» означает «дошло до сборки роутера», а не «слушает порт»: у серверной команды есть флаг --openapi <путь>, по которому она собирает граф, регистрирует маршруты, пишет файл и выходит, ни разу не вызвав Serve.
И дело не только в том, что так дешевле. Всё описанное выше говорит, что сделать, но не заставляет это сделать: прогон Reconcile остаётся чьей-то обязанностью, а обязанности забывают. Добавили ручку, не запустили, файл отстал — и мы вернулись к первой крайности из начала статьи, только теперь врёт не человек, а его невнимательность.
Лечится это тем, что документация становится golden-файлом. Прогоняем Reconcile в тесте на копии, сравниваем с закоммиченным — разошлось, сборка красная, дифф прямо в выводе:
func TestOpenAPIIsUpToDate(t *testing.T) { t.Setenv("DB_DSN", "postgres://x") // граф читает это при сборке mux := buildRouter(t) // тот же роутер, что в проде openapitest.AssertUpToDate(t, mux.Routes(), "docs/openapi.yaml", "Contracts API") }
Ровно та схема, по которой живут все мои генераторы: go test проверяет, go test -openapi.update обновляет. Держится она на одном свойстве: Reconcile идемпотентен — прогон на актуальном файле оставляет байты нетронутыми. Иначе сборка краснела бы на каждом коммите и её перестали бы читать за неделю.
И тут у сборки появляется второй способ покраснеть, который мне нравится больше первого. Помните TODO(openapi): fill semantics над только что добавленной записью? Тест может падать, пока в файле остаётся хоть один такой маркер. Первый способ ловит «добавил ручку и не обновил файл». Второй — «обновил файл и не написал ни слова о том, что ручка делает».
Маркер снимает человек, руками, и это единственное, что от него требуется: удаление маркера и есть подпись «я посмотрел». Чтобы это работало с первого дня, метки получает и самый первый файл — тот, в котором ещё не описано вообще ничего. Иначе проверка проходила бы по пустому месту.
Предел у этого важный. Никто не проверяет, что человек написал что-то осмысленное: маркер можно снести и не написать ни строчки. Это ловушка против забывчивости, а не гейт качества.
Но обратите внимание, что произошло. В середине статьи я утверждал, что смысл в коде не лежит и лежать не может, и потому прозу проверить нельзя. Это по-прежнему так. Оказалось только, что проверить нельзя прозу, а вот её отсутствие — можно.
Сколько документов
Вопрос, который встаёт сразу за тестом: а если у сервиса несколько API? У нас именно так — http:customer и http:admin это две команды одного бинарника, у каждой свой роутер. Тест тогда табличный:
for _, doc := range []struct { router server.Router path, name string }{ {app.RouterForCustomer(), "docs/customer.yaml", "Customer API"}, {app.RouterForAdmin(), "docs/admin.yaml", "Admin API"}, } { mux := server.New() // свежий на каждый документ doc.router.Setup(mux) openapitest.AssertUpToDate(t, mux.Routes(), doc.path, doc.name) }
Свежий server.New() обязателен: маршруты копятся в одном слайсе, и один mux на два роутера молча склеит две спеки в одну. Впрочем, это единственная ошибка, которую golden поймает сам — в файле окажутся чужие пути.
Из терминала то же самое выходит само, без всяких таблиц: в процессе живёт одна команда, значит и роутер в нём один. ./app http:customer --openapi docs/customer.yaml и ./app http:admin --openapi docs/admin.yaml — два прогона, два файла.
А вот дальше интереснее, потому что конфигураций у нас больше, чем документов. Приложение поднимается ещё и под две площадки, PROJECT=gws и PROJECT=dl, и по арифметике должно бы получиться четыре файла. Их два.
Причина ровно в том, о чём вся статья. Резать документ по конфигурации генератору ничего не стоит — он с удовольствием напишет четыре файла вместо двух. Стоит это человеку: у площадок девяносто процентов ручек общие, и разделение по площадкам означало бы поддерживать одни и те же описания, примеры и семантику в двух файлах. Ту самую половину, которая не выводится ни из чего и пишется руками.
Отсюда правило, которое я сформулировал уже задним числом: число документов определяется тем, где расходится проза, а не тем, где расходится конфигурация. Иначе на двадцати флагах у вас будет двадцать спецификаций, из которых девятнадцать — копии.
Цену и здесь назову. Пока площадки совпадают, один файл на аудиторию — точное описание. Когда разойдутся, файл станет объединением: прогон под gws допишет свои ручки, прогон под dl — свои, и каждый пожалуется на чужие словами «operation X: in doc, not in code». Это, кстати, и есть польза: день, когда площадки разъехались, не пройдёт молча — согласование сообщит о нём само, и вот тогда и решать, делить или оставить объединение с оговоркой для клиента.
Цена приёма
Вся она про тест. Он пишет в рабочее дерево — точнее, в копию, но всё равно это тест с побочным эффектом, и часть людей такое не любит справедливо. Жить он должен в сервисе, а не в библиотеке: таблица маршрутов сервисная. И переменные окружения, которые граф читает при сборке, придётся выставить — вот те самые t.Setenv. Как обойтись и без них, я расскажу в следующей статье: там появляется вещь, которой мне здесь не хватает.
Обновление документа у меня висит в сборке, рядом с генерацией и тестами: команда с флагом --openapi доходит до сборки роутера, пишет файл и выходит. Это стоит завести сразу — документация, которую надо обновлять отдельным движением, рано или поздно окажется устаревшей.
Чего здесь нет
Один медиа-тип, json. Почему настройки для этого нет — выше, в разделе про формат. Ручка, которая отдаёт другое, правит свой content руками, и слияние это уважает.
Один код ответа, 200 OK. Остальные ваши: их надо написать, и они выживут. Генератор их не придумывает, потому что не знает, чем именно ваш сервис отвечает на конфликт.
Ручки без типизированного хендлера невидимы. Зарегистрировали через нетипизированный Handle — типов нет, в спецификации ручки не будет. Не баг, а следствие: генератору неоткуда взять типы. Но помнить надо, иначе окажется, что половина API не задокументирована и никто не ругался.
info, servers, security, tags вне слияния. Написаны руками и сохраняются.
И опять налетел, уже на ровном месте
Фрагмент выше я собирал как иллюстрацию к тезису «невыразимое не выбрасывается», и первым примером взял lte=100. А потом полез проверить, почему lte невыразим.
Он выразим. lte=100 на целом поле — это ровно maximum: 100. В маппинге были разобраны min, max и len, а gte, lte, gt и lt уезжали туда же, куда уходит действительно невыразимое, хотя умеют то же самое. То есть это была не граница подхода, а недоделка, которая полтора абзаца притворялась дизайном.
Пришлось починить, прежде чем публиковать. Теперь gte и lte дают включающие границы, gt и lt — исключающие: для чисел это exclusiveMinimum и exclusiveMaximum из 3.1, а для длин строк и размеров массивов таких ключей в OpenAPI нет, поэтому граница сдвигается на единицу — gt=5 на строке означает minLength: 6. Длины целые, так что не теряется ничего.
Поэтому в листинге выше и стоит startswith=ORD-: чтобы показать этот случай, мне понадобилось правило, которое действительно невыразимо, а не то, которое я поленился разобрать.
Это третий такой случай за две статьи. Похоже, объяснение вслух работает как отладчик. Пока я держал эти места в голове, они выглядели решениями; стоило записать их так, чтобы понял читатель, — и недоделки стало видно самому. Обидно каждый раз, полезно каждый раз.
Что из этого следует
Обещание из первой статьи выполнено буквально: те же теги, второй читатель, никакой связи между ними. whisk про OpenAPI не знает, пакет с моделями не знает, а спецификация тем не менее выводится и не отстаёт.
Но приём, ради которого я всё это рассказываю, не про OpenAPI. Он про деление владения. Мучение с генерируемой документацией почти всегда сводится к тому, что генератор и человек претендуют на один и тот же файл целиком. Как только получается разделить его по зонам ответственности — вот это твоё, вот это моё, и в чужое никто не пишет — вражда кончается, и обе стороны начинают работать.
Так что если у вас в проекте есть файл, который генератор перетирает, а человек всё равно правит руками, — вопрос, скорее всего, не в том, как их примирить. А в том, по какой линии этот файл поделить.
А когда поделили — линию можно поставить под тест. Половина генератора проверяется сравнением, половина человека — наличием непогашенных маркеров, и вместе это значит, что «документация отстала» перестаёт быть чьей-то виной и становится красной сборкой.
Тот же ход сработал у меня ещё в одном месте, самом для меня неожиданном, — в сборке приложения. Про это — третья статья серии.
Код: gitlab.com/gorib/http, пакет openapi. Фрагмент спецификации выше сгенерирован тем самым Emit по показанным моделям, а не написан для статьи.
