← назад к разделу

Обычно API рождается из кода: написали обработчики, а описание (если оно есть) собрали потом из комментариев. API-first переворачивает порядок: сначала договариваемся о контракте, и только потом пишем код по нему. В Go этот путь особенно естественный: генератор даёт интерфейс, компилятор не даст его реализовать не так, а сгенерированный код по обычаю лежит в репозитории и виден на ревью.

Обязательно

Что такое API-first простыми словами

API-first — подход, при котором контракт API проектируется первым, до реализации, и становится главным артефактом, по которому работают все стороны. Контракт — машиночитаемое описание: какие есть маршруты, какие параметры и тела запросов, какие ответы и коды ошибок. Стандартный формат для REST — OpenAPI (YAML или JSON).

Короткая формула: сначала контракт, потом код. Контракт — договор между теми, кто API предоставляет, и теми, кто его потребляет.

Противоположность — code-first: пишем обработчики, а описание получаем из них. В Go это комментарии swag или библиотеки вроде huma, которые строят схему из структур и обработчиков. Оба варианта дают на выходе OpenAPI-документ, но порядок и источник правды разные.

Зачем это нужно

Фронтенд ждёт три недели, пока бэкенд допишет маршрут, а потом ещё неделю переделывает под то, что получилось не так, как договаривались на словах. Контракт, готовый раньше кода, убирает обе недели: фронт поднимает мок по спецификации и пишет интерфейс, пока бэкенд реализует логику.

Контракт один и машиночитаемый, поэтому не бывает «в коде одно, в документации другое»: документация, модели, интерфейс сервера и клиент генерируются из него же. Дизайн обсуждают на ревью YAML до первой строчки реализации, а поправить YAML дешевле, чем переписывать готовый код. И контракт сравнивают между версиями автоматически, ловя ломающие изменения в сборке.

Contract-first и code-first

Оба пути ведут к OpenAPI-документу, но по-разному.

Contract-first — источник правды это OpenAPI YAML. Сначала пишем спецификацию руками, затем oapi-codegen создаёт из неё модели, интерфейс сервера и клиента. Обработчики реализуют сгенерированный интерфейс, а ограничения из схемы — required, minLength, maximum — проверяет middleware по той же спецификации.

# фрагмент контракта: контракт первичен
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      tags: [orders]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Заказ
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Order" }

