Попробовать API
</>
Публичный API v1: breaking changes, cursor и SDK из OpenAPI
api-dizaynРазбор
14 мин

Публичный 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 как открытый:

yaml
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-guide

X-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). Минусы вылезли на стресс-тесте проекта с миллионом контактов:

sql
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 записей» клиент говорит «дай записи после такого-то значения», где значение — непрозрачный курсор от сервера. Внутри обычно лежит закодированная пара (значение_сортировки, идентификатор). Сервер декодирует курсор и строит запрос:

sql
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:

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
<?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 — хранение примеров запросов прямо внутри спеки, под каждым эндпоинтом:

yaml
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/*, а не флаги поверх обычных эндпоинтов.