diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b1bd6..a879d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## [3.2.0] - 2026-01-12 + +### 🎉 新特性 + +#### 模型类名前缀支持 +- ✅ **支持配置文件配置**:在 `generator_config.yaml` 中配置 `models.class_prefix`,自动为生成的模型类添加前缀。 +- ✅ **性能优化**:为 `ConfigRepository` 添加缓存机制,减少磁盘 I/O。 ## [3.1.0] - 2025-11-24 ### 🎉 新特性 diff --git a/example/pubspec.lock b/example/pubspec.lock index cbf585c..247db8e 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -558,7 +558,7 @@ packages: path: ".." relative: true source: path - version: "3.1.0" + version: "3.1.4" term_glyph: dependency: transitive description: diff --git a/lib/core/config.dart b/lib/core/config.dart index a5b3994..4fbef0e 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -54,6 +54,14 @@ class SwaggerConfig { static Map>? get enumKeyMappings => ConfigRepository.loadSync().enumKeyMappings; + /// 获取 JsonSerializable 配置(从配置文件读取) + static JsonSerializableConfig? get jsonSerializableConfig => + ConfigRepository.loadSync().jsonSerializableConfig; + + /// 获取模型类名前缀(从配置文件读取) + static String? get modelClassPrefix => + ConfigRepository.loadSync().modelClassPrefix; + /// 默认文档文件名 static const String defaultDocumentationFile = 'generated_api_documentation.md'; diff --git a/lib/core/config_repository.dart b/lib/core/config_repository.dart index dcaabb2..28e5067 100644 --- a/lib/core/config_repository.dart +++ b/lib/core/config_repository.dart @@ -17,6 +17,28 @@ class EnumKeyMapping { final String? description; } +/// JSON Serializable 配置 +class JsonSerializableConfig { + const JsonSerializableConfig({ + this.checked = false, + this.explicitToJson = false, + this.includeIfNull = true, + }); + + final bool checked; + final bool explicitToJson; + final bool includeIfNull; + + static JsonSerializableConfig? fromMap(Map? map) { + if (map == null) return null; + return JsonSerializableConfig( + checked: map['checked'] as bool? ?? false, + explicitToJson: map['explicit_to_json'] as bool? ?? false, + includeIfNull: map['include_if_null'] as bool? ?? true, + ); + } +} + /// 配置仓库 /// 负责加载和提供配置信息 class ConfigRepository { @@ -42,8 +64,16 @@ class ConfigRepository { } } + static ConfigRepository? _cachedConfig; + /// 同步加载配置(用于向后兼容或必须同步的场景) + /// 为默认配置路径启用缓存 static ConfigRepository loadSync([String? configPath]) { + // 仅在使用默认路径时使用缓存 + if (configPath == null && _cachedConfig != null) { + return _cachedConfig!; + } + final file = File(configPath ?? PathResolver.findConfigFile() ?? ''); if (!file.existsSync()) { return ConfigRepository({}); @@ -53,7 +83,12 @@ class ConfigRepository { final content = file.readAsStringSync(); final yaml = loadYaml(content); final map = _yamlToMap(yaml); - return ConfigRepository(map); + final config = ConfigRepository(map); + + if (configPath == null) { + _cachedConfig = config; + } + return config; } on Exception catch (e) { appLogger.warning('⚠️ 配置文件解析失败: $e'); return ConfigRepository({}); @@ -292,6 +327,22 @@ class ConfigRepository { return api?['base_page_result_import'] as String? ?? ''; } + /// 获取 JsonSerializable 配置 + JsonSerializableConfig? get jsonSerializableConfig { + final generation = _config['generation'] as Map?; + final models = generation?['models'] as Map?; + final jsonSerializable = + models?['json_serializable'] as Map?; + return JsonSerializableConfig.fromMap(jsonSerializable); + } + + /// 获取模型类名前缀 + String? get modelClassPrefix { + final generation = _config['generation'] as Map?; + final models = generation?['models'] as Map?; + return models?['class_prefix'] as String?; + } + /// 获取枚举键名映射配置 /// 返回格式: { "EnumName": { value: { "name": "KEY_NAME", "description": "描述" } } } Map>? get enumKeyMappings { diff --git a/lib/pipeline/generate/impl/model/model_content_builders.dart b/lib/pipeline/generate/impl/model/model_content_builders.dart index ce4d91c..354a434 100644 --- a/lib/pipeline/generate/impl/model/model_content_builders.dart +++ b/lib/pipeline/generate/impl/model/model_content_builders.dart @@ -127,10 +127,29 @@ String _generateAnnotatedModelCodeWithoutImports( buffer.writeln(StringHelper.generateComment(model.description)); } + // Build @JsonSerializable annotation string for factory constructor + String? jsonSerializableAnnotation; + final jsonConfig = SwaggerConfig.jsonSerializableConfig; + if (jsonConfig != null) { + final params = []; + if (jsonConfig.checked) params.add('checked: true'); + if (jsonConfig.explicitToJson) params.add('explicitToJson: true'); + if (!jsonConfig.includeIfNull) params.add('includeIfNull: false'); + + if (params.isNotEmpty) { + jsonSerializableAnnotation = '@JsonSerializable(${params.join(', ')})'; + } + } + buffer ..writeln('@freezed') - ..writeln('abstract class $className with _\$$className {') - ..writeln(' const factory $className({'); + ..writeln('abstract class $className with _\$$className {'); + + // Add @JsonSerializable annotation on factory constructor if configured + if (jsonSerializableAnnotation != null) { + buffer.writeln(' $jsonSerializableAnnotation'); + } + buffer.writeln(' const factory $className({'); model.properties.forEach((propName, property) { final dartType = generator.getDartPropertyType(property); diff --git a/lib/pipeline/generate/impl/model/model_file_writers.dart b/lib/pipeline/generate/impl/model/model_file_writers.dart index 10759e9..9062e2b 100644 --- a/lib/pipeline/generate/impl/model/model_file_writers.dart +++ b/lib/pipeline/generate/impl/model/model_file_writers.dart @@ -44,19 +44,17 @@ String buildSingleModelFile( ..writeln(); if (!model.isEnum) { - buffer - ..writeln( - "import 'package:freezed_annotation/freezed_annotation.dart';", - ) - ..writeln(); - } else { - buffer - ..writeln( - "import 'package:json_annotation/json_annotation.dart';", - ) - ..writeln(); + buffer.writeln( + "import 'package:freezed_annotation/freezed_annotation.dart';", + ); } + buffer + ..writeln( + "import 'package:json_annotation/json_annotation.dart';", + ) + ..writeln(); + final importedTypes = generator.getImportedTypes(model); if (importedTypes.isNotEmpty) { buffer diff --git a/lib/pipeline/generate/impl/retrofit_api/api_parameters.dart b/lib/pipeline/generate/impl/retrofit_api/api_parameters.dart index 0d7893d..89fb2f5 100644 --- a/lib/pipeline/generate/impl/retrofit_api/api_parameters.dart +++ b/lib/pipeline/generate/impl/retrofit_api/api_parameters.dart @@ -14,7 +14,7 @@ mixin RetrofitApiParameters { parameters.add( ApiMethodParameter( name: StringHelper.toDartPropertyName(param.name), - type: _getDartType(param.type), + type: _getDartTypeForParameter(param), annotation: _g.useRetrofit ? "@Path('${param.name}')" : '', required: param.required, description: param.description, @@ -47,7 +47,7 @@ mixin RetrofitApiParameters { parameters.add( ApiMethodParameter( name: StringHelper.toDartPropertyName(param.name), - type: '${_getDartType(param.type)}$nullable', + type: '${_getDartTypeForParameter(param)}$nullable', annotation: _g.useRetrofit ? "@Query('${param.name}')" : '', required: param.required, description: param.description, diff --git a/lib/pipeline/render/impl/template/template_loader.dart b/lib/pipeline/render/impl/template/template_loader.dart index 644721f..d0fc0f4 100644 --- a/lib/pipeline/render/impl/template/template_loader.dart +++ b/lib/pipeline/render/impl/template/template_loader.dart @@ -87,9 +87,27 @@ class TemplateLoader { final dirs = []; try { - // 尝试从 .dart_tool/package_config.json 解析包路径 - final packageConfigFile = File('.dart_tool/package_config.json'); - if (!packageConfigFile.existsSync()) { + // 递归向上查找 .dart_tool/package_config.json + File? packageConfigFile; + var currentDir = Directory.current; + const maxDepth = 6; // 增加搜索深度以支持 monorepo + var depth = 0; + + while (depth < maxDepth) { + final checkFile = + File(p.join(currentDir.path, '.dart_tool', 'package_config.json')); + if (checkFile.existsSync()) { + packageConfigFile = checkFile; + break; + } + + final parent = currentDir.parent; + if (parent.path == currentDir.path) break; + currentDir = parent; + depth++; + } + + if (packageConfigFile == null) { return dirs; } @@ -103,7 +121,7 @@ class TemplateLoader { ); if (package != null) { - final rootUri = package['rootUri'] as String; + var rootUri = package['rootUri'] as String; String packagePath; // 处理 file:// 协议 @@ -117,18 +135,27 @@ class TemplateLoader { // 如果不是绝对路径,需要相对于 .dart_tool/package_config.json 所在目录解析 if (!p.isAbsolute(packagePath)) { - // package_config.json 在 .dart_tool 目录下 - packagePath = p.normalize(p.join('.dart_tool', packagePath)); + // package_config.json 在 packageConfigFile.parent 目录下 (.dart_tool) + packagePath = + p.normalize(p.join(packageConfigFile.parent.path, packagePath)); } // 添加模板目录路径 - final templateDir = p.join(packagePath, 'lib', 'templates'); + // 1. 尝试 lib/templates (标准包结构) + var templateDir = p.join(packagePath, 'lib', 'templates'); if (Directory(templateDir).existsSync()) { dirs.add(templateDir); + } else { + // 2. 尝试 templates (本地调试或非标准结构) + templateDir = p.join(packagePath, 'templates'); + if (Directory(templateDir).existsSync()) { + dirs.add(templateDir); + } } } - } catch (_) { + } catch (e) { // 忽略错误,返回空列表 + print('Warning: Failed to load package templates: $e'); } return dirs; diff --git a/lib/utils/string_helper.dart b/lib/utils/string_helper.dart index a2f82e0..c186241 100644 --- a/lib/utils/string_helper.dart +++ b/lib/utils/string_helper.dart @@ -21,6 +21,7 @@ /// library; +import 'package:swagger_generator_flutter/core/config.dart'; import 'package:swagger_generator_flutter/core/models.dart'; import 'package:swagger_generator_flutter/utils/string_utils/formatting_utils.dart'; import 'package:swagger_generator_flutter/utils/string_utils/naming_converter.dart'; @@ -47,8 +48,16 @@ class StringHelper { NamingConverter.toDartPropertyName(propName); /// 生成 Dart 类名 - static String generateClassName(String name) => - NamingConverter.generateClassName(name); + static String generateClassName(String name) { + var className = NamingConverter.generateClassName(name); + final prefix = SwaggerConfig.modelClassPrefix; + if (prefix != null && prefix.isNotEmpty) { + if (!className.startsWith(prefix)) { + className = prefix + className; + } + } + return className; + } /// 生成常量名称 (UPPER_SNAKE_CASE) static String generateConstantName(String name) => diff --git a/pubspec.yaml b/pubspec.yaml index 5ad9605..d7dcdde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: swagger_generator_flutter description: A powerful Swagger/OpenAPI code generator for Flutter projects with Dio + Retrofit support -version: 3.1.4 +version: 3.2.0 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/config_prefix_test.dart b/test/config_prefix_test.dart new file mode 100644 index 0000000..21f31dd --- /dev/null +++ b/test/config_prefix_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:swagger_generator_flutter/core/config.dart'; +import 'package:swagger_generator_flutter/core/config_repository.dart'; +import 'package:swagger_generator_flutter/utils/string_helper.dart'; +import 'package:test/test.dart'; + +void main() { + group('Model Class Prefix Support', () { + late Directory tempDir; + late File configFile; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('swagger_gen_test_'); + configFile = File('${tempDir.path}/generator_config.yaml'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('should parse class_prefix from config', () { + configFile.writeAsStringSync(''' +generator: + name: test + +output: + models: + class_prefix: "MyPrefix" + +generation: + models: + class_prefix: "MyPrefix" +'''); + + final config = ConfigRepository.loadSync(configFile.path); + expect(config.modelClassPrefix, equals('MyPrefix')); + }); + + test('should return null when class_prefix is missing', () { + configFile.writeAsStringSync(''' +generator: + name: test +'''); + + final config = ConfigRepository.loadSync(configFile.path); + expect(config.modelClassPrefix, isNull); + }); + + // NOTE: Testing StringHelper.generateClassName directly implies checking if it reads from the GLOBAL config. + // However, ConfigRepository.loadSync() creates an instance, but SwaggerConfig accessors call ConfigRepository.loadSync() individually. + // Since ConfigRepository.loadSync() without args looks for default file, we need a way to inject the config or point it to our file. + // The current implementation of SwaggerConfig calls ConfigRepository.loadSync() which defaults to finding a config file. + + // Changing the implementation of StringHelper to depend on a reloadable config or global state would be better, + // but without changing that, we rely on how ConfigRepository finds the file. + // Ideally we should test ConfigRepository logic separately from StringHelper if StringHelper uses a static/global config lookup. + + // BUT, wait. ConfigRepository.loadSync() does THIS: + // final file = File(configPath ?? PathResolver.findConfigFile() ?? ''); + + // If we want SwaggerConfig (static) to pick up our test config, we might need to trick PathResolver + // OR we can explicitly pass the config path if the code supported it, but StringHelper uses static SwaggerConfig.modelClassPrefix. + + // The current implementation of `SwaggerConfig.modelClassPrefix` is: + // static String? get modelClassPrefix => ConfigRepository.loadSync().modelClassPrefix; + + // So every time we call `StringHelper.generateClassName`, it calls `ConfigRepository.loadSync()`. + // `ConfigRepository.loadSync()` calls `PathResolver.findConfigFile()`. + + // Since we cannot easily mock `PathResolver`'s static method or filesystem search path in a unit test without dependency injection, + // we might face issues testing `StringHelper` integration end-to-end here unless we run this test in a context where `findConfigFile` returns our temp file. + + // However, for verify purposes, verifying `ConfigRepository` parses it is the most critical part we added. + // The `StringHelper` logic is simple string concatenation. + }); +} diff --git a/test_verification.dart b/test_verification.dart new file mode 100644 index 0000000..2bf2cb0 --- /dev/null +++ b/test_verification.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:swagger_generator_flutter/core/config.dart'; +import 'package:swagger_generator_flutter/core/config_repository.dart'; +import 'package:swagger_generator_flutter/utils/string_helper.dart'; + +void main() { + final configFile = File('generator_config.yaml'); + final resultFile = File('test_result.txt'); + + try { + // 1. Setup config file + configFile.writeAsStringSync(''' +generator: + name: test +output: + models: + class_prefix: "MyPrefix" +generation: + models: + class_prefix: "MyPrefix" +'''); + + // 2. Test direct ConfigRepository load + final config = ConfigRepository.loadSync('generator_config.yaml'); + if (config.modelClassPrefix != 'MyPrefix') { + throw 'ConfigRepository failed to PARSE prefix. Got: ${config.modelClassPrefix}'; + } + + // 3. Test SwaggerConfig (static access) + // Note: ConfigRepository.loadSync() tries to find config file. + // Since we created generator_config.yaml in CWD, and PathResolver likely checks CWD, this matches. + if (SwaggerConfig.modelClassPrefix != 'MyPrefix') { + throw 'SwaggerConfig failed to READ prefix. Got: ${SwaggerConfig.modelClassPrefix}'; + } + + // 4. Test StringHelper + final className = StringHelper.generateClassName('User'); + if (className != 'MyPrefixUser') { + throw 'StringHelper failed to APPLY prefix. Got: $className'; + } + + // 5. Test Prefix Avoidance (Idempotency) + final className2 = StringHelper.generateClassName('MyPrefixUser'); + if (className2 != 'MyPrefixUser') { + throw 'StringHelper double-prefixed. Got: $className2'; + } + + resultFile.writeAsStringSync('PASS'); + print('Verification Passed'); + } catch (e, stack) { + resultFile.writeAsStringSync('FAIL: $e\n$stack'); + print('Verification Failed: $e'); + } finally { + // Cleanup + if (configFile.existsSync()) configFile.deleteSync(); + } +}