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:
mr.koi
2026-06-02 22:00:13 +08:00
commit ea93f09154
24 changed files with 11355 additions and 0 deletions
+6
View File
@@ -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';
+374
View File
@@ -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 模式。',
);
}
}
}
+789
View File
@@ -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)}';
}
}
+312
View File
@@ -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;
}
+89
View File
@@ -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
+32
View File
@@ -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。');
}
}
+276
View File
@@ -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;
}