golang

Documentation-Driven Development: как мы генерируем Go-код из OpenAPI-спецификаций

  • пятница, 27 февраля 2026 г. в 00:00:13
https://habr.com/ru/companies/ru_mts/articles/1003562/

Привет, Хабр. Я Матвей Лихота, старший Go-разработчик из МТС Web Services. По моему опыту, документация, которую пишут руками отдельно от кода, устаревает уже в момент следующего коммита. Из-за этого мы в команде тратили до 20% времени на поддержание актуальности swagger-документации в десятке микросервисов. И когда ошибки интеграции уже стали привычным фоном, мы все-таки решились и перевернули всё с ног на голову: внедрили Documentation-Driven Development (DDD) — подход в разработке, когда процесс начинается с документации. 

Что за подход и что он дал в итоге, зачем понадобилась утилита oapi-codegen и как мы генерируем Go-код из OpenAPI-спецификаций — подробно рассказал и показал под катом. 


Что такое DDD и как он работает

Сначала немного теории. Самый распространенный сценарий создания документации — это code-first, где мы сначала пишем код, а описание API собираем потом. Обычно это происходит через комментарии к хендлерам и моделям, из которых генератор по типу swaggo собирает Swagger или OpenAPI. В этой модели документация становится побочным продуктом разработки.

Проблем при таком подходе множество. Во-первых, документация вторична: она устаревает в момент следующего коммита, если мы забудем (а мы забудем) изменить комментарии и перегенерировать спецификацию. Это приводит к рассогласованности API и его описания, а если сервисов больше 10, то поддержка превращается в боль. 

В отличие от него, DDD (Documentation-Driven Development) — подход в разработке, когда процесс начинается с обсуждения и создания документации. В том числе с OpenAPI-спецификаций (если предполагается REST API), Proto-контрактов (если нужно использовать RPC) и им подобных. Иногда этот подход называют spec-first. 

Ключевой момент в том, что при DDD документация становится условным контрактом, с которым работают так же строго, как с кодом: 

  • согласовывают со всеми сторонами до написания кода; 

  • статистически валидируют на предмет противоречий (spectral, swagger-cli); 

  • выпускают релизы, используя семантическое версионирование (SemVer);

  • используют как единственный источник правды для разработчиков и пользователей API.

Разница между подходами хорошо видна на практике. С code-first легко начать, но дальше трудоемкость поддержки растет с увеличением количества эндпоинтов. Любая правка требует выполнения цепочки действий, и чем больше сервисов, тем больше таких циклов. При этом стоимость ошибки в документации растет вместе с количеством пользователей, так как это крайне негативно влияет на опыт взаимодей��твия с продуктом.

В spec-first сначала меняется openapi.yaml, затем генерируется код, и только после этого пишется или корректируется реализация. Обсуждаются только изменения легко читаемого контракта, а не уже написанного кода, который чаще всего понимает только пара разработчиков.

Кроме того, при spec-first ошибки обнаруживаются раньше. Когда вся команда смотрит на спецификацию до начала реализации, неудобные форматы, ненужные поля или неполные ответы замечаются на этапе code-review, а не за день до релиза. Притом, исправление в YAML занимает минуты и не требует переписывать готовую логику.

Из одной OpenAPI-спецификации можно получить серверные интерфейсы и модели, клиент на Go, актуальную документацию и моки для тестирования. Плюс и в том, что статическая проверка добавляет еще один уровень контроля: валидаторы находят несогласованные статусы, пропущенные поля или некорректные схемы еще до запуска сервиса.

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

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

Для наглядности сравним оба подхода по нескольким критериям:

Сравнение подходов code-first и spec-first 
Сравнение подходов code-first и spec-first 

Так что, на мой взгляд, подход code-first тоже хорош, но подходит только для определенных задач: 

  • Быстрых прототипов и MVP. На ранних стадиях развития проекта скорость итераций может быть важнее чистоты архитектуры, а пока вы будете проектировать идеальный openapi.yaml, конкуренты уже выпустят фичу. 

  • API, единственным потребителем которого являетесь вы сами. Например, сервис для управления конфигурацией других сервисов, который используют два разработчика внутри команды. Писать для него спецификацию — оверхэд. 

  • API с частыми изменениями требований. Пока идет цикл проектирования, требования не раз поменяются. 

  • Legacy-проектов без ресурсов на рефакторинг. Когда сервисы работают и планов по их развитию нет, удобно, если все останется как есть. 

Ну а мы покажем, как работаем со spec-first.

Как мы внедрили spec-first

Переходить на spec-first мы решили постепенно: взяли один сервис, выбрали пару самых живых эндпоинтов и договорились, что любое изменение API сначала оформляется в спецификации, а уже потом в коде. Расскажу по шагам, как все было и что мы делали.

Шаг 1. Установили oapi-codegen

Для генерации мы решили использовать oapi-codegen — это CLI-утилита и библиотека для преобразования спецификаций OpenAPI в код Go. Она читает OpenAPI-спецификацию и умеет создавать серверные заглушки (поддерживает Chi, Echo, Gin), строгие структуры из схем и полноценные клиенты, используя шаблоны Go в формате text/template (расширение *.tmpl). Установка максимально простая:

