Регламент синхронизации API-контракта (eatvio-web ↔ eatvio_app)
Стороны и артефакты
| Репозиторий | Артефакт | Роль |
|---|---|---|
| eatvio_app | tool/export_api_contract.dart |
генератор: парсит freezed-декларации из исходников (не из *.g.dart) |
| eatvio_app | api_contract/flutter_model_contract.json |
источник истины: требования мобильных моделей к JSON (required/optional ключи) |
| eatvio-web | tests/Fixtures/Contract/flutter_model_contract.json |
синхронизированная копия, по ней CI бэка валидирует API-ресурсы |
| eatvio-web | tests/Feature/Contract/FlutterApiContractTest.php |
контрактные тесты: worst-case сериализация ресурса ↔ манифест |
Контракт односторонний по данным: бэкенд отдаёт JSON → flutter читает (fromJson). Экстремальный случай — nullable-колонка на бэке при required-поле во flutter: ровно он дал продовой баг FLUTTER-1BX (satiety: null → TypeError в _$MealModelFromJson, дневник мобильных клиентов не работал, КБЖУ-информер показал нули).
Направление 1: backend → frontend (бэк меняет ответ API)
- Дев меняет API-ресурс в eatvio-web (например, делает поле nullable / удаляет ключ).
FlutterApiContractTestпадает, если новое поведение нарушает манифест — это и есть фильтр обратной несовместимости.- Дальше два осознанных пути:
- Сохранить совместимость (предпочтительно): вернуть значение/дефолт на бэке (
?? 0, как сtime→'00:00:00'). Манифест не меняется. - Ломать сознательно: мержится mobile-first — сначала в eatvio_app поле становится nullable/опциональным, выходит релиз приложения; затем PR в eatvio-web меняет ресурс и копию манифеста. Пока старые версии приложения в рынке — бэк держит старый формат.
- Сохранить совместимость (предпочтительно): вернуть значение/дефолт на бэке (
- Коммит копии манифеста в eatvio-web без изменения ресурса бессмысленен и не проходит ревью: манифест меняется только вместе с ресурсом.
Направление 2: frontend → backend (фронт ужесточает требования)
- Дев меняет freezed-модель в eatvio_app (например, добавляет
required int nutrition). - CI eatvio_app (
test.yml, шагAPI contract manifest check) регенерирует манифест и диффает: устарел → PR не проходит. - Дев обновляет манифест (
make export-api-contract) и в том же PR синхронизирует копию в eatvio-web (tests/Fixtures/Contract/). - CI eatvio-web прогоняет
FlutterApiContractTestпротив новой копии: если бэк не выдаёт новые required-ключи — падает уже там, и бэк добавляет гарантии до релиза приложения. - Порядок релизов: бэк докручивается первым (он назад-совместим), затем выходит приложение.
Правила совместимости (шпаргалка)
| Изменение на бэке | Ломает приложение? | Действие |
|---|---|---|
| Поле остаётся non-null | нет | ничего |
| Добавлен новый ключ | нет (flutter игнорирует лишнее) | ничего; добавить в модель отдельно при необходимости |
| non-null → nullable / ключ удалён | да | mobile-first: релиз приложения, потом бэк |
flutter: int → int? |
нет | make export-api-contract + синк манифеста |
flutter: добавлен required-ключ |
бэк может не выполнять | синк манифеста → бэк добавляет ключ/дефолт до релиза |
Автоматика
| Где | Что | Когда срабатывает |
|---|---|---|
| eatvio_app, CI | шаг API contract manifest check (test.yml) |
каждый PR: манифест обязан быть регенерирован из исходников |
| eatvio_app, CI | contract-sync.yml |
push в main с изменениями api_contract/** → авто-PR в eatvio-web с новой копией манифеста |
| eatvio_app, локально | make export-api-contract / make check-api-contract |
руками при работе над моделями |
| eatvio-web, CI | FlutterApiContractTest (12 тестов) |
каждый PR: worst-case сериализация ресурсов ↔ манифест |
| eatvio-web, CI | contract-drift-check.yaml |
еженощно: сравнение локальной копии манифеста с eatvio_app@main, при дрейфе — issue |
| eatvio-web, локально | make sync-flutter-contract |
руками, если авто-PR не пришёл |
Секреты: CONTRACT_SYNC_TOKEN — PAT с write на eatvio/eatvio-web (для contract-sync) и read eatvio/eatvio_app + issues на eatvio/eatvio-web (для drift-check).
Как команды держат друг друга в рабочем состоянии (три слоя)
- Предотвращение (до мержа). Контрактные тесты на обеих сторонах: flutter не смержит модели без регенерации манифеста; бэкенд не смержит ресурс, отдающий null/не тот тип там, где приложение требует non-null.
- Синхронизация (при мерже). Авто-PR из
contract-sync.ymlприносит новую копию манифеста в бэкенд; CI бэкенда на этом PR показывает, выполняет ли сервер новые требования. Ночной drift-check ловит всё, что проскочило мимо автоматики. - Обнаружение (после релиза). Sentry: группа
TypeError ... in _$XxxFromJson(проект flutter) = нарушение контракта в проде. Такой issue должен ссылаться на контрактную пару и закрываться фиксом ресурса + контрак тестом.
Ритуалы:
- Меняешь модель в приложении →
make export-api-contractв том же PR; манифест уедет в бэкенд автоматически. - Меняешь API-ресурс → прогоняй контрактный тест; красный = либо делай совместимо (дефолты), либо mobile-first релиз.
- WARNING генератора о коллизии имён моделей (
Step,Users, ...) — повод переименовать Dart-классы: под одним именем могут скрываться разные контракты.
Формат манифеста
Каждая модель: required / optional (JSON-ключи), types (Dart-тип поля) и kinds
(JSON-вид значения, по которому бэкенд-чекер валидирует payload):
| kind | Dart-типы | Ожидание в JSON |
|---|---|---|
number |
int, double, num |
число (int или float) |
string |
String |
строка |
boolean |
bool |
строго true/false (int 0/1 — нарушение) |
list |
List<...> |
массив |
map |
Map<...> |
объект |
model |
вложенная freezed-модель | объект |
enum |
Dart enum | строка-имя или int |
any / unknown |
dynamic и прочее |
не проверяется |
Это закрывает второй класс мобильных крашей: жёсткие касты (json['from_diet'] as bool,
json['quantity'] as num) роняют приложение даже на non-null значениях — например,
если PHP отдаст tinyint 0/1 вместо bool или строку "150" вместо числа.
Границы подхода
- Манифест покрывает ответы API (fromJson). Контракт запросов (retrofit → FormRequest) пока не формализован.
- Новый ключ бэкенда не требует изменения манифеста — flutter молча игнорирует лишнее; расширять модели — добровольно.
kinds: unknownне проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.