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