Семантическое версионирование (SemVer) для API: как не сломать пользователей
Разберём правила версионирования для публичных эндпоинтов и схем данных, а также типовые причины несовместимостей. Поймём, когда нужен minor, а когда обязательно major.
Содержание
Семантическое версионирование (SemVer) для API: как не сломать пользователей
Когда API выходит в публичное использование, оно начинает жить собственной жизнью: разработчики строят на ваших контрактах бизнес-логику, интеграции и пайплайны, а вы — даже при лучших намерениях — остаётесь источником изменений. Проблема в том, что «выпустить фикс и улучшения» почти никогда не означает «не повредить никому». Особенно опасны изменения, которые на стороне сервера кажутся безобидными: например, переименование поля, изменение формата даты или ужесточение валидации.
Семантическое версионирование (SemVer) — попытка дать формальный язык изменениям и контрактам. Для API это не просто про цифры MAJOR.MINOR.PATCH. Это способ честно описывать обратную совместимость и прогнозировать влияние релизов. В этой статье разберём, как применять SemVer к публичным эндпоинтам и схемам данных, какие причины несовместимостей встречаются чаще всего, и как понять: когда можно ограничиться minor, а когда без major не обойтись.
Что именно “ломает” пользователей: интуитивная модель совместимости
Прежде чем говорить о правилах, важно договориться о терминах. В контексте API «совместимость» обычно понимают как способность клиентского кода продолжать работать без изменений после обновления сервера и схем.
Удобно разделить совместимость на несколько уровней:
Прямая (backward compatibility) — клиент без изменений продолжает работать
Это то, что нам нужно от minor и patch. Если клиент отправляет старые запросы и ожидает старые ответы, сервер не должен нарушать эти ожидания.
Поведенческая несовместимость
Даже если формат ответа “не поменялся” (например, JSON остался JSON), изменение семантики может сломать логику: поменялся смысл поля, порядок сортировки по умолчанию, правила ошибок, гарантии идемпотентности.
Контрактная несовместимость схем
Это самая очевидная группа: поля исчезли, типы изменились, обязательность поменялась, поля получили новый формат или значения перестали подчиняться прежним ограничениям.
Несовместимость транспортного уровня
Случается реже, но тоже важно: смена статусов, заголовков, схемы аутентификации (например, OAuth scopes), требований к CORS, ограничения на методы/редиректы и т. п.
SemVer в API должен отражать именно эти риски.
Введение в SemVer: как интерпретировать MAJOR/MINOR/PATCH для API
SemVer формально описывает правила версионирования пакетов:
- PATCH (
x.y.Z) — обратимые исправления, не меняющие публичный контракт. - MINOR (
x.Y.z) — добавления функциональности в обратной совместимости. - MAJOR (
X.y.z) — изменения, нарушающие обратную совместимость.
Для API эти правила нужно адаптировать к “публичному контракту”: эндпоинты, параметры, ответы, схемы, коды ошибок, договорённости о семантике.
Практическое правило
- Если клиент, написанный под прежнюю версию, может продолжать работать без правок, то это кандидат на
minorилиpatch. - Если гарантировать это нельзя (или для большинства клиентов нужно менять код), это кандидат на
major.
Однако “может продолжать работать” — не бинарный факт. Нужны методичные критерии.
Что считать публичным контрактом в API
В API контракт — это не только GET /items/{id} и JSON. В реальных интеграциях публичными становятся:
Элементы запроса
- URI и HTTP-метод (GET/POST/PUT/PATCH/DELETE).
- Схема и формат query-параметров.
- Тело запроса (формат, структура, обязательные/опциональные поля).
- Заголовки (например,
Idempotency-Key,If-Match,Authorization, кастомные заголовки корреляции). - Кодировки и правила сериализации (например, как обрабатываются пробелы/escape, формат даты).
Элементы ответа
- HTTP status codes и правила их назначения.
- JSON-схемы: поля, типы, nullability, перечисления (enum), формат чисел (decimal/float), ограничения.
- Текст ошибок (если клиенты парсят message).
- Заголовки (paging, rate limit и т. п.).
Семантика и инварианты
- Идемпотентность для метода/эндпоинта.
- Гарантии порядка вывода, стабильность сортировки по умолчанию.
- Правила валидации (например, что считается допустимым значением).
- Политика отмены/обработки long-running операций.
- Поведение при частичном успехе (если есть массив ошибок).
Чем больше вы позволяете клиентам полагаться на поведение, тем строже нужно относиться к версиям.
PATCH: что можно менять безопасно
Обычно patch — это исправления, которые не меняют форму и смысл контракта. В API это часто:
1) Устранение багов без изменения контракта
- Ошибка в расчёте поля, которое по контракту всегда было
number, а возвращалось “почти number”. - Неправильное сопоставление фильтров, которое давало неверный результат, но формат ответа оставался корректным.
- Исправление неверного status code в ситуации, где ожидалось другое, но спецификация была понятна.
2) Улучшения наблюдаемости без влияния на контракт
- Логирование, tracing, добавление новых внутренних event’ов.
- Добавление заголовков, если клиенты их игнорируют и заголовки не конфликтуют с существующими (хотя “потенциально” — тонкое место).
3) Критические security fixes при сохранении API
Если вы ужесточаете безопасность так, что часть клиентов начинает получать 401/403 вместо 200/422 — это может стать несовместимостью. Поэтому безопасность почти всегда заканчивается major, если старые клиенты не готовы.
Ключевой тест для PATCH: клиент, следующий спецификации, должен продолжить получать ответы той же формы и с той же семантикой.
MINOR: добавления, которые остаются совместимыми
minor — это добавление возможностей, не ломающее старые клиенты. Для API это означает: вы расширяете контракт так, чтобы старый клиент не заметил изменения или продолжал работать.
Добавление новых опциональных полей в ответ
Пример: было
{ "id": "123", "name": "A" }
стало
{ "id": "123", "name": "A", "metadata": { "source": "crm" } }
Если metadata опционально и отсутствует — старый клиент не сломается. Даже если присутствует — если клиент использует “пропускаем неизвестные поля” (что типично для JSON-парсеров), совместимость сохраняется.
Подводный камень: если старые клиенты используют строгие схемы (например, десериализация в строго типизированную модель с запретом “unknown fields”), то любое новое поле может ломать. В таких экосистемах лучше заранее документировать поведение сериализации и политику unknown fields — или решать это через major. SemVer формально предполагает разумное поведение клиентов, но на практике надо учитывать реальные фреймворки.
Добавление новых опциональных query-параметров
Если новые параметры не меняют поведение по умолчанию, это хороший minor.
- Старые запросы работают как прежде.
- Новые клиенты используют новые параметры для улучшенного поведения.
Добавление новых эндпоинтов
Добавление нового endpoint’а обычно не ломает существующие. Это кандидат на minor.
Добавление новых значений в enum
Осторожно: добавление нового значения в перечисление считается совместимым в семантике “клиент не должен падать при неизвестном enum”. Если клиенты делают “switch без default” и падают на неожиданных значениях — это уже их хрупкость. Но вы должны понимать риск.
Подводный камень: если enum использовался как “закрытый” список в логике клиента, новое значение может привести к некорректному поведению. Формально контракт расширен, но практически — возможен “тихий” сбой.
Поддержка новых content-types при сохранении старых
Например, дополнительно поддержали application/json; charset=utf-8 (часто по факту это то же самое) или добавили альтернативный формат представления, сохранив JSON.
MAJOR: изменения, требующие слома контракта (или гарантий)
major нужен, когда вы меняете публичный контракт так, что старые клиенты могут перестать работать или начнут получать неверное поведение.
1) Переименование или удаление полей
- Удаление поля из ответа.
- Замена
snake_caseнаcamelCaseбез поддержки старого формата. - Переименование параметра query или тела запроса.
Даже если “можно легко поправить” — это всё равно несовместимость.
2) Изменение типов и форматов
Классический пример: поле было integer, стало string. Или дата была в ISO 8601, а стала Unix timestamp.
Проблема особенно остра, если клиентские схемы строгие.
3) Изменение обязательности (required/optional)
Если поле стало обязательным — старые клиенты не отправят его и получат ошибку валидации.
Если поле раньше было optional и возвращалось не всегда, а теперь всегда возвращается — обычно это minor (старые клиенты игнорируют доп. поля), но если для возврата ввели новые значения/гарантии — иногда это может стать minor/patch в зависимости от семантики. Однако если вы ужесточаете правила так, что клиент начинает получать другое поведение — риск major.
4) Изменение semantics: то, что “значит поле”
Например:
- Поле
statusраньше означало жизненный цикл A, стало означать жизненный цикл B. limitиoffsetпоменяли интерпретацию.- Порядок сортировки по умолчанию изменился.
Это может быть самым коварным источником поломок: формат не меняется, но бизнес-логика клиента рушится.
5) Изменение кодов ответов и договорённостей об ошибках
Если вы меняете статус кода:
- было
200+ ошибка внутри JSON, - стало
400/422, клиенты, которые обрабатывают ошибки “по старому паттерну”, могут ломаться.
Если вы меняете формат тела ошибки или структуру errors[], это тоже потенциальный major.
6) Пересмотр правил валидации с “отказом” вместо “допуска”
Если раньше поле принимали как string произвольного вида, а теперь требуете конкретный regex и начинаете отвечать ошибкой — старые клиенты могут перестать проходить валидацию.
Если валидация “расширилась” (стало принимать больше значений) — это minor. Но “ужесточение” почти всегда ведёт к major.
7) Поведенческие контракты на уровне идемпотентности и ретраев
Если endpoint ранее поддерживал идемпотентность при повторной отправке POST с тем же Idempotency-Key, а теперь гарантии пропали — это может быть серьёзной несовместимостью. Для распределённых систем это ломает обработку ретраев.
Критерий “minor vs major”: чеклист для принятия решения
Ниже — практический чеклист, который помогает избежать “споров после релиза”.
Шаг 1: можно ли представить “старый клиент” как программу, следующую спецификации?
Если да — проверяйте:
- Старые запросы принимаются?
- Старые ответы имеют тот же формат?
- Ошибки (статусы и структура) совместимы?
- Семантика ключевых полей та же?
Если любой пункт нет — это, скорее всего, major.
Шаг 2: изменения расширяют контракт или сужают?
- Расширяют (добавили опциональные поля/параметры, разрешили больше значений) →
minor. - Сужают (удалили, переименовали, сделали обязательным, ужесточили валидацию, сменили формат) →
major.
Шаг 3: есть ли эффект “по умолчанию”?
Даже если вы добавили поле/параметр, но он влияет на поведение по умолчанию — это может стать несовместимостью. Если “присутствие новых параметров” меняет поведение старых запросов (например, по умолчанию включили новый флаг) — это major или минимум очень осмысленный minor с миграцией.
Шаг 4: есть ли “скрытая” зависимость клиентов от статусов/ошибок?
Если вы меняете ошибки “на практике” (например, раньше отдавали 200 и ошибку в body, теперь 422) — это ломает клиентов, которые парсят конкретные форматы.
Шаг 5: что с документацией и версионностью схем?
Если вы используете OpenAPI/JSON Schema, то изменения должны соответствовать версионности спецификации. Полезно иметь автоматизированные проверки (см. ниже) — они снижают человеческий фактор.
Несовместимости на уровне схем: почему JSON и “просто добавили поле” иногда не спасают
JSON “прощает” многое, но не всё.
Строгая типизация и десериализация
Многие клиенты используют строгие модели:
- Go:
json.Decoder.DisallowUnknownFields() - Java/Kotlin: Jackson с
FAIL_ON_UNKNOWN_PROPERTIES - C#:
System.Text.Jsonможет игнорировать unknown fields по умолчанию, но многие проектируют модели и валидацию иначе
Если клиент не готов к неизвестным полям, добавление даже опционального поля может быть несовместимым.
Практический вывод: чтобы SemVer работал, вы должны описывать политику “unknown fields” в вашем контракте и в примерах. Если неизвестные поля допускаются — фиксируйте это.
Изменение nullability
- Было: поле может отсутствовать или быть
null. - Стало: поле всегда строка и никогда
null.
С точки зрения клиентов это может быть ломающее изменение. И особенно опасно, если клиенты отличают “отсутствует” и “null”.
Изменение ограничений (constraints)
Даже если тип не поменялся (string осталось строкой), ограничение может стать несовместимостью:
- минимальная длина стала больше,
- enum расширился/сузился,
- формат (regex) ужесточился.
Если такое изменение влияет на набор допустимых значений в запросах — это обычно major.
Пример: как сформулировать правила на конкретных артефактах OpenAPI
Допустим, у вас есть публичная спецификация OpenAPI. Тогда SemVer можно связать с изменениями конкретных компонентов: Path/Operation, Schema, Parameter.
Критическое: совместимость схем
Если вы генерируете типы для клиентов, то Schema changes должны интерпретироваться строго:
Добавление optional поляв response schema →minorДобавление required поля→majorУдаление поля→majorИзменение type(string → integer) →majorИзменение format(date-time → int64) →majorИзменение enum: удалили значения →major, добавили новые — обычноminor, но с оговоркой о поведении клиентов
Пример политики в терминах “API breaking change”
В документации к релизу полезно описывать не только число версии, но и список категорий изменений: “breaking / non-breaking / patch”.
Автоматизация: как снизить риск неверного MAJOR
Ручная оценка “минор или мажор” часто превращается в угадывание по ощущениям. Практика показывает: лучше иметь автоматизированные проверки контрактных изменений.
Идея
- Храните спецификацию API версионно (OpenAPI/JSON Schema).
- На PR сравнивайте “старую” и “новую” спецификацию.
- Классифицируйте diff по правилам совместимости.
Существуют готовые подходы и инструменты для “contract testing” и “API compatibility checks”, но даже без конкретного инструмента вы можете организовать проверку:
- сравнивайте удаление/изменение required полей,
- отслеживайте типы полей,
- проверяйте status codes и схемы ошибок,
- смотрите изменения параметров.
Минимальный пример сравнения схем (идея)
Если у вас JSON Schema, можно хотя бы программно выявлять удаление required-полей и изменения типов. Например, грубая проверка на уровне required и types:
import json
def load_schema(path):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def index_properties(schema):
# Упрощение: предполагаем, что schema вида {"type":"object","properties":{...},"required":[...]}
props = schema.get("properties", {})
required = set(schema.get("required", []))
types = {name: prop.get("type") for name, prop in props.items()}
return props, required, types
def breaking_required_removal(old_schema, new_schema):
_, old_required, _ = index_properties(old_schema)
_, new_required, _ = index_properties(new_schema)
removed_required = old_required - new_required
return removed_required # формально removal required само по себе не breaking, но сигнал для ревью
def breaking_type_change(old_schema, new_schema):
_, _, old_types = index_properties(old_schema)
_, _, new_types = index_properties(new_schema)
breaking = []
for field, old_t in old_types.items
Комментарии
Пока нет комментариев