golang

Одна команда вместо тысячи аннотаций: как я сделал генератор OpenAPI для Fiber

  • понедельник, 27 июля 2026 г. в 00:00:21
https://habr.com/ru/articles/1063084/

Есть особый жанр боли, знакомый всем гоферам, которые хоть раз отдавали свою HTTP‑ручку наружу: документация. Не та, что «напишите README», а честная машиночитаемая спека, по которой фронтенд сгенерирует клиент, QA — коллекцию, а API Gateway — валидацию.

// CreateUser godoc
// @Summary      Create user
// @Accept       json
// @Produce      json
// @Param        request body CreateUserRequest true "user"
// @Success      201 {object} models.User
// @Failure      400 {object} ErrorResponse
// @Router       /api/v1/users [post]
func (h *UserHandler) Create(c fiber.Ctx) error { ... }

Восемь строк дублирования на одну ручку. И самое обидное: через месяц кто‑то поменяет models.User на dto.UserResponse, а комментарий останется. Компилятор промолчит, линтер промолчит, спека соврёт. Документация, которая расходится с кодом — хуже, чем её отсутствие, потому что ей верят.

Мне хотелось до неприличия простого: зайти в корень существующего проекта, выполнить одну команду и получить openapi.yaml. Не переписывая хендлеры, не навешивая аннотации, не внедряя новый роутер, не добавляя ни единой строчки в рабочий код.

Так появился fibgen.

go install github.com/nyawave/fibgen/cmd/fibgen@latest
fibgen ./... > openapi.yaml

Дальше — про то, почему это оказалось не так тривиально, как хотелось бы, и что из этого вышло.

Почему с Fiber всё сложно

Экосистема Go в последнее время неплохо решает задачу «типизированного API». Есть huma, есть fuego, есть spec‑first подход через oapi‑codegen. Все они работают, и работают хорошо. Но у них общее свойство: они хотят, чтобы вы писали код по‑другому. Хендлер становится обобщённой функцией с явными типами func(ctx, In) (Out, error), из которых спека выводится тривиально.

А теперь посмотрим на типичный хендлер Fiber:

func (h *UserHandler) Create(c fiber.Ctx) error

Это всё. Сигнатура не содержит ничего. Ни типа запроса, ни типа ответа. Они существуют исключительно как локальные переменные внутри тела:

var req CreateUserRequest
if err := c.Bind().Body(&req); err != nil { ... }
...
return c.Status(fiber.StatusCreated).JSON(user)

Рантайм‑рефлексия здесь бесполезна: с точки зрения reflect все хендлеры проекта — один и тот же тип. Информация о типах не то чтобы спрятана — она просто находится в другом месте. Внутри тела функции.

Значит, туда и надо идти.

Статический анализ: go/packages + go/types

Идея простая, хотя реализация — не очень. Мы загружаем проект тем же тайпчекером, которым его собирает компилятор, и ходим по AST:

  1. Находим регистрации маршрутов: app.Get(...), app.Post(...), Add(method, path,...), All(...).

  2. Разворачиваем группы: app.Group(“/api/v1”).Group(“/users”) → префикс /api/v1/users. Причём префиксы надо резолвить и через переменные (v1:= app.Group(“/api/v1”)), и через роутеры, переданные в функцию параметром — потому что примерно все так и делают: func RegisterUserRoutes(r fiber.Router).

  3. Резолвим сам хендлер до тела функции: именованная функция, method value контроллера (h.Create), инлайновое замыкание, переменная функционального типа.

  4. Идём внутрь тела и собираем вызовы c.*.

  5. Переводим найденные Go‑типы в JSON Schema через go/types.

Пункт 5 — это, неожиданно, самая объёмная часть. Потому что «взять структуру и сделать из неё схему» звучит просто ровно до момента, когда вы вспоминаете про: json‑теги, omitempty (который на самом деле управляет списком required), встроенные поля с промоушеном, указатели (это nullable — причём в 3.1 и 3.0 nullable выражается по‑разному), слайсы, мапы, []byte, time.Time, uuid.UUID, primitive.ObjectID, json.RawMessage, any, и коллизии имён между пакетами, когда в проекте живут одновременно models.User и dto.User.

Отдельно порадовало вот это: если в коде есть именованный тип и группа констант этого типа:

type Status string

const (
    StatusActive  Status = "active"
    StatusBlocked Status = "blocked"
)