# команда для установки инструмента v2.3.0 или выше 
go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest

После установки бинарник появился в $(go env GOPATH)/bin, а что директория добавлена в PATH мы убедились командой:

oapi-codegen --version

Команда отрабатывает — двигаемся дальше. По умолчанию oapi-codegen использует встроенные шаблоны в формате text/template (расширение *.tmpl). В большинстве случаев этого достаточно, но как только появляется желание изменить поведение генерации, приходится подключать собственные. 

Кроме того, утилита позволяет указать директорию с шаблонами через флаг -templates. Важно, чтобы структура файлов внутри этой директории по��торяла структуру оригинальных шаблонов, иначе они просто не будут использованы, ошибки при этом не покажется.

В качестве примера покажу шаблон генерации клиента с запросами для разрешения коллизий с сервером при одновременной генерации (он еще пригодится):

client-with-responses.tmpl
// ClientWithResponses builds on ClientInterface to offer response payloads
type ClientWithResponses struct {
    ClientInterface
}

// NewClientWithResponses creates a new ClientWithResponses, which wraps
// Client with return type handling
func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
    client, err := NewClient(server, opts...)
    if err != nil {
        return nil, err
    }
    return &ClientWithResponses{client}, nil
}

{{$clientTypeName := opts.OutputOptions.ClientTypeName -}}

// WithBaseURL overrides the baseURL.
func WithBaseURL(baseURL string) ClientOption {
	return func(c *{{ $clientTypeName }}) error {
		newBaseURL, err := url.Parse(baseURL)
		if err != nil {
			return err
		}
		c.Server = newBaseURL.String()
		return nil
	}
}

// ClientWithResponsesInterface is the interface specification for the client with responses above.
type ClientWithResponsesInterface interface {
{{range . -}}
{{$hasParams := .RequiresParamObject -}}
{{$pathParams := .PathParams -}}
{{$opid := .OperationId -}}
    // {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse request{{if .HasBody}} with any body{{end}}
    {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse(ctx context.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error)
{{range .Bodies}}
    {{if .IsSupportedByClient -}}
        {{$opid}}{{.Suffix}}WithResponse(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error)
    {{end -}}
{{end}}{{/* range .Bodies */}}
{{end}}{{/* range . $opid := .OperationId */}}
}

{{range .}}{{$opid := .OperationId}}{{$op := .}}
{{$responseTypeDefinitions := getResponseTypeDefinitions .}}
type {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}} struct {
    Body         []byte
	HTTPResponse *http.Response
    {{- range $responseTypeDefinitions}}
    {{.TypeName}} *{{.Schema.TypeDecl}}
    {{- end}}
}

{{- range $responseTypeDefinitions}}
  {{- range .AdditionalTypeDefinitions}}
    type {{.TypeName}} {{if .IsAlias }}={{end}} {{.Schema.TypeDecl}}
  {{- end}}
{{- end}}

// Status returns HTTPResponse.Status
func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) Status() string {
    if r.HTTPResponse != nil {
        return r.HTTPResponse.Status
    }
    return http.StatusText(0)
}

// StatusCode returns HTTPResponse.StatusCode
func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) StatusCode() int {
    if r.HTTPResponse != nil {
        return r.HTTPResponse.StatusCode
    }
    return 0
}

{{ if opts.OutputOptions.ClientResponseBytesFunction }}
// Bytes is a convenience method to retrieve the raw bytes from the HTTP response
func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) Bytes() []byte {
    return r.Body
}
{{end}}
{{end}}


{{range .}}
{{$opid := .OperationId -}}
{{/* Generate client methods (with responses)*/}}

// {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse request{{if .HasBody}} with arbitrary body{{end}} returning *{{genResponseTypeName $opid}}
func (c *ClientWithResponses) {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse(ctx context.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error){
    rsp, err := c.{{$opid}}{{if .HasBody}}WithBody{{end}}(ctx{{genParamNames .PathParams}}{{if .RequiresParamObject}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}, reqEditors...)
    if err != nil {
        return nil, err
    }
    return Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp)
}

{{$hasParams := .RequiresParamObject -}}
{{$pathParams := .PathParams -}}
{{$bodyRequired := .BodyRequired -}}
{{range .Bodies}}
{{if .IsSupportedByClient -}}
func (c *ClientWithResponses) {{(printf "Client%s" $opid)}}{{.Suffix}}WithResponse(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) {
    rsp, err := c.{{$opid}}{{.Suffix}}(ctx{{genParamNames $pathParams}}{{if $hasParams}}, params{{end}}, body, reqEditors...)
    if err != nil {
        return nil, err
    }
    return Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp)
}
{{end}}
{{end}}

{{end}}{{/* operations */}}

{{/* Generate parse functions for responses*/}}
{{range .}}{{$opid := .OperationId}}

// Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}} parses an HTTP response from a {{$opid}}WithResponse call
func Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp *http.Response) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) {
    bodyBytes, err := io.ReadAll(rsp.Body)
    defer func() { _ = rsp.Body.Close() }()
    if err != nil {
        return nil, err
    }

    response := {{genResponsePayload (printf "Client%s" $opid)}}

    {{genResponseUnmarshal .}}

    return response, nil
}
{{end}}{{/* range . $opid := .OperationId */}}

