golang

Документация, которая не врёт

  • пятница, 11 сентября 2026 г. в 00:00:19
https://habr.com/ru/articles/1080460/

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

Две одинаково безнадёжные крайности

С документацией к 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=100maximum: 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, ссылка на схему тела и ответа, и ключевые слова, полученные из validateformat, 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 по показанным моделям, а не написан для статьи.