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

API — это контракт между сервером и клиентом. Если ты поменяешь формат ответа, переименуешь поле или удалишь эндпоинт, все, кто уже использует твой API, получат сломанное приложение. Версионирование — это способ менять API без того, чтобы всё вокруг рушилось.

Представь: у тебя мобильное приложение, которое работает с твоим бэкендом. Ты решил переименовать поле user_name в username. Звучит невинно. Но на старой версии приложения, которую пользователи не обновили, всё сломается. Причём ты не знаешь, сколько именно пользователей сидят на старой версии, и узнаешь об этом только из потока жалоб. Версионирование позволяет оставить старый контракт живым, пока все клиенты не перейдут на новый.

Четыре основных подхода

URL-версионирование

Самый популярный и понятный способ — добавить номер версии прямо в URL:

GET /api/v1/users
GET /api/v2/users

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

Минусы: нарушает принципы REST (URL должен указывать на ресурс, а не на его версию), при смене версии нужно обновлять все ссылки.

Это самый распространённый выбор для публичных API. Stripe, GitHub, Twilio — все используют именно его. И не зря: разработчики сразу видят, с какой версией работают, без необходимости лезть в заголовки.

Версионирование через заголовки

Версия передаётся в HTTP-заголовке:

GET /api/users
Accept-Version: v2

Или через кастомный заголовок:

X-API-Version: 2

Плюсы: URL остаётся чистым, технически более корректно с точки зрения REST.

Минусы: сложнее дебажить — из URL версию не видно. Нельзя просто скопировать ссылку и отправить коллеге. Нагрузочные тесты, мониторинг, логи — везде придётся дополнительно настраивать парсинг заголовков.

Версионирование через параметры запроса

GET /api/users?version=2

Плюсы: видно в URL, легко переключаться между версиями вручную.

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

Content negotiation

Через заголовок Accept:

Accept: application/vnd.myapi.v2+json

Плюсы: формально самый «правильный» REST-подход.

Минусы: невероятно неудобно для разработчиков-потребителей. Сложно тестировать, сложно объяснять. На практике почти никто не использует это в production для публичных API — и правильно делает.

Что выбрать

Если API публичный или будет использоваться внешними разработчиками — URL-версионирование. Никаких дискуссий.

Если API внутренний и у тебя полный контроль над всеми клиентами — можно рассмотреть заголовки. Но даже в этом случае URL-версионирование проще в сопровождении и отладке. Выбирай его по умолчанию, а от стандарта отступай только при наличии веского повода.

Параметры запроса и content negotiation оставь для очень специфичных случаев или академических дискуссий.

Когда создавать новую версию

Не каждое изменение требует новой версии. Обратно совместимые изменения можно вкатывать без смены версии:

  • Добавление нового поля в ответ
  • Добавление нового необязательного параметра запроса
  • Добавление нового эндпоинта
  • Расширение допустимых значений enum

Версию нужно менять при ломающих изменениях (breaking changes):

  • Удаление поля из ответа
  • Переименование поля
  • Изменение типа данных (строка стала числом)
  • Изменение смысла существующего поля
  • Удаление или переименование эндпоинта
  • Изменение обязательных параметров

Практический совет: веди changelog и при каждом изменении явно помечай, breaking это или нет. Через полгода ты скажешь себе спасибо.

Подводные камни

Слишком много версий

Самая частая проблема — версии накапливаются, а удалять их страшно. В итоге поддерживаешь v1, v2, v3, v4 одновременно, и при каждом изменении нужно трогать их все. Кодовая база пухнет, тесты множатся, инженеры путаются.

Решение: устанавливай срок жизни версий заранее. Например: «Версия поддерживается 12 месяцев после выхода следующей версии». Уведомляй клиентов через заголовки в ответах:

Deprecation: Sun, 01 Jun 2025 00:00:00 GMT
Sunset: Mon, 01 Jun 2026 00:00:00 GMT

RFC 8594 описывает стандарт для заголовка Sunset — когда именно API перестанет работать. Используй его: клиенты могут парсить эту дату автоматически и показывать предупреждения своим пользователям.

Дублирование кода

Если для каждой версии писать отдельный контроллер — утонешь в дублировании. Типичная ошибка: скопировал /api/v1/users в /api/v2/users и поменял одну строку. Теперь при фиксе бага в v1 нужно помнить поправить и v2. Спойлер: однажды забудешь.

Лучший подход — одна бизнес-логика, трансформеры для разных версий:

def get_user(user_id, version):
    user = db.get_user(user_id)  # общая логика
    
    if version == 'v1':
        return UserV1Serializer(user).data
    elif version == 'v2':
        return UserV2Serializer(user).data

