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