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