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

Когда API выходит за пределы одной команды — у него появляются клиенты, которые рассчитывают на стабильный контракт. Стоит изменить поле или убрать эндпоинт — чужой код ломается. Версионирование решает эту проблему: старый контракт живёт под /api/v1, новый — под /api/v2.

Куда ставить номер версии

Есть несколько вариантов, где записать версию API: в URL, в заголовке, в query-параметре. Самый простой и очевидный — URL-путь:

/api/v1/orders
/api/v2/orders

Версию видно в браузере, в логах, в curl — без лишних усилий. Формат: буква v и целое число (v1, v2, v3). Минорные варианты вроде v1.2 или даты вроде /api/2024 — не используют: они усложняют маршрутизацию и путают клиентов.

Префикс /api обязателен для всех бизнес-эндпоинтов. Служебные эндпоинты (/health, /metrics) стоят вне /api и без версии — они не являются частью публичного контракта.

Версия в query-параметре (?version=2) тоже не используется: её легко потерять при проксировании и кэшировании.

Базовая структура роутера с chi

В Go с библиотекой chi версии монтируются через r.Route:

r := chi.NewRouter()

r.Get("/health", healthHandler)
r.Get("/metrics", metricsHandler)

r.Route("/api/v1", func(r chi.Router) {
    r.Use(authMiddleware, tracingMiddleware)
    r.Route("/orders", ordersRouterV1)
    r.Route("/products", productsRouterV1)
    r.Route("/customers", customersRouterV1)
})

Все middleware (авторизация, трейсинг) подключаются один раз на уровне версии — не дублируются в каждом ресурсе.

Что такое breaking change

Не каждое изменение требует новой версии. Ключевое различие: сломает ли изменение существующий клиентский код?

Изменения, требующие новой версии (breaking):

  • Удалить или переименовать эндпоинт
  • Удалить или переименовать поле в ответе (customerIdclientId)
  • Изменить тип поля (total: stringtotal: int64)
  • Убрать значение из перечисления (OrderStatus.DRAFT исчезает)
  • Изменить HTTP-метод эндпоинта
  • Сделать новое поле обязательным в запросе
  • Ужесточить валидацию (maxLength: 200maxLength: 50)
  • Удалить или переименовать query-параметр

Изменения, которые делают в текущей версии (non-breaking):

  • Добавить новое необязательное поле в ответ
  • Добавить новый эндпоинт
  • Добавить новое значение в перечисление
  • Добавить необязательный query-параметр
  • Ослабить валидацию (maxLength: 50maxLength: 200)
  • Добавить новый код ошибки

Частая ошибка — создавать v2 ради добавления одного опционального поля. Это лишняя работа: добавить metadata в OrderResponse — non-breaking, просто добавьте поле в v1.

Параллельная поддержка v1 и v2

Когда breaking change всё же нужен — создаётся новая версия. Старая продолжает работать, пока клиенты не мигрируют:

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/orders", ordersRouterV1)
})

r.Route("/api/v2", func(r chi.Router) {
    r.Route("/orders", ordersRouterV2)
})

Внутри v1 и v2 могут использовать один и тот же слой бизнес-логики — разница только в DTO и преобразованиях:

func listOrdersV1(w http.ResponseWriter, r *http.Request) {
    orders := svc.ListOrders(r.Context())
    writeJSON(w, http.StatusOK, toOrderListResponseV1(orders))
}

func listOrdersV2(w http.ResponseWriter, r *http.Request) {
    orders := svc.ListOrders(r.Context())
    writeJSON(w, http.StatusOK, toOrderListResponseV2(orders))
}

Логика svc.ListOrders общая, меняются только функции преобразования результата.

Клиент и добавление полей

Правильно написанный клиент игнорирует незнакомые поля в ответе. Стандартный encoding/json в Go делает это по умолчанию — это верное поведение:

type OrderResponse struct {
    OrderID string `json:"orderId"`
    Status  string `json:"status"`
    Total   int64  `json:"total"`
    // если сервер добавит новое поле — этот код не сломается
}

var resp OrderResponse
json.Unmarshal(body, &resp) // неизвестные поля молча игнорируются

Не стоит использовать DisallowUnknownFields() в клиентском коде: это делает клиента хрупким — любое расширение сервера будет его ломать.

Аналогично для перечислений — неизвестные значения нужно обрабатывать как unknown, а не падать:

type OrderStatus string

const (
    StatusNew       OrderStatus = "NEW"
    StatusConfirmed OrderStatus = "CONFIRMED"
    StatusUnknown   OrderStatus = ""
)

func parseStatus(s string) OrderStatus {
    switch OrderStatus(s) {
    case StatusNew, StatusConfirmed:
        return OrderStatus(s)
    default:
        return StatusUnknown // неизвестное значение — не ошибка
    }
}

Как объявить версию устаревшей

Когда v2 вышел в продакшн и клиенты могут мигрировать, v1 помечается устаревшим через HTTP-заголовки. Клиенты, которые следят за заголовками, увидят предупреждение задолго до отключения:

func deprecatedMiddleware(sunset, successorURL string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Sunset", sunset)
            w.Header().Set("Deprecation", "true")
            w.Header().Set("Link", `<`+successorURL+`>; rel="successor-version"`)
            next.ServeHTTP(w, r)
        })
    }
}

r.Route("/api/v1", func(r chi.Router) {
    r.Use(deprecatedMiddleware(
        "Sat, 01 Jan 2027 00:00:00 GMT",
        "https://api.example.com/api/v2",
    ))
    r.Route("/orders", ordersRouterV1)
})

Заголовок Sunset содержит дату отключения. Link с rel="successor-version" указывает на замену.

Коротко

  • Версия идёт в URL-путь: /api/v1/orders. Формат — v + целое число.
  • Префикс /api обязателен для бизнес-эндпоинтов. /health и /metrics — вне /api, без версии.
  • Новую версию создают только при breaking change: удаление/переименование поля или эндпоинта, смена типа, ужесточение валидации.
  • Добавление необязательного поля, нового эндпоинта, нового значения enum — non-breaking, делается в текущей версии.
  • v1 и v2 могут делить один слой бизнес-логики; разница — в DTO и преобразованиях.
  • encoding/json игнорирует неизвестные поля по умолчанию. DisallowUnknownFields() в клиенте — плохая практика.
  • Устаревшую версию помечают заголовками Sunset, Deprecation, Link.

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

  • URL и ресурсы — структура маршрутов chi.
  • Ошибки RFC 9457 — как расширять коды ошибок без breaking change.
  • Rate limiting и deprecation — заголовки Sunset подробнее.