И для примера шаблон для удобной генерации констант enum’ов:

constants.tmpl
{{- if gt (len .SecuritySchemeProviderNames) 0 }}
const (
{{range $ProviderName := .SecuritySchemeProviderNames}}
    {{- $ProviderName | ucFirst}}Scopes = "{{$ProviderName}}.Scopes"
{{end}}
)
{{end}}
typedef.tmpl
{{range .Types}}
// {{ with .Schema.Description }}{{ . }}{{ else }}{{.TypeName}} defines model for {{.JsonName}}.{{ end }}
{{- if eq .Schema.TypeDecl "openapi_types.UUID" }}
type {{.TypeName}} = {{.Schema.TypeDecl}}
{{- else }}
type {{.TypeName}} {{.Schema.TypeDecl}}
{{- end }}
{{- if gt (len .Schema.EnumValues) 0 }}
// List of {{ .TypeName }}
const (
	{{- $typeName := .TypeName }}
    {{- range $key, $value := .Schema.EnumValues }}
    {{ $typeName }}{{ $key }} {{ $typeName }} = "{{ $value }}"
    {{- end }}
)
{{- end }}
{{end}}

Всё это можно использовать для кастомизации вывода генератора oapi-codegen. 

Шаг 2. Разработали спецификации сервиса

Теперь нам нужно было получить openapi.yaml, который можно ревьюить. Если вы тоже выносите контракты в отдельный репозиторий или хотя бы в отдельную директорию, первым делом инициализируйте Go-модуль в корне. Это важно, чтобы сгенерированный код нормально собирался и мог импортироваться из сервисов. 

Для этого в корне папки contracts мы выполнили:

go mod init contracts

После этого появился go.mod (если спецификаций несколько и они лежат в одном репозитории, одного модуля достаточно). Дальше создали структуру для спецификаций. Нам удобно было держать их отдельно от кода сервисов, в одном месте с общими схемами, поэтому получилось так: 

После этого начали писать спецификацию сервиса и common.yaml с общими типами ошибок и другими компонентами. В целом, выделять что-то в отдельный файл не обязательно. Но, по опыту, рано или поздно это понадобится.

Мы придерживались нескольких важных правил: 

  • у каждого метода должен быть свой operationId — он напрямую влияет на имена сгенерированных методов в Go, поэтому к нему стоит относиться внимательно;

  • ошибки и общие форматы ответов «с ошибкой» выносили в common.yaml, чтобы формат был единым для всех сервисов;

  • несмотря на одинаковые тела успешных ответов, мы не переносили их в common.yaml — запросы и успешные ответы имеют свойство меняться в зависимости от требований.

Кроме того, важно читать и обсуждать спецификацию до того, как кто-то начнет писать реализацию. Именно здесь всплывают ненужные поля, неудобные форматы со структурами и не интуитивно понятные константы — исправить их в YAML намного проще, чем переписывать код.

Шаг 3. Сгенерировали сервер

Итак, у нас был openapi.yaml и установленный oapi-codegen, теперь нужно было получить серверный каркас. Тут мы особенно обращали внимание на две вещи: типы и интерфейс хендлеров, чтобы реализация всегда шла по контракту. 

# openapi/phoneBook/openapi.yaml 
openapi: 3.0.0
info:
  title: Phone Book API
  version: 1.0.0
paths:
  /abonents:
    put:
      summary: Creates an Abonent
      operationId: createAbonent
      tags:
        - abonent
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
          required: true
      requestBody:
        $ref: "#/components/requestBodies/CreateAbonentRequest"
      responses:
        "201":
          $ref: "#/components/responses/GetAbonentResponse"
        "400":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        "500":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        default:
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
    get:
      summary: Retrieve a list of Abonents
      operationId: GetAbonentsList
      tags:
        - abonent
      parameters:
        - $ref: "#/components/parameters/PageToken"
        - $ref: "#/components/parameters/Limit"
      responses:
        '200':
          $ref: "#/components/responses/GetAbonentsListResponse"
        '400':
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        default:
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"

  /abonent/{id}:
    get:
      summary: Retrieves specific Abonent
      operationId: getAbonent
      tags:
        - abonent
      parameters:
        - name: id
          in: path
          description: Identifier of Abonent (uuid)
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          $ref: "#/components/responses/GetAbonentResponse"
        "400":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        "404":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        "500":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        default:
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
    put:
      summary: Replaces Abonent information per ID
      operationId: replaceAbonent
      tags:
        - abonent
      parameters:
        - name: id
          in: path
          description: Identifier of Abonent (uuid)
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        $ref: "#/components/requestBodies/ReplaceAbonentRequest"
      responses:
        "200":
          $ref: "#/components/responses/GetAbonentResponse"
        "400":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        "403":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        "500":
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
        default:
          $ref: "../common.yaml#/components/responses/ApiErrorResponse"
          