Логика получения данных одна, а представление — разное для каждой версии. Это и проще поддерживать, и тестировать.

Версионирование без стратегии миграции

Выкатить v2 — половина дела. Нужно помочь клиентам переехать. Что должно быть:

  • Changelog с описанием всех изменений
  • Руководство по миграции с конкретными примерами до/после
  • Период поддержки обеих версий параллельно
  • Уведомления (email, заголовки Deprecation) заблаговременно

Если клиент узнаёт о закрытии версии за неделю до дедлайна — это провал, который ударит по репутации API не меньше, чем сама поломка.

Версионирование только URL, но не документации

Если у тебя есть документация (Swagger/OpenAPI), она тоже должна быть версионированной. Нет ничего хуже, чем документация на v2, а в продакшн торчит v1 с другим поведением — или наоборот.

Решение: генерируй документацию автоматически из кода, храни по одной OpenAPI-спецификации на каждую поддерживаемую версию. Swagger UI умеет показывать несколько спецификаций в одном интерфейсе с переключателем.

Stripe-подход: версионирование по дате

Stripe использует дату вместо номера версии:

Stripe-Version: 2024-11-20

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

Минус: сложнее в реализации, и клиентам труднее понять, что конкретно изменилось между 2024-06-15 и 2024-11-20. Подход оправдан для зрелого публичного API с тысячами интеграций, но для старта явно избыточен.

Реальный сценарий: переезд с v1 на v2

Допустим, в v1 пользователь возвращается так:

{
  "user_name": "john_doe",
  "user_email": "john@example.com",
  "created": "2024-01-15"
}

В v2 ты хочешь нормализовать имена полей и добавить временную зону:

{
  "username": "john_doe",
  "email": "john@example.com",
  "created_at": "2024-01-15T00:00:00Z"
}

Правильный план:

  1. Выкатываешь v2 рядом с v1
  2. В ответах v1 добавляешь заголовок Deprecation с датой через 6 месяцев
  3. Публикуешь changelog и руководство по миграции
  4. Рассылаешь уведомления всем клиентам с активными токенами для v1
  5. За месяц до дедлайна — напоминание
  6. В день дедлайна v1 возвращает 410 Gone с сообщением, куда переехать
  7. Через год — удаляешь код v1 полностью

410 Gone лучше, чем просто отключить эндпоинт: клиент получает внятное сообщение вместо таймаута или 404, который никак не объясняет ситуацию.

Тестирование при версионировании

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

Подход — контрактное тестирование. Фиксируешь ожидаемую схему ответа для каждой версии и запускаешь тесты при каждом деплое. Pact — популярный инструмент для этого, поддерживает большинство языков.

Без автоматики ты рано или поздно случайно сломаешь старую версию. И узнаешь об этом от злых клиентов.

Внутренние API: нужно ли версионировать

Если у тебя монолит и один фронтенд, которые деплоятся вместе — формальное версионирование избыточно. Деплоишь всё синхронно — нет проблемы несовместимости.

Но если у тебя микросервисы или несколько клиентов (веб, мобилка, партнёры) — версионирование нужно даже для «внутренних» API. Мобильное приложение обновляется не сразу у всех пользователей, и старая версия приложения может ходить в твой API ещё год после релиза новой версии. Это не гипотетический сценарий — это норма.

Когда в REEXY проектируем API для проектов с мобильным приложением, версионирование закладываем с первого дня — даже если в момент запуска кажется, что «потом разберёмся». Потом разобраться стоит дороже и больнее.

Инструменты

  • Kong, AWS API Gateway, Apigee — умеют маршрутизировать по версиям на уровне gateway, снимая эту задачу с приложения
  • OpenAPI/Swagger — храни отдельный spec-файл на каждую версию
  • Pact — контрактное тестирование между клиентом и сервером
  • Postman — коллекции можно организовывать по версиям

Если пишешь на Node.js — express-version-route позволяет роутить запросы по версии прямо в Express без лишнего кода. На Python с FastAPI удобно делать отдельный роутер на каждую версию и подключать их с префиксами.

Нет одного правильного способа

URL-версионирование выбирают не потому что это «правильно по REST», а потому что оно понятно всем: разработчикам, тестировщикам, DevOps-инженерам, и даже менеджеру, который смотрит в логи. Когда что-то ломается в три часа ночи, это важно.

Планируй deprecated-период заранее. Автоматизируй тесты на все поддерживаемые версии. Не накапливай версии бесконечно — устанавливай конкретные даты отключения и держись их. И документируй breaking changes так, будто твои клиенты не читали предыдущую документацию — потому что, скорее всего, они её не читали.