Зачем вообще версионировать 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"
}
Правильный план:
- Выкатываешь v2 рядом с v1
- В ответах v1 добавляешь заголовок
Deprecation с датой через 6 месяцев
- Публикуешь changelog и руководство по миграции
- Рассылаешь уведомления всем клиентам с активными токенами для v1
- За месяц до дедлайна — напоминание
- В день дедлайна v1 возвращает
410 Gone с сообщением, куда переехать
- Через год — удаляешь код 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 так, будто твои клиенты не читали предыдущую документацию — потому что, скорее всего, они её не читали.