forked from eatvio/eatvio_app
73 lines
6.7 KiB
Markdown
73 lines
6.7 KiB
Markdown
# Регламент синхронизации 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` не проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.
|