MidТеория5 min

API versioning

URL path, header, query, content negotiation. Semver, deprecation, backward-compatible vs breaking. Protobuf, GraphQL, Symfony и Go примеры

Зачем версионировать

API -- это публичный контракт. Пока у тебя один клиент (мобильное приложение, которое ты сам деплоишь вместе с backend'ом) -- можно менять всё. Как только появился второй клиент, которого ты не контролируешь (web, партнёрский сервис, third-party интеграция), любое ломающее изменение = inc ident.

Версионирование -- это способ одновременно поддерживать старых и новых клиентов, давая командам возможность мигрировать в их собственном темпе.

Ключевые вопросы до дизайна

  1. Кто клиент? (внутренний -- можно жёстче; внешний -- долгая поддержка)
  2. Как часто контракт меняется?
  3. Какой срок жизни старой версии? (3 месяца? год?)
  4. Публичный API или только внутри компании?

Backward-compatible vs breaking changes

Backward-compatible (без bump версии)

Старые клиенты продолжают работать. Добавление -- да, удаление/изменение -- нет.

Что можно:

  • Добавить optional поле в response
  • Добавить новый endpoint
  • Добавить необязательный query параметр
  • Ослабить валидацию (принимать больше форматов)
  • Добавить новый enum-value, если клиент обязан игнорировать неизвестные
  • Добавить новый HTTP-заголовок

Что нельзя (breaking):

  • Удалить поле из response
  • Переименовать поле
  • Сделать optional поле required
  • Изменить тип поля (string -> int)
  • Изменить HTTP-статус для существующего сценария
  • Убрать endpoint
  • Изменить формат даты (2025-01-01 -> 01/01/2025)
  • Добавить required поле в request
  • Изменить валидацию в сторону строгости

Пример: добавление поля

// v1 response
{"id": "ord_1", "total": 1000}

// Можно добавить без bump (клиент v1 игнорирует неизвестные поля):
{"id": "ord_1", "total": 1000, "currency": "USD", "discount": 50}

Пример: breaking

// Было
{"user": {"name": "Alice"}}

// Переименование name -> full_name = breaking
{"user": {"full_name": "Alice"}}

Это уже требует новую версию, иначе клиент упадёт.

Стратегии версионирования

1. URL path versioning (/v1/orders, /v2/orders)

Самая распространённая в REST. Плюс: тривиально кэшируется, видно в логах, тестируется curl'ом.

GET /v1/orders/123
GET /v2/orders/123

Минус: "каждый endpoint принадлежит версии" -- мешает эволюции (ленимся поднимать всю версию из-за одного поля).

2. Header versioning (API-Version: 2)

GET /orders/123
API-Version: 2

Плюс: URL один, не путает SEO (для публичных API). Минус: хуже кэшируется (нужен Vary: API-Version), хуже тестируется из браузера.

3. Query parameter (/orders/123?v=2)

GET /orders/123?version=2

Плюс: просто. Минус: легко забыть параметр, CDN-кэши по URL путаются.

4. Content negotiation (Accept header)

Самый "RESTful" способ -- версия как часть media type:

GET /orders/123
Accept: application/vnd.company.order.v2+json

Плюс: соответствует REST-идее "resource has representations". Минус: сложнее, разработчики постоянно путают синтаксис, CDN требует Vary: Accept.

Сравнительная таблица

Стратегия CDN-friendly Лёгкость клиента "RESTful" Практика
URL path /v1/ отлично просто нет доминирующая в индустрии
Header API-Version с Vary просто средне популярна в stripe-style API
Query ?v=1 с Vary средне нет встречается редко
Content negotiation с Vary сложно да GitHub API, академически чисто

Практическая рекомендация: /v1/ в URL -- дефолт для большинства случаев. Для внутренних API можно header'ом.

Semver для API

Применяем SemVer (MAJOR.MINOR.PATCH):

  • MAJOR -- breaking change (v1 -> v2 в URL)
  • MINOR -- backward-compatible добавление (новое поле, новый endpoint)
  • PATCH -- исправление без изменения контракта

Только MAJOR отражается в URL/header. MINOR/PATCH остаются внутри одной версии.

v1.0.0 - initial
v1.1.0 - added "discount" field to order response (compat)
v1.1.1 - fixed bug in "total" calculation (compat)
v2.0.0 - renamed "user_id" to "customer_id" (breaking)

Deprecation policy

Версии не живут вечно. Нужна прописанная политика:

  1. Announce -- минимум 6 месяцев до удаления, email + changelog + Deprecation header
  2. Warn -- все response'ы старой версии включают Deprecation: true и Sunset: <date> headers (RFC 8594)
  3. Metrics -- мониторить процент трафика на deprecated версии
  4. Sunset -- в день X вернуть 410 Gone
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: </v2/orders/123>; rel="successor-version"

Публичные API (Stripe, Twilio, GitHub) -- поддерживают старые версии годами. Внутренние -- 3-6 месяцев стандарт.

Реализация: Symfony router

<?php

declare(strict_types=1);

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

/**
 * V1 keeps original field names and status semantics.
 * No new features land here - only bug fixes.
 */
#[Route('/v1/orders', name: 'api_v1_orders_')]
final class OrdersV1Controller extends AbstractController
{
    public function __construct(
        private readonly OrderQueryService $queries,
    ) {}

    #[Route('/{id}', name: 'get', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $order = $this->queries->findById($id);

        $response = new JsonResponse([
            'id' => $order->id,
            'user_id' => $order->customerId,   // old field name
            'total' => $order->totalCents / 100,
        ]);

        // Deprecation headers per RFC 8594
        $response->headers->set('Deprecation', 'true');
        $response->headers->set('Sunset', 'Wed, 31 Dec 2025 23:59:59 GMT');
        $response->headers->set('Link', '</v2/orders/'.$id.'>; rel="successor-version"');

        return $response;
    }
}

