forked from eatvio/eatvio_app
Compare commits
9 Commits
main
...
fix/1087-e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3519595ebf | |||
| 30932d9f28 | |||
| b30bf037db | |||
| bfb43104d8 | |||
| 4aadc8a9d8 | |||
| 7308e3e972 | |||
| 76c26457fc | |||
| 1255c9658c | |||
| 85cfcc519e |
64
.gitea/workflows/contract-sync.yml
Normal file
64
.gitea/workflows/contract-sync.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: "Contract sync: flutter manifest -> eatvio-web"
|
||||
|
||||
# Автоматический перенос манифеста API-контракта из eatvio_app в eatvio-web.
|
||||
# Триггер: push в main с изменениями api_contract/**.
|
||||
# Результат: PR в eatvio-web с обновлённым tests/Fixtures/Contract/flutter_model_contract.json.
|
||||
# Далее CI eatvio-web сам прогонит FlutterApiContractTest и покажет,
|
||||
# выполняет ли бэкенд требования новых моделей.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "api_contract/**"
|
||||
workflow_dispatch: # ручной запуск для проверки секрета/прав: Actions → Contract sync → Run workflow
|
||||
|
||||
jobs:
|
||||
sync-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Open PR in eatvio-web with updated manifest
|
||||
env:
|
||||
TOKEN: ${{ secrets.CONTRACT_SYNC_TOKEN }} # PAT с write на eatvio/eatvio-web
|
||||
API: http://gitea.office.local:3000/api/v1
|
||||
BACKEND_REPO: eatvio/eatvio-web
|
||||
SRC: api_contract/flutter_model_contract.json
|
||||
DST: tests/Fixtures/Contract/flutter_model_contract.json
|
||||
run: |
|
||||
set -e
|
||||
SHORT_SHA="${GITHUB_SHA::8}"
|
||||
BRANCH="contract-sync/manifest-${SHORT_SHA}"
|
||||
auth="Authorization: token ${TOKEN}"
|
||||
|
||||
DEFAULT_BRANCH=$(curl -sf -H "$auth" "${API}/repos/${BACKEND_REPO}" | grep -o '"default_branch":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Файл не изменился относительно предыдущего синка — выходим тихо.
|
||||
REMOTE_SHA=$(curl -sf -H "$auth" "${API}/repos/${BACKEND_REPO}/contents/${DST}?ref=${DEFAULT_BRANCH}" | grep -o '"sha":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
LOCAL_SHA=$(git hash-object -- "${SRC}")
|
||||
if [ "${REMOTE_SHA}" = "${LOCAL_SHA}" ]; then
|
||||
echo "Manifest already in sync with ${BACKEND_REPO}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1. Ветка от дефолтной (если уже существует — переиспользуем).
|
||||
curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"${DEFAULT_BRANCH}\"}" \
|
||||
"${API}/repos/${BACKEND_REPO}/branches" || echo "branch ${BRANCH} already exists"
|
||||
|
||||
# 2. Обновить файл (contents API требует sha текущего файла).
|
||||
CONTENT_B64=$(base64 -w0 "${SRC}")
|
||||
curl -sf -X PUT -H "$auth" -H "Content-Type: application/json" \
|
||||
-d "{\"content\":\"${CONTENT_B64}\",\"message\":\"chore(contract): sync flutter manifest (${SHORT_SHA})\",\"sha\":\"${REMOTE_SHA}\",\"branch\":\"${BRANCH}\"}" \
|
||||
"${API}/repos/${BACKEND_REPO}/contents/${DST}" > /dev/null
|
||||
|
||||
# 3. PR (если уже открыт — пропускаем).
|
||||
curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
|
||||
-d "{\"title\":\"[contract-sync] flutter manifest ${SHORT_SHA}\",\"head\":\"${BRANCH}\",\"base\":\"${DEFAULT_BRANCH}\",\"body\":\"Автоматический синк манифеста из eatvio_app@${GITHUB_SHA}.\\n\\nCI бэкенда прогонит FlutterApiContractTest — если бэкенд не выполняет новые требования моделей, тест упадёт на этом PR.\"}" \
|
||||
"${API}/repos/${BACKEND_REPO}/pulls" > /dev/null \
|
||||
|| echo "PR already exists for ${BRANCH}"
|
||||
|
||||
echo "Sync PR opened: ${BACKEND_REPO} <- ${BRANCH}"
|
||||
@@ -160,6 +160,15 @@ jobs:
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# Контракт манифеста API: манифест обязан быть регенерирован из исходников freezed-моделей.
|
||||
# Diff после регенерации = PR менял модели, но не обновил api_contract/flutter_model_contract.json.
|
||||
# Обновлённый манифест нужно также синхронизировать в eatvio-web: tests/Fixtures/Contract/.
|
||||
- name: API contract manifest check
|
||||
run: |
|
||||
dart run tool/export_api_contract.dart api_contract/flutter_model_contract.json
|
||||
git diff --exit-code api_contract/flutter_model_contract.json \
|
||||
|| (echo "::error::api_contract/flutter_model_contract.json устарел. Регенерируй манифест (make export-api-contract) и синхронизируй его в eatvio-web: tests/Fixtures/Contract/flutter_model_contract.json" && exit 1)
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
|
||||
|
||||
76
AGENTS.md
Normal file
76
AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# AGENTS.md
|
||||
|
||||
Flutter/Dart mobile app (iOS + Android, Russian-market nutrition/consulting product).
|
||||
Gitea-hosted; CI is Gitea Actions in `.gitea/workflows/` (not GitHub Actions, though it references `github.com/actions/*`).
|
||||
|
||||
## Toolchain (trust these over README — README version numbers are stale)
|
||||
|
||||
- CI runs **Flutter 3.41.2 stable**, **Java/JDK 21** (`.gitea/workflows/test.yml`).
|
||||
- `pubspec.yaml` requires **Dart SDK ^3.10.4**; app version is `1.3.23+105` (README claims 1.3.0+79 — wrong).
|
||||
- Build/test/lint via Flutter directly or the `Makefile`. The repo has a `package.json` **only** to wire a git pre-push hook via `simple-git-hooks`; it is not a Node project.
|
||||
|
||||
## CRITICAL: generated code is NOT committed
|
||||
|
||||
`.gitignore` excludes `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `*.mocks.dart`. After any checkout / pull / model change you **must regenerate** before `flutter analyze`, `flutter test`, or any build, or they fail on missing files:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
# or: make build-generated-files
|
||||
```
|
||||
|
||||
Generators in use: `freezed`, `json_serializable`, `retrofit_generator`, `mockito` (`build_runner`).
|
||||
Exception: `lib/generated/` (l10n output from `intl_utils` / `flutter_intl`) **is** committed — don't regenerate-and-clobber it casually.
|
||||
|
||||
## `.env` (required for running the app, NOT for unit tests)
|
||||
|
||||
- The app loads `.env` at startup (`lib/core/app_initializer.dart` → `dotenv.load`) and ships it as a Flutter asset (`pubspec.yaml` assets).
|
||||
- There is **no `env.example` in the repo** despite README/`ENV_SETUP.md` saying `cp env.example .env`. Locally you must obtain `.env` from the team. In CI it is checked out from a **private `eatvio/eatvio_app_env` repo**.
|
||||
- `flutter test` does **not** need `.env`: tests seed env via `dotenv.loadFromString(...)` (see `test/core/app_config_test.dart`). Access values only through `lib/core/env_config.dart` (`EnvConfig`).
|
||||
- Build types: `AppBuildType { prod, tests, dev }` (`lib/core/app_initializer.dart`). `prod` → `BASE_URL`; `tests`/`dev` → `DEV_BASE_URL`. `main.dart` hardcodes `prod`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
make run-analyse # flutter analyze
|
||||
make run-tests # flutter test
|
||||
make run-checks # analyze THEN test (also the pre-push hook)
|
||||
make build-generated-files # build_runner --delete-conflicting-outputs
|
||||
make clean-coverage # strips generated files from coverage/lcov.info
|
||||
```
|
||||
|
||||
Run a single test file / single test:
|
||||
```bash
|
||||
flutter test test/core/app_config_test.dart
|
||||
flutter test test/core/app_config_test.dart --plain-name "creates config with prod build type"
|
||||
```
|
||||
|
||||
Coverage (must be run with the flag): `flutter test --coverage`, then `make clean-coverage`. `analyze_coverage.py` / `analyze_coverage_by_uncovered.py` summarize `coverage/lcov.info`.
|
||||
|
||||
## CI gate (binding constraint on PRs)
|
||||
|
||||
`.gitea/workflows/test.yml` runs on every PR, in order:
|
||||
1. `flutter clean` → `flutter pub get` → `dart run build_runner build --delete-conflicting-outputs`
|
||||
2. `flutter analyze`
|
||||
3. `flutter test --coverage`
|
||||
4. **Coverage must be ≥ 70%** (generated files excluded) or the job fails — keep this in mind when adding untested code.
|
||||
5. `flutter build apk --release --dart-define=HIDE_PLANS=false --dart-define=BUILD_DATE=...`
|
||||
|
||||
Note: other workflow files (`build.yml`, `docker-run-tests.yml`) are simpler/older and do **not** run `build_runner`; `test.yml` is the authoritative one.
|
||||
|
||||
## Architecture map
|
||||
|
||||
- Entry: `lib/main.dart` → `AppInitializer.initialize` (env → DI → Sentry → Firebase → analytics) → runs app under Sentry.
|
||||
- DI: `get_it` (`sl` global), registered in `lib/core/locator_service.dart` via `lib/core/di/*_registrations.dart` (app_config, external, service, repository, cubit).
|
||||
- State: `flutter_bloc` (Cubits/Blocs). Global providers declared in `lib/core/app_config.dart` (`globalBlocProviders`).
|
||||
- Routing: `go_router` (`lib/core/app_router.dart`), with deep-link/push handling in `lib/core/notifications/`.
|
||||
- Networking: `dio` + `retrofit` (→ `.gr.dart`); auth token interceptor in `lib/core/interceptors/`.
|
||||
- Models: `freezed` + `json_serializable` (→ `.freezed.dart` / `.g.dart`).
|
||||
- Realtime: Pusher channels via `lib/core/websockets/` (`WsManager`).
|
||||
- Features: ~40 self-contained folders under `lib/features/<feature>/`, conventionally `.../presentation/bloc/<name>_cubit/<name>_cubit.dart`. Tests mirror `lib/` under `test/` with fixtures in `test/fixtures/`.
|
||||
- Localization: Russian only (`ru`); strings via generated `S` class from `lib/generated/l10n.dart`.
|
||||
|
||||
## Conventions / gotchas
|
||||
|
||||
- `analysis_options.yaml` enables `flutter_lints` + extra `prefer_const_*` rules and **excludes generated + l10n files** from analysis. `dart_code_metrics` is also configured (cyclomatic ≤20, ≤50 lines executable, ≤4 params, nesting ≤5) but metrics exclude `test/**`.
|
||||
- Don't commit secrets: `.env`, `android/key.properties`, `*.jks` keystores are all gitignored.
|
||||
- Comments and many code identifiers are in Russian (UI strings, doc comments like `/// Загрузка переменных окружения`); match surrounding style.
|
||||
11
Makefile
11
Makefile
@@ -11,3 +11,14 @@ build-generated-files:
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
|
||||
# Регенерация манифеста API-контракта из исходников freezed-моделей.
|
||||
# Манифест — источник требований мобильных моделей для eatvio-web
|
||||
# (tests/Fixtures/Contract/flutter_model_contract.json).
|
||||
export-api-contract:
|
||||
dart run tool/export_api_contract.dart api_contract/flutter_model_contract.json
|
||||
|
||||
# Проверка актуальности манифеста (для CI): регенерирует и диффает с закоммиченным.
|
||||
check-api-contract: export-api-contract
|
||||
@git diff --exit-code api_contract/flutter_model_contract.json \
|
||||
|| (echo "api_contract/flutter_model_contract.json устарел: регенерируй (make export-api-contract) и синхронизируй в eatvio-web tests/Fixtures/Contract/" && exit 1)
|
||||
|
||||
89
api_contract/README.md
Normal file
89
api_contract/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Регламент синхронизации 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` не проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.
|
||||
3988
api_contract/flutter_model_contract.json
Normal file
3988
api_contract/flutter_model_contract.json
Normal file
File diff suppressed because it is too large
Load Diff
242
tool/export_api_contract.dart
Normal file
242
tool/export_api_contract.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
// FILE: tool/export_api_contract.dart
|
||||
// VERSION: 1.0.0
|
||||
// PURPOSE: Экспорт контракта мобильных моделей для бэкенд-репозитория (eatvio-web).
|
||||
//
|
||||
// Обходит lib/**/*.dart, находит @freezed фабрики (const factory XModel({...}))
|
||||
// и извлекает JSON-контракт каждой модели: какие ключи обязательны (required),
|
||||
// а какие допускают null/отсутствие.
|
||||
//
|
||||
// Источник истины — исходные декларации, НЕ сгенерированные *.g.dart:
|
||||
// nullability = "?" в типе Dart; отсутствие ключа безопасно при @Default(...);
|
||||
// JSON-имя = @JsonKey(name: "...") либо имя поля (дефолт json_serializable).
|
||||
//
|
||||
// USAGE:
|
||||
// dart run tool/export_api_contract.dart # манифест в stdout
|
||||
// dart run tool/export_api_contract.dart out.json # манифест в файл
|
||||
//
|
||||
// CI (flutter-репо): регенерить и сравнивать с закоммиченной копией;
|
||||
// diff → fail с требованием обновить манифест и синхронизировать его
|
||||
// в eatvio-web: tests/Fixtures/Contract/flutter_model_contract.json
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
void main(List<String> args) {
|
||||
final root = Directory('lib');
|
||||
if (!root.existsSync()) {
|
||||
stderr.writeln('Run from the flutter app root (directory "lib" not found).');
|
||||
exit(2);
|
||||
}
|
||||
|
||||
final sources = <File, String>{};
|
||||
final enumNames = <String>{};
|
||||
|
||||
final files = root.listSync(recursive: true).whereType<File>().toList();
|
||||
// ДЕТЕРМИНИЗМ: сортируем по пути — порядок Directory.listSync зависит от файловой системы,
|
||||
// а при коллизии имён моделей побеждает последняя обработанная. Без сортировки локально
|
||||
// и на CI получаются разные манифесты (баг, из-за которого CI падал на чистом PR).
|
||||
files.sort((a, b) => a.path.compareTo(b.path));
|
||||
|
||||
for (final file in files) {
|
||||
if (!file.path.endsWith('.dart') || file.path.endsWith('.g.dart') || file.path.endsWith('.freezed.dart')) {
|
||||
continue;
|
||||
}
|
||||
final source = file.readAsStringSync();
|
||||
sources[file] = source;
|
||||
enumNames.addAll(
|
||||
RegExp(r'^\s*enum\s+(\w+)', multiLine: true)
|
||||
.allMatches(source)
|
||||
.map((m) => m.group(1)!),
|
||||
);
|
||||
}
|
||||
|
||||
final contract = <String, Map<String, dynamic>>{};
|
||||
// modelOrigin: имя модели -> файл, из которого она взята (для предупреждений о коллизиях).
|
||||
final modelOrigin = <String, String>{};
|
||||
for (final entry in sources.entries) {
|
||||
final extracted = extractModels(entry.value);
|
||||
for (final modelName in extracted.keys) {
|
||||
if (contract.containsKey(modelName)) {
|
||||
stderr.writeln(
|
||||
'WARNING: model "$modelName" defined in both "${modelOrigin[modelName]}" '
|
||||
'and "${entry.key.path}" — keeping the one from "${entry.key.path}" (sorted-order last wins). '
|
||||
'Consider renaming the Dart classes to make the contract unambiguous.',
|
||||
);
|
||||
}
|
||||
modelOrigin[modelName] = entry.key.path;
|
||||
contract[modelName] = extracted[modelName]!;
|
||||
}
|
||||
}
|
||||
|
||||
// kinds считаются второй проходкой: нужны все имена моделей (contract.keys) и enum'ов.
|
||||
final modelNames = contract.keys.toSet();
|
||||
for (final entry in contract.entries) {
|
||||
final types = (entry.value['types'] ?? <String, String>{}) as Map<String, String>;
|
||||
entry.value['kinds'] = {
|
||||
for (final e in types.entries) e.key: jsonKindFor(e.value, modelNames, enumNames),
|
||||
};
|
||||
}
|
||||
|
||||
final sorted = <String, Map<String, dynamic>>{
|
||||
for (final key in contract.keys.toList()..sort()) key: contract[key]!,
|
||||
};
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
final output = encoder.convert(sorted);
|
||||
|
||||
if (args.isNotEmpty) {
|
||||
File(args.first).writeAsStringSync('$output\n');
|
||||
stdout.writeln('Contract written to ${args.first} (${sorted.length} models)');
|
||||
} else {
|
||||
stdout.writeln(output);
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-вид значения для Dart-типа — по нему бэкенд-чекер валидирует payload:
|
||||
/// number (int/double), string, boolean, list, map, model (JSON-объект),
|
||||
/// enum (строка-имя или int), any (не проверяем).
|
||||
String jsonKindFor(String dartType, Set<String> modelNames, Set<String> enumNames) {
|
||||
final type = dartType.trim();
|
||||
if (type == 'dynamic' || type == 'Object') {
|
||||
return 'any';
|
||||
}
|
||||
if (type.startsWith('List<')) {
|
||||
return 'list';
|
||||
}
|
||||
if (type.startsWith('Map<')) {
|
||||
return 'map';
|
||||
}
|
||||
if (const {'int', 'double', 'num'}.contains(type)) {
|
||||
return 'number';
|
||||
}
|
||||
if (type == 'String') {
|
||||
return 'string';
|
||||
}
|
||||
if (type == 'bool') {
|
||||
return 'boolean';
|
||||
}
|
||||
if (enumNames.contains(type)) {
|
||||
return 'enum';
|
||||
}
|
||||
if (modelNames.contains(type)) {
|
||||
return 'model';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/// Извлекает все @freezed-модели из исходника.
|
||||
/// Возвращает {ModelName: {required: [...], optional: [...]}}.
|
||||
Map<String, Map<String, dynamic>> extractModels(String source) {
|
||||
final result = <String, Map<String, dynamic>>{};
|
||||
|
||||
// Блоки "const factory ModelName({ ... }) = _ModelName[...];" (одно- и многострочные).
|
||||
final factoryRe = RegExp(r'const\s+factory\s+(\w+)\(\s*\{(.*?)\}\s*\)\s*=', dotAll: true);
|
||||
for (final match in factoryRe.allMatches(source)) {
|
||||
final modelName = match.group(1)!;
|
||||
final body = match.group(2)!;
|
||||
|
||||
final required = <String>[];
|
||||
final optional = <String>[];
|
||||
final types = <String, String>{};
|
||||
|
||||
for (final rawLine in _splitTopLevel(body)) {
|
||||
final line = rawLine.trim();
|
||||
if (line.isEmpty || line.startsWith('//')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final field = _parseField(line);
|
||||
if (field == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
(field.required ? required : optional).add(field.jsonName);
|
||||
types[field.jsonName] = field.dartType;
|
||||
}
|
||||
|
||||
if (required.isNotEmpty || optional.isNotEmpty) {
|
||||
result[modelName] = {
|
||||
'required': required,
|
||||
'optional': optional,
|
||||
'types': types,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Разбивает тело фабрики по запятым верхнего уровня,
|
||||
/// не разрывая дженерики (`List<int, String>`) и вложенные скобки.
|
||||
List<String> _splitTopLevel(String body) {
|
||||
final parts = <String>[];
|
||||
final current = StringBuffer();
|
||||
var angleDepth = 0;
|
||||
var parenDepth = 0;
|
||||
var braceDepth = 0;
|
||||
|
||||
for (final char in body.runes) {
|
||||
final ch = String.fromCharCode(char);
|
||||
if (ch == '<') {
|
||||
angleDepth++;
|
||||
} else if (ch == '>') {
|
||||
angleDepth = (angleDepth - 1).clamp(0, 1 << 30);
|
||||
} else if (ch == '(') {
|
||||
parenDepth++;
|
||||
} else if (ch == ')') {
|
||||
parenDepth = (parenDepth - 1).clamp(0, 1 << 30);
|
||||
} else if (ch == '{') {
|
||||
braceDepth++;
|
||||
} else if (ch == '}') {
|
||||
braceDepth = (braceDepth - 1).clamp(0, 1 << 30);
|
||||
} else if (ch == ',' && angleDepth == 0 && parenDepth == 0 && braceDepth == 0) {
|
||||
parts.add(current.toString());
|
||||
current.clear();
|
||||
|
||||
continue;
|
||||
}
|
||||
current.write(ch);
|
||||
}
|
||||
parts.add(current.toString());
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
class _Field {
|
||||
_Field(this.jsonName, this.required, this.dartType);
|
||||
|
||||
final String jsonName;
|
||||
final bool required;
|
||||
final String dartType;
|
||||
}
|
||||
|
||||
_Field? _parseField(String line) {
|
||||
// Убираем комментарии в конце строки.
|
||||
final code = line.split('//').first.trim();
|
||||
if (code.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// JSON-имя: @JsonKey(name: "type_no") или имя поля (одинарные и двойные кавычки).
|
||||
final jsonKeyMatch =
|
||||
RegExp(r'''@JsonKey\([^)]*?\bname:\s*["']([^"']+)["']''').firstMatch(code);
|
||||
// Убираем аннотации, оставляем "модификаторы тип поле".
|
||||
final declaration = code.replaceAll(RegExp(r'@\w+\([^)]*\)'), '').trim();
|
||||
|
||||
final fieldMatch = RegExp(r'^(?:(?:required|final|const)\s+)*([\w<>?,\s]+?)\s+(\w+)$').firstMatch(declaration);
|
||||
if (fieldMatch == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final type = fieldMatch.group(1)!.trim();
|
||||
final dartName = fieldMatch.group(2)!;
|
||||
|
||||
// Метаданные исходной строки.
|
||||
final hasDefault = RegExp(r'@Default\(').hasMatch(line);
|
||||
final isNullable = type.endsWith('?') || type == 'dynamic';
|
||||
|
||||
return _Field(
|
||||
jsonKeyMatch?.group(1) ?? dartName,
|
||||
!isNullable && !hasDefault,
|
||||
type.endsWith('?') ? type.substring(0, type.length - 1) : type,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user