// 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 args) { final root = Directory('lib'); if (!root.existsSync()) { stderr.writeln('Run from the flutter app root (directory "lib" not found).'); exit(2); } final sources = {}; final enumNames = {}; final files = root.listSync(recursive: true).whereType().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 = >{}; // modelOrigin: имя модели -> файл, из которого она взята (для предупреждений о коллизиях). final modelOrigin = {}; 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'] ?? {}) as Map; entry.value['kinds'] = { for (final e in types.entries) e.key: jsonKindFor(e.value, modelNames, enumNames), }; } final sorted = >{ 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 modelNames, Set 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> extractModels(String source) { final result = >{}; // Блоки "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 = []; final optional = []; final types = {}; 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`) и вложенные скобки. List _splitTopLevel(String body) { final parts = []; 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, ); }