Files
eatvio_app/tool/export_api_contract.dart
2026-10-24 14:40:51 +03:00

243 lines
8.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
);
}