/**
 * V2 - current version. Renamed customer_id, added currency,
 * returns cents as integer instead of float "total".
 */
#[Route('/v2/orders', name: 'api_v2_orders_')]
final class OrdersV2Controller extends AbstractController
{
    public function __construct(
        private readonly OrderQueryService $queries,
    ) {}

    #[Route('/{id}', name: 'get', methods: ['GET'])]
    public function get(string $id): JsonResponse
    {
        $order = $this->queries->findById($id);

        return new JsonResponse([
            'id' => $order->id,
            'customer_id' => $order->customerId,    // renamed
            'total_cents' => $order->totalCents,    // cents as integer
            'currency' => $order->currency,         // new
        ]);
    }
}

Header-based в Symfony (альтернатива)

Если хочется одну URL:

#[Route('/orders/{id}', methods: ['GET'])]
public function get(string $id, Request $req): JsonResponse
{
    $version = (int) ($req->headers->get('API-Version') ?? 1);
    $order = $this->queries->findById($id);

    return match ($version) {
        1 => $this->responseV1($order),
        2 => $this->responseV2($order),
        default => new JsonResponse(
            ['error' => 'unsupported_version'],
            Response::HTTP_NOT_ACCEPTABLE,
        ),
    };
}

Минус: разветвление в одном контроллере быстро становится messy. Лучше делегировать в serializer.

Реализация: Go chi groups

package api

import (
	"encoding/json"
	"net/http"

	"github.com/go-chi/chi/v5"
)

type OrderV1 struct {
	ID     string  `json:"id"`
	UserID string  `json:"user_id"` // old name
	Total  float64 `json:"total"`   // float dollars
}

type OrderV2 struct {
	ID         string `json:"id"`
	CustomerID string `json:"customer_id"` // renamed
	TotalCents int64  `json:"total_cents"` // integer cents
	Currency   string `json:"currency"`    // new
}

// MountAPI wires versioned routes under /api.
func MountAPI(r chi.Router, svc *OrderService) {
	r.Route("/api/v1", func(r chi.Router) {
		r.Get("/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
			id := chi.URLParam(r, "id")
			order, err := svc.ByID(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusNotFound)
				return
			}
			w.Header().Set("Deprecation", "true")
			w.Header().Set("Sunset", "Wed, 31 Dec 2025 23:59:59 GMT")
			w.Header().Set("Link", "</api/v2/orders/"+id+">; rel=\"successor-version\"")
			json.NewEncoder(w).Encode(OrderV1{
				ID:     order.ID,
				UserID: order.CustomerID,
				Total:  float64(order.TotalCents) / 100,
			})
		})
	})

	r.Route("/api/v2", func(r chi.Router) {
		r.Get("/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
			id := chi.URLParam(r, "id")
			order, err := svc.ByID(r.Context(), id)
			if err != nil {
				http.Error(w, err.Error(), http.StatusNotFound)
				return
			}
			json.NewEncoder(w).Encode(OrderV2{
				ID:         order.ID,
				CustomerID: order.CustomerID,
				TotalCents: order.TotalCents,
				Currency:   order.Currency,
			})
		})
	})
}

