Establish the Koi Swagger parser release
Prepare the OpenAPI parser and Dart generator as a publishable Koi package with CLI entrypoint, docs, examples, tests, and pub.dev release metadata. Constraint: Initial GitHub import follows koi_blueprint_flutter identity mr.koi <i@mrkoi.is>. Rejected: Keep local runtime state in the initial repository | .dart_tool, .omx, and pubspec.lock are ignored for a reusable package source tree. Confidence: high Scope-risk: moderate Directive: Keep generated-output behavior covered by parser and generator tests before changing rendering rules. Tested: dart analyze; dart test; PUB_HOSTED_URL=https://pub.dev dart pub publish --dry-run Not-tested: End-to-end downstream build_runner compilation of generated Retrofit/Freezed output.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
library;
|
||||
|
||||
export 'src/config/config.dart';
|
||||
export 'src/ir/ir.dart';
|
||||
export 'src/koi_swagger_parser.dart';
|
||||
export 'src/preset/preset.dart';
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
import '../preset/preset.dart';
|
||||
import '../util/utils.dart';
|
||||
|
||||
enum ResponseWrapperMode { passthrough, externalGeneric }
|
||||
|
||||
class KoiSwaggerConfig {
|
||||
KoiSwaggerConfig({
|
||||
required this.configPath,
|
||||
required this.workingDirectory,
|
||||
required this.input,
|
||||
required this.output,
|
||||
required this.generation,
|
||||
});
|
||||
|
||||
final String? configPath;
|
||||
final String workingDirectory;
|
||||
final InputConfig input;
|
||||
final OutputConfig output;
|
||||
final GenerationConfig generation;
|
||||
|
||||
static Future<KoiSwaggerConfig> load({
|
||||
String? configPath,
|
||||
String? workingDirectory,
|
||||
}) {
|
||||
return KoiSwaggerConfigLoader.load(
|
||||
configPath: configPath,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InputConfig {
|
||||
const InputConfig({required this.swaggerUrls});
|
||||
|
||||
final List<String> swaggerUrls;
|
||||
}
|
||||
|
||||
class OutputConfig {
|
||||
const OutputConfig({
|
||||
required this.baseDir,
|
||||
required this.apiDir,
|
||||
required this.modelsDir,
|
||||
required this.splitByTags,
|
||||
required this.extractVersionFromUrl,
|
||||
this.includedTags,
|
||||
this.excludedTags,
|
||||
});
|
||||
|
||||
final String baseDir;
|
||||
final String apiDir;
|
||||
final String modelsDir;
|
||||
final List<String>? includedTags;
|
||||
final List<String>? excludedTags;
|
||||
final bool splitByTags;
|
||||
final bool extractVersionFromUrl;
|
||||
}
|
||||
|
||||
class GenerationConfig {
|
||||
const GenerationConfig({
|
||||
required this.preset,
|
||||
required this.api,
|
||||
required this.models,
|
||||
required this.formatting,
|
||||
});
|
||||
|
||||
final GeneratorPreset preset;
|
||||
final ApiGenerationConfig api;
|
||||
final ModelGenerationConfig models;
|
||||
final FormattingConfig formatting;
|
||||
}
|
||||
|
||||
class ApiGenerationConfig {
|
||||
const ApiGenerationConfig({
|
||||
required this.enabled,
|
||||
required this.useRetrofit,
|
||||
required this.cancelTokenSupport,
|
||||
required this.client,
|
||||
required this.responseWrapper,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool useRetrofit;
|
||||
final bool cancelTokenSupport;
|
||||
final ClientConfig client;
|
||||
final ResponseWrapperConfig responseWrapper;
|
||||
}
|
||||
|
||||
class ClientConfig {
|
||||
const ClientConfig({required this.className, required this.fileName});
|
||||
|
||||
final String className;
|
||||
final String fileName;
|
||||
}
|
||||
|
||||
class ResponseWrapperConfig {
|
||||
const ResponseWrapperConfig({
|
||||
required this.mode,
|
||||
required this.successDataField,
|
||||
required this.detectStandardEnvelope,
|
||||
this.externalImport,
|
||||
this.externalClassName,
|
||||
});
|
||||
|
||||
final ResponseWrapperMode mode;
|
||||
final String successDataField;
|
||||
final bool detectStandardEnvelope;
|
||||
final String? externalImport;
|
||||
final String? externalClassName;
|
||||
}
|
||||
|
||||
class ModelGenerationConfig {
|
||||
const ModelGenerationConfig({
|
||||
required this.enabled,
|
||||
required this.classPrefix,
|
||||
required this.defaultNonNullableListToEmpty,
|
||||
required this.jsonSerializable,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final String classPrefix;
|
||||
final bool defaultNonNullableListToEmpty;
|
||||
final JsonSerializableConfig jsonSerializable;
|
||||
}
|
||||
|
||||
class JsonSerializableConfig {
|
||||
const JsonSerializableConfig({
|
||||
required this.checked,
|
||||
required this.explicitToJson,
|
||||
required this.includeIfNull,
|
||||
});
|
||||
|
||||
final bool checked;
|
||||
final bool explicitToJson;
|
||||
final bool includeIfNull;
|
||||
}
|
||||
|
||||
class FormattingConfig {
|
||||
const FormattingConfig({
|
||||
required this.runDartFormat,
|
||||
required this.runDartFix,
|
||||
});
|
||||
|
||||
final bool runDartFormat;
|
||||
final bool runDartFix;
|
||||
}
|
||||
|
||||
class KoiSwaggerConfigLoader {
|
||||
static const List<String> _defaultNames = <String>[
|
||||
'koi_swagger_parser.yaml',
|
||||
'koi_swagger_parser.yml',
|
||||
'generator_config.yaml',
|
||||
'generator_config.yml',
|
||||
];
|
||||
|
||||
static Future<KoiSwaggerConfig> load({
|
||||
String? configPath,
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
final cwd = p.normalize(workingDirectory ?? Directory.current.path);
|
||||
final resolvedConfig = configPath == null
|
||||
? _findConfigFile(cwd)
|
||||
: _resolvePath(configPath, cwd);
|
||||
final configDir = resolvedConfig == null ? cwd : p.dirname(resolvedConfig);
|
||||
final rawConfig = resolvedConfig == null
|
||||
? <String, dynamic>{}
|
||||
: await _readConfigFile(resolvedConfig);
|
||||
|
||||
final output = rawConfig['output'] as Map<String, dynamic>?;
|
||||
final generation = rawConfig['generation'] as Map<String, dynamic>?;
|
||||
final preset = generation?['preset'] as Map<String, dynamic>?;
|
||||
final api = generation?['api'] as Map<String, dynamic>?;
|
||||
final models = generation?['models'] as Map<String, dynamic>?;
|
||||
final formatting = rawConfig['formatting'] as Map<String, dynamic>?;
|
||||
final responseWrapper = api?['response_wrapper'] as Map<String, dynamic>?;
|
||||
final externalGeneric =
|
||||
responseWrapper?['external_generic'] as Map<String, dynamic>?;
|
||||
final client = api?['client'] as Map<String, dynamic>?;
|
||||
final jsonSerializable =
|
||||
models?['json_serializable'] as Map<String, dynamic>?;
|
||||
|
||||
final baseDir = _resolvePath(
|
||||
output?['base_dir'] as String? ?? './lib/src',
|
||||
configDir,
|
||||
);
|
||||
final apiDir = _resolvePath(
|
||||
output?['api_dir'] as String? ?? p.join(baseDir, 'api'),
|
||||
configDir,
|
||||
);
|
||||
final modelsDir = _resolvePath(
|
||||
output?['models_dir'] as String? ?? p.join(baseDir, 'api_models'),
|
||||
configDir,
|
||||
);
|
||||
|
||||
final legacyBaseResultImport = api?['base_result_import'] as String?;
|
||||
final wrapperMode = _parseWrapperMode(
|
||||
responseWrapper?['mode'] as String?,
|
||||
legacyBaseResultImport: legacyBaseResultImport,
|
||||
);
|
||||
|
||||
return KoiSwaggerConfig(
|
||||
configPath: resolvedConfig,
|
||||
workingDirectory: cwd,
|
||||
input: InputConfig(
|
||||
swaggerUrls: _parseSwaggerUrls(
|
||||
rawConfig['input'] as Map<String, dynamic>?,
|
||||
configDir,
|
||||
),
|
||||
),
|
||||
output: OutputConfig(
|
||||
baseDir: baseDir,
|
||||
apiDir: apiDir,
|
||||
modelsDir: modelsDir,
|
||||
includedTags: _stringList(output?['included_tags']),
|
||||
excludedTags: _stringList(output?['excluded_tags']),
|
||||
splitByTags: output?['split_by_tags'] as bool? ?? true,
|
||||
extractVersionFromUrl:
|
||||
output?['extract_version_from_url'] as bool? ?? false,
|
||||
),
|
||||
generation: GenerationConfig(
|
||||
preset: GeneratorPresetRegistry.resolve(preset?['name'] as String?),
|
||||
api: ApiGenerationConfig(
|
||||
enabled: api?['enabled'] as bool? ?? true,
|
||||
useRetrofit: api?['use_retrofit'] as bool? ?? true,
|
||||
cancelTokenSupport: api?['cancel_token_support'] as bool? ?? false,
|
||||
client: ClientConfig(
|
||||
className: sanitizeIdentifier(
|
||||
client?['class_name'] as String? ?? 'ApiClient',
|
||||
fallback: 'ApiClient',
|
||||
),
|
||||
fileName: toSnakeCase(
|
||||
client?['file_name'] as String? ?? 'api_client',
|
||||
),
|
||||
),
|
||||
responseWrapper: ResponseWrapperConfig(
|
||||
mode: wrapperMode,
|
||||
successDataField:
|
||||
responseWrapper?['success_data_field'] as String? ?? 'data',
|
||||
detectStandardEnvelope:
|
||||
responseWrapper?['detect_standard_envelope'] as bool? ?? true,
|
||||
externalImport:
|
||||
externalGeneric?['import'] as String? ?? legacyBaseResultImport,
|
||||
externalClassName:
|
||||
externalGeneric?['class_name'] as String? ??
|
||||
(legacyBaseResultImport == null ? null : 'BaseResult'),
|
||||
),
|
||||
),
|
||||
models: ModelGenerationConfig(
|
||||
enabled: models?['enabled'] as bool? ?? true,
|
||||
classPrefix: sanitizeIdentifier(
|
||||
models?['class_prefix'] as String? ?? 'Koi',
|
||||
fallback: 'Koi',
|
||||
),
|
||||
defaultNonNullableListToEmpty:
|
||||
models?['default_non_nullable_list_to_empty'] as bool? ?? true,
|
||||
jsonSerializable: JsonSerializableConfig(
|
||||
checked: jsonSerializable?['checked'] as bool? ?? true,
|
||||
explicitToJson:
|
||||
jsonSerializable?['explicit_to_json'] as bool? ?? true,
|
||||
includeIfNull:
|
||||
jsonSerializable?['include_if_null'] as bool? ?? false,
|
||||
),
|
||||
),
|
||||
formatting: FormattingConfig(
|
||||
runDartFormat: formatting?['run_dart_format'] as bool? ?? true,
|
||||
runDartFix: formatting?['run_dart_fix'] as bool? ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> _readConfigFile(String path) async {
|
||||
final file = File(path);
|
||||
final exists = file.existsSync();
|
||||
if (exists == false) {
|
||||
throw FileSystemException('配置文件不存在。', path);
|
||||
}
|
||||
final yaml = loadYaml(await file.readAsString());
|
||||
return yamlToMap(yaml);
|
||||
}
|
||||
|
||||
static String? _findConfigFile(String cwd) {
|
||||
for (final name in _defaultNames) {
|
||||
final candidate = p.join(cwd, name);
|
||||
if (File(candidate).existsSync()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _resolvePath(String rawPath, String baseDir) {
|
||||
final trimmed = rawPath.trim();
|
||||
if (trimmed.startsWith('file://')) {
|
||||
return p.normalize(Uri.parse(trimmed).toFilePath());
|
||||
}
|
||||
if (p.isAbsolute(trimmed)) {
|
||||
return p.normalize(trimmed);
|
||||
}
|
||||
return p.normalize(p.join(baseDir, trimmed));
|
||||
}
|
||||
|
||||
static List<String> _parseSwaggerUrls(
|
||||
Map<String, dynamic>? input,
|
||||
String configDir,
|
||||
) {
|
||||
final sources = input?['swagger_urls'];
|
||||
if (sources is List) {
|
||||
final result = <String>[];
|
||||
for (final item in sources) {
|
||||
if (item is String && item.trim().isNotEmpty) {
|
||||
result.add(_normalizeSource(item, configDir));
|
||||
continue;
|
||||
}
|
||||
if (item is Map<String, dynamic>) {
|
||||
final enabled = item['enabled'] as bool? ?? true;
|
||||
final url = item['url'] as String?;
|
||||
final hasUrl = url?.trim().isNotEmpty ?? false;
|
||||
if (enabled && hasUrl) {
|
||||
result.add(_normalizeSource(url ?? '', configDir));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return <String>[];
|
||||
}
|
||||
|
||||
static String _normalizeSource(String raw, String configDir) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.startsWith('file://') || isHttpUrl(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return _resolvePath(trimmed, configDir);
|
||||
}
|
||||
|
||||
static List<String>? _stringList(dynamic value) {
|
||||
if (value is List) {
|
||||
final result = value
|
||||
.map((item) => item.toString().trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
return result.isEmpty ? null : result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static ResponseWrapperMode _parseWrapperMode(
|
||||
String? rawMode, {
|
||||
String? legacyBaseResultImport,
|
||||
}) {
|
||||
if (rawMode == null || rawMode.trim().isEmpty) {
|
||||
return legacyBaseResultImport == null
|
||||
? ResponseWrapperMode.passthrough
|
||||
: ResponseWrapperMode.externalGeneric;
|
||||
}
|
||||
switch (rawMode.trim()) {
|
||||
case 'passthrough':
|
||||
return ResponseWrapperMode.passthrough;
|
||||
case 'external_generic':
|
||||
return ResponseWrapperMode.externalGeneric;
|
||||
default:
|
||||
throw ArgumentError.value(
|
||||
rawMode,
|
||||
'response_wrapper.mode',
|
||||
'未知的 wrapper 模式。',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../config/config.dart';
|
||||
import '../ir/ir.dart';
|
||||
import '../util/utils.dart';
|
||||
|
||||
class DartGenerator {
|
||||
DartGenerator(this.config);
|
||||
|
||||
final KoiSwaggerConfig config;
|
||||
late Map<String, ModelSpec> _modelIndex;
|
||||
|
||||
GeneratedBundle generate(ParsedApiDocument document) {
|
||||
_modelIndex = document.models;
|
||||
final files = <GeneratedFile>[];
|
||||
|
||||
final modelFiles = <String, String>{};
|
||||
for (final model in document.models.values) {
|
||||
modelFiles[model.name] = p.join(
|
||||
_modelCategoryDirectory(model.category),
|
||||
'${_modelFileStem(model)}.dart',
|
||||
);
|
||||
}
|
||||
|
||||
if (config.generation.models.enabled) {
|
||||
for (final model in document.models.values) {
|
||||
final relativePath = modelFiles[model.name]!;
|
||||
final absolutePath = p.join(config.output.modelsDir, relativePath);
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: absolutePath,
|
||||
content: _renderModelFile(
|
||||
model,
|
||||
relativePath: relativePath,
|
||||
modelFiles: modelFiles,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
files.addAll(_buildModelIndexFiles(document.models.values, modelFiles));
|
||||
}
|
||||
|
||||
if (config.generation.api.enabled) {
|
||||
files.addAll(_buildApiFiles(document.operations));
|
||||
}
|
||||
|
||||
return GeneratedBundle(
|
||||
files: files,
|
||||
operationsCount: document.operations.length,
|
||||
modelsCount: document.models.length,
|
||||
);
|
||||
}
|
||||
|
||||
List<GeneratedFile> _buildModelIndexFiles(
|
||||
Iterable<ModelSpec> models,
|
||||
Map<String, String> modelFiles,
|
||||
) {
|
||||
final files = <GeneratedFile>[];
|
||||
final groups = <ModelCategory, List<ModelSpec>>{
|
||||
ModelCategory.request: <ModelSpec>[],
|
||||
ModelCategory.result: <ModelSpec>[],
|
||||
ModelCategory.shared: <ModelSpec>[],
|
||||
ModelCategory.enums: <ModelSpec>[],
|
||||
};
|
||||
|
||||
for (final model in models) {
|
||||
groups[model.category]!.add(model);
|
||||
}
|
||||
|
||||
for (final entry in groups.entries) {
|
||||
final exports =
|
||||
entry.value
|
||||
.map(
|
||||
(model) => "export '${p.basename(modelFiles[model.name]!)}';",
|
||||
)
|
||||
.toList(growable: false)
|
||||
..sort();
|
||||
|
||||
final directory = _modelCategoryDirectory(entry.key);
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.modelsDir, directory, 'index.dart'),
|
||||
content: _simpleLibraryFile(exports),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.modelsDir, 'api_models.dart'),
|
||||
content: _simpleLibraryFile(const <String>[
|
||||
"export 'request/index.dart';",
|
||||
"export 'result/index.dart';",
|
||||
"export 'shared/index.dart';",
|
||||
"export 'enums/index.dart';",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.modelsDir, 'index.dart'),
|
||||
content: _simpleLibraryFile(const <String>[
|
||||
"export 'api_models.dart';",
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
List<GeneratedFile> _buildApiFiles(List<ApiOperation> operations) {
|
||||
if (!config.generation.api.useRetrofit) {
|
||||
throw UnsupportedError('当前版本只支持 Retrofit API 生成。');
|
||||
}
|
||||
|
||||
final grouped = <String, Map<String, List<ApiOperation>>>{};
|
||||
for (final operation in operations) {
|
||||
final versionGroup = grouped.putIfAbsent(
|
||||
operation.version,
|
||||
() => <String, List<ApiOperation>>{},
|
||||
);
|
||||
final groupKey = config.output.splitByTags ? operation.tag : 'api';
|
||||
versionGroup.putIfAbsent(groupKey, () => <ApiOperation>[]).add(operation);
|
||||
}
|
||||
|
||||
final files = <GeneratedFile>[];
|
||||
final versionImports = <String>[];
|
||||
final clientFields = <String>[];
|
||||
|
||||
final sortedVersions = grouped.keys.toList()..sort();
|
||||
for (final version in sortedVersions) {
|
||||
final groups = grouped[version]!;
|
||||
final apiExports = <String>[];
|
||||
final sortedGroupKeys = groups.keys.toList()..sort();
|
||||
|
||||
for (final groupKey in sortedGroupKeys) {
|
||||
final apiFileName = _apiFileName(groupKey, version);
|
||||
final apiClassName = _apiClassName(groupKey, version);
|
||||
final absolutePath = p.join(config.output.apiDir, version, apiFileName);
|
||||
|
||||
final sortedOperations = groups[groupKey]!
|
||||
..sort((left, right) {
|
||||
final pathCompare = left.path.compareTo(right.path);
|
||||
if (pathCompare != 0) {
|
||||
return pathCompare;
|
||||
}
|
||||
return left.method.compareTo(right.method);
|
||||
});
|
||||
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: absolutePath,
|
||||
content: _renderApiFile(
|
||||
operations: sortedOperations,
|
||||
version: version,
|
||||
className: apiClassName,
|
||||
fileName: apiFileName,
|
||||
),
|
||||
),
|
||||
);
|
||||
apiExports.add("export '$apiFileName';");
|
||||
|
||||
final propertyName = _clientPropertyName(groupKey, version);
|
||||
clientFields.add(
|
||||
' late final $version.$apiClassName $propertyName = '
|
||||
'$version.$apiClassName(_dio, baseUrl: baseUrl);',
|
||||
);
|
||||
}
|
||||
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.apiDir, version, 'index.dart'),
|
||||
content: _simpleLibraryFile(apiExports..sort()),
|
||||
),
|
||||
);
|
||||
versionImports.add("import '$version/index.dart' as $version;");
|
||||
}
|
||||
|
||||
final clientFileName =
|
||||
'${toSnakeCase(config.generation.api.client.fileName)}.dart';
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.apiDir, clientFileName),
|
||||
content: _renderClientFile(
|
||||
versionImports: versionImports,
|
||||
clientFields: clientFields,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final rootExports = <String>[
|
||||
"export '$clientFileName';",
|
||||
for (final version in sortedVersions) "export '$version/index.dart';",
|
||||
];
|
||||
files.add(
|
||||
GeneratedFile(
|
||||
path: p.join(config.output.apiDir, 'index.dart'),
|
||||
content: _simpleLibraryFile(rootExports),
|
||||
),
|
||||
);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
String _renderModelFile(
|
||||
ModelSpec model, {
|
||||
required String relativePath,
|
||||
required Map<String, String> modelFiles,
|
||||
}) {
|
||||
switch (model.kind) {
|
||||
case ModelKind.enumType:
|
||||
return _renderEnumFile(model);
|
||||
case ModelKind.unionType:
|
||||
return _renderUnionFile(
|
||||
model,
|
||||
relativePath: relativePath,
|
||||
modelFiles: modelFiles,
|
||||
);
|
||||
case ModelKind.object:
|
||||
return _renderObjectModelFile(
|
||||
model,
|
||||
relativePath: relativePath,
|
||||
modelFiles: modelFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _renderObjectModelFile(
|
||||
ModelSpec model, {
|
||||
required String relativePath,
|
||||
required Map<String, String> modelFiles,
|
||||
}) {
|
||||
final currentDir = p.dirname(relativePath);
|
||||
final candidateNames = modelFiles.keys.toSet();
|
||||
final imports = <String>{};
|
||||
for (final field in model.fields) {
|
||||
final referencedNames = field.type.collectReferencedNames(candidateNames)
|
||||
..remove(model.name);
|
||||
for (final referencedName in referencedNames) {
|
||||
final targetPath = modelFiles[referencedName]!;
|
||||
final importPath = p
|
||||
.relative(targetPath, from: currentDir)
|
||||
.replaceAll('\\', '/');
|
||||
imports.add("import '$importPath';");
|
||||
}
|
||||
}
|
||||
|
||||
final jsonSerializable = config.generation.models.jsonSerializable;
|
||||
final fieldLines = <String>[];
|
||||
for (final field in model.fields) {
|
||||
final description = field.description;
|
||||
if (description != null && description.trim().isNotEmpty) {
|
||||
fieldLines.addAll(_docComment(description, prefix: ' '));
|
||||
}
|
||||
|
||||
final annotations = <String>[];
|
||||
if (field.name != field.wireName) {
|
||||
annotations.add(
|
||||
"@JsonKey(name: '${escapeDartString(field.wireName)}')",
|
||||
);
|
||||
}
|
||||
|
||||
final defaultValue = _defaultExpressionFor(field);
|
||||
if (defaultValue != null) {
|
||||
annotations.add('@Default($defaultValue)');
|
||||
}
|
||||
|
||||
for (final annotation in annotations) {
|
||||
fieldLines.add(' $annotation');
|
||||
}
|
||||
|
||||
final parameterPrefix = defaultValue == null && field.required
|
||||
? 'required '
|
||||
: '';
|
||||
fieldLines.add(
|
||||
' $parameterPrefix${field.type.render()} ${field.name},',
|
||||
);
|
||||
}
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln('// ignore_for_file: type=lint, invalid_annotation_target')
|
||||
..writeln()
|
||||
..writeln("import 'package:freezed_annotation/freezed_annotation.dart';");
|
||||
|
||||
if (imports.isNotEmpty) {
|
||||
buffer.writeln();
|
||||
final sortedImports = imports.toList()..sort();
|
||||
for (final import in sortedImports) {
|
||||
buffer.writeln(import);
|
||||
}
|
||||
}
|
||||
|
||||
final fileStem = p.basenameWithoutExtension(relativePath);
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("part '$fileStem.freezed.dart';")
|
||||
..writeln("part '$fileStem.g.dart';")
|
||||
..writeln();
|
||||
|
||||
if (model.description != null && model.description!.trim().isNotEmpty) {
|
||||
for (final line in _docComment(model.description!)) {
|
||||
buffer.writeln(line);
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln('@freezed')
|
||||
..writeln('abstract class ${model.name} with _\$${model.name} {')
|
||||
..writeln(
|
||||
' @JsonSerializable('
|
||||
'checked: ${jsonSerializable.checked}, '
|
||||
'explicitToJson: ${jsonSerializable.explicitToJson}, '
|
||||
'includeIfNull: ${jsonSerializable.includeIfNull})',
|
||||
)
|
||||
..writeln(' const factory ${model.name}({');
|
||||
|
||||
for (final line in fieldLines) {
|
||||
buffer.writeln(line);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(' }) = _${model.name};')
|
||||
..writeln()
|
||||
..writeln(
|
||||
' factory ${model.name}.fromJson(Map<String, dynamic> json) => '
|
||||
'_\$${model.name}FromJson(json);',
|
||||
)
|
||||
..writeln('}');
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderUnionFile(
|
||||
ModelSpec model, {
|
||||
required String relativePath,
|
||||
required Map<String, String> modelFiles,
|
||||
}) {
|
||||
final currentDir = p.dirname(relativePath);
|
||||
final candidateNames = modelFiles.keys.toSet();
|
||||
final imports = <String>{};
|
||||
for (final unionCase in model.unionCases) {
|
||||
final referencedNames = unionCase.type.collectReferencedNames(
|
||||
candidateNames,
|
||||
)..remove(model.name);
|
||||
for (final referencedName in referencedNames) {
|
||||
final targetPath = modelFiles[referencedName]!;
|
||||
final importPath = p
|
||||
.relative(targetPath, from: currentDir)
|
||||
.replaceAll('\\', '/');
|
||||
imports.add("import '$importPath';");
|
||||
}
|
||||
}
|
||||
|
||||
final discriminatorKey = model.discriminatorKey ?? 'type';
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln();
|
||||
|
||||
if (imports.isNotEmpty) {
|
||||
final sortedImports = imports.toList()..sort();
|
||||
for (final import in sortedImports) {
|
||||
buffer.writeln(import);
|
||||
}
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
if (model.description != null && model.description!.trim().isNotEmpty) {
|
||||
for (final line in _docComment(model.description!)) {
|
||||
buffer.writeln(line);
|
||||
}
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln('sealed class ${model.name} {')
|
||||
..writeln(' const ${model.name}();')
|
||||
..writeln()
|
||||
..writeln(' factory ${model.name}.fromJson(Map<String, dynamic> json) {')
|
||||
..writeln(
|
||||
" final discriminator = json['${escapeDartString(discriminatorKey)}'];",
|
||||
)
|
||||
..writeln(' switch (discriminator) {');
|
||||
|
||||
for (final unionCase in model.unionCases) {
|
||||
buffer
|
||||
..writeln(' case ${_dartLiteral(unionCase.wireValue)}:')
|
||||
..writeln(
|
||||
' return ${model.name}${unionCase.caseName}('
|
||||
'${unionCase.type.render()}.fromJson(json));',
|
||||
);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln(' default:')
|
||||
..writeln(
|
||||
" throw ArgumentError.value(discriminator, '$discriminatorKey', 'Unsupported discriminator for ${model.name}.');",
|
||||
)
|
||||
..writeln(' }')
|
||||
..writeln(' }')
|
||||
..writeln()
|
||||
..writeln(' Map<String, dynamic> toJson();')
|
||||
..writeln('}')
|
||||
..writeln();
|
||||
|
||||
for (final unionCase in model.unionCases) {
|
||||
buffer
|
||||
..writeln(
|
||||
'final class ${model.name}${unionCase.caseName} extends ${model.name} {',
|
||||
)
|
||||
..writeln(' const ${model.name}${unionCase.caseName}(this.value);')
|
||||
..writeln()
|
||||
..writeln(' final ${unionCase.type.render()} value;')
|
||||
..writeln()
|
||||
..writeln(' @override')
|
||||
..writeln(' Map<String, dynamic> toJson() => value.toJson();')
|
||||
..writeln('}')
|
||||
..writeln();
|
||||
}
|
||||
|
||||
return '${buffer.toString().trimRight()}\n';
|
||||
}
|
||||
|
||||
String _renderEnumFile(ModelSpec model) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln()
|
||||
..writeln("import 'package:json_annotation/json_annotation.dart';")
|
||||
..writeln();
|
||||
|
||||
if (model.description != null && model.description!.trim().isNotEmpty) {
|
||||
for (final line in _docComment(model.description!)) {
|
||||
buffer.writeln(line);
|
||||
}
|
||||
}
|
||||
|
||||
buffer.writeln('enum ${model.name} {');
|
||||
for (final value in model.enumValues) {
|
||||
buffer.writeln(' @JsonValue(${_dartLiteral(value.rawValue)})');
|
||||
buffer.writeln(' ${value.name},');
|
||||
}
|
||||
buffer.writeln('}');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderApiFile({
|
||||
required List<ApiOperation> operations,
|
||||
required String version,
|
||||
required String className,
|
||||
required String fileName,
|
||||
}) {
|
||||
final currentDir = p.join(config.output.apiDir, version);
|
||||
final modelsIndexPath = p
|
||||
.relative(
|
||||
p.join(config.output.modelsDir, 'api_models.dart'),
|
||||
from: currentDir,
|
||||
)
|
||||
.replaceAll('\\', '/');
|
||||
|
||||
final imports = <String>[
|
||||
if (operations.any(
|
||||
(operation) => _operationUsesNamedType(operation, 'File'),
|
||||
))
|
||||
"import 'dart:io';",
|
||||
if (operations.any(
|
||||
(operation) => _operationUsesNamedType(operation, 'Uint8List'),
|
||||
))
|
||||
"import 'dart:typed_data';",
|
||||
"import 'package:dio/dio.dart';",
|
||||
"import 'package:retrofit/retrofit.dart';",
|
||||
"import '$modelsIndexPath';",
|
||||
];
|
||||
|
||||
final wrapperImport = config.generation.api.responseWrapper.externalImport;
|
||||
if (config.generation.api.responseWrapper.mode ==
|
||||
ResponseWrapperMode.externalGeneric &&
|
||||
wrapperImport != null &&
|
||||
wrapperImport.isNotEmpty) {
|
||||
imports.add("import '$wrapperImport';");
|
||||
}
|
||||
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln();
|
||||
final sortedImports = imports.toList()..sort();
|
||||
for (final import in sortedImports) {
|
||||
buffer.writeln(import);
|
||||
}
|
||||
|
||||
final partStem = p.basenameWithoutExtension(fileName);
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln("part '$partStem.g.dart';")
|
||||
..writeln()
|
||||
..writeln('@RestApi(parser: Parser.JsonSerializable)')
|
||||
..writeln('abstract class $className {')
|
||||
..writeln(
|
||||
' factory $className(Dio dio, {String? baseUrl}) = _$className;',
|
||||
)
|
||||
..writeln();
|
||||
|
||||
for (final operation in operations) {
|
||||
if (operation.summary != null && operation.summary!.trim().isNotEmpty) {
|
||||
for (final line in _docComment(operation.summary!)) {
|
||||
buffer.writeln(' $line');
|
||||
}
|
||||
}
|
||||
if (operation.description != null &&
|
||||
operation.description!.trim().isNotEmpty &&
|
||||
operation.description != operation.summary) {
|
||||
for (final line in _docComment(operation.description!)) {
|
||||
buffer.writeln(' $line');
|
||||
}
|
||||
}
|
||||
if (operation.deprecated) {
|
||||
buffer.writeln(" @Deprecated('Deprecated in OpenAPI spec.')");
|
||||
}
|
||||
if (operation.requestBody?.kind == ApiRequestBodyKind.multipart) {
|
||||
buffer.writeln(' @MultiPart()');
|
||||
}
|
||||
buffer
|
||||
..writeln(" @${operation.method}('${operation.path}')")
|
||||
..writeln(
|
||||
' Future<${operation.returnType.render()}> '
|
||||
'${operation.methodName}(${_renderApiParameters(operation)});',
|
||||
)
|
||||
..writeln();
|
||||
}
|
||||
|
||||
buffer.writeln('}');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderApiParameters(ApiOperation operation) {
|
||||
final parameters = <String>[];
|
||||
for (final parameter in operation.parameters) {
|
||||
final annotation = switch (parameter.location) {
|
||||
'path' => "@Path('${escapeDartString(parameter.wireName)}')",
|
||||
'header' => "@Header('${escapeDartString(parameter.wireName)}')",
|
||||
_ => "@Query('${escapeDartString(parameter.wireName)}')",
|
||||
};
|
||||
final defaultValue = _defaultExpressionForType(
|
||||
parameter.defaultValue,
|
||||
parameter.type,
|
||||
);
|
||||
final requiredPrefix = parameter.required ? 'required ' : '';
|
||||
final defaultSuffix = defaultValue == null ? '' : ' = $defaultValue';
|
||||
parameters.add(
|
||||
'$annotation $requiredPrefix${parameter.type.render()} ${parameter.name}$defaultSuffix',
|
||||
);
|
||||
}
|
||||
|
||||
final requestBody = operation.requestBody;
|
||||
if (requestBody != null) {
|
||||
if (requestBody.kind == ApiRequestBodyKind.multipart) {
|
||||
for (final field in requestBody.multipartFields) {
|
||||
parameters.add(_renderMultipartParameter(field));
|
||||
}
|
||||
} else {
|
||||
final requiredPrefix = requestBody.required ? 'required ' : '';
|
||||
final bodyType = requestBody.type?.render() ?? 'Object';
|
||||
parameters.add(
|
||||
'@Body() $requiredPrefix$bodyType ${requestBody.parameterName}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.generation.api.cancelTokenSupport) {
|
||||
parameters.add('@CancelRequest() CancelToken? cancelToken');
|
||||
}
|
||||
|
||||
if (parameters.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final buffer = StringBuffer()..writeln('{');
|
||||
for (final parameter in parameters) {
|
||||
buffer.writeln(' $parameter,');
|
||||
}
|
||||
buffer.write(' }');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _renderMultipartParameter(ApiMultipartField field) {
|
||||
final contentType = field.contentType;
|
||||
final annotation = contentType == null
|
||||
? "@Part(name: '${escapeDartString(field.wireName)}')"
|
||||
: "@Part(name: '${escapeDartString(field.wireName)}', contentType: '${escapeDartString(contentType)}')";
|
||||
final requiredPrefix = field.required ? 'required ' : '';
|
||||
return '$annotation $requiredPrefix${field.type.render()} ${field.name}';
|
||||
}
|
||||
|
||||
bool _operationUsesNamedType(ApiOperation operation, String typeName) {
|
||||
for (final parameter in operation.parameters) {
|
||||
if (_typeUsesNamed(parameter.type, typeName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final requestBody = operation.requestBody;
|
||||
if (requestBody == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final bodyType = requestBody.type;
|
||||
if (bodyType != null && _typeUsesNamed(bodyType, typeName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (final field in requestBody.multipartFields) {
|
||||
if (_typeUsesNamed(field.type, typeName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _typeUsesNamed(DartType type, String typeName) {
|
||||
if (type.kind == TypeKind.named && type.name == typeName) {
|
||||
return true;
|
||||
}
|
||||
for (final argument in type.arguments) {
|
||||
if (_typeUsesNamed(argument, typeName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String _renderClientFile({
|
||||
required List<String> versionImports,
|
||||
required List<String> clientFields,
|
||||
}) {
|
||||
final clientClassName = config.generation.api.client.className;
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln()
|
||||
..writeln("import 'package:dio/dio.dart';")
|
||||
..writeln();
|
||||
|
||||
final sortedImports = versionImports.toList()..sort();
|
||||
for (final import in sortedImports) {
|
||||
buffer.writeln(import);
|
||||
}
|
||||
|
||||
buffer
|
||||
..writeln()
|
||||
..writeln('class $clientClassName {')
|
||||
..writeln(' $clientClassName(this._dio, {this.baseUrl});')
|
||||
..writeln()
|
||||
..writeln(' final Dio _dio;')
|
||||
..writeln(' final String? baseUrl;')
|
||||
..writeln();
|
||||
|
||||
final sortedFields = clientFields.toList()..sort();
|
||||
for (final field in sortedFields) {
|
||||
buffer.writeln(field);
|
||||
}
|
||||
|
||||
buffer.writeln('}');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String? _defaultExpressionFor(ModelField field) {
|
||||
final explicitDefault = _defaultExpressionForType(
|
||||
field.defaultValue,
|
||||
field.type,
|
||||
);
|
||||
if (explicitDefault != null) {
|
||||
return explicitDefault;
|
||||
}
|
||||
|
||||
if (config.generation.models.defaultNonNullableListToEmpty &&
|
||||
field.required &&
|
||||
field.type.kind == TypeKind.list) {
|
||||
return '<${field.type.arguments.first.render()}>[]';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _defaultExpressionForType(Object? value, DartType type) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type.kind == TypeKind.named) {
|
||||
final model = _modelIndex[type.name];
|
||||
if (model != null && model.kind == ModelKind.enumType) {
|
||||
for (final enumValue in model.enumValues) {
|
||||
if (enumValue.rawValue == value) {
|
||||
return '${type.name}.${enumValue.name}';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _dartLiteral(value);
|
||||
}
|
||||
|
||||
String _dartLiteral(Object? value) {
|
||||
if (value == null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value is String) {
|
||||
return "'${escapeDartString(value)}'";
|
||||
}
|
||||
if (value is bool || value is num) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value is List) {
|
||||
final items = value.map(_dartLiteral).join(', ');
|
||||
return '[$items]';
|
||||
}
|
||||
if (value is Map) {
|
||||
final entries = value.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
'${_dartLiteral(entry.key)}: ${_dartLiteral(entry.value)}',
|
||||
)
|
||||
.join(', ');
|
||||
return '{$entries}';
|
||||
}
|
||||
return "'${escapeDartString(value.toString())}'";
|
||||
}
|
||||
|
||||
String _simpleLibraryFile(List<String> exports) {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.')
|
||||
..writeln()
|
||||
..writeln('library;')
|
||||
..writeln();
|
||||
for (final export in exports) {
|
||||
buffer.writeln(export);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
List<String> _docComment(String text, {String prefix = ''}) {
|
||||
return text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => '$prefix/// ${line.trim()}')
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _modelCategoryDirectory(ModelCategory category) {
|
||||
return switch (category) {
|
||||
ModelCategory.request => 'request',
|
||||
ModelCategory.result => 'result',
|
||||
ModelCategory.shared => 'shared',
|
||||
ModelCategory.enums => 'enums',
|
||||
};
|
||||
}
|
||||
|
||||
String _modelFileStem(ModelSpec model) {
|
||||
final prefix = config.generation.models.classPrefix;
|
||||
final trimmed = model.name.startsWith(prefix)
|
||||
? model.name.substring(prefix.length)
|
||||
: model.name;
|
||||
final candidate = trimmed.isEmpty ? model.name : trimmed;
|
||||
return toSnakeCase(candidate);
|
||||
}
|
||||
|
||||
String _apiFileName(String groupKey, String version) {
|
||||
if (config.output.splitByTags) {
|
||||
return '${toSnakeCase(groupKey)}_api.dart';
|
||||
}
|
||||
return '${toSnakeCase(version)}_api.dart';
|
||||
}
|
||||
|
||||
String _apiClassName(String groupKey, String version) {
|
||||
if (config.output.splitByTags) {
|
||||
return '${toPascalCase(groupKey)}Api';
|
||||
}
|
||||
final base = config.generation.api.client.className;
|
||||
return version == 'v1' ? '${base}Api' : '$base${toPascalCase(version)}Api';
|
||||
}
|
||||
|
||||
String _clientPropertyName(String groupKey, String version) {
|
||||
final base = config.output.splitByTags
|
||||
? toCamelCase(groupKey)
|
||||
: toCamelCase(version);
|
||||
return version == 'v1' || !config.output.splitByTags
|
||||
? base
|
||||
: '$base${toPascalCase(version)}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
enum ModelKind { object, enumType, unionType }
|
||||
|
||||
enum ModelCategory { request, result, shared, enums }
|
||||
|
||||
enum UsageKind { request, result, shared }
|
||||
|
||||
enum TypeKind { named, list, map, dynamicType, objectType, voidType }
|
||||
|
||||
enum ApiRequestBodyKind { json, multipart }
|
||||
|
||||
class DartType {
|
||||
const DartType.named(
|
||||
this.name, {
|
||||
this.nullable = false,
|
||||
this.arguments = const <DartType>[],
|
||||
}) : kind = TypeKind.named;
|
||||
|
||||
DartType.list(DartType itemType, {this.nullable = false})
|
||||
: kind = TypeKind.list,
|
||||
name = 'List',
|
||||
arguments = <DartType>[itemType];
|
||||
|
||||
DartType.map(DartType valueType, {this.nullable = false})
|
||||
: kind = TypeKind.map,
|
||||
name = 'Map',
|
||||
arguments = <DartType>[const DartType.named('String'), valueType];
|
||||
|
||||
const DartType.dynamicType({this.nullable = false})
|
||||
: kind = TypeKind.dynamicType,
|
||||
name = 'dynamic',
|
||||
arguments = const <DartType>[];
|
||||
|
||||
const DartType.objectType({this.nullable = false})
|
||||
: kind = TypeKind.objectType,
|
||||
name = 'Object',
|
||||
arguments = const <DartType>[];
|
||||
|
||||
const DartType.voidType()
|
||||
: kind = TypeKind.voidType,
|
||||
name = 'void',
|
||||
arguments = const <DartType>[],
|
||||
nullable = false;
|
||||
|
||||
final TypeKind kind;
|
||||
final String name;
|
||||
final List<DartType> arguments;
|
||||
final bool nullable;
|
||||
|
||||
DartType asNullable() {
|
||||
if (nullable || kind == TypeKind.voidType) {
|
||||
return this;
|
||||
}
|
||||
switch (kind) {
|
||||
case TypeKind.named:
|
||||
return DartType.named(name, nullable: true, arguments: arguments);
|
||||
case TypeKind.list:
|
||||
return DartType.list(arguments.first, nullable: true);
|
||||
case TypeKind.map:
|
||||
return DartType.map(arguments.last, nullable: true);
|
||||
case TypeKind.dynamicType:
|
||||
return const DartType.dynamicType(nullable: true);
|
||||
case TypeKind.objectType:
|
||||
return const DartType.objectType(nullable: true);
|
||||
case TypeKind.voidType:
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isCollection => kind == TypeKind.list || kind == TypeKind.map;
|
||||
|
||||
String render() {
|
||||
final suffix = nullable ? '?' : '';
|
||||
return switch (kind) {
|
||||
TypeKind.named =>
|
||||
arguments.isEmpty
|
||||
? '$name$suffix'
|
||||
: '$name<${arguments.map((item) => item.render()).join(', ')}>$suffix',
|
||||
TypeKind.list => 'List<${arguments.first.render()}>$suffix',
|
||||
TypeKind.map => 'Map<String, ${arguments.last.render()}>$suffix',
|
||||
TypeKind.dynamicType => 'dynamic',
|
||||
TypeKind.objectType => nullable ? 'Object?' : 'Object',
|
||||
TypeKind.voidType => 'void',
|
||||
};
|
||||
}
|
||||
|
||||
Set<String> collectReferencedNames(Set<String> candidates) {
|
||||
final result = <String>{};
|
||||
if (kind == TypeKind.named && candidates.contains(name)) {
|
||||
result.add(name);
|
||||
}
|
||||
for (final argument in arguments) {
|
||||
result.addAll(argument.collectReferencedNames(candidates));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class ModelField {
|
||||
const ModelField({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
required this.type,
|
||||
required this.required,
|
||||
this.description,
|
||||
this.defaultValue,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String wireName;
|
||||
final DartType type;
|
||||
final bool required;
|
||||
final String? description;
|
||||
final Object? defaultValue;
|
||||
}
|
||||
|
||||
class EnumValue {
|
||||
const EnumValue({required this.name, required this.rawValue});
|
||||
|
||||
final String name;
|
||||
final Object rawValue;
|
||||
}
|
||||
|
||||
class UnionCase {
|
||||
const UnionCase({
|
||||
required this.caseName,
|
||||
required this.wireValue,
|
||||
required this.type,
|
||||
required this.originalName,
|
||||
});
|
||||
|
||||
final String caseName;
|
||||
final String wireValue;
|
||||
final DartType type;
|
||||
final String originalName;
|
||||
}
|
||||
|
||||
class ModelSpec {
|
||||
ModelSpec({
|
||||
required this.name,
|
||||
required this.originalName,
|
||||
required this.kind,
|
||||
List<ModelField>? fields,
|
||||
List<EnumValue>? enumValues,
|
||||
List<UnionCase>? unionCases,
|
||||
this.discriminatorKey,
|
||||
this.description,
|
||||
}) : fields = fields ?? <ModelField>[],
|
||||
enumValues = enumValues ?? <EnumValue>[],
|
||||
unionCases = unionCases ?? <UnionCase>[],
|
||||
usages = <UsageKind>{};
|
||||
|
||||
final String name;
|
||||
final String originalName;
|
||||
final ModelKind kind;
|
||||
final List<ModelField> fields;
|
||||
final List<EnumValue> enumValues;
|
||||
final List<UnionCase> unionCases;
|
||||
final String? discriminatorKey;
|
||||
final String? description;
|
||||
final Set<UsageKind> usages;
|
||||
|
||||
ModelCategory get category {
|
||||
if (kind == ModelKind.enumType) {
|
||||
return ModelCategory.enums;
|
||||
}
|
||||
if (usages.contains(UsageKind.shared) ||
|
||||
(usages.contains(UsageKind.request) &&
|
||||
usages.contains(UsageKind.result))) {
|
||||
return ModelCategory.shared;
|
||||
}
|
||||
if (usages.contains(UsageKind.result)) {
|
||||
return ModelCategory.result;
|
||||
}
|
||||
if (usages.contains(UsageKind.request)) {
|
||||
return ModelCategory.request;
|
||||
}
|
||||
return ModelCategory.shared;
|
||||
}
|
||||
}
|
||||
|
||||
class ApiParameter {
|
||||
const ApiParameter({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
required this.location,
|
||||
required this.type,
|
||||
required this.required,
|
||||
this.description,
|
||||
this.defaultValue,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String wireName;
|
||||
final String location;
|
||||
final DartType type;
|
||||
final bool required;
|
||||
final String? description;
|
||||
final Object? defaultValue;
|
||||
}
|
||||
|
||||
class ApiMultipartField {
|
||||
const ApiMultipartField({
|
||||
required this.name,
|
||||
required this.wireName,
|
||||
required this.type,
|
||||
required this.required,
|
||||
this.description,
|
||||
this.contentType,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String wireName;
|
||||
final DartType type;
|
||||
final bool required;
|
||||
final String? description;
|
||||
final String? contentType;
|
||||
}
|
||||
|
||||
class ApiRequestBody {
|
||||
const ApiRequestBody({
|
||||
required this.kind,
|
||||
required this.required,
|
||||
required this.parameterName,
|
||||
this.type,
|
||||
this.contentType,
|
||||
this.multipartFields = const <ApiMultipartField>[],
|
||||
});
|
||||
|
||||
final ApiRequestBodyKind kind;
|
||||
final DartType? type;
|
||||
final bool required;
|
||||
final String parameterName;
|
||||
final String? contentType;
|
||||
final List<ApiMultipartField> multipartFields;
|
||||
}
|
||||
|
||||
class ApiOperation {
|
||||
const ApiOperation({
|
||||
required this.path,
|
||||
required this.method,
|
||||
required this.tag,
|
||||
required this.version,
|
||||
required this.methodName,
|
||||
required this.returnType,
|
||||
required this.parameters,
|
||||
this.requestBody,
|
||||
this.summary,
|
||||
this.description,
|
||||
this.deprecated = false,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String method;
|
||||
final String tag;
|
||||
final String version;
|
||||
final String methodName;
|
||||
final DartType returnType;
|
||||
final List<ApiParameter> parameters;
|
||||
final ApiRequestBody? requestBody;
|
||||
final String? summary;
|
||||
final String? description;
|
||||
final bool deprecated;
|
||||
}
|
||||
|
||||
class ParsedApiDocument {
|
||||
const ParsedApiDocument({
|
||||
required this.models,
|
||||
required this.operations,
|
||||
required this.sourceDocuments,
|
||||
});
|
||||
|
||||
final Map<String, ModelSpec> models;
|
||||
final List<ApiOperation> operations;
|
||||
final List<String> sourceDocuments;
|
||||
}
|
||||
|
||||
class GeneratedFile {
|
||||
const GeneratedFile({required this.path, required this.content});
|
||||
|
||||
final String path;
|
||||
final String content;
|
||||
}
|
||||
|
||||
class GeneratedBundle {
|
||||
const GeneratedBundle({
|
||||
required this.files,
|
||||
required this.operationsCount,
|
||||
required this.modelsCount,
|
||||
});
|
||||
|
||||
final List<GeneratedFile> files;
|
||||
final int operationsCount;
|
||||
final int modelsCount;
|
||||
}
|
||||
|
||||
class GenerationResult {
|
||||
const GenerationResult({
|
||||
required this.configPath,
|
||||
required this.sourceDocuments,
|
||||
required this.writtenFiles,
|
||||
required this.operationsCount,
|
||||
required this.modelsCount,
|
||||
required this.dryRun,
|
||||
});
|
||||
|
||||
final String? configPath;
|
||||
final List<String> sourceDocuments;
|
||||
final List<String> writtenFiles;
|
||||
final int operationsCount;
|
||||
final int modelsCount;
|
||||
final bool dryRun;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'config/config.dart';
|
||||
import 'generator/generator.dart';
|
||||
import 'ir/ir.dart';
|
||||
import 'openapi/openapi.dart';
|
||||
|
||||
class KoiSwaggerParser {
|
||||
const KoiSwaggerParser();
|
||||
|
||||
Future<GenerationResult> generate({
|
||||
String? configPath,
|
||||
String? workingDirectory,
|
||||
bool dryRun = false,
|
||||
bool verbose = false,
|
||||
}) async {
|
||||
final config = await KoiSwaggerConfig.load(
|
||||
configPath: configPath,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
if (config.input.swaggerUrls.isEmpty) {
|
||||
throw StateError('没有可用的 OpenAPI 文档源,请检查配置文件的 input.swagger_urls。');
|
||||
}
|
||||
|
||||
final loader = OpenApiDocumentLoader();
|
||||
final loaded = await loader.load(config.input.swaggerUrls);
|
||||
if (verbose) {
|
||||
stdout.writeln(
|
||||
'[koi_swagger_parser] loaded ${loaded.sources.length} source(s).',
|
||||
);
|
||||
}
|
||||
|
||||
final parser = OpenApiDocumentParser(config);
|
||||
final document = parser.parse(loaded);
|
||||
if (verbose) {
|
||||
stdout.writeln(
|
||||
'[koi_swagger_parser] parsed '
|
||||
'${document.operations.length} operation(s), '
|
||||
'${document.models.length} model(s).',
|
||||
);
|
||||
}
|
||||
|
||||
final generator = DartGenerator(config);
|
||||
final bundle = generator.generate(document);
|
||||
final filePaths = bundle.files
|
||||
.map((file) => file.path)
|
||||
.toList(growable: false);
|
||||
|
||||
if (!dryRun) {
|
||||
for (final file in bundle.files) {
|
||||
final ioFile = File(file.path);
|
||||
ioFile.parent.createSync(recursive: true);
|
||||
ioFile.writeAsStringSync(file.content);
|
||||
}
|
||||
|
||||
if (config.generation.formatting.runDartFormat) {
|
||||
await _runFormatter(<String>[
|
||||
config.output.apiDir,
|
||||
config.output.modelsDir,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return GenerationResult(
|
||||
configPath: config.configPath,
|
||||
sourceDocuments: loaded.sources,
|
||||
writtenFiles: filePaths,
|
||||
operationsCount: bundle.operationsCount,
|
||||
modelsCount: bundle.modelsCount,
|
||||
dryRun: dryRun,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runFormatter(List<String> targets) async {
|
||||
final uniqueTargets = targets.toSet().toList(growable: false);
|
||||
final result = await Process.run(Platform.resolvedExecutable, <String>[
|
||||
'format',
|
||||
...uniqueTargets,
|
||||
]);
|
||||
if (result.exitCode != 0) {
|
||||
throw ProcessException(
|
||||
Platform.resolvedExecutable,
|
||||
<String>['format', ...uniqueTargets],
|
||||
result.stderr.toString(),
|
||||
result.exitCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
enum BinaryTransportStyle { multipartFile, file, bytes, string }
|
||||
|
||||
class GeneratorPreset {
|
||||
const GeneratorPreset({
|
||||
required this.name,
|
||||
required this.multipartBinaryStyle,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final BinaryTransportStyle multipartBinaryStyle;
|
||||
final String description;
|
||||
}
|
||||
|
||||
class GeneratorPresetRegistry {
|
||||
static const GeneratorPreset koiFlutter = GeneratorPreset(
|
||||
name: 'koi_flutter',
|
||||
multipartBinaryStyle: BinaryTransportStyle.multipartFile,
|
||||
description:
|
||||
'Koi Flutter preset,面向 Retrofit + Dio + Freezed 场景,'
|
||||
'multipart 二进制字段默认生成为 MultipartFile。',
|
||||
);
|
||||
|
||||
static GeneratorPreset resolve(String? rawName) {
|
||||
final name = rawName?.trim();
|
||||
if (name == null || name.isEmpty || name == koiFlutter.name) {
|
||||
return koiFlutter;
|
||||
}
|
||||
|
||||
throw ArgumentError.value(name, 'generation.preset.name', '未知的 preset。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
const Set<String> dartKeywords = {
|
||||
'abstract',
|
||||
'as',
|
||||
'assert',
|
||||
'async',
|
||||
'await',
|
||||
'base',
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'covariant',
|
||||
'default',
|
||||
'deferred',
|
||||
'do',
|
||||
'dynamic',
|
||||
'else',
|
||||
'enum',
|
||||
'export',
|
||||
'extends',
|
||||
'extension',
|
||||
'external',
|
||||
'factory',
|
||||
'false',
|
||||
'final',
|
||||
'finally',
|
||||
'for',
|
||||
'Function',
|
||||
'get',
|
||||
'hide',
|
||||
'if',
|
||||
'implements',
|
||||
'import',
|
||||
'in',
|
||||
'interface',
|
||||
'is',
|
||||
'late',
|
||||
'library',
|
||||
'mixin',
|
||||
'new',
|
||||
'null',
|
||||
'of',
|
||||
'on',
|
||||
'operator',
|
||||
'part',
|
||||
'required',
|
||||
'rethrow',
|
||||
'return',
|
||||
'sealed',
|
||||
'set',
|
||||
'show',
|
||||
'static',
|
||||
'super',
|
||||
'switch',
|
||||
'sync',
|
||||
'this',
|
||||
'throw',
|
||||
'true',
|
||||
'try',
|
||||
'typedef',
|
||||
'var',
|
||||
'void',
|
||||
'when',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
};
|
||||
|
||||
final RegExp _leadingAcronymBoundary = RegExp(r'([A-Z]+)([A-Z][a-z])');
|
||||
final RegExp _camelBoundary = RegExp(r'([a-z0-9])([A-Z])');
|
||||
final RegExp _nonWordChars = RegExp(r'[^A-Za-z0-9]+');
|
||||
final RegExp _multiSpace = RegExp(r'\s+');
|
||||
const List<String> _knownSnakeCaseAcronyms = <String>[
|
||||
'api',
|
||||
'http',
|
||||
'https',
|
||||
'iot',
|
||||
'jwt',
|
||||
'mqtt',
|
||||
'uri',
|
||||
'url',
|
||||
'uuid',
|
||||
];
|
||||
|
||||
List<String> splitWords(String input) {
|
||||
final normalized = input
|
||||
.replaceAllMapped(
|
||||
_leadingAcronymBoundary,
|
||||
(match) => '${match.group(1)} ${match.group(2)}',
|
||||
)
|
||||
.replaceAllMapped(
|
||||
_camelBoundary,
|
||||
(match) => '${match.group(1)} ${match.group(2)}',
|
||||
)
|
||||
.replaceAll(_nonWordChars, ' ')
|
||||
.trim();
|
||||
|
||||
if (normalized.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(_multiSpace)
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String toPascalCase(String input) {
|
||||
final words = splitWords(input);
|
||||
if (words.isEmpty) {
|
||||
return 'GeneratedValue';
|
||||
}
|
||||
|
||||
final value = words
|
||||
.map(
|
||||
(word) =>
|
||||
word.substring(0, 1).toUpperCase() +
|
||||
word.substring(1).toLowerCase(),
|
||||
)
|
||||
.join();
|
||||
return sanitizeIdentifier(value, fallback: 'GeneratedValue');
|
||||
}
|
||||
|
||||
String toCamelCase(String input) {
|
||||
final pascal = toPascalCase(input);
|
||||
if (pascal.isEmpty) {
|
||||
return 'generatedValue';
|
||||
}
|
||||
final value = pascal.substring(0, 1).toLowerCase() + pascal.substring(1);
|
||||
return sanitizeIdentifier(value, fallback: 'generatedValue');
|
||||
}
|
||||
|
||||
String toSnakeCase(String input) {
|
||||
final words = _mergeSnakeCaseAcronyms(splitWords(input));
|
||||
if (words.isEmpty) {
|
||||
return 'generated_value';
|
||||
}
|
||||
return words.map((word) => word.toLowerCase()).join('_');
|
||||
}
|
||||
|
||||
List<String> _mergeSnakeCaseAcronyms(List<String> words) {
|
||||
if (words.length < 2) {
|
||||
return words;
|
||||
}
|
||||
|
||||
final merged = <String>[];
|
||||
var index = 0;
|
||||
while (index < words.length) {
|
||||
final acronym = _matchSnakeCaseAcronym(words, index);
|
||||
if (acronym != null) {
|
||||
merged.add(acronym.acronym);
|
||||
index = acronym.endIndex;
|
||||
continue;
|
||||
}
|
||||
merged.add(words[index]);
|
||||
index += 1;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
_SnakeCaseAcronymMatch? _matchSnakeCaseAcronym(List<String> words, int start) {
|
||||
for (final acronym in _knownSnakeCaseAcronyms) {
|
||||
var combined = '';
|
||||
for (var end = start; end < words.length && end < start + 4; end += 1) {
|
||||
combined += words[end].toLowerCase();
|
||||
if (combined == acronym) {
|
||||
return _SnakeCaseAcronymMatch(acronym: acronym, endIndex: end + 1);
|
||||
}
|
||||
if (acronym.startsWith(combined) == false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
class _SnakeCaseAcronymMatch {
|
||||
const _SnakeCaseAcronymMatch({required this.acronym, required this.endIndex});
|
||||
|
||||
final String acronym;
|
||||
final int endIndex;
|
||||
}
|
||||
|
||||
String sanitizeIdentifier(String raw, {required String fallback}) {
|
||||
var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), '');
|
||||
if (value.isEmpty) {
|
||||
value = fallback;
|
||||
}
|
||||
if (RegExp(r'^\d').hasMatch(value)) {
|
||||
value = 'v$value';
|
||||
}
|
||||
if (dartKeywords.contains(value)) {
|
||||
value = '${value}Value';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String escapeDartString(String value) {
|
||||
return value
|
||||
.replaceAll('\\', r'\\')
|
||||
.replaceAll("'", r"\'")
|
||||
.replaceAll('\n', r'\n');
|
||||
}
|
||||
|
||||
Map<String, dynamic> yamlToMap(dynamic value) {
|
||||
if (value is YamlMap) {
|
||||
return value.map(
|
||||
(key, dynamic innerValue) =>
|
||||
MapEntry(key.toString(), yamlValueToDynamic(innerValue)),
|
||||
);
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.map(
|
||||
(key, dynamic innerValue) =>
|
||||
MapEntry(key.toString(), yamlValueToDynamic(innerValue)),
|
||||
);
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
dynamic yamlValueToDynamic(dynamic value) {
|
||||
if (value is YamlMap || value is Map) {
|
||||
return yamlToMap(value);
|
||||
}
|
||||
if (value is YamlList || value is List) {
|
||||
return [
|
||||
for (final item in value as Iterable<dynamic>) yamlValueToDynamic(item),
|
||||
];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Map<String, dynamic> deepMergeMaps(
|
||||
Map<String, dynamic> base,
|
||||
Map<String, dynamic> override,
|
||||
) {
|
||||
final result = Map<String, dynamic>.from(base);
|
||||
for (final entry in override.entries) {
|
||||
final existing = result[entry.key];
|
||||
final incoming = entry.value;
|
||||
if (existing is Map<String, dynamic> && incoming is Map<String, dynamic>) {
|
||||
result[entry.key] = deepMergeMaps(existing, incoming);
|
||||
} else {
|
||||
result[entry.key] = incoming;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isHttpUrl(String value) {
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
}
|
||||
|
||||
Map<String, dynamic> decodeStructuredDocument(String content) {
|
||||
final trimmed = content.trimLeft();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
throw const FormatException('OpenAPI 根节点必须是对象。');
|
||||
}
|
||||
|
||||
final yaml = loadYaml(content);
|
||||
final mapped = yamlToMap(yaml);
|
||||
if (mapped.isEmpty) {
|
||||
throw const FormatException('无法解析 OpenAPI 文档内容。');
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
Reference in New Issue
Block a user