forked from eatvio/eatvio_app
224 lines
7.5 KiB
Dart
224 lines
7.5 KiB
Dart
// 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>{};
|
||
|
||
for (final file in root.listSync(recursive: true).whereType<File>()) {
|
||
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>>{};
|
||
for (final source in sources.values) {
|
||
contract.addAll(extractModels(source));
|
||
}
|
||
|
||
// 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]!,
|
||
};
|
||
final 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,
|
||
);
|
||
}
|