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