Навигация
Обещание совместимости /api/v1
Что означает /api/v1, что в ответах считается договором, что менять запрещено, как объявляется устаревание и сколько живёт устаревший вызов
Собственный интерфейс GitRiver отвечает по адресу /api/v1. Этот документ —
обязательство: что в ответах считается договором и не меняется, что менять
запрещено, как объявляется устаревание и сколько живёт устаревший вызов.
Обязательство нужно тем, кто пишет по интерфейсу один раз и живёт с
написанным годами: скриптам выгрузки и переноса, шаблонам отчётов, службам
развёртывания, сторонним переходникам (RiverMCP ходит только в /api/v1 и
ничего внутреннего не использует). Без записанного обещания опереться было не
на что, кроме нашей добросовестности.
Обещание действует с версии 1.1.0 (18.09.2026) — первого выпуска, который его несёт. Всё, что отвечает в 1.1.0, договором и является. До неё вышла одна версия — 1.0.0 (29.03.2026); что меняется при переходе с неё, названо в конце документа поимённо.
Что означает v1
Номер в пути — версия договора, а не версия продукта. Продукт растёт
(1.0 → 1.1 → дальше), путь остаётся /api/v1: новая минорная версия узла не
требует от клиента ни строчки правок.
Версия узла приходит отдельно и запрашивается явно:
| Где | Что отдаёт |
|---|---|
GET /health |
версия сборки и состояние базы: status (ok/error) и version |
GET /api/openapi.json, поле info.version |
версия сборки, отдавшей спецификацию |
GET /api/v1/auth/me |
редакция установки, в которой работает вызывающий: instance_is_pro, instance_is_max, is_pro, license_grace_until |
Редакция приходит вызывающему, а не анониму, и это решение: GET /api/v1/server-info отвечает без токена, и редакция в его ответе объявляла
бы состояние лицензии установки любому, кто открыл адрес узла. Сам
server-info отдаёт настройки, нужные интерфейсу до входа — внешний адрес,
включён ли SSH и приставку адреса клонирования, видны ли профили анониму, —
и версии с редакцией в нём нет.
Часть договора
| Свойство | Пример |
|---|---|
| Путь вызова и метод | PATCH /api/v1/repos/{owner}/{name}/issues/{number} |
| Код успешного ответа | 201 у создания, 204 у удаления, 200 у чтения и действия, 202 у принятой в работу |
| Обязательные поля тела ответа и их типы | id, number, status у задачи |
| Форма конверта отказа | {"error": "<текст>", "code": "<машинный код>"} — оба поля есть всегда |
| Значения машинных кодов отказа | not_found, ref_not_found, unauthorized, forbidden, conflict, validation, internal, bad_gateway, too_large, license_required, rate_limited, registration_closed |
| Имена параметров страницы и полей выдачи у конкретного вызова | per_page, after в запросе; items, next_cursor, total в ответе |
| Требование к токену | вызов, отвечавший анониму, продолжает отвечать анониму |
| Принадлежность к редакции | вызов, работавший в Community, не уходит под шлюз редакций; вызов, которому хватало лицензии установки, не начинает требовать места Pro; вызов не начинает требовать более старшей редакции. Правило действует с 1.1.0; ужесточение, сделанное при переходе с 1.0.0, названо в конце документа поимённо |
Имена мест подстановки в пути ({owner}, {name}) адрес клиента не меняют, но
попадают в спецификацию и оттуда в сгенерированный по ней клиент — поэтому мы
не переименовываем и их.
Форма ответов и правила пагинации описаны в руководстве пользователя; здесь сказано не как они устроены, а что из этого мы обязуемся не менять.
Договор — про интерфейс, а не про настройки установки. Владелец узла вправе закрыть профили, завести ограничения по сетевому происхождению или отозвать права: аноним увидит меньше, но это его решение, а не смена договора.
Не часть договора
- Порядок ключей в объектах JSON.
- Порядок элементов в списках, для которых сортировка не объявлена. Объявленная — часть договора (обсуждение идёт от старых к новым).
- Тексты: поле
error, описания операций в спецификации, надписи интерфейса. Текст переводится и уточняется; программа опирается наcode. - Устройство непрозрачных строк.
next_cursor— непрозрачная строка: её нужно вернуть следующим запросом как есть. Разбирать её содержимое нельзя, кодирование может измениться. - Значения пределов: размер страницы, пороги частоты, предельный размер
тела. Они настраиваются владельцем узла и от установки к установке
различаются. Договор — поведение на границе, а не число: размер страницы и
смещение усекаются к допустимому диапазону (
?limit=-1отдаёт одну запись, а не400), тело сверх предела получает413, запрос сверх частоты —429сRetry-After. - Время ответа, номера идентификаторов, содержимое журналов.
Где конверта отказа нет
Конверт {"error", "code"} отдают обработчики API — в том числе на
несуществующем адресе под /api/v1 (404 с кодом not_found) и при
превышении частоты (429, там же поле retry_after и заголовки
Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining).
Без конверта приходят отказы, которые порождают не обработчики, а
маршрутизатор и общие слои: 405 — путь есть, метод другой; 504 —
истёк предельный срок ответа; обрыв слишком большого тела там, где предел
считает слой, а не обработчик (выгрузка файла составным телом). Тела у них
может не быть вовсе, и опираться клиенту следует на код состояния. Договор
здесь — сам код состояния, а не то, что лежит (или не лежит) в теле.
Там, где предел считает сам обработчик (приём архива артефактов и кеша,
вложение выпуска), 413 приходит обычным конвертом с кодом too_large. Тело
в формате JSON — тоже исключение: его разбирает извлекатель самого API, и все
отказы разбора, включая 413, приходят общим конвертом (см. ниже).
Что менять запрещено
Каждая строка — то, что молча ломает уже написанного клиента.
| Запрещено | Что видит клиент |
|---|---|
| Снять путь | 404 на вызове, который работал |
| Снять метод у пути, включая устаревший псевдоним | 405 на вызове, который работал |
| Переименовать или убрать обязательное поле ответа | отсутствующий ключ, KeyError в скрипте |
| Сменить тип поля | разбор падает либо считает не то |
| Сменить код успешного ответа | клиент, сгенерированный по спецификации, считает ответ неожиданным |
| Переименовать машинный код отказа или сменить код ответа у рода отказа | ветка обработки перестаёт выбираться |
| Потребовать токен там, где отвечали анониму | 401 вместо данных |
| Сделать обязательным параметр, который был необязательным | 400 на прежнем запросе |
| Сузить допустимые значения входа, ужесточить проверку | принимавшееся перестаёт приниматься |
| Увести работавший вызов под шлюз редакций | 403 с license_required там, где работало в Community |
| Потребовать место Pro там, где хватало лицензии установки | 403 с license_required у учётной записи без места |
| Потребовать более старшую редакцию, чем требовалось | 403 с license_required у установки с прежней лицензией |
Как это проверяется
Четыре свойства из этого списка — путь, метод, код успеха, требование к
токену — плюс шлюз редакций записаны снимком в дереве. Колонка шлюза называет
ОБЕ его оси: редакцию и меру — без-шлюза, шлюз-pro (редакция Pro, местами
не меряется), шлюз-pro-место (редакция Pro и место у вызывающего),
шлюз-max (редакция Max). Снимок лежит в
crates/gitriver-api/src/routes/manifest/api_v1_surface.txt, по строке на
операцию. Правка, снимающая вызов или меняющая эти свойства, роняет сборку и
называет, что именно снято или изменено. Добавление вызова обещания не нарушает, но снимок
обязан оставаться полным — иначе завтрашний вызов не защищён ничем, поэтому
сборка требует дописать в него новую строку.
Границы проверки называются прямо: состав полей тела ответа снимок не видит. Убранное поле — слом обещания, но ловится он ревизией, а не сборкой.
Что разрешено и сломом не считается
-
Новый путь; новый метод у существующего пути.
-
Новое необязательное поле в успешном ответе. Образец такого добавления уже есть: поле
partial— оговорка «данные неполные», сегодня с единственным значением"truncated"(выдача обрезана пределом). Клиент, не знающий о поле, читает ответ как раньше; отсутствие поля означает полный ответ.Оговорка о неполноте бывает и НЕ конвертной. У файла в diff (
files[].truncated) и у просмотра файла (truncated) она относится к ОДНОМУ объекту, а не к выдаче целиком: обрезан не список, а содержимое конкретного файла, перешагнувшего предел (MAX_DIFF_FILE_SIZE,MAX_BLOB_SIZE). Признак поднят только по причине РАЗМЕРА и не смешивается сis_binary: до его появления обе причины отсутствия содержимого объяснялись одной, и крупный текстовый файл выдавался за двоичный. Третья причина пустого патча — содержимое не менялось (переименование, смена прав) — не поднимает ни одного из двух признаков. -
Новый необязательный параметр запроса, если без него поведение прежнее.
-
Новое значение в перечислении — машинного кода отказа или причины частичности. Отсюда требование к клиенту: незнакомое значение обязано разбираться по коду состояния HTTP и не ронять обработку. Пополнение объявлено заранее: причин частичности станет больше.
-
Ослабление проверки входа, расширение допустимых значений.
-
Смена текста, порядка ключей, порядка неотсортированной выдачи.
-
Отказ на неизвестное поле в теле запроса. Раньше поле, которого вызов не знает, отбрасывалось молча: запрос создания репозитория с полем видимости, названным привычным для чужих платформ словом, возвращал
201и создавал репозиторий приватным, потому что настоящее поле зовётся иначе. Клиент получал успех и не тот результат, о котором просил. Ужесточением договора это не считается ровно по этой причине: запросы с неизвестными полями и раньше не работали так, как ожидал их автор, — теперь они об этом сообщают. Отказ —400с кодомvalidation; текст называет нераспознанные поля полным путём от корня тела.Правило действует в собственном интерфейсе
/api/v1— включая адреса, которые обслуживает мастер установки. Слои совместимости (/api/v3,/api/v4), SCIM, OIDC, Git LFS и протоколы реестров пакетов принимают незнакомые поля молча: форму запроса там задаёт чужая сторона, и она же вправе её пополнять.По той же причине вне правила и протокол исполнителя (
/api/v1/runner/**), хотя адреса у него наши: исполнитель обновляется отдельно от узла, и исполнитель новее узла получал бы отказ на каждое обновление состояния. Тело там составляет наш же код, опечатке в имени поля взяться неоткуда.
Отказ разбора тела приходит общим конвертом
Нечитаемое или не той формы тело запроса /api/v1 отвечает 400 с конвертом
{"error", "code": "validation"} — как и всякая другая непройденная проверка
данных. Прежде код зависел от рода отказа: сломанный JSON давал 400, а
разобранный, но не той формы (нет обязательного поля, не тот тип значения,
повторённый ключ) — 422; и то и другое приходило с англоязычным текстом
библиотеки (Failed to deserialize the JSON body into the target type: missing field ...), то есть без машинного кода. Клиент не мог ни выбрать по нему
ветку, ни перевести сообщение.
Сменой кода у рода отказа это не является: рода отказа «тело не
разбирается» в договоре не было вовсе — не было и кода, по которому его можно
опознать. Клиент, различавший этот случай по 422, опознаёт его теперь по
коду validation вместе с остальными проверками входа.
Отдельный код остался у одного случая: тело, присланное без
Content-Type: application/json, отвечает 415 — сервер до содержимого не
дошёл. Конверт и код у него те же.
Так же изменились отказы разбора ПАРАМЕТРОВ адреса и строки запроса: раньше
нечитаемый идентификатор отвечал 400 с текстом библиотеки («Invalid URL:
Cannot parse abc to a Uuid»), теперь — тем же конвертом с кодом
validation и текстом, называющим параметр. Код прежний, добавился машинный код.
Состав строки запроса строгим НЕ стал и не станет: незнакомый параметр пропускается молча. Адрес с параметрами пересылают ссылкой, и по дороге к нему приписываются чужие параметры — метки переходов, счётчики, возврат посредника; отказ на них бил бы по обычной работе, а не по опечатке.
Ужесточения, сделанные намеренно
Таблица запретов называет «ужесточить проверку» сломом — и это правило. Исключения из него делаются поимённо и только там, где прежнее поведение ОТВЕЧАЛО УСПЕХОМ НА НЕВЫПОЛНЕННОЕ, то есть договор и так не соблюдался:
| Вызов | Было | Стало | Почему это не слом |
|---|---|---|---|
PATCH /api/v1/users/{username} |
200 и запись без изменений, если прислать is_admin или is_pro_seat без прав |
403 с перечнем полей |
Поля и раньше не применялись; клиент считал действие выполненным |
PUT /api/v1/repos/{owner}/{name}/environments/{env} |
имя среды из тела не применялось, бралось из адреса | 400, если имя в теле не совпадает с адресом |
Запрос с другим именем правил СРЕДУ ИЗ АДРЕСА, а клиент считал, что создал вторую |
POST/PATCH поставщика SAML |
пустой sp_entity_id принимался |
400 |
Пустой идентификатор ломает вход, и обнаруживалось это при первой попытке войти, а не при сохранении |
Общее у всех трёх: запрос, который перестал приниматься, и раньше не делал того, чего от него ждали. Ужесточение, у которого такого объяснения нет, по-прежнему запрещено.
Устаревание: как объявляется и что видит клиент
Устаревшая операция помечена в спецификации /api/openapi.json признаком
deprecated: true и описанием, куда переходить. Страница /api/docs
показывает пометку, а генератор клиента по спецификации переносит её в
сгенерированный код.
Так помечены все PUT-псевдонимы частичного обновления: канонический метод —
PATCH, а PUT на том же пути оставлен работающим ради уже написанных
скриптов. Например:
PATCH /api/v1/users/{username} канонический
PUT /api/v1/users/{username} устаревший
PATCH /api/v1/repos/{owner}/{name}/issues/{number} канонический
PUT /api/v1/repos/{owner}/{name}/issues/{number} устаревший
PATCH /api/v1/repos/{owner}/{name}/pull_requests/{number} канонический
PUT /api/v1/repos/{owner}/{name}/pull_requests/{number} устаревший
Так же помечен GET /api/v1/import/remote-repos — по другой причине. Список
репозиториев внешней системы запрашивается с токеном доступа к ней, а токен в
адресе оседает в журнале каждого посредника на пути: строку запроса целиком
пишут обратный прокси, балансировщик и сервер доступа, и живут эти журналы
дольше самого переноса. Канонический вызов — тот же путь методом POST, токен
в теле:
POST /api/v1/import/remote-repos канонический
GET /api/v1/import/remote-repos?auth_token=… устаревший
Второй канал — заметки о выпуске и руководство пользователя.
Заголовка Deprecation в ответе нет, и это решение, а не пробел. Смысл
такого заголовка (RFC 9745) — предупредить о сроке отключения, который
объявляют рядом (Sunset, RFC 8594). Внутри v1 отключения не бывает (см.
ниже), объявлять нечего, а заголовок без срока сообщал бы клиенту неправду.
Сколько живёт устаревший вызов
Внутри v1 — столько же, сколько сам v1. Пометка deprecated означает
«есть канонический способ, новый код пишите им», а не отсчёт до отключения:
снятие метода запрещено правилом выше, и на устаревшие псевдонимы оно
распространяется наравне с остальными.
Снять что-либо можно только вместе со сменой номера договора — заведением
/api/v2. При этом:
v2объявляется в заметках о выпуске и в этом документе, с перечнем отличий и порядком перехода;/api/v1продолжает отвечать не менее двенадцати месяцев с даты выхода версии, в которой появился/api/v2;- в течение этого срока
v1получает исправления безопасности по общему правилу SECURITY.md; новые возможности в него не добавляются; - дата прекращения
v1называется в момент объявленияv2, а не позже.
Двенадцать месяцев — срок под годовой цикл обновлений у заказчика: установка, обновляющаяся раз в год, обязана застать обе версии живыми хотя бы однажды. Он намеренно длиннее срока поддержки предыдущей минорной версии (полгода): смена номера договора требует правок в чужом коде, а обновление минорной версии — не требует.
Что живёт по чужим номерам версий
Обещание распространяется только на /api/v1. Ниже — то, чей договор пишем не
мы; там мы обязуемся следовать чужой спецификации, а если она поменяется —
пойти за ней.
| Адрес | Чей договор |
|---|---|
/api/v3 |
интерфейс ГитХаба (покрытие) |
/api/v4 |
интерфейс ГитЛаба (покрытие) |
/scim/v2 |
RFC 7644 (подача учётных записей) |
/v2 |
OCI Distribution Spec (реестр образов) |
/oauth/*, /.well-known/* |
RFC 6749, RFC 8414, OpenID Connect |
/{owner}/{repo} по git и LFS |
протоколы git и Git LFS |
Слои совместимости живут по номерам чужих платформ: v3 и v4 в этих путях —
их версии, не наши. Наше обещание на них не распространяется, а их собственное
— «ответ совпадает с ответом чужой платформы»; что поддержано, а что нет,
названо в документах покрытия.
Отдельный случай — нативные протоколы пакетов (npm, PyPI, Cargo, Maven,
NuGet, Composer, Generic). Они лежат ПОД /api/v1, например
GET /api/v1/packages/{owner}/{name}/cargo/index/config.json. Пути наши, и
правило «путь не снимают» на них распространяется. А вот форму тела — и
успешного ответа, и отказа — задаёт пакетный менеджер: она меняется вслед за
его спецификацией, и общий конверт {"error", "code"} там не действует.
Разделение редакций в 1.1.0
Что изменилось. КОРПОРАТИВНЫЕ возможности — вход по SAML SSO и его
настройки, внешнее управление учётными записями по SCIM, LDAP, журнал аудита,
квоты, правила по адресу узла, оформление, пределы черновиков знания —
открывает теперь редакция Max. В 1.0.0 их открывала любая действующая
лицензия Pro. В снимке поверхности у этих вызовов колонка шлюза сменилась с
шлюз-pro на шлюз-max.
Признак корпоративной возможности проверяемый: можно ли, купив одно место,
получить её для всей компании. Настройки помощника под него не подходят —
каждое обращение к модели меряется местом, — и остались в редакции Pro
(колонка шлюз-pro, как и была).
Кого это задевает. Установку с лицензией Pro, которая пользовалась чем-то
из этого списка. Вызовы отвечают 403 с кодом license_required, пока
редакция не изменена на Max. Всё остальное платное — проверки безопасности,
лицензионная чистота, метрики DORA, указатель по коду, свои роли и переменные
группы, помощник целиком вместе с его настройками — редакция Pro открывает
по-прежнему, и для него не изменилось ничего.
Почему это сделано. Лицензия на ОДНО место включала корпоративный вход, провижининг и журнал аудита всей установке: компания на триста человек платила за одно место и получала SAML для всех. Место меряет работу участника, а вход по SAML происходит до того, как участник появляется, — минимум мест при покупке эту дыру не закрывает. Закрывает её только редакция.
Почему это названо здесь. Правило «принадлежность к редакции не ужесточается» действует с 1.1.0, и это ужесточение сделано при переходе с 1.0.0 — до того, как правило вступило в силу. Прикрываться этим мы не собираемся: правка, отнимающая у оплаченной установки возможность, называется поимённо, вместе с перечнем задетых вызовов и порядком перехода, — и дальше, когда правило действует, тем более. Молча такие правки не делаются, и снимок поверхности их молча не пропускает: изменённая колонка роняет сборку.
Порядок перехода. Владельцу установки с лицензией Pro, которому нужны корпоративные возможности, издатель выдаёт файл активации редакции Max с тем же идентификатором лицензии — срок при этом двигать не обязательно. Установка принимает такой файл и отвечает «Лицензия обновлена: редакция повышена с Pro до Max»; мест не удваивается, вторая строка в списке лицензий не появляется. Вводится он там же, где продление (лицензирование), и тем же путём доезжает автоматически с сервера лицензий.
Редакцию узел берёт из подписи, а не из того, что и когда куплено. В подписанном ответе активации есть поле редакции, и шлюз сравнивает именно с ним; даты покупки узел не разбирает вовсе. Поэтому переоформление, сделанное на стороне издателя, доезжает само — очередной отметкой, без действий администратора, — и ручной ввод файла нужен там, где отметка не ходит (закрытый контур), либо когда переход нужен немедленно. По той же причине условия продажи — кому какая редакция полагается и с какого дня — в поведении узла не отражаются и здесь не описываются: узел исполняет то, что подписано.
Подтверждение права в 1.1.0
Что изменилось. Подписанный ответ активации годен теперь ограниченное время
(lease_until), а не до конца срока лицензии. Пока подписи доходят — отметкой к
серверу лицензий или файлом, вставленным вручную, — не меняется ничего. Когда
подписи перестают доходить, платные вызовы начинают отвечать 403 с кодом
license_required, хотя срок лицензии ещё идёт.
Почему это не слом договора. Ни один путь, метод, параметр и ни одно поле
ответа не изменились и не исчезли; код и конверт отказа — прежние
(license_required, 403), и клиент, который уже умеет их читать, читает их
так же. Изменилось УСЛОВИЕ, при котором платная возможность открыта, а условия
лицензирования договором /api/v1 не были никогда: он обещает форму вызовов,
а не то, что лицензия у установки есть.
Кого это задевает. Клиентов, написанных в расчёте на то, что 403 license_required невозможен у установки с действующей лицензией. Такого
обещания не было и раньше — лицензию можно снять из админки в любой момент, —
но теперь появился второй способ в него упереться. Лечение то же: показать
пользователю текст отказа, а не считать его сбоем.
Что добавилось в ответы. /api/v1/admin/license отдаёт поля lease_until,
lease_days_left, lease_expired и db_moved у каждой лицензии и
lease_warning, lease_expired, db_moved, db_fingerprint в сводке; в ленте
активности появилось значение license_lease_expiring. И то и другое —
добавления, разрешённые списком выше; клиент, который о них не знает, читает
ответ как раньше.
Подробности — лицензирование.
Что меняется при переходе с 1.0.0 на 1.1.0
1.0.0 вышла до этого обещания, и переход на 1.1.0 — единственное место, где меняется то, что дальше объявлено неизменным. Поэтому здесь названо всё сразу: и то, что видит клиент интерфейса, и то, что видит владелец оплаченной установки. Дальше этот список не растёт.
Форма ответа стала страницей в трёх местах. Вместо массива верхнего уровня
приходит {"items": [...], "next_cursor": ...}:
- комментарии задачи, комментарии и обзоры запроса на слияние;
- пакеты репозитория и версии одного пакета;
- сводные списки квот —
GET /admin/quotas/usersи/groups.
Причина у всех одна: длину списка назначаем не мы, а пользователь, и обсуждение на тысячи записей читалось в память целиком на каждый просмотр. Подробности и порядок перехода — в руководстве пользователя.
Впредь так не делаем: форма существующего ответа не меняется, а постраничная выдача, если она понадобится ещё где-то, заводится отдельным вызовом.
Корпоративные возможности ушли в редакцию Max — разделение редакций выше: перечень задетых вызовов и порядок перехода там же.
Право подтверждается ограниченное время — подтверждение
права: установка, до которой перестали доходить
подписи, получает 403 license_required раньше конца срока лицензии.
Места считаются. Установка с лицензией НА ЧИСЛО МЕСТ обнаружит, что платные разделы открыты только тем участникам, у кого признак места проставлен, — лицензирование. Установок с лицензией без ограничения мест это не касается.