components:
  parameters:
    PageToken:
      name: pageToken
      in: query
      schema:
        type: string
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema:
        type: string
        format: uuid
        description: UUID для идемпотентных мутаций

  requestBodies:
    CreateAbonentRequest:
      description: Information for creating Abonent
      required: true
      content:
        application/json:
          schema:
            type: object
            properties:
              AbonentInfo:
                $ref: "#/components/schemas/AbonentInfo"
    
    ReplaceAbonentRequest:
      description: Information for replacing Abonent
      required: true
      content:
        application/json:
          schema:
            properties:
              AbonentInfo:
                $ref: "#/components/schemas/AbonentInfo"

  schemas:
    Abonent:
      type: object
      properties:
        id:
          type: string
          format: uuid
        description:
          type: string
        type:
          $ref: "#/components/schemas/AbonentType"
        trusted:
          description: Is abonent trusted
          type: boolean
    
    AbonentInfo:
      type: object
      properties:
        description:
          type: string
        type:
          $ref: "#/components/schemas/AbonentType"
        trusted:
          description: Is abonent trusted
          type: boolean

    AbonentType:
      type: string
      enum:
        - organization # организация
        - scammer      # мошенник
        - advertising  # реклама
        - personal     # физическое лицо

  responses:
    GetAbonentsListResponse:
      description: OK
      content:
        application/json:
          schema:
            type: array
            items:
              $ref: '#/components/schemas/Abonent'
    
    GetAbonentResponse:
      description: OK
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Abonent'
# openapi/common.yaml
openapi: "3.0.3"

info:
  title: Common OpenAPI definitions
  version: "1.0"
  description: Common entities
  contact:
    name: Best MWS development team
    url: https://confluence.mws.ru/display/Development/Best+Team
    email: matwey.likhota@yandex.ru

components:
  responses:
    ApiErrorResponse:
      description: General error response
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ApiError"

  schemas:
    ApiErrorCode:
      type: string
      enum:
        - bad_request # 400
        - unauthorized # 401
        - forbidden # 403
        - not_found # 404
        - method_not_allowed # 405
        - not_acceptable # 406
        - request_timeout # 408
        - state_conflict # 409
        - not_implemented # 501
        - request_cancelled # 499
        - internal # 500
    ApiError:
      description: Описание ошибки
      type: object
      properties:
        error:
          $ref: "#/components/schemas/BaseError"
      required:
        - error
    BaseError:
      type: object
      properties:
        code:
          $ref: "#/components/schemas/ApiErrorCode"
        message:
          type: string
          description: Описание ошибки
        params:
          $ref: "#/components/schemas/ApiErrorParams"
      required:
        - code
        - message
    ApiErrorParams:
      type: object
      description: Параметры ошибки
      properties:
        validations:
          description: Информации о валидации полей
          type: array
          items:
            $ref: "#/components/schemas/ValidationInfo"
        additional_info:
          description: Дополнительная информация о ошибке
          type: array
          items:
            type: string
    ValidationInfo:
      description: Информация о валидации поля
      type: object
      properties:
        field:
          type: string
        message:
          type: string
      required:
        - field
        - message

Для этого мы создали файл конфигурации утилиты oapi-codegen — oapi-codegen.yaml — в  папке со спецификацией сервиса. Содержимое получилось таким: 

oapi-codegen.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi codegen/oapi-codegen/HEAD/configuration-schema.json 
package: phoneBook 
output: openapi/phoneBook/openapi.gen.go 
generate: 
 echo-server: true 
 models: true

Затем запустили генерацию из корня репозитория контрактов: 

oapi-codegen -package openapi -generate types,skip-prune -o  openapi/common.gen.go openapi/common.yaml

В нашей спецификации были ссылки на общий файл ../common.yaml, поэтому мы должны обращаться к нему как к импортируемому модулю, для этого нужно добавить ключ import-mapping со значением [путь к спецификации, на которую ссылаемся: ссылка на пакет, который сгенерируется]: 

oapi-codegen -config ./openapi/phoneBook/oapi-codegen.yaml -import mapping=../common.yaml:contracts/openapi ./openapi/phoneBook/openapi.yaml

После этого в папке openapi появился файл common.gen.go, в папке openapi/phoneBook — файл openapi.gen.go с реализацией echo-роутера. К слову, для генерации gin, chi, gorilla/mux достаточно изменить oapi-codegen.yaml — вот подробное описание ключей.

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

Фрагмент openapi.gen.go
// ServerInterface represents all server handlers. 
type ServerInterface interface { 
    // Retrieves specific Abonent 
    // (GET /abonent/{id}) 
    GetAbonent(ctx echo.Context, id openapi_types.UUID) error  // Replaces Abonent information per ID 
    // Replaces Abonent information per ID 
    // (PUT /abonent/{id}) 
    ReplaceAbonent(ctx echo.Context, id openapi_types.UUID) error  // Retrieve a list of Abonents 
    // Retrieve a list of Abonents
    // (GET /abonents) 
    GetAbonentsList(ctx echo.Context, params GetAbonentsListParams) error  // Creates an Abonent 
    // Creates an Abonent
    // (PUT /abonents) 
    CreateAbonent(ctx echo.Context, params CreateAbonentParams) error 
} 

type EchoRouter interface {
	CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
	TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
}

// RegisterHandlers adds each server route to the EchoRouter.
func RegisterHandlers(router EchoRouter, si ServerInterface) {
	RegisterHandlersWithBaseURL(router, si, "")
}

Шаг 4. Подключили сгенерированный сервер и написали логику