Middleware для header-based версии

func APIVersion(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		v := r.Header.Get("API-Version")
		if v == "" {
			v = "2" // default to current
		}
		ctx := context.WithValue(r.Context(), ctxKeyVersion{}, v)
		w.Header().Set("Vary", "API-Version") // critical for CDN caches
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

GraphQL: версионирование через deprecation

В GraphQL нет "v2 schema" в практике -- вместо этого поля помечаются @deprecated, новые добавляются рядом.

type Order {
  id: ID!
  userId: ID! @deprecated(reason: "Use customerId instead. Will be removed on 2025-12-31")
  customerId: ID!
  total: Float @deprecated(reason: "Use totalCents (integer)")
  totalCents: Int!
  currency: String!
}

Плюс: клиенты сами выбирают, какие поля запрашивать -- breaking change затрагивает только тех, кто запрашивает deprecated поле. Минус: schema растёт, нужен discipline по удалению.

Стратегия эволюции GraphQL

  1. Добавить новое поле рядом (customerId) -- non-breaking
  2. Пометить старое @deprecated с датой удаления
  3. Метрики по запросам старого поля (Apollo Studio, Hasura)
  4. Когда usage около нуля -- удалить

gRPC / Protobuf: field rules

Protobuf разработан под эволюцию. Правила для совместимости:

Что можно (backward + forward compatible)

  • Добавлять новые поля с новыми field numbers
  • Удалять optional/singular поля (старые клиенты получат default value)
  • Менять singular <-> repeated для scalar types (с осторожностью)

Что нельзя

  • Переиспользовать field number (катастрофа: старые данные декодируются в новое поле)
  • Менять тип поля (int32 -> string)
  • Переименовывать поля (имя не в wire-формате, но JSON-маппинг ломается)
syntax = "proto3";
package payment.v1;

message Order {
  string id = 1;

  // Field 2 was "user_id", removed in v1.2. Do not reuse!
  reserved 2;
  reserved "user_id";

  string customer_id = 3;           // new in v1.2
  int64 total_cents = 4;
  string currency = 5;

  // Forward-compat: adding this in v1.3 won't break v1.2 clients
  optional string promo_code = 6;
}

Версионирование пакета

Для настоящих breaking changes (пересмотр структуры) создаётся новый package:

// v1 package - frozen
package payment.v1;

// v2 package - new contract
package payment.v2;

Сервер реализует оба, клиенты постепенно мигрируют. По сути -- тот же URL path versioning, но для gRPC.

Поддержка N версий одновременно

На сколько версий назад поддерживать?

  • N (текущая) -- полная
  • N-1 -- полная, если возможна
  • N-2 -- только критичные security fixes

Большее -- технический долг растёт нелинейно. Разделяйте версии тонким слоем (presenter / serializer), а не копированием всей бизнес-логики.

[Controller v1] \
                 -> [one OrderService] -> [DB]
[Controller v2] /

Правильно: контроллеры/serializer'ы разные, domain logic один. Неправильно: полный fork кода под каждую версию.

Типичные ошибки

  1. Нет версии вообще -- первая же эволюция ломает всех
  2. Bump MAJOR на мелочь -- "добавил поле -> v2". Лишняя работа миграции клиентов
  3. "Версию добавим потом" -- потом это превращается в /v1/, /api/, /api/v1/, /new-api/ одновременно
  4. Нет deprecation header -- клиенты не знают что пора мигрировать
  5. Удалили версию без метрик -- забыли что интеграция партнёра всё ещё ей пользуется
  6. Версия в URL + версия в header -- непонятно что приоритетнее
  7. Копи-паст контроллеров -- изменение бизнес-логики надо делать в N местах

Выводы

Версионирование API -- это обязательный пункт дизайна с первого дня. Практичный дефолт: /v1/ в URL path, Semver (только MAJOR виден), добавление полей -- MINOR без новой версии, переименование/удаление -- новая версия. Поддержка 1-2 версий одновременно, deprecation headers по RFC 8594, метрики по версиям. Для GraphQL -- @deprecated и эволюция schema; для gRPC -- дисциплина с field numbers и reserved. Главный принцип: добавлять безопасно, удалять медленно, всегда говорить клиентам заранее.