Code-first — источник правды это код. swag читает комментарии над обработчиками (// @Param id path string true "...") и собирает swagger.json; huma и fuego идут дальше и строят схему из типов запроса и ответа без комментариев, заодно проверяя входные данные. Быстрее на старте, но контракт здесь — следствие кода, а не договор, и комментарии swag расходятся с кодом при первом же рефакторинге, потому что компилятор их не читает.

Что выбрать: для публичного API и для нескольких команд, которым нужно договориться заранее, обычно лучше contract-first. Для небольшого внутреннего сервиса, который пишет одна команда, code-first на huma проще и его достаточно.

Как выглядит процесс contract-first

  1. Пишем/правим OpenAPI-контракт — маршруты, схемы, ошибки, версии.
  2. Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
  3. Генерация — oapi-codegen создаёт модели, интерфейс сервера и клиента; результат коммитится.
  4. Реализация — тип реализует сгенерированный интерфейс; логика пишется внутри, сгенерированный файл не правится руками.
  5. Проверка в сборке — линт контракта, сравнение с предыдущей версией, свежесть сгенерированного кода.

Важное правило команды: правки контракта идут в YAML, а не в сгенерированный код.

Как это собирается: oapi-codegen

Описание лежит в репозитории сервиса, рядом с кодом, который его реализует: api/openapi.yaml. Генератор настраивается файлом, а не флагами, чтобы настройки были видны на ревью:

# api/oapi-codegen.yaml
package: api
output: internal/api/gen.go
generate:
  chi-server: true
  strict-server: true
  models: true
  embedded-spec: true

Запуск — через go generate, чтобы команда была одна и та же у всех:

//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.yaml
package api

С Go 1.24 генератор подключают как инструмент модуля (go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen): версия зафиксирована в go.mod, и у всех разработчиков и в сборке генерируется одно и то же. До 1.24 то же делали через go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1.

Четыре настройки, которые здесь решают. chi-server генерирует регистрацию маршрутов под chi и разбор параметров пути, запроса и заголовков. strict-server добавляет типизированный интерфейс: вместо (w, r) метод получает структуру запроса с разобранным телом и возвращает структуру ответа — писать json.NewDecoder и WriteHeader руками больше не нужно. models даёт структуры схем с тегами json. embedded-spec встраивает сам контракт в бинарник, откуда его берёт валидатор запросов.

Как выглядит реализация. Сгенерированный интерфейс описывает подписи, типы и коды; ваш тип его реализует:

type OrdersServer struct {
    create *usecase.CreateOrder
    orders *query.Orders
}

func (s *OrdersServer) GetOrder(ctx context.Context, req api.GetOrderRequestObject) (api.GetOrderResponseObject, error) {
    o, err := s.orders.ByID(ctx, req.Id)
    if errors.Is(err, order.ErrNotFound) {
        return api.GetOrder404ApplicationProblemPlusJSONResponse{Code: ptr("ORDER_NOT_FOUND")}, nil
    }
    if err != nil {
        return nil, err
    }
    return api.GetOrder200JSONResponse(toAPI(o)), nil
}

func (s *OrdersServer) CreateOrder(ctx context.Context, req api.CreateOrderRequestObject) (api.CreateOrderResponseObject, error) {
    id, err := s.create.Handle(ctx, toCommand(req.Body), req.Params.IdempotencyKey)
    if err != nil {
        return nil, err
    }
    return api.CreateOrder201JSONResponse(toAPI(s.orders.MustByID(ctx, id))), nil
}
spec, _ := api.GetSwagger()
spec.Servers = nil
r := chi.NewRouter()
r.Use(nethttpmiddleware.OapiRequestValidator(spec))
api.HandlerFromMux(api.NewStrictHandler(&OrdersServer{...}, nil), r)

Главное свойство этой схемы: подпись метода нельзя изменить, не изменив описание. Добавили параметр в обработчик — код не компилируется, пока параметр не появился в контракте. На каждый код ответа из контракта есть свой тип (GetOrder200JSONResponse, GetOrder404ApplicationProblemPlusJSONResponse), и вернуть код, которого в контракте нет, нельзя физически. Это и есть та дисциплина, ради которой всё затевалось; при генерации из кода она отсутствует.

Валидация по схеме. Сам oapi-codegen ограничения из схемы не проверяет: minLength и minimum в сгенерированной структуре никак не отражены. Проверяет их OapiRequestValidator из nethttp-middleware: он берёт встроенный контракт и сверяет каждый запрос — формат uuid в пути, обязательный заголовок Idempotency-Key, qty не меньше единицы — до того, как запрос дойдёт до обработчика. Строка spec.Servers = nil нужна, потому что иначе валидатор сверяет ещё и адрес сервера из контракта и отвергает запросы на localhost.

У валидатора есть своя форма ответа на ошибку, и она текстовая. Чтобы нарушения приходили в вашем формате Problem Details, валидатору передают Options{ErrorHandler: …}, который пишет через тот же httperr.Write, что и всё приложение, — об этом статья про единый обработчик ошибок.

Клиент из того же описания. Добавив в настройки client: true, получают типизированного клиента с теми же моделями: client.GetOrderWithResponse(ctx, id) возвращает структуру с разобранными JSON200 и ApplicationproblemJSON404. Потребитель на Go не пишет обращения руками, а меняется клиент вместе с контрактом.

Первая ловушка: сгенерированный код коммитят

В Go сгенерированный код лежит в репозитории, и это отличается от привычки других стеков, где его прячут в каталог сборки. Причина проста: go build не запускает go generate, и потребитель модуля не обязан иметь генератор. Отсюда два следствия.

Первое: правка руками в gen.go живёт, пока кто-то не перегенерирует, — и обычно это происходит через месяц, в чужой ветке. Файл помечен строкой // Code generated by ... DO NOT EDIT., линтеры и ревьюеры её видят, но полагаться на это нельзя.

Второе, которое и решает первое: сборка проверяет свежесть сгенерированного кода.

- run: go generate ./...
- run: git diff --exit-code -- internal/api/gen.go

Изменили контракт и забыли перегенерировать — сборка красная. Поправили gen.go руками — сборка красная. Правило «правки только в YAML» держится не на договорённости, а на двух строках конвейера.

Вторая ловушка: контракт и реализация разъезжаются

Главная беда contract-first не в том, что его трудно начать, а в том, что через полгода описание отличается от поведения. В Go два места из трёх закрыты генератором, но третье остаётся.

Поля появляются мимо описания. Сгенерированная модель ответа содержит ровно поля схемы, и лишнее поле туда не попадёт без правки контракта. Но если обработчик возвращает свою структуру через обычный json.Encoder, минуя сгенерированные типы ответа, расхождение возвращается. Строгий сервер это закрывает: вернуть можно только тип ответа из контракта.

Коды ответов не совпадают. Тип на каждый код решает вопрос для успешных путей. Остаётся единый обработчик ошибок: он отдаёт 409 и 422, о которых контракт может не знать. Ловится тестом, который сверяет ответы с контрактом: openapi3filter.ValidateResponse из kin-openapi в интеграционных тестах падает на коде или поле, которого в описании нет.

Примеры врут. Пример в описании собран руками год назад. Лечится проверкой примеров по схеме — spectral это умеет — или генерацией примеров из снимков реальных ответов в тестах.

Отсюда практическое правило: contract-first без проверки ответов по схеме превращается в code-first с лишним файлом. Описание должно быть проверяемым, иначе оно документация, а не контракт.

Чем проверяют контракт

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

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

go run github.com/oasdiff/oasdiff@latest breaking --fail-on ERR main/api/openapi.yaml api/openapi.yaml

Проверка ответов по схеме. Линт и сравнение проверяют описание, а не поведение. openapi3filter из kin-openapi (та же библиотека, на которой работает валидатор запросов) проверяет и ответы: в тесте через httptest ответ сервера сверяется со схемой, и лишнее поле, не тот тип или код, которого нет в контракте, роняют тест.

Моки для потребителя. prism mock api/openapi.yaml поднимает сервер, отвечающий примерами из описания, — фронтенд начинает работу, не дожидаясь реализации. Оговорка: мок отвечает примерами, поэтому качество примеров становится частью работы.

Кто владеет контрактом и где он лежит

В репозитории сервиса (обычный выбор). Описание меняется вместе с реализацией, ревью одно. Потребитель получает его как часть модуля: Go-клиент генерируется в отдельный пакет pkg/ordersclient, и соседний сервис подключает его обычным go get с версией из тега.

В отдельном репозитории схем (для организации с десятками сервисов). Все контракты в одном месте, общие правила линта, общие схемы ошибок. Минус — изменение контракта требует изменения в двух репозиториях.

Кто владеет: команда сервиса, а не команда потребителя. Потребитель участвует в обсуждении до принятия, и это оформляют как ревью изменения описания.

Чем платят за contract-first

Ревью описания. Каждое изменение API требует ревью ещё одного файла, и обсуждение формы занимает время до начала работы. Это и есть главная выгода (обсудить до кода) и главная цена (медленнее старт).

Конфликты при слиянии. Описание на несколько тысяч строк, в которое одновременно пишут пять человек. Лечится разбиением на файлы по тегам с $ref между ними — oapi-codegen и oasdiff умеют читать такие.

Генератор неудобен на краях. Загрузка файлов (multipart), потоковые ответы, oneOf с discriminator — всё это oapi-codegen описывает беднее, чем хотелось бы: oneOf превращается в json.RawMessage с методами AsXxx, потоки приходится отдавать обычным обработчиком. Обычный выход — описать маршрут в контракте, а реализовать его руками, вне строгого интерфейса. Если таких краёв много, смотрят на ogen: он строже следует спецификации, генерирует валидацию и oneOf с типами, но жёстче к самому контракту и не на всякий YAML соглашается.

Дисциплина против скорости. Прототип, который выкидывают через две недели, contract-first только замедляет. Контракт нужен там, где по ту сторону другая команда или внешний клиент; там, где обе стороны в одном репозитории и выкатываются вместе, он избыточен.

Компромисс: описание из кода, проверяемое как контракт

Распространённый средний путь: сервис пишется на huma или с комментариями swag, описание генерируется из кода, но затем публикуется и проверяется как контракт — линт, oasdiff против предыдущей версии, запрет ломающих изменений в сборке.

Что это даёт: скорость code-first и главную гарантию contract-first (ломающее изменение не проходит незамеченным). Чего не даёт: обсуждения формы до реализации. Для внутренних сервисов этого обычно достаточно; для публичного API и контрактов между командами остаётся полноценный contract-first.

Контракт в работе: моки, версии, границы

Моки и параллельная работа. Главный практический выигрыш — мок прямо из контракта: Prism поднимает сервер по OpenAPI-файлу, и потребители начинают интеграцию, не дожидаясь готового бэкенда. Когда реальный сервис готов, переключаются на него — контракт тот же.

Версионирование и эволюция. Добавлять необязательные поля можно, удалять или переименовывать существующие — ломающее изменение, которое требует новой версии. Контракт — удобное место, где это видно: oasdiff показывает разницу двух YAML сразу. Подробный разбор — в статье про версионирование.

Когда API-first избыточен. Контракт нужно поддерживать, генерация добавляет шаг. Для одноразового прототипа или крошечного внутреннего маршрута это перебор. API-first окупается, когда API переживёт не один спринт, его потребляет больше одной команды или он публичный.

Дополнительно: при первом чтении можно пропустить

Глубже: проверка совместимости в сборкерасширенное

Обещание «контракт не сломается» ничего не стоит, пока его не проверяет сборка. Три инструмента разного уровня, и в Go-проекте все три живут в одном конвейере.

Сравнение двух версий. oasdiff breaking против главной ветки перечисляет ломающие изменения по правилам, которые статья про версионирование описывает словами. Сборка падает, если список не пуст и изменение не помечено как новая версия. Для gRPC то же делает buf breaking --against '.git#branch=main'.

Линт. Spectral с набором правил компании прогоняется на каждом изменении: каждая закрытая операция описывает 401 и 403, у каждого списка есть size с максимумом, у каждой ошибки тело application/problem+json. Правила — те самые соглашения из статей раздела, записанные машинно.

Контрактные тесты. Со стороны поставщика — openapi3filter в собственных тестах. Со стороны потребителя — Pact (pact-go): потребитель записывает, какие запросы делает и какие ответы ожидает, публикует контракт в брокер, а сборка поставщика проигрывает контракты всех потребителей против своего кода. Так поставщик узнаёт до выката, что поле, которое он «никому не нужным» удалил, читает мобильное приложение.

Порядок внедрения: сначала сравнение спецификаций — оно бесплатно и ловит самое дорогое; потом линт с пятью правилами, а не пятьюдесятью; контрактные тесты с потребителями — когда потребителей больше одного и они не в вашей команде.

Коротко

  • API-first = сначала контракт (OpenAPI), потом код по нему; contract-first — источник правды YAML, code-first — код (swag, huma).
  • oapi-codegen с chi-server, strict-server, models и embedded-spec даёт интерфейс, который компилятор не даст реализовать не по контракту: на каждый код ответа свой тип.
  • Ограничения схемы проверяет не генератор, а OapiRequestValidator по встроенному контракту; spec.Servers = nil, иначе валидатор отвергнет localhost; ошибки валидатора — через свой ErrorHandler в формате приложения.
  • Сгенерированный код в Go коммитят, а свежесть проверяет сборка: go generate ./... и git diff --exit-code.
  • Генератор как инструмент модуля (go get -tool, Go 1.24+) фиксирует версию в go.mod; запуск через //go:generate.
  • Расхождение контракта и кода ловят проверкой ответов по схеме через openapi3filter; без неё contract-first — code-first с лишним файлом.
  • Совместимость проверяет сборка: oasdiff breaking против главной ветки, Spectral с правилами компании, pact-go с потребителями, когда их больше одного.
  • oneOf, multipart и потоки у генератора бедные: описать в контракте, реализовать руками или посмотреть на ogen.
  • Контракт нужен там, где по ту сторону другая команда или внешний клиент; внутри одной команды хватает code-first с проверкой oasdiff.

Что почитать дальше