После генерации мы получили интерфейс ServerInterface и функцию RegisterHandlers. Интерфейс требует от реализации совпадать со спецификацией, а функция автоматически регистрирует все пути. 

Дальше создали структуру сервера, реализовали методы и подключили роуты — покажу на примере минимального каркаса на Echo. Пути импорта можно подправить под свою структуру, а остальное — скопировать как есть:

Пример реализации сервера
package phoneBook 
import ( 
  "context" 
  "net/http" 
 
  "github.com/labstack/echo/v4" 
  "yourproject/internal/api" // сгенерированный пакет ) 
type HttpServer struct { 
  base *echo.Echo 
  usc Usecase // интерфейс юзкейса 
} 
// Реализуем эти сгенерированные методы 
func (s *HttpServer) GetAbonent(ctx echo.Context, id openapi_types.UUID)  error { 
  // TODO: implement me 
  panic(“implement me”) 
} 
func (s *HttpServer) ReplaceAbonent(ctx echo.Context, id  openapi_types.UUID) error { 
  // TODO: implement me 
  panic(“implement me”) 
} 
func (s *HttpServer) GetAbonentsList(ctx echo.Context, params  GetAbonentsListParams) error { 
  // TODO: implement me 
  panic(“implement me”) 
} 
func (s *HttpServer) CreateAbonent(ctx echo.Context, params  CreateAbonentParams) error { 
  // TODO: implement me 
  panic(“implement me”) 
} 
func StartServer(usc Usecase) err { 
  e := echo.New() // здесь может быть любая обертка над echo сервером  server := &HttpServer{ 
    base: e, 
    usc: usc, 
  } 
  
  // Магия: регистрируем все роуты из спецификации 
  api.RegisterHandlers(e, server) 
  e.Logger.Fatal(server.base.Start(":8080")) 
}

На этом моменте уже можно увидеть киллер-фичу подхода: если меняешь контракт, то меняется и код. Компилятор первым сообщает, что именно надо поправить в реализации. 

Шаг 5. Сгенерировали клиент

Сервер мы сгенерировали, теперь изменим файл oapi-codegen.yaml для генерации кл��ента:

oapi-codegen.yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json
package: phoneBook
output: openapi/phoneBook/openapi.gen.go
generate:
  echo-server: true
  models: true
  client: true

При генерации той же командой получаем ошибку GetAbonentResponse redeclared in this block. А произошло это из-за ошибки в дефолтных шаблонах генерации клиента и сервера. 

Чтобы решить проблему, вернулись на первый шаг. Положили нужный шаблон в contracts/openapi/templates/client-with-responses.tmpl и запустили генерацию с указанием папки:

oapi-codegen -config ./openapi/phoneBook/oapi-codegen.yaml -import mapping=../common.yaml:contracts/openapi -templates "./openapi/templates/" ./openapi/phoneBook/openapi.yaml

Теперь в том же файле, помимо сервера, сгенерирован еще и готовый к  использованию клиент: 

Фрагмент oapi.gen.go
// Client which conforms to the OpenAPI3 specification for this service.
type Client struct {
	// The endpoint of the server conforming to this interface, with scheme,
	// https://api.deepmap.com for example. This can contain a path relative
	// to the server, such as https://api.deepmap.com/dev-test, and all the
	// paths in the swagger spec will be appended to the server.
	Server string

	// Doer for performing requests, typically a *http.Client with any
	// customized settings, such as certificate chains.
	Client HttpRequestDoer

	// A list of callbacks for modifying requests which are generated before sending over
	// the network.
	RequestEditors []RequestEditorFn
}

// ClientWithResponses builds on ClientInterface to offer response payloads
type ClientWithResponses struct {
	ClientInterface
}

// NewClientWithResponses creates a new ClientWithResponses, which wraps
// Client with return type handling
func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) {
	client, err := NewClient(server, opts...)
	if err != nil {
		return nil, err
	}
	return &ClientWithResponses{client}, nil
}

// ClientWithResponsesInterface is the interface specification for the client with responses above.
type ClientWithResponsesInterface interface {
	// GetAbonentWithResponse request
	GetAbonentWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*ClientGetAbonentResponse, error)

	// ReplaceAbonentWithBodyWithResponse request with any body
	ReplaceAbonentWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClientReplaceAbonentResponse, error)

	ReplaceAbonentWithResponse(ctx context.Context, id openapi_types.UUID, body ReplaceAbonentJSONRequestBody, reqEditors ...RequestEditorFn) (*ClientReplaceAbonentResponse, error)

	// GetAbonentsListWithResponse request
	GetAbonentsListWithResponse(ctx context.Context, params *GetAbonentsListParams, reqEditors ...RequestEditorFn) (*ClientGetAbonentsListResponse, error)

	// CreateAbonentWithBodyWithResponse request with any body
	CreateAbonentWithBodyWithResponse(ctx context.Context, params *CreateAbonentParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClientCreateAbonentResponse, error)

	CreateAbonentWithResponse(ctx context.Context, params *CreateAbonentParams, body CreateAbonentJSONRequestBody, reqEditors ...RequestEditorFn) (*ClientCreateAbonentResponse, error)
}

Клиент полностью соответствует документации. Если в openapi.yaml меняется структура ответа или сигнатура функции, компилятор покажет, где код больше не совпадает со спецификацией. 

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

