Публичный API v1: breaking changes, cursor и SDK из OpenAPI
TL;DR: перед открытием публичного API у нас было три инженерных вопроса — что считать breaking change и как это коммуницировать, переходить ли с offset на cursor сразу или потом, и как держать три SDK (PHP, Python, Node) синхронными с контрактом. Разбираю решение по каждому: с фрагментами `openapi.yaml`, политикой версионирования и пайплайном генерации SDK. Публичный API v1.1.0 открыт 2026-05-22, база — `https://api.botix.pro/public/v1/`.
Коротко
- Breaking change — это контракт, а не строка в URL. Заранее зафиксирована политика из трёх частей: критерии breaking, срок поддержки старой версии (12 месяцев), три канала уведомления — RSS, email и HTTP-заголовок
X-API-Deprecationв ответах самого API. - Cursor вместо offset — сразу, а не «потом».
OFFSET 1000000валится по таймауту и ловит race condition при обходе меняющейся коллекции. Cursor даёт выборку O(log N) на любой странице и предсказуемый снимок; переход offset→cursor — сам по себе breaking change, поэтому его закладывают на старте. - SDK генерируется из
openapi.yaml, не пишется руками. Один файл — шесть артефактов (три SDK, Redoc, Swagger UI, Postman). Ручные SDK неизбежно расходятся с API тремя способами: documentation drift, type drift, coverage drift.
До публичного API у нас был внутренний API кабинета (/api/v1/*, авторизация по сессии): эндпоинты переименовывались под задачи UI, поля добавлялись без анонса, версия не значилась нигде. Когда появилось решение открывать API наружу — на api.botix.pro/public/v1/*, с Bearer-токеном на проект, — обнаружились три проблемы: breaking change без анонса (узнавали из тикетов), offset-пагинация на проекте с миллионом контактов (OFFSET 500000 валился по таймауту), документация отставала от реальности. Ниже — три решения, по одному на каждую проблему.
Тезис 1. Что считать breaking change
Версионирование — это не строка в URL, а контракт между поставщиком и интегратором, который должен быть прописан явно. Когда команда подключается к публичному API, она в неявной форме подписывается на условия: «вот сейчас этот эндпоинт возвращает такие поля, вот так выглядят коды ошибок, вот так пагинируется коллекция». Если поставщик решит, что одно из этих утверждений больше не верно, у интегратора посыпется прод — причём не у одного, а у всех, кто поставил интеграцию однажды и больше к ней не возвращался. А таких большинство.
Поэтому первое, что должно быть зафиксировано в публичном API ещё до первого внешнего пользователя — политика версионирования. Не «у нас семвер», а документ с тремя разделами: критерии breaking change, срок поддержки старой версии, каналы уведомления.
К breaking change относятся: удаление эндпоинта, удаление/переименование поля в ответе, изменение типа поля (был string, стал object), изменение HTTP-статуса для существующего сценария (был 200, стал 202), добавление обязательного параметра, изменение формата идентификаторов, изменение семантики кода ошибки, ужесточение rate-limit (было 1000 req/min, стало 100 — структура не меняется, но интегратор, спроектировавший воркер под старый лимит, лежит). Наблюдаемый контракт нарушен — значит, это breaking.
Non-breaking — то, что не ломает существующие интеграции: новый эндпоинт, новый опциональный параметр, новое поле в ответе — при условии, что клиент игнорирует неизвестные поля (это требование контракта, а не предположение).
Серая зона — расширение enum. Если контракт описан как закрытое множество, а клиент сгенерировал SDK через openapi-generator со строгой валидацией, новое значение enum его сломает. Третьего не дано: либо документировать enum как открытый набор, либо считать расширение breaking. Мы выбрали первое — документировать enum как открытый:
EventType:
type: string
description: |
Тип события. Список расширяется без breaking change.
Клиент должен игнорировать неизвестные значения.
example: contact.createdОтдельно — семантический контракт. Если эндпоинт POST /contacts/import ранее обрабатывал дубликаты как ошибку, а в новой версии начал тихо их пропускать — это breaking, даже если формат запроса и ответа идентичен. Семантика — часть контракта, и её изменения тоже проходят через цикл депрекации. Похожая логика идемпотентности разобрана отдельно — идемпотентность POST-эндпоинтов и повтор запросов.
Срок поддержки и три канала уведомления
Срок поддержки старой версии — 12 месяцев с момента анонса депрекации (developers.botix.pro/versioning). Индустрия сходится на интервале 6–24 месяца: меньше — неуважение к интегратору, встроившему вас в продакшен; больше — лишние затраты на поддержку обвешанного эндпоинта. Уточнение, которое тоже должно быть в политике явно: security-фиксы продолжают наезжать на обе версии.
Каналов уведомления три: RSS на developers.botix.pro/changelog/rss, email-подписка на анонсы, HTTP-заголовок в ответах самого API. RSS оставлен специально — у команд, встраивающих API в продакшен, обычно уже есть feed-агрегатор или Slack-бот, читающий ленты.
Третий канал — самый важный. Если разработчик не следит за блогом и не читает email, единственный шанс достучаться — сам API. Каждый ответ с эндпоинта депрекейтнутой версии содержит:
X-API-Deprecation: version=1.0.0; sunset=2027-05-22; replacement=/public/v2
X-API-Notice: Migration guide: https://developers.botix.pro/migration-guideX-API-Deprecation — машиночитаемый сигнал о выводе версии; X-API-Notice — информационные уведомления, не привязанные к депрекации (окно техобслуживания, «вы достигли 80% месячного лимита»). Хороший SDK ловит заголовок и логирует предупреждение в stderr — интегратор видит его в логах своего сервиса и заводит задачу на миграцию задолго до sunset-даты.
Чего не делать: прятать breaking changes в патч-релизах под формулировкой «улучшение надёжности»; делать silent deprecation, когда эндпоинт молча начинает возвращать другую структуру; смешивать URL-версионирование (/v1/, /v2/) с header-версионированием — выберите одно и фиксируйте. Стоимость каждого такого изменения мы считаем отдельно — сколько стоит каждый breaking change, а саму механику перехода без downtime — миграция клиентов с v1 на v2.
Тезис 2. Cursor vs offset: почему не отложили миграцию
Offset-пагинация выглядит проще ровно до момента, когда коллекция перерастает несколько миллионов записей или начинает активно меняться во время обхода. Версия 1.0.0 использовала offset (?limit=20&offset=100). Минусы вылезли на стресс-тесте проекта с миллионом контактов:
SELECT * FROM btx_contacts
WHERE project_id = ?
ORDER BY created_at DESC
LIMIT 20 OFFSET 1000000Для БД это «отсортируй миллион записей, отбрось их, верни следующие 20». Даже с индексом по (project_id, created_at) PostgreSQL/MySQL проходит миллион строк, чтобы их пропустить. Таймаут 30 секунд. На 10 миллионах offset не работает физически: запрос отваливается по таймауту, съедает память или блокирует читающие реплики.
Вторая проблема — race condition при изменении коллекции во время обхода. Между запросом первой и второй страницы в начало коллекции (сортировка created_at DESC) добавились 5 новых записей. На странице 2 с offset=20 клиент получит записи, сдвинутые новыми: часть строк он увидит дважды (на первой и на второй странице), а часть — не увидит вообще. От этого артефакта невозможно избавиться, пока вы остаётесь в парадигме offset.
Cursor решает обе проблемы. Вместо «пропусти N записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор от сервера. Внутри обычно лежит закодированная пара (значение_сортировки, идентификатор). Сервер декодирует курсор и строит запрос:
SELECT * FROM btx_contacts
WHERE project_id = ?
AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20Здесь критичны две вещи. С индексом по (project_id, created_at, id) это выборка O(log N) независимо от страницы — сервер не «пропускает» записи, а сразу прыгает в нужную точку индекса. И пара (created_at, id) гарантирует уникальность позиции, даже если created_at повторяется: будь в курсоре только created_at, две записи с одинаковым временем могли бы попасть на стык страниц.
Race condition при cursor-пагинации почти полностью исчезает. Новые записи с более свежим created_at имеют меньший курсор-приоритет, чем уже выданные, и просто не попадают в текущий обход — клиент получает предсказуемый снимок коллекции на момент начала. Удаление записи с позиции курсора тоже не ломает следующий запрос — БД просто пропустит её.
Фрагмент openapi.yaml:
ContactListResponse:
type: object
required: [data, has_more]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Contact'
next_cursor:
type: string
nullable: true
description: |
Непрозрачный курсор для запроса следующей страницы.
Формат — деталь реализации сервера, клиент должен
относиться к значению как к чёрному ящику.
example: eyJjcmVhdGVkX2F0IjoiMjAyNi0wNS0yMlQxNDozNyIsImlkIjoxNTQyfQ==
has_more:
type: boolean
description: Есть ли страницы за текущей.Параметр cursor_limit управляет размером страницы (по умолчанию 20, максимум 200). Фильтры применяются до курсора: если клиент меняет фильтры посреди обхода, курсор от предыдущей выборки уже невалиден — он относился к другому множеству. Сервер обязан это поймать и вернуть 400 INVALID_CURSOR, а не выдать произвольный кусок отфильтрованной коллекции. Сам курсор подписывается (HMAC), чтобы клиент не мог подменить его и добраться до чужих данных.
На клиенте обход коллекции (PHP-SDK v1.1.0):
<?php
// composer require botix-pro/sdk:^1.1
use BOTIX\SDK\Client;
$client = new Client(getenv('BOTIX_API_KEY'));
$cursor = null;
do {
$page = $client->contacts()->list([
'cursor' => $cursor,
'cursor_limit' => 200, // макс 200
]);
foreach ($page->data as $contact) {
processContact($contact);
}
$cursor = $page->next_cursor;
} while ($page->has_more);Минусы cursor перечислю честно. Нельзя прыгнуть на «страницу 47» произвольным доступом — только последовательный обход; на UI-листингах с номерами страниц это проблема, поэтому для кабинета offset остался, а в публичном API — только cursor. Нельзя дёшево показать «всего 12 354 записи»: COUNT(*) на больших таблицах сам по себе тяжёлый, и если счётчик критичен — его держат в отдельной материализованной таблице. И реализация на сервере чуть сложнее: кодирование/декодирование, валидация, HMAC-подпись курсора.
Тем не менее на любых масштабах cursor выигрывает: на 50 записях оба подхода равны, на 10 миллионах offset уже не работает, а cursor продолжает. Поэтому переход offset→cursor мы не откладывали, хотя это сам по себе breaking change. Смежные решения по обходу больших коллекций — пагинация, фильтры и производительность list-эндпоинтов и проектирование фильтров без N+1.
Тезис 3. Single source of truth: SDK генерируется из OpenAPI
Три SDK должны быть синхронны с API без ручной работы. Большинство стартапов пишет SDK руками — на PHP отдельной командой, на Python отдельной, на Node отдельной, — потому что на старте это быстрее и кажется «контролируемым». Через год оказывается, что путь дорогой: ручной SDK ломается тремя способами, и все три неизбежны.
Documentation drift. Эндпоинт появился в коде и в документации, а в PHP-SDK — через две недели, в Python — через месяц, в Node — через два. В этот промежуток на странице документации метод описан, а в библиотеке его нет. Прилетает тикет «в доках метод X есть, в SDK нет — это баг».
Type drift. created_at в PHP типизировали как string, в Python — как datetime, в Node — как Date | string. Меняется формат на Unix timestamp: на сервере — один коммит, в трёх SDK — три PR, три релиза, три обновления доков, и вероятность, что все три пройдут синхронно, стремится к нулю.
Coverage drift. Сервер имеет 60 эндпоинтов. PHP покрывает 60, Python — 55, Node — 47. Появляется 61-й — у него три разных приоритета в трёх командах. Паритет ручным трудом держится, только если есть отдельный человек, единственная задача которого — следить за паритетом. Это редкий формат.
Решение — генерация. Единственный источник правды — файл openapi.yaml в репозитории платформы: каждый эндпоинт, каждая модель, каждый код ошибки, каждое поле запроса и ответа. Из него генерируется всё остальное:
openapi.yaml
├── openapi-generator-cli -> sdk-php (Composer botix-pro/sdk)
├── openapi-generator-cli -> sdk-python (PyPI botix)
├── openapi-generator-cli -> sdk-node (npm @botix/sdk)
├── Redoc -> developers.botix.pro
├── Swagger UI -> developers.botix.pro/try-it
└── Postman Collection -> Postman/Bruno экспортОдин файл — шесть артефактов. Любое изменение распространяется через генератор за минуту, без копипасты.
x-codeSamples: примеры внутри спеки
Примеры использования обычно пишутся вручную в Markdown, и их синхронизация с API — отдельная боль: переименовали поле — надо вспомнить все примеры, где оно встречается, и это всегда делается не до конца. В OpenAPI 3 есть расширение x-codeSamples — хранение примеров запросов прямо внутри спеки, под каждым эндпоинтом:
paths:
/contacts:
post:
summary: Создать контакт
operationId: createContact
x-codeSamples:
- lang: curl
source: |
curl -X POST https://api.botix.pro/public/v1/contacts \
-H "Authorization: Bearer $BOTIX_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"phone":"+79991234567","name":"Иван"}'
- lang: PHP
source: |
$contact = $client->contacts()->create([
'phone' => '+79991234567',
'name' => 'Иван',
]);
- lang: Python
source: |
contact = client.contacts.create(
phone="+79991234567",
name="Иван",
)У нас 92 таких блока — по 3-4 языка на каждый осмысленный эндпоинт. При смене сигнатуры сэмпл правится в одном месте и через Redoc попадает в документацию автоматически. (В примере выше $BOTIX_API_KEY — переменная окружения; реальный ключ формата btx_live_XXXX… в код и в спеку не коммитится.)
Что держать в голове при генерации
Качество спеки определяет качество SDK. Если тип поля описан как oneOf без discriminator, генератор не построит корректный union-тип в TypeScript. Если код ошибки указан только в description, но не в responses — сгенерированный клиент не сможет его обрабатывать. Спека — это не отчёт о том, что есть, а контракт, по которому работает генератор: любая неточность всплывёт во всех SDK одновременно.
Шаблоны — точка кастомизации. openapi-generator-cli позволяет переопределять шаблоны для каждого языка. Нет declare(strict_types=1) в PHP — правится в шаблоне; шаблон не вставляет timing-safe сравнение в verifyWebhook — правится в шаблоне. В сгенерированный код руками не лезем: при следующей регенерации правки в шаблоне сохранятся.
Версия SDK ≠ версия API. API может быть в 1.1.0, а SDK — в 1.1.2: третья цифра отражает патчи самого SDK (фиксы шаблонов, обновления зависимостей). Связь поддерживается через MIGRATION.md в каждом SDK-репозитории.
Тесты пишутся отдельно. Генератор покрывает структуру: типы, сигнатуры, сериализацию. Поведенческие тесты — что метод действительно отправляет правильный запрос и корректно обрабатывает ответ — пишутся руками, но их объём в разы меньше объёма ручного SDK.
Архитектурный приём для нестандартных мест (long-polling, batch-склейка, Laravel facade): сгенерированный код кладётся в src/Generated/, ручные надстройки — в src/Extension/. При регенерации Generated/ перезаписывается целиком, Extension/ остаётся нетронутым, а конфликт становится явным при сборке. Подробнее про zero-dependency SDK и надстройки — устройство SDK: генерация, слои и адаптеры под фреймворки.
Что не получилось
Migration с offset на cursor — breaking change. Выпустили 1.1.0 с cursor, оставили 1.0.0 в поддержке на 12 месяцев, миграционный гайд «было/стало» написали руками — генератора под него нет.
Async-режим для bulk-эндпоинтов. Все bulk-эндпоинты синхронные. Если интегратор зальёт 100 контактов и timeout 60 сек не хватит — ничего не выйдет. План — перейти на async с job_id. Отложено до первой жалобы.
Партнёрский (Partner API) уровень. В первой итерации API под одного клиента: один проект, один ключ. Партнёрская модель в roadmap'е — отдельный префикс /partner/v1/*, а не флаги поверх обычных эндпоинтов.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Миграция клиентов с v1 на v2 публичного API без downtime
Публичный API-контракт: deprecation, rate-limit, white-label
Production-ready POST: idempotency, retry и rate-limit
SDK BOTIX 1.1: zero-deps, генерация из OpenAPI и триал
Idempotency-Key, rate-limit и audit-лог публичного API
