# Регламент синхронизации 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) 1. Дев меняет API-ресурс в eatvio-web (например, делает поле nullable / удаляет ключ). 2. `FlutterApiContractTest` падает, если новое поведение нарушает манифест — **это и есть фильтр обратной несовместимости**. 3. Дальше два осознанных пути: - **Сохранить совместимость** (предпочтительно): вернуть значение/дефолт на бэке (`?? 0`, как с `time` → `'00:00:00'`). Манифест не меняется. - **Ломать сознательно**: мержится mobile-first — сначала в eatvio_app поле становится nullable/опциональным, выходит релиз приложения; затем PR в eatvio-web меняет ресурс и копию манифеста. Пока старые версии приложения в рынке — бэк держит старый формат. 4. Коммит копии манифеста в eatvio-web без изменения ресурса бессмысленен и не проходит ревью: манифест меняется только вместе с ресурсом. ## Направление 2: frontend → backend (фронт ужесточает требования) 1. Дев меняет freezed-модель в eatvio_app (например, добавляет `required int nutrition`). 2. CI eatvio_app (`test.yml`, шаг `API contract manifest check`) регенерирует манифест и диффает: устарел → PR не проходит. 3. Дев обновляет манифест (`make export-api-contract`) и **в том же PR** синхронизирует копию в eatvio-web (`tests/Fixtures/Contract/`). 4. CI eatvio-web прогоняет `FlutterApiContractTest` против новой копии: если бэк не выдаёт новые required-ключи — падает уже там, и бэк добавляет гарантии **до** релиза приложения. 5. Порядок релизов: бэк докручивается первым (он назад-совместим), затем выходит приложение. ## Правила совместимости (шпаргалка) | Изменение на бэке | Ломает приложение? | Действие | |---|---|---| | Поле остаётся 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). ## Как команды держат друг друга в рабочем состоянии (три слоя) 1. **Предотвращение (до мержа).** Контрактные тесты на обеих сторонах: flutter не смержит модели без регенерации манифеста; бэкенд не смержит ресурс, отдающий null/не тот тип там, где приложение требует non-null. 2. **Синхронизация (при мерже).** Авто-PR из `contract-sync.yml` приносит новую копию манифеста в бэкенд; CI бэкенда на этом PR показывает, выполняет ли сервер новые требования. Ночной drift-check ловит всё, что проскочило мимо автоматики. 3. **Обнаружение (после релиза).** 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` не проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.