import "contracts/openapi/phoneBook" 
func main() { 
  c := phoneBook.NewClientWithResponses("http://api:8080")  
  resp, err := c.GetAbonentsListWithResponse(context.Background(),  params) 
  // resp — это уже сгенерированный тип ClientGetAbonentsListResponse 
}

Шаг 6. Подключили генерацию к CI

Если генерацию запускать руками, спецификация и код снова начнут расходиться. Поэтому следующим логичным для нас шагом было — сделать так, чтобы код генерировался автоматически.

Мы пошли простым путем. Добавили один скрипт, который проходит по всем openapi.yaml в репозитории и запускает oapi-codegen с нужными флагами:

У каждой спецификации свой oapi-codegen.yaml. Наполнение файла скрипта для генерации примерно такое:

oapi-generate-all.sh
#!/bin/bash

echo "Generate golang code from OpenAPI specs"

templates="./openapi/templates/"
oapi-codegen -package openapi -generate types,skip-prune -templates "${templates}" -o openapi/common.gen.go openapi/common.yaml;
spec_file_mask="*/openapi.yaml"
spec_files=$(find ./openapi -type f -path "${spec_file_mask}")

for opapi_file_rel_path in ${spec_files}; do
    directory=$(dirname "${opapi_file_rel_path}")
    local_config="${directory}/oapi-codegen.yaml"
    if [ ! -f $local_config ]
    then
        >&2 echo "OpenAPI code generation failed for '${opapi_file_rel_path}':"
        >&2 echo "    Script requires local config '${local_config}' in the same directory!"
        exit -1
    else
        >&2 echo "Processing '${directory}':"
        oapi-codegen \
            -config ${local_config} \
            -templates ${templates} \
            -import-mapping=../common.yaml:contracts/openapi \
            "${opapi_file_rel_path}"
        >&2 echo "Done!"
    fi
done

Чтобы генерация была воспроизводимой, мы зафиксировали версию oapi-codegen в Docker-образе. 

oapi.Dockerfile
ARG GO_VERSION=1.24.11

FROM golang:${GO_VERSION}-bookworm AS base 

RUN apt update \ 
&& apt install -y zip dos2unix 

WORKDIR /work

ARG OAPI_CODEGEN_VERSION=2.5.1 

RUN go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v${OAPI_CODEGEN_VERSION} 

ENV PATH=$PATH:/work/bin 

COPY ./deployment/golang/oapi-generate-all.sh . 

RUN dos2unix oapi-generate-all.sh && chmod +x oapi-generate-all.sh 

ENTRYPOINT [ "oapi-codegen" ]

Запуск сервиса oapi-gen-go из docker-compose.yml будет генерировать код для всех openapi-спецификаций репозитория контрактов, что удобно внедрить в пайплайн.

docker-compose.yml
version: '3.7' 
services: 
  oapi-gen-go: 
    image: oapi-gen-go:latest 
    container_name: oapi-gen-go 
    build: 
      context: ../.. 
      dockerfile: ./deployment/golang/oapi.Dockerfile 
      args: 
        - GO_VERSION=1.24.11
        - OAPI_CODEGEN_VERSION=2.5.1
    volumes: 
      - ../../openapi:/work/openapi 
    entrypoint: [ "/work/oapi-generate-all.sh" ]

После подключения CI-процесс стал автоматическим: спецификация меняется — код перегенерируется — ошибки находятся автоматически. При желании, перед этапом генерации можно валидировать openapi-контракт утилитами spectral и/или swagger-cli.

Шаг 7. Добавили валидацию, чтобы хендлеры не превратились в свалку

После того, как сервер и клиент начинают генерироваться из спецификации, вылезает следующая типовая боль — валидация входных данных. Сначала их немного, потом в каждом хендлере появляются одинаковые проверки. body != nil, длины строк, диапазоны чисел и обязательные поля. Код быстро обрастает if’ами, часть проверок делается по привычке, а часть вовсе забывается.

Вообще, удобная валидация — это еще одна киллер-фича DDD-подхода, так как все ограничения можно реализовать в репозитории контрактов, а код приложения при этом будет максимально чистым.

Для этого мы дописали шаблоны генерации и включили валидацию, которую oapi-codegen генерирует, в данном случае, для Echo. 

1. echo-interface.tmpl: 

// ServerInterface represents all server handlers.
type ServerInterface interface {
{{range .}}{{$opid := .OperationId}}{{.SummaryAsComment }}
// ({{.Method}} {{.Path}})
{{.OperationId}}(httpCtx echo.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{.OperationId}}Params{{end}}{{if .Bodies}}{{$hasJSON := false}}{{$hasForm := false}}{{range .Bodies}}{{if or (eq .ContentType "application/json") (eq .ContentType "application/json-patch+json")}}{{$hasJSON = true}}{{end}}{{if eq .ContentType "application/x-www-form-urlencoded"}}{{$hasForm = true}}{{end}}{{end}}{{if $hasJSON}}, body *{{$opid}}JSONRequestBody{{else if $hasForm}}, body *{{$opid}}FormdataRequestBody{{else}}, body *{{$opid}}JSONRequestBody{{end}}{{end}}) error
{{end}}
}

