# Регламент синхронизации 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, локально: `make export-api-contract` / `make check-api-contract`. - eatvio_app, CI: шаг `API contract manifest check` в `.gitea/workflows/test.yml`. - eatvio-web, локально (если рядом лежит копия eatvio_app): `make sync-flutter-contract`. - eatvio-web, CI: `tests/Feature/Contract/FlutterApiContractTest.php` в общем прогоне. ## Формат манифеста Каждая модель: `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` не проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.