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 @@
.dart_tool/
.omx/
build/
coverage/
.DS_Store
pubspec.lock
+5
View File
@@ -0,0 +1,5 @@
## 0.1.0
- 初始化 `koi_swagger_parser`。
- 支持 OpenAPI 3.1/FastAPI 常见结构解析、Koi 风格 Retrofit API 生成、Freezed 模型生成。
- 支持兼容读取旧版 `generator_config.yaml`。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mrkoi-is
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+137
View File
@@ -0,0 +1,137 @@
# koi_swagger_parser
`koi_swagger_parser` 是一个面向 Koi 生态的新一代 OpenAPI 解析与 Dart 代码生成器,目标不是继续修补旧式 Swagger 生成链,而是为未来的 `koi_blueprint_flutter` 提供一套更稳、更清晰、更可扩展的基础设施。
当前版本聚焦 Koi/FastAPI 场景,优先解决这几类问题:
- OpenAPI 3.1 下 `anyOf[T, null]`、`type: [T, null]` 的 nullable 解析。
- FastAPI `code/message/data` 成功 envelope 的识别与外部泛型包装。
- `multipart/form-data` 请求体生成 `@MultiPart()` + `@Part()`,并按 preset 选择二进制类型。
- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper。
- 以 Koi 目录结构输出 `api/`、`api_models/request|result|shared|enums/`。
- 兼容旧版 `generator_config.yaml`,平滑替换 `swagger_generator_flutter`。
## 设计目标
- 解析层和 Koi 业务约束解耦:parser core 不硬编码 `BaseResult`。
- 先产出中间表示(IR),再做 Koi 风格渲染,方便未来扩展到 `koi_blueprint_flutter`。
- 默认以 Retrofit + Freezed 为输出基线,但 response wrapper 可配置。
- 对 FastAPI/SQLAlchemy 风格服务端有良好适配,同时不把服务端补丁写死在生成器里。
## 配置文件
推荐使用 `koi_swagger_parser.yaml`:
```yaml
input:
swagger_urls:
- url: ./openapi.json
enabled: true
output:
base_dir: ./lib/src
api_dir: ./lib/src/api
models_dir: ./lib/src/api_models
included_tags:
- auth
- stores
- materials
split_by_tags: true
extract_version_from_url: false
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: true
client:
class_name: DbaApiClient
file_name: dba_api_client
response_wrapper:
mode: external_generic
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:dba_network/dba_network.dart
class_name: BaseResult
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: true
```
也兼容旧版 `generator_config.yaml`:
- `generation.api.base_result_import` 会自动映射为 `response_wrapper.mode = external_generic`。
- `output.included_tags`、`generation.models.class_prefix` 等旧字段会继续生效。
## 命令行
```bash
dart run koi_swagger_parser generate --all
```
常用参数:
- `--config`:指定配置文件路径。
- `--cwd`:指定工作目录。
- `--dry-run`:只解析与渲染,不落盘。
- `--verbose`:打印更多诊断信息。
## 输出结构
默认会生成:
```text
lib/src/
api/
dba_api_client.dart
index.dart
v1/
index.dart
auth_api.dart
stores_api.dart
api_models/
api_models.dart
request/
result/
shared/
enums/
```
## 当前能力
当前版本已经覆盖 Koi 当前最需要的能力:
- OpenAPI 3.1 文档加载(本地 JSON/YAML、远程 URL、多 source merge)。
- `object` / `array` / `enum` / `additionalProperties` / `allOf` 基础支持。
- nullable query/path/body 字段解析。
- FastAPI envelope 检测与 `BaseResult<T>` 外部包装。
- `multipart/form-data` 生成 `MultipartFile` / `File` / `Uint8List` / `String` 等 preset 驱动参数。
- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper,并可被其他模型继续引用。
- Retrofit API 文件、Freezed 模型文件、分组 index 文件生成。
下一阶段可以继续往前加:
- 更丰富的 multipart metadata(如 encoding / runtime fileName / contentType preset)。
- 无 discriminator 的更强联合类型兜底策略。
- 更多 preset(例如 Chopper / blueprint preset / 网络层集成 preset)。
- 与 `koi_blueprint_flutter` 的一键联动模板。
## 开发与验证
```bash
dart pub get
dart format .
dart test
```
架构设计说明见 `doc/ARCHITECTURE.md`。
+12
View File
@@ -0,0 +1,12 @@
include: package:lints/recommended.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
linter:
rules:
public_member_api_docs: false
lines_longer_than_80_chars: false
prefer_single_quotes: true
+80
View File
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
Future<void> main(List<String> arguments) async {
final parser = ArgParser()
..addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息。');
final generateParser = ArgParser()
..addFlag('all', abbr: 'a', negatable: false, help: '兼容旧命令格式,当前等价于生成全部资源。')
..addOption('config', abbr: 'c', help: '配置文件路径。')
..addOption('cwd', help: '工作目录。')
..addFlag('dry-run', negatable: false, help: '只解析与渲染,不落盘。')
..addFlag('verbose', abbr: 'v', negatable: false, help: '打印更多诊断信息。');
parser.addCommand('generate', generateParser);
late final ArgResults result;
try {
result = parser.parse(arguments);
} on FormatException catch (error) {
stderr.writeln(error.message);
stderr.writeln(parser.usage);
exitCode = 64;
return;
}
if ((result['help'] as bool?) ?? false) {
stdout.writeln(_usage(parser));
return;
}
final command = result.command;
if (command == null || command.name == 'generate') {
final commandArgs = command ?? result;
await _runGenerate(commandArgs, parser);
return;
}
stdout.writeln(_usage(parser));
}
Future<void> _runGenerate(ArgResults result, ArgParser parser) async {
final generator = const KoiSwaggerParser();
try {
final summary = await generator.generate(
configPath: result['config'] as String?,
workingDirectory: result['cwd'] as String?,
dryRun: result['dry-run'] as bool? ?? false,
verbose: result['verbose'] as bool? ?? false,
);
final mode = summary.dryRun ? 'dry-run' : 'written';
stdout.writeln(
'[koi_swagger_parser] $mode ${summary.writtenFiles.length} file(s), '
'${summary.operationsCount} operation(s), '
'${summary.modelsCount} model(s).',
);
} on Object catch (error, stackTrace) {
stderr.writeln('[koi_swagger_parser] generation failed: $error');
if ((result['verbose'] as bool?) ?? false) {
stderr.writeln(stackTrace);
} else {
stderr.writeln('使用 --verbose 可以查看详细堆栈。');
}
stderr.writeln(parser.usage);
exitCode = 1;
}
}
String _usage(ArgParser parser) {
return [
'koi_swagger_parser',
'',
'用法:',
' dart run koi_swagger_parser generate --all',
'',
parser.usage,
].join('\n');
}
+114
View File
@@ -0,0 +1,114 @@
# Architecture
`koi_swagger_parser` 采用“加载 -> 解析 -> IR -> 渲染 -> 落盘”的分层设计,避免把 OpenAPI 细节、Koi 业务约定、具体 Dart 输出模板揉在一起。
## 1. 分层
### config
负责读取与归一化配置:
- 新配置:`koi_swagger_parser.yaml`
- 兼容配置:`generator_config.yaml`
- 路径归一化、默认值、旧字段兼容映射
- preset 解析(当前内置 `koi_flutter`)
### openapi
负责:
- 本地/远程文档加载
- 多 source 深合并
- schema/ref 解析
- OpenAPI 3.1 nullable / anyOf / oneOf / additionalProperties / allOf 归一化
- multipart request body 与 discriminator union 识别
### ir
产出与 OpenAPI 原文解耦的中间表示:
- API operation
- request body / parameter
- model / enum
- Dart type graph
IR 是后续接入 `koi_blueprint_flutter` preset 的关键。
### generator
只关心如何把 IR 渲染成 Dart 文件:
- Retrofit API
- Freezed model
- enums
- index/client 聚合文件
这一层不直接处理 OpenAPI 细节。
### preset
用于把 parser core 的 IR 和具体客户端技术栈解耦。当前先提供一个很薄的 preset 层:
- `koi_flutter`:默认面向 Retrofit + Dio + Freezed
- 当前主要控制 multipart 二进制字段该生成为 `MultipartFile`、`File`、`Uint8List` 还是 `String`
- 后续可以继续扩展 blueprint preset、网络层 preset、命名/包装策略 preset
## 2. Response Wrapper 设计
parser core 不直接写死 `BaseResult`。
当前支持两种模式:
- `passthrough`
- 返回真实 OpenAPI response schema
- 适合你希望保留服务端 envelope 模型的场景
- `external_generic`
- 识别标准成功 envelope(默认 `code/message/data`)
- 将 `data` 提取为 inner type
- API 返回 `ExternalWrapper<Inner>`,例如 `BaseResult<KoiStoreResponse>`
这样做的意义是:
- parser core 保持通用
- Koi 网络层包裹策略可配置
- 未来可以挂更多 wrapper preset,而不是继续堆服务端补丁
## 3. 模型分组
模型按使用语义归档:
- `request/`:只在请求体根节点使用
- `result/`:只在成功响应根节点使用
- `shared/`:被请求与响应共同依赖,或用于错误响应
- `enums/`:枚举单独分组
这比“按 swagger component 平铺”更符合 Koi 项目结构,也更利于未来蓝图模板整理。
## 4. 命名原则
- 类名统一 PascalCase。
- 字段统一 camelCase,并保留 `@JsonKey(name: ...)` 对应 wire name。
- 文件名统一 snake_case。
- API 默认按 `version + tag` 分文件。
命名转换统一收口到 util 层,避免每个阶段各写一套字符串处理逻辑。
## 5. 为什么新建项目而不是继续修补旧生成器
旧生成器的问题不是单点 bug,而是结构性耦合:
- OpenAPI 解析和业务输出绑定太深。
- nullable / anyOf / wrapper 规则分散在多个模板与解析阶段。
- 很难为未来 `koi_blueprint_flutter` 抽出复用 IR。
所以这里直接重新收口:
- parser core 面向 OpenAPI 语义
- Koi 输出作为 renderer/preset
- 兼容旧配置,但不延续旧架构负担
## 6. 近期路线
- 第一阶段:打通 Koi/FastAPI 场景(已完成)
- 第二阶段:补齐 multipart、discriminator union、preset 基础层(当前版本)
- 第三阶段:扩更强 union fallback、更多 preset,并服务 `koi_blueprint_flutter`
+43
View File
@@ -0,0 +1,43 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
Future<void> main() async {
final packageRoot = Directory.current.path;
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_example_',
);
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${p.join(packageRoot, 'test', 'fixtures', 'dba_openapi.json')}
enabled: true
output:
base_dir: ${p.join(tempDir.path, 'lib', 'src')}
included_tags:
- stores
- materials
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
dryRun: true,
);
print(
'loaded ${result.sourceDocuments.length} source(s), '
'parsed ${result.operationsCount} operation(s), '
'rendered ${result.modelsCount} model(s).',
);
}
+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;
}
+26
View File
@@ -0,0 +1,26 @@
name: koi_swagger_parser
description: OpenAPI 3.1 parser and Koi-style Dart API generator for Retrofit + Freezed projects.
version: 0.1.0
homepage: https://github.com/mrkoi-is/koi_swagger_parser
repository: https://github.com/mrkoi-is/koi_swagger_parser
issue_tracker: https://github.com/mrkoi-is/koi_swagger_parser/issues
topics:
- openapi
- swagger
- retrofit
- codegen
- dart
environment:
sdk: ^3.11.0
dependencies:
args: ^2.7.0
http: ^1.5.0
path: ^1.9.1
yaml: ^3.1.3
dev_dependencies:
lints: ^6.0.0
test: ^1.26.3
+76
View File
@@ -0,0 +1,76 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('KoiSwaggerConfigLoader', () {
test('兼容旧版 generator_config.yaml 字段', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_config_',
);
addTearDown(() => tempDir.delete(recursive: true));
final configFile = File(p.join(tempDir.path, 'generator_config.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ./openapi.json
enabled: true
output:
base_dir: ./lib/src
api_dir: ./lib/src/api
models_dir: ./lib/src/api_models
included_tags:
- stores
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: true
base_result_import: package:dba_network/dba_network.dart
client:
class_name: DbaApiClient
file_name: dba_api_client
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
final config = await KoiSwaggerConfig.load(
workingDirectory: tempDir.path,
);
expect(config.input.swaggerUrls, <String>[
p.join(tempDir.path, 'openapi.json'),
]);
expect(config.output.includedTags, <String>['stores']);
expect(
config.generation.api.responseWrapper.mode,
ResponseWrapperMode.externalGeneric,
);
expect(
config.generation.api.responseWrapper.externalImport,
'package:dba_network/dba_network.dart',
);
expect(
config.generation.api.responseWrapper.externalClassName,
'BaseResult',
);
expect(config.generation.api.cancelTokenSupport, isTrue);
expect(config.generation.preset.name, 'koi_flutter');
expect(config.generation.models.classPrefix, 'Koi');
expect(config.generation.formatting.runDartFormat, isFalse);
});
});
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('default values and optional parameters', () {
test('枚举默认值与 query 默认值生成合法 Dart 代码', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_defaults_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Defaults Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/samples": {
"get": {
"tags": ["samples"],
"operationId": "list_samples_api_v1_samples_get",
"parameters": [
{
"name": "page_no",
"in": "query",
"required": false,
"schema": {"type": "integer", "default": 1}
},
{
"name": "tag",
"in": "query",
"required": false,
"schema": {"type": "string"}
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/SampleResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SampleResponse": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"conflict_policy": {
"type": "string",
"enum": ["exclusive", "mix"],
"default": "exclusive"
}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
included_tags:
- samples
split_by_tags: true
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'samples_api.dart'),
).readAsStringSync();
expect(apiFile, contains("@Query('page_no') int pageNo = 1"));
expect(apiFile, contains("@Query('tag') String? tag"));
final resultFile = File(
p.join(outputDir, 'api_models', 'result', 'sample_response.dart'),
).readAsStringSync();
expect(
resultFile,
contains('@Default(KoiSampleResponseConflictPolicy.exclusive)'),
);
});
});
}
+6803
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('DBA fixture generation', () {
test('正确生成 Koi 风格 API 与模型文件', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_dba_',
);
addTearDown(() => tempDir.delete(recursive: true));
final outputDir = p.join(tempDir.path, 'lib', 'src');
final fixturePath = p.normalize(
p.join(Directory.current.path, 'test', 'fixtures', 'dba_openapi.json'),
);
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: $fixturePath
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
included_tags:
- stores
- materials
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: true
client:
class_name: DbaApiClient
file_name: dba_api_client
response_wrapper:
mode: external_generic
detect_standard_envelope: true
success_data_field: data
external_generic:
import: package:dba_network/dba_network.dart
class_name: BaseResult
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(result.operationsCount, greaterThan(0));
expect(result.modelsCount, greaterThan(0));
final materialsApi = File(
p.join(outputDir, 'api', 'v1', 'materials_api.dart'),
).readAsStringSync();
expect(
materialsApi,
contains('Future<BaseResult<KoiMaterialListResponse>> listMaterials'),
);
expect(materialsApi, contains("@Query('store_id') int? storeId"));
expect(materialsApi, contains("@Query('tag') String? tag"));
final storesApi = File(
p.join(outputDir, 'api', 'v1', 'stores_api.dart'),
).readAsStringSync();
expect(
storesApi,
contains('Future<BaseResult<List<KoiStoreResponse>>> listStores'),
);
expect(
storesApi,
contains('Future<BaseResult<Map<String, bool>>> deleteStore'),
);
final storeWrite = File(
p.join(outputDir, 'api_models', 'request', 'store_write.dart'),
).readAsStringSync();
expect(storeWrite, contains('String? address'));
final materialResponse = File(
p.join(outputDir, 'api_models', 'result', 'material_response.dart'),
).readAsStringSync();
expect(materialResponse, contains("@JsonKey(name: 'store_id')"));
expect(materialResponse, contains('required int storeId'));
final sharedError = File(
p.join(outputDir, 'api_models', 'shared', 'http_validation_error.dart'),
);
expect(sharedError.existsSync(), isTrue);
});
});
}
+298
View File
@@ -0,0 +1,298 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('multipart generation', () {
test('按 preset 生成 Retrofit multipart 参数', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_multipart_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Multipart Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/uploads": {
"post": {
"tags": ["uploads"],
"operationId": "create_upload_api_v1_uploads_post",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["file", "meta"],
"properties": {
"file": {"type": "string", "format": "binary"},
"files": {
"type": "array",
"items": {"type": "string", "format": "binary"}
},
"meta": {"$ref": "#/components/schemas/UploadMeta"},
"note": {"type": "string"}
}
}
}
}
},
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/UploadResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"UploadMeta": {
"type": "object",
"required": ["album_id"],
"properties": {
"album_id": {"type": "integer"},
"tags": {
"type": "array",
"items": {"type": "string"}
}
}
},
"UploadResponse": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'uploads_api.dart'),
).readAsStringSync();
expect(apiFile, contains('@MultiPart()'));
expect(
apiFile,
contains("@Part(name: 'file') required MultipartFile file"),
);
expect(
apiFile,
contains("@Part(name: 'files') List<MultipartFile>? files"),
);
expect(
apiFile,
contains(
"@Part(name: 'meta', contentType: 'application/json') required KoiUploadMeta meta",
),
);
expect(apiFile, contains("@Part(name: 'note') String? note"));
final metaFile = File(
p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'),
).readAsStringSync();
expect(metaFile, contains('class KoiUploadMeta'));
expect(metaFile, contains("@JsonKey(name: 'album_id')"));
});
});
group('discriminator union generation', () {
test('生成 sealed wrapper 并在结果模型中引用', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_union_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Union Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/pets/{pet_id}": {
"get": {
"tags": ["pets"],
"operationId": "get_pet_api_v1_pets__pet_id__get",
"parameters": [
{
"name": "pet_id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PetResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"PetResponse": {
"type": "object",
"required": ["pet"],
"properties": {
"pet": {"$ref": "#/components/schemas/Pet"}
}
},
"Pet": {
"oneOf": [
{"$ref": "#/components/schemas/Cat"},
{"$ref": "#/components/schemas/Dog"}
],
"discriminator": {
"propertyName": "pet_type",
"mapping": {
"cat": "#/components/schemas/Cat",
"dog": "#/components/schemas/Dog"
}
}
},
"Cat": {
"type": "object",
"required": ["pet_type", "lives"],
"properties": {
"pet_type": {"type": "string", "enum": ["cat"]},
"lives": {"type": "integer"}
}
},
"Dog": {
"type": "object",
"required": ["pet_type", "breed"],
"properties": {
"pet_type": {"type": "string", "enum": ["dog"]},
"breed": {"type": "string"}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final unionFile = File(
p.join(outputDir, 'api_models', 'result', 'pet.dart'),
).readAsStringSync();
expect(unionFile, contains('sealed class KoiPet'));
expect(
unionFile,
contains('factory KoiPet.fromJson(Map<String, dynamic> json)'),
);
expect(unionFile, contains("case 'cat':"));
expect(unionFile, contains('return KoiPetCat(KoiCat.fromJson(json));'));
expect(unionFile, contains('final class KoiPetDog extends KoiPet'));
final responseFile = File(
p.join(outputDir, 'api_models', 'result', 'pet_response.dart'),
).readAsStringSync();
expect(responseFile, contains('required KoiPet pet'));
});
});
}
+139
View File
@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('passthrough wrapper', () {
test('保留 envelope 模型并生成 enum', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_passthrough_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString('''
{
"openapi": "3.1.0",
"info": {
"title": "Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/orders": {
"get": {
"tags": ["orders"],
"operationId": "list_orders_api_v1_orders_get",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"\$ref": "#/components/schemas/ApiResponse_OrderStatusResponse_"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ApiResponse_OrderStatusResponse_": {
"type": "object",
"required": ["data"],
"properties": {
"code": {"type": "integer"},
"message": {"type": "string"},
"data": {"\$ref": "#/components/schemas/OrderStatusResponse"}
}
},
"OrderStatusResponse": {
"type": "object",
"required": ["status"],
"properties": {
"status": {"\$ref": "#/components/schemas/OrderStatus"}
}
},
"OrderStatus": {
"type": "string",
"enum": ["pending", "done"]
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: true
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'orders_api.dart'),
).readAsStringSync();
expect(
apiFile,
contains('Future<KoiApiResponseOrderStatusResponse> listOrders'),
);
expect(apiFile, isNot(contains('BaseResult')));
final envelopeModel = File(
p.join(
outputDir,
'api_models',
'result',
'api_response_order_status_response.dart',
),
).readAsStringSync();
expect(
envelopeModel,
contains('class KoiApiResponseOrderStatusResponse'),
);
final enumFile = File(
p.join(outputDir, 'api_models', 'enums', 'order_status.dart'),
).readAsStringSync();
expect(enumFile, contains('enum KoiOrderStatus'));
expect(enumFile, contains("@JsonValue('pending')"));
});
});
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:koi_swagger_parser/src/util/utils.dart';
import 'package:test/test.dart';
void main() {
group('toSnakeCase', () {
test('保持常见缩写合并后的文件名可读性', () {
expect(toSnakeCase('IoTDeviceResponse'), 'iot_device_response');
expect(
toSnakeCase('ApiResponse_IoTDeviceListResponse_'),
'api_response_iot_device_list_response',
);
expect(toSnakeCase('HTTPValidationError'), 'http_validation_error');
});
});
}