2. echo-wrappers.tmpl: 

echo-wrappers.tmpl
// ServerInterfaceWrapper converts echo contexts to parameters.
type ServerInterfaceWrapper struct {
    Handler ServerInterface
}

{{range .}}{{$opid := .OperationId}}// {{$opid}} converts echo context to params.
func (w *ServerInterfaceWrapper) {{.OperationId}} (httpCtx echo.Context) error {
    var err error
{{range .PathParams}}// ------------- Path parameter "{{.ParamName}}" -------------
    var {{$varName := .GoVariableName}}{{$varName}} {{.TypeDef}}
{{if .IsPassThrough}}
    {{$varName}} = httpCtx.Param("{{.ParamName}}")
{{end}}
{{if .IsJson}}
    err = json.Unmarshal([]byte(httpCtx.Param("{{.ParamName}}")), &{{$varName}})
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON"))
    }
{{end}}
{{if .IsStyled}}
    err = runtime.BindStyledParameterWithLocation("{{.Style}}",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationPath, httpCtx.Param("{{.ParamName}}"), &{{$varName}})
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err))
    }
{{end}}
{{end}}

{{if .RequiresParamObject}}
    // Parameter object where we will unmarshal all parameters from the context
    var params {{.OperationId}}Params

{{range $paramIdx, $param := .QueryParams}}// ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" -------------
    {{if .IsStyled}}
    err = runtime.BindQueryParameter("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", httpCtx.QueryParams(), &params.{{.GoName}})
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err))
    }
    {{else}}
    if paramValue := httpCtx.QueryParam("{{.ParamName}}"); paramValue != "" {
    {{if .IsPassThrough}}
    params.{{.GoName}} = {{if not .Required}}&{{end}}paramValue
    {{end}}
    {{if .IsJson}}
    var value {{.TypeDef}}
    err = json.Unmarshal([]byte(paramValue), &value)
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON"))
    }
    params.{{.GoName}} = {{if not .Required}}&{{end}}value
    {{end}}
    }{{if .Required}} else {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("query argument '{{.ParamName}}' is required, but not found"))
    }{{end}}
    {{end}}
{{end}}

{{if .HeaderParams}}
    headers := httpCtx.Request().Header
{{range .HeaderParams}}// ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" -------------
    if valueList, found := headers[http.CanonicalHeaderKey("{{.ParamName}}")]; found {
        var {{.GoName}} {{.TypeDef}}
        n := len(valueList)
        if n != 1 {
            return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("expected one value for '{{.ParamName}}', got %d", n))
        }
{{if .IsPassThrough}}
        params.{{.GoName}} = {{if not .Required}}&{{end}}valueList[0]
{{end}}
{{if .IsJson}}
        err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}})
        if err != nil {
            return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON"))
        }
{{end}}
{{if .IsStyled}}
        err = runtime.BindStyledParameterWithLocation("{{.Style}}",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationHeader, valueList[0], &{{.GoName}})
        if err != nil {
            return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err))
        }
{{end}}
        params.{{.GoName}} = {{if not .Required}}&{{end}}{{.GoName}}
        } {{if .Required}}else {
            return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("header parameter '{{.ParamName}}' is required, but not found"))
        }{{end}}
{{end}}
{{end}}

{{range .CookieParams}}
    if cookie, err := httpCtx.Cookie("{{.ParamName}}"); err == nil {
    {{if .IsPassThrough}}
    params.{{.GoName}} = {{if not .Required}}&{{end}}cookie.Value
    {{end}}
    {{if .IsJson}}
    var value {{.TypeDef}}
    var decoded string
    decoded, err := url.QueryUnescape(cookie.Value)
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unescaping cookie parameter '{{.ParamName}}'"))
    }
    err = json.Unmarshal([]byte(decoded), &value)
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON"))
    }
    params.{{.GoName}} = {{if not .Required}}&{{end}}value
    {{end}}
    {{if .IsStyled}}
    var value {{.TypeDef}}
    err = runtime.BindStyledParameterWithLocation("simple",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationCookie, cookie.Value, &value)
    if err != nil {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err))
    }
    params.{{.GoName}} = {{if not .Required}}&{{end}}value
    {{end}}
    }{{if .Required}} else {
        return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("query argument '{{.ParamName}}' is required, but not found"))
    }{{end}}

{{end}}{{/* .CookieParams */}}
    // Validate parsed params
    if err = httpCtx.Validate(params); err != nil {
      return err
    }

{{end}}{{/* .RequiresParamObject */}}