то это очевидным образом enum, и в схему он попадает именно так. Никаких аннотаций для этого не нужно, информация уже есть в коде. Собственно, это и есть главный тезис всей затеи.

Что удалось вытащить из тела хендлера

Запросы. Тело — c.BodyParser(&dto) для v2, c.Bind().Body(&dto) и .JSON(&dto) для v3. Query — как структурой (c.QueryParser, c.Bind().Query, с разворачиванием по тегам query:), так и поштучно (c.Query, c.QueryInt, c.QueryBool). Path‑параметры берутся из шаблона маршрута, но тип уточняется по коду: если в теле есть c.ParamsInt("id"), значит это integer, а не string. Заголовки — c.Get("X-Request-Id") и парсеры.

Кстати, про пути. Fiber использует свой синтаксис, который надо переводить в OpenAPI::id → {id},:id? → необязательный,:id<int> → параметр с типом, * и + → wildcards.

Ответы. c.JSON(x) — это 200 со схемой x. c.Status(code).JSON(x) — статус берётся из code, причём как из литерала 201, так и из fiber.StatusCreated. Плюс SendStatus, SendString, Redirect.

Два случая, на которые ушло неприлично много времени, но без них выхлоп выглядел бы бедно — Ad‑hoc объекты. Гоферы, пишущие на Fiber, обожают fiber.Map:

return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})

Формально это map[string]any, и честный вывод типа дал бы бесполезное object без свойств. Поэтому литералы разворачиваются в схему с конкретными полями, типы значений выводятся из выражений, вложенные литералы и $ref поддерживаются. То есть {"message": "ok", "data": user} превращается в объект с message: string и data: $ref User.

Несколько форм ответа на один статус. Классика: хендлер отдаёт короткий объект при?short=true и полный в остальных случаях. Оба варианта — 200. Раньше я бы просто взял первый попавшийся; сейчас альтернативы собираются в oneOf.

Про детерминированность вывода

Мелочь, о которой я не подумал сразу, а потом пришлось переделывать: если спека коммитится в репозиторий (а её стоит коммитить — тогда её ревьюят вместе с кодом, и «ой, я не заметил, что сломал контракт» перестаёт быть отговоркой), то порядок ключей в YAML должен быть стабильным.

Мапы в Go, как известно, ходят в произвольном порядке. Первая версия давала на одном и том же коде разный вывод от запуска к запуску, и диффы выглядели как полная перезапись файла. Пришлось завести отдельный тип — упорядоченную по вставке мапу — и рендерить всё через неё. Скучно, но без этого инструмент бесполезен в CI.

Честные ограничения

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

  1. Ответы через хелперы не отслеживаются. Если у вас respondJSON(c, http.StatusOK, user) вместо прямого c.JSON(user) — тип не восстановится. Анализируется только тело самого хендлера, межпроцедурного анализа пока нет. Это первое, что стоит сделать дальше.

  2. Динамические пути — если путь собирается из неконстантной строки, маршрут пропускается (с note: в stderr).

  3. Хендлеры за DI‑контейнером или интерфейсом, которые невозможно статически свести к функции, попадают в спеку с дефолтным ответом 200.

  4. Дженерики в типах ответа обрабатываются ограниченно.

  5. Описаний и summary нет. И не будет какое то время — их неоткуда взять, если не читать комментарии. Это сознательный размен: либо ноль аннотаций, либо человекочитаемые описания. Я выбрал первое, а комментарии можно будет добавить и в будущем.

Всё, что анализатор не смог разрешить, пишется в stderr как note:... — то есть вы видите, где спека неполна, а не думаете, что всё отлично. Заглушается флагом ‑quiet.

Что в итоге

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

Поддерживаются Fiber v2 и v3, OpenAPI 3.0 и 3.1, вывод в YAML или JSON. В репозитории лежат два примера‑проекта (на v2 и v3), на которых гоняются тесты — сгенерированные документы валидируются через kin‑openapi.

fibgen -dir ./myservice -o openapi.json --openapi-version 3.0 ./...

Проект молодой, поэтому наверняка есть паттерны регистрации маршрутов и биндинга, которые я не предусмотрел просто потому, что сам так не пишу. Если fibgen на вашем проекте выдал пустоту или ерунду — это интересный баг, заводите issue с минимальным примером. Самые полезные направления для контрибьюта, на мой взгляд: межпроцедурный анализ (проход в хелперы ответов), новые паттерны биндинга и расширение списка well‑known типов.

Репозиторий: github.com/nyawave/fibgen, лицензия MIT.