Версионирование API
Урок о том, как менять API так, чтобы старые клиенты не падали в день релиза.
Версионирование API — это способ предоставить несколько контрактов одновременно, чтобы изменения не ломали уже работающих потребителей.
Представьте: вы выкатили публичный API, его подключили мобильное приложение, партнёрский сайт и десяток скриптов. Через полгода вам нужно переименовать поле name в full_name и поменять формат даты. Если вы просто измените ответ, в тот же миг сломаются все клиенты, которые вы не контролируете. Версионирование решает эту проблему: старый контракт продолжает жить под именем v1, а новый появляется как v2. Клиенты мигрируют в своём темпе, а не по вашему расписанию.
Зачем вообще версии
API — это публичное обещание. Как только им начали пользоваться внешние потребители, вы теряете право молча менять формат запросов и ответов. Любое несовместимое изменение — это нарушенное обещание, которое выливается в упавшие интеграции, ночные звонки и потерю доверия. Версия даёт вам легальный канал для слома: «всё, что несовместимо, едет в новую версию, старая остаётся стабильной».
Важно сразу различать два типа изменений. Аддитивные (добавили новое поле, новый необязательный параметр, новый эндпоинт) совместимы и НЕ требуют новой версии. Ломающие (удалили/переименовали поле, сузили тип, сделали необязательное обязательным) требуют. Версионируют только из-за ломающих изменений — плодить версии на каждое добавленное поле не нужно.
Стратегия 1: версия в URL
Самый распространённый и наглядный подход — номер версии прямо в пути:
GET /v1/users/42
GET /v2/users/42
Версию видно невооружённым глазом, её легко затестить из браузера или curl, легко закешировать, легко роутить на разные бэкенды. Минус — формально это нарушает идею, что URL идентифицирует ресурс, а не его представление: /v1/users/42 и /v2/users/42 — это «один и тот же» пользователь под двумя адресами. На практике большинство крупных API (GitHub, Stripe в части путей, Twitter) живут именно так, потому что простота побеждает чистоту.
curl https://api.example.com/v1/users/42
curl https://api.example.com/v2/users/42
Стратегия 2: версия в заголовке
Более «правильный» по канонам REST способ — оставить URL чистым, а версию запрашивать через согласование содержимого (content negotiation) в заголовке Accept:
GET /users/42
Accept: application/vnd.example.api+json;version=1
Здесь vnd.example — это vendor-специфичный media type. URL идентифицирует ресурс, а версия описывает представление — концептуально чисто. Минусы серьёзные: версию не видно в строке адреса, её нельзя просто открыть в браузере, сложнее отлаживать, легче забыть заголовок и получить дефолтную версию. Так делают там, где важна строгость и где клиенты — машины, а не люди (например, у Pinterest, в части API GitHub через Accept: application/vnd.github+json).
curl -H "Accept: application/vnd.example.api+json;version=1" \
https://api.example.com/users/42
Стратегия 3: версия в query-параметре
Компромисс — передавать версию параметром строки запроса:
GET /users/42?version=1
GET /users/42?api-version=2023-10-01
Видно в URL, легко затестить, не нужен заголовок. Но query-параметры семантически предназначены для фильтрации и сортировки ресурса, а не для выбора контракта; они засоряют кеш-ключи и легко теряются при копировании ссылок. Этот вариант любят облачные провайдеры (например, Azure с ?api-version=...), где версия — это дата, а не порядковый номер.
Сравнение стратегий
| Критерий | URL /v1/ | Заголовок Accept | Query ?version= |
| Видно в адресе | да | нет | да |
| Легко тестить в браузере | да | нет | да |
| Чистота REST | средняя | высокая | низкая |
| Дружит с кешем | да | сложнее | средне |
| Распространённость | очень высокая | средняя | средняя |
Семантическое версионирование
Внутри одной мажорной версии полезно мыслить категориями SemVer — MAJOR.MINOR.PATCH. Меняем MAJOR при ломающих изменениях, MINOR при совместимых добавлениях, PATCH при исправлениях без изменения контракта. В URL обычно отражают только мажор (/v1/, /v2/): минорные и патч-изменения по определению совместимы, поэтому не требуют новой ветки URL. Это держит число версий маленьким — мажор бампают редко.
Что версионировать: всё API или ресурс
Есть два уровня гранулярности. Глобальная версия — весь API едет под одним /v1/, любой слом поднимает версию всего. Просто для понимания, но болезненно: из-за одного изменённого эндпоинта приходится тащить в /v2/ весь остальной неизменный API. Версия на ресурс — каждый ресурс версионируется отдельно (/users/v2/, /orders/v1/). Гибко, но запутанно: клиенту тяжело держать в голове, что где. Для большинства команд разумный дефолт — одна глобальная мажорная версия, которую бампают крайне редко, а совместимые изменения вкатывают без новой версии вообще.
Как работает под капотом
Версия — это всего лишь признак, по которому маршрутизатор выбирает обработчик. При версии в URL роутер сопоставляет префикс /v1/ с одним набором контроллеров, /v2/ — с другим. При версии в заголовке middleware парсит Accept, достаёт version=1 и кладёт его в контекст запроса, а дальше тот же роутер выбирает ветку. Часто оба варианта внутри сводятся к одному: запрос нормализуется в число версии, и дальше работает общая логика. Распространённый приём — не дублировать весь код, а держать общий доменный слой и тонкие версионные адаптеры, которые мапят старый формат ответа в новый и обратно. Тогда v1 — это просто адаптер поверх актуальной модели.
Частые ошибки
- Версионировать на каждое добавление. Добавили поле — это совместимо, новая версия не нужна. Иначе версии плодятся до
/v17/. - Нет дефолтной версии или, наоборот, неявный дефолт. Если клиент не указал версию, поведение должно быть документировано. Тихий «прыжок» на последнюю версию ломает старых клиентов при каждом релизе.
- Смешивать стратегии. Часть API версионируется через URL, часть — через заголовок. Клиент путается. Выберите одну стратегию на весь API.
- Бесконечно поддерживать все версии. Без процесса вывода из эксплуатации (deprecation) старые версии копятся вечно и превращаются в груз легаси.
Итоги
- Версионируют только из-за ломающих изменений; аддитивные — совместимы и версии не требуют.
- Три стратегии: в URL (просто, наглядно), в заголовке
Accept(чисто по REST), в query (компромисс). - В URL отражают только мажорную версию SemVer; минор/патч совместимы по определению.
- Разумный дефолт — одна глобальная мажорная версия, бампается редко.
- Всегда задавайте явную дефолтную версию и процесс вывода старых из эксплуатации.