{{if .Bodies}}
{{$hasJSON := false}}{{$hasForm := false}}{{range .Bodies}}{{if or (eq .ContentType "application/json") (eq .ContentType "application/json-patch+json")}}{{$hasJSON = true}}{{end}}{{if eq .ContentType "application/x-www-form-urlencoded"}}{{$hasForm = true}}{{end}}{{end}}
    // Parse and validate request body
{{if and $hasJSON $hasForm}}// Supports both application/json and application/x-www-form-urlencoded
    body := &{{$opid}}JSONRequestBody{}
    contentType := httpCtx.Request().Header.Get("Content-Type")
    if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
        formBody := &{{$opid}}FormdataRequestBody{}

        if err = httpCtx.Bind(formBody); err != nil {
            return err
        }

        if err = httpCtx.Validate(formBody); err != nil {
            return err
        }

        // Convert form body to JSON body type
        *body = {{$opid}}JSONRequestBody(*formBody)
    } else {
        if err = httpCtx.Bind(body); err != nil {
            return err
        }

        if err = httpCtx.Validate(body); err != nil {
            return err
        }
    }
{{else if $hasForm}}body := &{{$opid}}FormdataRequestBody{}

    if err = httpCtx.Bind(body); err != nil {
        return err
    }

    if err = httpCtx.Validate(body); err != nil {
        return err
    }
{{else}}body := &{{$opid}}JSONRequestBody{}

    if err = httpCtx.Bind(body); err != nil {
        return err
    }

    if err = httpCtx.Validate(body); err != nil {
        return err
    }
{{end}}
{{end}}

    // Invoke the callback with all the unmarshalled arguments
    err = w.Handler.{{.OperationId}}(httpCtx{{genParamNames .PathParams}}{{if .RequiresParamObject}}, &params{{end}}{{if .Bodies}}, body{{end}})

    return err
}
{{end}}

Шаблоны положили в contracts/openapi/templates/echo. Используя их, oapi-codegen сгенерирует валидацию параметров и тела запроса на входе.

Теперь для автоматической валидации параметров осталось просто  реализовать метод Validate() error у всех типов, которые мы использовали для параметров и тел запросов. Мне нравится библиотека ozzo-validation, поэтому использовал ее. А в качестве примера — вот реализации методов Validate() для сущностей сервера из практикума: 

openapi/phonebook/validation.go
package phoneBook

import (
	"regexp"

	validation "github.com/go-ozzo/ozzo-validation/v4"
)

const (
	MinLimit = 1
	MaxLimit = 200
)

const (
	uuidRegexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$"
)

/*
	RULE SETS
*/

var ValidationUuidRules = []validation.Rule{
	validation.Required,
	validation.Length(36, 36),
	validation.Match(regexp.MustCompile(uuidRegexp)),
}

var ValidationLimitRules = []validation.Rule{
	validation.Required,
	validation.Min(MinLimit),
	validation.Max(MaxLimit),
}

var ValidationPageTokenRules = []validation.Rule{
	validation.Required,
	validation.Length(1, 200),
}

/*
	BASE TYPES VALIDATION
*/

func (t *AbonentInfo) Validate() error {
	return validation.ValidateStruct(&t,
		validation.Field(&t.Description, validation.Required, validation.Length(1, 100)),
		validation.Field(&t.Trusted, validation.Required),
		validation.Field(&t.Type, validation.Required, validation.In(AbonentTypeAdvertising, AbonentTypeOrganization, AbonentTypePersonal, AbonentTypeScammer)),
	)
}

/*
	REQUEST PARAMS VALIDATION
*/

func (p *GetAbonentsListParams) Validate() error {
	return validation.ValidateStruct(&p,
		validation.Field(&p.Limit, validation.NilOrNotEmpty, validation.When(p.Limit != nil, ValidationLimitRules...)),
		validation.Field(&p.PageToken, validation.NilOrNotEmpty, validation.When(p.PageToken != nil, ValidationPageTokenRules...)),
	)
}

func (p *CreateAbonentParams) Validate() error {
	return validation.ValidateStruct(&p,
		validation.Field(&p.IdempotencyKey, ValidationUuidRules...),
	)
}

/*
REQUEST BODIES VALIDATION
*/

func (b *CreateAbonentJSONRequestBody) Validate() error {
	return b.AbonentInfo.Validate()
}

func (b *ReplaceAbonentJSONRequestBody) Validate() error {
	return b.AbonentInfo.Validate()
}

В итоге код хендлеров становится чистым — в них остается только то, ради чего и существует эндпоинт. А вся рутина по проверкам ушла в один слой, где ее проще поддерживать и проще тестировать.

Как все это внедрить и каких результатов ждать 

Если решите повторить все шаги и команды выше, то на выходе у вас уже будет базовый набор — репозиторий со спецификациями OpenAPI, генерация кода, которую можно запускать одной командой, и понятное место, где лежат шаблоны, если нужно подкрутить результат под свои задачи. Весь получившийся код можно найти на GitHub.

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

  • начните с одного нового сервиса по DDD, при этом в коде лучше создавать второй отдельный сервер с использованием сгенерированного кода; 

  • оставьте как есть существующие эндпоинты старого сервера, а новые тестируйте интеграционными тестами; 

  • мигрируйте только тогда, когда все протестировано.

По нашим подсчетам, DDD-подход окупился за несколько месяцев: согласование нового эндпоинта сократилось с одного–двух дней переписки и встреч до ревью одного yaml-файла. К слову, самый большой сервис мы согласовывали примерно два часа. 

Исчезли инциденты из-за несовместимости API, и высвободилось около 20% времени разработчиков, которое мы теперь используем для создания бизнес-логики, вместо поддержания документации.

Если тоже так хотите — выбирайте эндпоинт и начинайте с первого шага. Но будьте готовы: после перехода на DDD работать по-старому вы уже не захотите.

А как вы сейчас пишете документацию и сколько времени на это уходит?