Release 0.2.0

Complete OpenAPI 3.0/3.1 generation, diagnostics, lifecycle safety, enum and naming mappings, bilingual documentation, examples, and pub.dev release validation.
This commit is contained in:
mr.koi
2026-07-31 16:47:39 +08:00
parent ea93f09154
commit 454e6c51d7
56 changed files with 14506 additions and 491 deletions
+12
View File
@@ -0,0 +1,12 @@
.dart_tool/
.omx/
.DS_Store
build/
coverage/
test/
tool/
doc/api/
doc/internal/
pubspec.lock
example/generated/
*.log
+79 -4
View File
@@ -1,5 +1,80 @@
## 0.1.0 # Changelog
- 初始化 `koi_swagger_parser`。 All notable changes to this package are documented in this file.
- 支持 OpenAPI 3.1/FastAPI 常见结构解析、Koi 风格 Retrofit API 生成、Freezed 模型生成。
- 支持兼容读取旧版 `generator_config.yaml`。 [English](CHANGELOG.md) | [Chinese (Simplified)](doc/zh-CN/CHANGELOG.md)
## 0.2.0 - 2026-07-31
### Breaking changes
- The primary library no longer exports parser IR implementation types such as
`DartType`, `ModelSpec`, `ApiOperation`, `GeneratedFile`, and
`GeneratedBundle`. Use `KoiSwaggerParser` and its documented
`GenerationResult` instead.
- `KoiSwaggerConfigLoader` is no longer exported. Use
`KoiSwaggerConfig.load()` to load configuration programmatically.
### Added
- Structured configuration, OpenAPI, filtering, and lifecycle diagnostics;
errors no longer silently produce known-invalid code.
- Local, relative, file, and HTTP(S) `$ref` resolution with caching, cycle
detection, dependency tracking, external component bundling, and
deterministic multi-source conflict handling.
- `enum_mappings`, legacy `enum_key_mappings`, common `x-enum` extensions,
descriptions, and optional unknown enum fallback.
- Model, property, parameter, operation, schema, type, and import mappings with
collision validation.
- OpenAPI path, query, header, and cookie parameter serialization, including
supported matrix, label, form, simple, space-delimited, pipe-delimited,
deep-object, explode, and `allowReserved` combinations.
- JSON, text, multipart, form-urlencoded, and binary uploads; byte and stream
downloads.
- Multiple successful responses, 204, `2XX`, `default`, error models, response
headers, and `HttpResponse<T>` mode.
- Required, nullable, and absent-value semantics; read-only/write-only
projections; root `properties` plus `additionalProperties` round trips.
- `allOf`, discriminator unions, and supported primitive, container, and object
`oneOf`/`anyOf` unions.
- Server variables, security schemes, and operation OR/AND security metadata.
- Exact, glob, and regular-expression filters for tags, paths, operations, and
models.
- A locked manifest, spec/config/content SHA-256 hashes, unchanged generation
skipping, safe stale cleanup, atomic writes, and dry-run change plans.
- Built-in build_runner build/watch integration, reviewed golden output,
OpenAPI 3.0/3.1 fixtures, and temporary generated-package verification.
- English canonical documentation with a complete Simplified Chinese mirror,
a configuration reference, compatibility matrix, diagnostics reference, and
migration guide.
### Changed
- `GenerationResult.writtenFiles` now contains only files actually written;
`generatedFiles` contains the complete target set. Dry runs use
`wouldWriteFiles` and `wouldDeleteFiles`.
- When a configuration file is passed explicitly and neither
`workingDirectory` nor `--cwd` is set, its directory becomes the host project
working directory.
- A `default` response is no longer treated as successful. XML, Protobuf, and
other response media types without a safe decoder now produce diagnostics.
- `dart fix` and `dart format` operate only on primary generated files changed
by the current run.
- All non-test Dart declarations now satisfy `public_member_api_docs` without
file-level exclusions, including internal IR and lifecycle types.
### Fixed
- Made `formatting.run_dart_fix` and `output.extract_version_from_url`
effective.
- Avoided Dio/Retrofit `Headers` import conflicts, missing Freezed custom
`toJson` methods, and parameter, field, and method mapping collisions.
- Synchronized the generator version recorded in lifecycle manifests with the
package's `0.2.0` version.
## 0.1.0 - 2026-06-02
- Initial release of `koi_swagger_parser`.
- Added common OpenAPI 3.1/FastAPI parsing, Koi-style Retrofit API generation,
and Freezed model generation.
- Added compatibility loading for legacy `generator_config.yaml` files.
+316 -81
View File
@@ -1,26 +1,95 @@
# koi_swagger_parser # koi_swagger_parser
`koi_swagger_parser` 是一个面向 Koi 生态的新一代 OpenAPI 解析与 Dart 代码生成器,目标不是继续修补旧式 Swagger 生成链,而是为未来的 `koi_blueprint_flutter` 提供一套更稳、更清晰、更可扩展的基础设施。 [English](README.md) | [Chinese (Simplified)](README.zh-CN.md)
当前版本聚焦 Koi/FastAPI 场景,优先解决这几类问题: `koi_swagger_parser` is an OpenAPI 3.0/3.1 Dart and Flutter client generator
for projects that use Dio, Retrofit, Freezed, and json_serializable. It
separates document loading, `$ref` resolution, validation, an intermediate
representation (IR), rendering, and safe incremental writes.
- OpenAPI 3.1 下 `anyOf[T, null]`、`type: [T, null]` 的 nullable 解析。 Generated code follows a Koi-oriented layout:
- 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`。
## 设计目标 ```text
lib/src/
api/
api_client.dart
index.dart
v1/
index.dart
stores_api.dart
api_models/
api_models.dart
request/
result/
shared/
enums/
```
- 解析层和 Koi 业务约束解耦:parser core 不硬编码 `BaseResult`。 ## Features
- 先产出中间表示(IR),再做 Koi 风格渲染,方便未来扩展到 `koi_blueprint_flutter`。
- 默认以 Retrofit + Freezed 为输出基线,但 response wrapper 可配置。
- 对 FastAPI/SQLAlchemy 风格服务端有良好适配,同时不把服务端补丁写死在生成器里。
## 配置文件 - Local JSON/YAML files, HTTP(S) documents, multiple root documents, and
local, relative, file, and remote `$ref` values.
- Component reference resolution, caching, cycle detection, deterministic
conflict handling, and dependency tracking.
- Retrofit APIs and Freezed/json_serializable request, response, shared,
enum, and union models.
- `enum_mappings`, legacy `enum_key_mappings`, `x-enumNames`,
`x-enum-varnames`, `x-enum-values`, descriptions, and optional unknown enum
fallback.
- Model, property, parameter, operation, schema, type, and import mappings
with collision diagnostics.
- OpenAPI path, query, header, and cookie parameters, including supported
style, explode, and `allowReserved` combinations.
- JSON, text, multipart, form-urlencoded, and binary requests; byte and
stream downloads.
- Multiple successful responses, 204, `2XX`, `default` and error responses,
response headers, and optional `HttpResponse<T>` results.
- Required, nullable, and absent-value semantics; read-only/write-only
projections; `additionalProperties`; `allOf`; and supported `oneOf`/`anyOf`
forms.
- Server variables and security metadata. Credentials remain the
responsibility of the host application's Dio interceptor or provider.
- Exact, glob, and regular-expression filters.
- A locked manifest, SHA-256 change detection, safe stale-file cleanup,
atomic writes, dry runs, and optional build_runner integration.
推荐使用 `koi_swagger_parser.yaml`: See the [compatibility matrix](doc/COMPATIBILITY.md) for precise boundaries.
## Requirements
- Dart SDK `^3.11.0`.
- An OpenAPI 3.0.x or 3.1.x document in JSON or YAML.
- A host Dart or Flutter project configured for Retrofit, Freezed, and
json_serializable when generating both APIs and models.
## Installation
Add the generator as a development dependency in the host project:
```bash
dart pub add --dev koi_swagger_parser
```
For a typical generated client, add these host runtime dependencies:
```bash
dart pub add dio retrofit freezed_annotation json_annotation
```
Add the matching code-generation tools:
```bash
dart pub add --dev build_runner retrofit_generator freezed json_serializable
```
For Flutter projects, use `flutter pub add` in place of `dart pub add` if that
is how the project manages dependencies.
## Quick start
1. Copy [the complete configuration template](koi_swagger_parser.example.yaml)
to `koi_swagger_parser.yaml` in the host project.
2. Set at least one OpenAPI source and the output directories:
```yaml ```yaml
input: input:
@@ -32,12 +101,6 @@ output:
base_dir: ./lib/src base_dir: ./lib/src
api_dir: ./lib/src/api api_dir: ./lib/src/api
models_dir: ./lib/src/api_models models_dir: ./lib/src/api_models
included_tags:
- auth
- stores
- materials
split_by_tags: true
extract_version_from_url: false
generation: generation:
preset: preset:
@@ -45,93 +108,265 @@ generation:
api: api:
enabled: true enabled: true
use_retrofit: true use_retrofit: true
cancel_token_support: true
client:
class_name: DbaApiClient
file_name: dba_api_client
response_wrapper: response_wrapper:
mode: external_generic mode: passthrough
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:dba_network/dba_network.dart
class_name: BaseResult
models: models:
enabled: true enabled: true
class_prefix: Koi class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting: formatting:
run_dart_format: true run_dart_format: true
run_build_runner: true
build_runner_mode: build
``` ```
也兼容旧版 `generator_config.yaml`: 3. Preview the change set:
- `generation.api.base_result_import` 会自动映射为 `response_wrapper.mode = external_generic`。 ```bash
- `output.included_tags`、`generation.models.class_prefix` 等旧字段会继续生效。 dart run koi_swagger_parser generate --all --dry-run
```
## 命令行 4. Generate the client:
```bash ```bash
dart run koi_swagger_parser generate --all dart run koi_swagger_parser generate --all
``` ```
常用参数: When `formatting.run_build_runner` is `false`, run build_runner separately:
- `--config`:指定配置文件路径。 ```bash
- `--cwd`:指定工作目录。 dart run build_runner build --delete-conflicting-outputs
- `--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/
``` ```
## 当前能力 ## CLI usage
当前版本已经覆盖 Koi 当前最需要的能力: ```text
dart run koi_swagger_parser generate [options]
- OpenAPI 3.1 文档加载(本地 JSON/YAML、远程 URL、多 source merge)。 --all, -a Generate all enabled resources. Kept for command compatibility.
- `object` / `array` / `enum` / `additionalProperties` / `allOf` 基础支持。 --config, -c Configuration file path.
- nullable query/path/body 字段解析。 --cwd Host project working directory.
- FastAPI envelope 检测与 `BaseResult<T>` 外部包装。 --dry-run Parse and render without writing or deleting files.
- `multipart/form-data` 生成 `MultipartFile` / `File` / `Uint8List` / `String` 等 preset 驱动参数。 --watch Start build_runner watch after generation.
- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper,并可被其他模型继续引用。 --verbose, -v Print informational diagnostics and failure stack traces.
- Retrofit API 文件、Freezed 模型文件、分组 index 文件生成。 ```
下一阶段可以继续往前加: The loader searches the working directory for, in order:
- 更丰富的 multipart metadata(如 encoding / runtime fileName / contentType preset)。 1. `koi_swagger_parser.yaml`
- 无 discriminator 的更强联合类型兜底策略。 2. `koi_swagger_parser.yml`
- 更多 preset(例如 Chopper / blueprint preset / 网络层集成 preset)。 3. `generator_config.yaml`
- 与 `koi_blueprint_flutter` 的一键联动模板。 4. `generator_config.yml`
## 开发与验证 When `--config` points to a file outside the current directory and `--cwd` is
not set, the configuration directory becomes the host project working
directory. Relative sources and output paths are resolved against the
configuration directory. `--cwd` changes where host-project commands run; it
does not change those path semantics.
## Library usage
The same pipeline is available as a Dart API:
```dart
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
Future<void> main() async {
final result = await const KoiSwaggerParser().generate(
configPath: 'koi_swagger_parser.yaml',
dryRun: true,
);
print('${result.operationsCount} operations');
print('${result.modelsCount} models');
for (final diagnostic in result.diagnostics) {
print(diagnostic.format());
}
}
```
On diagnostic errors, generation throws `KoiDiagnosticException`. Automation
should match diagnostic `code` values instead of message text. See
[Diagnostics](doc/DIAGNOSTICS.md).
## Enum mappings
Enum wire values can be mapped to stable Dart member names in configuration:
```yaml
generation:
models:
enum_unknown_fallback: true
enum_mappings:
OrderStatus:
- value: pending
name: waitingForPayment
description: Waiting for payment
- value: done
name: completed
description: Completed
```
Map syntax is also accepted:
```yaml
generation:
models:
enum_mappings:
OrderStatus:
pending: waitingForPayment
done:
name: completed
description: Completed
```
The legacy `enum_key_mappings` field remains supported. Name resolution uses
this precedence:
1. `enum_mappings`
2. `enum_key_mappings`
3. `x-enumNames`, `x-enum-names`, or `x-enum-varnames`
4. `x-enum-values`
5. A name derived from the wire value
Descriptions use configuration first, then `x-enumDescriptions`,
`x-enum-descriptions`, and `x-enum-values.description`. With
`enum_unknown_fallback: true`, the generator adds a stable `unknown` member
and applies `JsonKey.unknownEnumValue` to enum references.
## Naming and type mappings
```yaml
generation:
mappings:
models:
UserDto: User
properties:
UserDto:
user_id: id
parameters:
getUser:
user_id: id
operations:
getUser: fetchUser
"DELETE /api/v1/users/{id}": removeUser
schemas:
Money:
type: Money
import: package:money2/money2.dart
types:
string:uuid:
type: UuidValue
import: package:uuid/uuid_value.dart
integer:int64: int
imports:
UuidValue: package:uuid/uuid_value.dart
```
Operation mapping keys accept either an `operationId` or `METHOD /path`.
Schema mappings mark a schema as externally implemented, so no model is
generated for it. The external type must provide the JSON conversion behavior
required by its generated usage.
## Responses and transport
```yaml
generation:
api:
cancel_token_support: true
response_mode: http_response # body | http_response
binary_response_mode: bytes # bytes | stream
client:
class_name: ApiClient
file_name: api_client
response_wrapper:
mode: external_generic # passthrough | external_generic
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:your_network/your_network.dart
class_name: BaseResult
```
When Retrofit annotations cannot represent a supported matrix, label, cookie,
deep-object, or reserved-character parameter, the generator emits a typed
transport adapter in front of a private Retrofit delegate. A `default`
response is always treated as an error response.
## Filtering and lifecycle
```yaml
output:
included_tags: [stores]
excluded_paths: [/internal/**]
included_operations: [getStore, "POST /api/v1/stores"]
excluded_models: [Legacy*]
clean_stale_files: true
skip_if_unchanged: true
manifest_path: ./.dart_tool/koi_swagger_parser/manifest.json
```
Filters accept exact values, `*`, `?`, and `**` globs, or Dart regular
expressions prefixed with `regex:`. Exclusions take precedence over
inclusions. `included_models` can add otherwise unreferenced components as
generation roots.
The manifest records only files owned by this generator. Stale cleanup removes
an old generated file only when it is still inside the configured API/model
roots and its content still matches the previous manifest hash. Modified files
and handwritten neighbors are preserved.
## Limitations
The current release intentionally does not generate:
- Swagger/OpenAPI 2.0 clients.
- Callbacks, webhooks, or Link Object call chains.
- XML, Protobuf, or arbitrary custom codecs.
- A complete JSON Schema 2020-12 implementation.
- Server stubs, mock servers, or non-Dart targets.
- A pluggable external renderer/template API.
Unsupported or lossy constructs produce structured warnings or errors; they
are not silently presented as fully supported. Consult
[Compatibility](doc/COMPATIBILITY.md) before adopting the generator for a new
schema corpus.
## Documentation
- [Configuration reference](doc/CONFIGURATION.md)
- [Compatibility matrix](doc/COMPATIBILITY.md)
- [Diagnostics reference](doc/DIAGNOSTICS.md)
- [Architecture](doc/ARCHITECTURE.md)
- [Migration from swagger_generator_flutter](doc/MIGRATION.md)
- [Changelog](CHANGELOG.md)
## Development
```bash ```bash
dart pub get dart pub get
dart format . dart format .
dart analyze
dart test dart test
dart run tool/verify_generated_package.dart
``` ```
架构设计说明见 `doc/ARCHITECTURE.md`。 The final command creates a temporary Dart package, invokes this generator and
its build_runner integration, and then runs analysis and runtime tests in the
generated package.
## Support and contributing
Use [GitHub Issues](https://github.com/mrkoi-is/koi_swagger_parser/issues) for
reproducible bug reports and feature requests. Include the generator version,
Dart version, a minimal OpenAPI document, the relevant configuration, and
diagnostic codes. Remove credentials and private service data before sharing a
reproduction.
Contributions should include focused tests and update the compatibility matrix
and configuration reference when behavior changes. Run the development checks
above before opening a pull request.
## License
[MIT](LICENSE)
+350
View File
@@ -0,0 +1,350 @@
# koi_swagger_parser
[English](README.md) | [简体中文](README.zh-CN.md)
`koi_swagger_parser` 是面向 Dio、Retrofit、Freezed 和
json_serializable 项目的 OpenAPI 3.0/3.1 Dart/Flutter 客户端生成器。它将文档
加载、`$ref` 解析、校验、中间表示(IR)、渲染和安全的增量写入相互分离。
生成代码采用面向 Koi 项目的目录结构:
```text
lib/src/
api/
api_client.dart
index.dart
v1/
index.dart
stores_api.dart
api_models/
api_models.dart
request/
result/
shared/
enums/
```
## 功能
- 本地 JSON/YAML、HTTP(S)、多个根文档,以及本地、相对、file 和远程 `$ref`;
- component 引用解析、缓存、循环检测、确定性冲突处理和依赖追踪;
- Retrofit API,以及 Freezed/json_serializable 请求、响应、共享、枚举和联合模型;
- `enum_mappings`、兼容字段 `enum_key_mappings`、`x-enumNames`、
`x-enum-varnames`、`x-enum-values`、描述和可选 unknown enum fallback;
- model、property、parameter、operation、schema、type 和 import 映射及冲突诊断;
- OpenAPI path、query、header 和 cookie 参数,包括已支持的 style、explode 和
`allowReserved` 组合;
- JSON、text、multipart、form-urlencoded 和 binary 请求,以及 bytes/stream 下载;
- 多个成功响应、204、`2XX`、`default`、错误响应、响应头和可选
`HttpResponse<T>` 返回值;
- required、nullable、absent 语义,readOnly/writeOnly 投影,
`additionalProperties`、`allOf` 和已支持的 `oneOf`/`anyOf`;
- server variables 和 security metadata;凭证仍由宿主应用的 Dio interceptor
或 provider 注入;
- 精确、glob 和正则过滤;
- 带文件锁的 manifest、SHA-256 变更检测、安全 stale cleanup、原子写入、
dry-run 和可选 build_runner 集成。
准确边界见[兼容性矩阵](doc/zh-CN/COMPATIBILITY.md)。
## 环境要求
- Dart SDK `^3.11.0`;
- JSON 或 YAML 格式的 OpenAPI 3.0.x/3.1.x 文档;
- 同时生成 API 和模型时,宿主 Dart/Flutter 项目需配置 Retrofit、Freezed 和
json_serializable。
## 安装
在宿主项目中把生成器添加为开发依赖:
```bash
dart pub add --dev koi_swagger_parser
```
典型生成客户端需要以下宿主运行时依赖:
```bash
dart pub add dio retrofit freezed_annotation json_annotation
```
添加相应代码生成工具:
```bash
dart pub add --dev build_runner retrofit_generator freezed json_serializable
```
Flutter 项目若统一使用 Flutter 管理依赖,可把 `dart pub add` 换成
`flutter pub add`。
## 快速开始
1. 将[完整配置模板](koi_swagger_parser.example.yaml)复制为宿主项目中的
`koi_swagger_parser.yaml`。
2. 至少设置一个 OpenAPI source 和输出目录:
```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
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
response_wrapper:
mode: passthrough
models:
enabled: true
class_prefix: Koi
formatting:
run_dart_format: true
run_build_runner: true
build_runner_mode: build
```
3. 预览变更:
```bash
dart run koi_swagger_parser generate --all --dry-run
```
4. 生成客户端:
```bash
dart run koi_swagger_parser generate --all
```
当 `formatting.run_build_runner` 为 `false` 时,单独执行:
```bash
dart run build_runner build --delete-conflicting-outputs
```
## CLI 用法
```text
dart run koi_swagger_parser generate [options]
--all, -a 生成所有已启用资源;为了命令兼容而保留。
--config, -c 配置文件路径。
--cwd 宿主项目工作目录。
--dry-run 完成解析和渲染,但不写入或删除文件。
--watch 生成后启动 build_runner watch。
--verbose, -v 显示 info 诊断和失败堆栈。
```
加载器按以下顺序在工作目录中查找:
1. `koi_swagger_parser.yaml`
2. `koi_swagger_parser.yml`
3. `generator_config.yaml`
4. `generator_config.yml`
当 `--config` 指向当前目录外的文件且未设置 `--cwd` 时,配置文件目录会成为宿主
项目工作目录。相对 source 和输出路径始终以配置文件目录解析;`--cwd` 只改变
宿主项目命令的运行位置,不改变这些路径语义。
## Library 用法
也可以通过 Dart API 使用相同流水线:
```dart
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
Future<void> main() async {
final result = await const KoiSwaggerParser().generate(
configPath: 'koi_swagger_parser.yaml',
dryRun: true,
);
print('${result.operationsCount} operations');
print('${result.modelsCount} models');
for (final diagnostic in result.diagnostics) {
print(diagnostic.format());
}
}
```
遇到 error 诊断时会抛出 `KoiDiagnosticException`。自动化应匹配诊断 `code`,
而不是 message 文本。详见[诊断参考](doc/zh-CN/DIAGNOSTICS.md)。
## Enum 映射
可在配置中把枚举 wire value 映射成稳定的 Dart 成员名:
```yaml
generation:
models:
enum_unknown_fallback: true
enum_mappings:
OrderStatus:
- value: pending
name: waitingForPayment
description: 等待付款
- value: done
name: completed
description: 已完成
```
也支持 map 写法:
```yaml
generation:
models:
enum_mappings:
OrderStatus:
pending: waitingForPayment
done:
name: completed
description: 已完成
```
兼容字段 `enum_key_mappings` 仍然可用。名称解析优先级为:
1. `enum_mappings`
2. `enum_key_mappings`
3. `x-enumNames`、`x-enum-names` 或 `x-enum-varnames`
4. `x-enum-values`
5. 根据 wire value 自动生成的名称
描述优先采用配置,其次是 `x-enumDescriptions`、
`x-enum-descriptions` 和 `x-enum-values.description`。启用
`enum_unknown_fallback: true` 后,生成器会添加稳定的 `unknown` 成员,并在
枚举引用上应用 `JsonKey.unknownEnumValue`。
## 命名与类型映射
```yaml
generation:
mappings:
models:
UserDto: User
properties:
UserDto:
user_id: id
parameters:
getUser:
user_id: id
operations:
getUser: fetchUser
"DELETE /api/v1/users/{id}": removeUser
schemas:
Money:
type: Money
import: package:money2/money2.dart
types:
string:uuid:
type: UuidValue
import: package:uuid/uuid_value.dart
integer:int64: int
imports:
UuidValue: package:uuid/uuid_value.dart
```
operation 映射键可以是 `operationId` 或 `METHOD /path`。schema 映射表示该
schema 由外部实现,因此不会为其生成模型。外部类型必须提供生成用法所需的
JSON 转换能力。
## 响应与传输
```yaml
generation:
api:
cancel_token_support: true
response_mode: http_response # body | http_response
binary_response_mode: bytes # bytes | stream
client:
class_name: ApiClient
file_name: api_client
response_wrapper:
mode: external_generic # passthrough | external_generic
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:your_network/your_network.dart
class_name: BaseResult
```
当 Retrofit annotation 无法表达已支持的 matrix、label、cookie、deepObject
或 reserved-character 参数时,生成器会在私有 Retrofit delegate 前生成 typed
transport adapter。`default` response 始终被视为错误响应。
## 过滤与生命周期
```yaml
output:
included_tags: [stores]
excluded_paths: [/internal/**]
included_operations: [getStore, "POST /api/v1/stores"]
excluded_models: [Legacy*]
clean_stale_files: true
skip_if_unchanged: true
manifest_path: ./.dart_tool/koi_swagger_parser/manifest.json
```
过滤器支持精确值、`*`、`?`、`**` glob,或带 `regex:` 前缀的 Dart 正则。
exclude 优先于 include。`included_models` 可把原本未被引用的 component 加为
生成根。
manifest 只记录本生成器拥有的文件。仅当旧生成文件仍位于配置的 API/model
输出根内,且内容仍与旧 manifest hash 相同时,stale cleanup 才会删除它。
被修改的文件及旁边的手写文件会保留。
## 限制
当前版本明确不生成:
- Swagger/OpenAPI 2.0 客户端;
- callbacks、webhooks 或 Link Object 调用链;
- XML、Protobuf 或任意自定义 codec;
- 完整 JSON Schema 2020-12;
- server stub、mock server 或非 Dart 目标;
- 可由外部接入的 renderer/template API。
不支持或存在信息损失的结构会产生结构化 warning/error,不会静默宣称完全
支持。为新的 schema corpus 引入本生成器前请查看[兼容性](doc/zh-CN/COMPATIBILITY.md)。
## 文档
- [配置参考](doc/zh-CN/CONFIGURATION.md)
- [兼容性矩阵](doc/zh-CN/COMPATIBILITY.md)
- [诊断参考](doc/zh-CN/DIAGNOSTICS.md)
- [架构](doc/zh-CN/ARCHITECTURE.md)
- [从 swagger_generator_flutter 迁移](doc/zh-CN/MIGRATION.md)
- [更新日志](doc/zh-CN/CHANGELOG.md)
## 开发
```bash
dart pub get
dart format .
dart analyze
dart test
dart run tool/verify_generated_package.dart
```
最后一条命令会创建临时 Dart 包,调用本生成器及其 build_runner 集成,并在
生成的包内执行分析和运行时测试。
## 支持与贡献
请通过 [GitHub Issues](https://github.com/mrkoi-is/koi_swagger_parser/issues)
提交可复现的问题和功能需求。请附带生成器版本、Dart 版本、最小 OpenAPI 文档、
相关配置和诊断 code;分享前移除凭证和私有服务数据。
贡献代码时应增加聚焦的测试;行为变化时同步兼容性矩阵和配置参考。提交 PR 前
请运行上述开发检查。
## 许可证
[MIT](LICENSE)
+1 -1
View File
@@ -7,6 +7,6 @@ analyzer:
linter: linter:
rules: rules:
public_member_api_docs: false public_member_api_docs: true
lines_longer_than_80_chars: false lines_longer_than_80_chars: false
prefer_single_quotes: true prefer_single_quotes: true
+116 -22
View File
@@ -5,22 +5,59 @@ import 'package:koi_swagger_parser/koi_swagger_parser.dart';
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
final parser = ArgParser() final parser = ArgParser()
..addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息。'); ..addFlag('help', abbr: 'h', negatable: false, help: 'Show command help.');
final generateParser = ArgParser() final generateParser = ArgParser()
..addFlag('all', abbr: 'a', negatable: false, help: '兼容旧命令格式,当前等价于生成全部资源。') ..addFlag(
..addOption('config', abbr: 'c', help: '配置文件路径。') 'help',
..addOption('cwd', help: '工作目录。') abbr: 'h',
..addFlag('dry-run', negatable: false, help: '只解析与渲染,不落盘。') negatable: false,
..addFlag('verbose', abbr: 'v', negatable: false, help: '打印更多诊断信息。'); help: 'Show help for the generate command.',
)
..addFlag(
'all',
abbr: 'a',
negatable: false,
help:
'Generate all resources. Retained for compatibility with the '
'legacy command.',
)
..addOption(
'config',
abbr: 'c',
help: 'Path to the YAML configuration file.',
valueHelp: 'path',
)
..addOption(
'cwd',
help: 'Working directory used to resolve project-relative paths.',
valueHelp: 'directory',
)
..addFlag(
'dry-run',
negatable: false,
help: 'Parse and render without writing or deleting files.',
)
..addFlag(
'watch',
negatable: false,
help: 'Start build_runner watch after generation.',
)
..addFlag(
'verbose',
abbr: 'v',
negatable: false,
help: 'Print detailed diagnostics and stack traces.',
);
parser.addCommand('generate', generateParser); parser.addCommand('generate', generateParser);
late final ArgResults result; late final ArgResults result;
try { try {
result = parser.parse(arguments); result = parser.parse(arguments);
} on FormatException catch (error) { } on FormatException catch (error) {
stderr.writeln(error.message); stderr.writeln('Error: ${error.message}');
stderr.writeln(parser.usage); stderr.writeln();
stderr.writeln(_usage(parser));
exitCode = 64; exitCode = 64;
return; return;
} }
@@ -31,9 +68,16 @@ Future<void> main(List<String> arguments) async {
} }
final command = result.command; final command = result.command;
if (command == null || command.name == 'generate') { if (command == null) {
final commandArgs = command ?? result; stdout.writeln(_usage(parser));
await _runGenerate(commandArgs, parser); return;
}
if (command.name == 'generate') {
if ((command['help'] as bool?) ?? false) {
stdout.writeln(_generateUsage(generateParser));
return;
}
await _runGenerate(command, generateParser);
return; return;
} }
@@ -48,22 +92,50 @@ Future<void> _runGenerate(ArgResults result, ArgParser parser) async {
workingDirectory: result['cwd'] as String?, workingDirectory: result['cwd'] as String?,
dryRun: result['dry-run'] as bool? ?? false, dryRun: result['dry-run'] as bool? ?? false,
verbose: result['verbose'] as bool? ?? false, verbose: result['verbose'] as bool? ?? false,
buildRunnerWatch: result['watch'] as bool? ?? false,
); );
final mode = summary.dryRun ? 'dry-run' : 'written'; for (final diagnostic in summary.diagnostics) {
stdout.writeln( if (diagnostic.severity == DiagnosticSeverity.info &&
'[koi_swagger_parser] $mode ${summary.writtenFiles.length} file(s), ' !(result['verbose'] as bool? ?? false)) {
'${summary.operationsCount} operation(s), ' continue;
'${summary.modelsCount} model(s).', }
); stderr.writeln('[koi_swagger_parser] ${diagnostic.format()}');
}
if (summary.dryRun) {
stdout.writeln(
'[koi_swagger_parser] dry-run: '
'${summary.wouldWriteFiles.length} file(s) would be written, '
'${summary.wouldDeleteFiles.length} stale file(s) would be deleted; '
'${summary.operationsCount} operation(s), '
'${summary.modelsCount} model(s).',
);
} else if (summary.skippedUnchanged) {
stdout.writeln(
'[koi_swagger_parser] unchanged: skipped '
'${summary.generatedFiles.length} generated file(s); '
'${summary.operationsCount} operation(s), '
'${summary.modelsCount} model(s).',
);
} else {
stdout.writeln(
'[koi_swagger_parser] wrote ${summary.writtenFiles.length} file(s), '
'deleted ${summary.deletedFiles.length} stale file(s), '
'manages ${summary.generatedFiles.length} file(s); '
'${summary.operationsCount} operation(s), '
'${summary.modelsCount} model(s).',
);
}
} on Object catch (error, stackTrace) { } on Object catch (error, stackTrace) {
stderr.writeln('[koi_swagger_parser] generation failed: $error'); stderr.writeln('[koi_swagger_parser] Generation failed.');
if ((result['verbose'] as bool?) ?? false) { if ((result['verbose'] as bool?) ?? false) {
stderr.writeln(error);
stderr.writeln(stackTrace); stderr.writeln(stackTrace);
} else { } else {
stderr.writeln('使用 --verbose 可以查看详细堆栈。'); stderr.writeln('Run with --verbose to see the underlying error.');
} }
stderr.writeln(parser.usage); stderr.writeln();
stderr.writeln(_generateUsage(parser));
exitCode = 1; exitCode = 1;
} }
} }
@@ -72,9 +144,31 @@ String _usage(ArgParser parser) {
return [ return [
'koi_swagger_parser', 'koi_swagger_parser',
'', '',
'用法:', 'Generate Retrofit and Freezed Dart sources from OpenAPI 3.0 or 3.1.',
' dart run koi_swagger_parser generate --all',
'', '',
'Usage:',
' koi_swagger_parser <command> [arguments]',
' dart run koi_swagger_parser <command> [arguments]',
'',
'Global options:',
parser.usage,
'',
'Available commands:',
' generate Generate API clients and models.',
'',
'Run "koi_swagger_parser generate --help" for command options.',
].join('\n');
}
String _generateUsage(ArgParser parser) {
return [
'Generate API clients and models.',
'',
'Usage:',
' koi_swagger_parser generate [options]',
' dart run koi_swagger_parser generate [options]',
'',
'Options:',
parser.usage, parser.usage,
].join('\n'); ].join('\n');
} }
+132 -74
View File
@@ -1,114 +1,172 @@
# Architecture # Architecture
`koi_swagger_parser` 采用“加载 -> 解析 -> IR -> 渲染 -> 落盘”的分层设计,避免把 OpenAPI 细节、Koi 业务约定、具体 Dart 输出模板揉在一起。 [English](ARCHITECTURE.md) | [Chinese (Simplified)](zh-CN/ARCHITECTURE.md)
## 1. 分层 The generation pipeline is deliberately split into stages:
### config ```text
configuration
-> source loader
-> external/local $ref resolver
-> multi-source merger
-> validation + parser
-> transport/schema IR
-> preset + Dart renderer
-> manifest lifecycle
-> fix / format / build_runner
```
负责读取与归一化配置: The main invariant is that loading produces a self-contained document with
traceable dependencies; the parser represents OpenAPI semantics; the renderer
does not reinterpret the specification; and the lifecycle manages only files
declared in its manifest.
- 新配置:`koi_swagger_parser.yaml` ## Configuration
- 兼容配置:`generator_config.yaml`
- 路径归一化、默认值、旧字段兼容映射
- preset 解析(当前内置 `koi_flutter`)
### openapi `lib/src/config/config.dart`:
负责: - discovers `koi_swagger_parser.yaml` and compatible
`generator_config.yaml` names;
- resolves source and output paths against the configuration directory;
- chooses the host-project command directory from `--cwd` or the explicit
configuration directory;
- validates YAML scalar types before casting;
- parses enum, naming, type/import mappings, and filters;
- validates unsupported combinations, mapping collisions, regular
expressions, and enum mappings;
- supports migration from the legacy `base_result_import` setting.
- 本地/远程文档加载 Unknown keys produce warnings. Invalid types and incomplete combinations
- 多 source 深合并 produce errors. See [Configuration](CONFIGURATION.md).
- schema/ref 解析
- OpenAPI 3.1 nullable / anyOf / oneOf / additionalProperties / allOf 归一化
- multipart request body 与 discriminator union 识别
### ir ## Loader, resolver, and merger
产出与 OpenAPI 原文解耦的中间表示: For each root source, `OpenApiReferenceResolver`:
- API operation 1. normalizes a local file, file URL, or HTTP(S) source;
- request body / parameter 2. caches each loaded document;
- model / enum 3. resolves local, relative, and remote `$ref` values;
- Dart type graph 4. bundles external components into the root document;
5. assigns deterministic names to component conflicts;
6. records dependencies and diagnoses unsupported cross-document cycles.
IR 是后续接入 `koi_blueprint_flutter` preset 的关键。 `OpenApiDocumentMerger` then merges roots in configuration order:
### generator - a duplicate operation method/path is an error;
- components with the same name but different content are renamed
deterministically, and references from that source are rewritten;
- later inputs never silently overwrite conflicts;
- version, info, and path-parameter conflicts are reported.
只关心如何把 IR 渲染成 Dart 文件: The parser should receive only root-document JSON Pointers. A remaining
external reference is treated as a resolver error.
- Retrofit API ## Validation and parsing
- Freezed model
- enums
- index/client 聚合文件
这一层不直接处理 OpenAPI 细节。 `OpenApiDocumentParser` handles:
### preset - OpenAPI 3.0/3.1 version and core structure checks;
- operation, path, tag, and model filters;
- parameter location, style, explode, `allowReserved`, and `content`;
- request media types and response status, content, and headers;
- servers, security schemes, and security requirements;
- required, nullable, read-only/write-only, and `additionalProperties`
semantics;
- enum configuration/vendor-extension precedence;
- `allOf`, discriminated unions, and supported generic unions;
- custom mappings and Dart name collisions.
用于把 parser core 的 IR 和具体客户端技术栈解耦。当前先提供一个很薄的 preset 层: A structure that cannot be rendered with reliable wire semantics must not
silently become `dynamic`. A safe but lossy continuation produces a warning;
for example, different successful JSON body types may converge on `Object?`.
A media type without a supported decoder, such as XML or Protobuf, produces an
error.
- `koi_flutter`:默认面向 Retrofit + Dio + Freezed ## Intermediate representation
- 当前主要控制 multipart 二进制字段该生成为 `MultipartFile`、`File`、`Uint8List` 还是 `String`
- 后续可以继续扩展 blueprint preset、网络层 preset、命名/包装策略 preset
## 2. Response Wrapper 设计 `lib/src/ir/ir.dart` is the parser/renderer contract. Its main nodes include:
parser core 不直接写死 `BaseResult`。 - `ApiOperation`: method, path, tag, version, server, and security data;
- `ApiParameter`: Dart/wire name, location, style, explode, and
`allowReserved`;
- `ApiRequestBody`: JSON, multipart, form-urlencoded, and binary requests;
- `ApiResponse`: status/default, body kind/type, content type, and headers;
- `ModelSpec`: object, enum, and union models with usage category and extras;
- `ModelField`: required/nullability/read-write flags and constraints;
- `DartType`: named, list, map, object, dynamic, and void type graphs.
当前支持两种模式: Models are grouped as request, result, shared, or enum models. A component with
read-only/write-only properties can be projected into request and response
variants so that read-only values are not required in requests and
write-only secrets are not exposed in responses.
- `passthrough` A generic `oneOf` requires exactly one decoder match. A generic `anyOf` retains
- 返回真实 OpenAPI response schema the raw value and all matching cases. Object branches are prefiltered by
- 适合你希望保留服务端 envelope 模型的场景 required keys and enum constraints before decoding.
- `external_generic`
- 识别标准成功 envelope(默认 `code/message/data`)
- 将 `data` 提取为 inner type
- API 返回 `ExternalWrapper<Inner>`,例如 `BaseResult<KoiStoreResponse>`
这样做的意义是: ## Preset and renderer
- parser core 保持通用 The current `koi_flutter` preset targets Dio, Retrofit, Freezed, and
- Koi 网络层包裹策略可配置 json_serializable.
- 未来可以挂更多 wrapper preset,而不是继续堆服务端补丁
## 3. 模型分组 The renderer:
模型按使用语义归档: - uses Retrofit annotations for parameter forms Retrofit can represent;
- emits a typed public adapter and private Retrofit delegate when a supported
style, cookie, or `allowReserved` combination needs custom serialization;
- renders Freezed object, enum, and union models;
- renders custom root-map read/write helpers for
`properties` plus `additionalProperties`;
- collects model dependencies and external type imports;
- groups API files by version and tag and emits client/index files.
- `request/`:只在请求体根节点使用 The renderer does not load references or decide whether an OpenAPI structure
- `result/`:只在成功响应根节点使用 is valid.
- `shared/`:被请求与响应共同依赖,或用于错误响应
- `enums/`:枚举单独分组
这比“按 swagger component 平铺”更符合 Koi 项目结构,也更利于未来蓝图模板整理。 ## Generation lifecycle
## 4. 命名原则 Before writing, `GenerationLifecycle` calculates:
- 类名统一 PascalCase。 - the canonical merged-spec SHA-256;
- 字段统一 camelCase,并保留 `@JsonKey(name: ...)` 对应 wire name。 - the normalized configuration SHA-256;
- 文件名统一 snake_case。 - each rendered path and content SHA-256.
- API 默认按 `version + tag` 分文件。
命名转换统一收口到 util 层,避免每个阶段各写一套字符串处理逻辑。 The manifest records its schema/generator version, source roots, dependencies,
and the actual hash of each primary generated file. Writes use an operating
system exclusive lock and same-directory temporary-file rename.
## 5. 为什么新建项目而不是继续修补旧生成器 Safety invariants:
旧生成器的问题不是单点 bug,而是结构性耦合: 1. Renderer output must stay inside the configured API/model roots.
2. A stale candidate must come from the previous manifest.
3. Its path must still be inside the current managed roots.
4. Its current hash must equal the previous manifest hash before deletion.
5. A corrupt or unknown-version manifest is never trusted for deletion.
6. A failed fix, format, or build step does not write a success manifest.
7. Watch mode starts after the generation lock is released.
- OpenAPI 解析和业务输出绑定太深。 build_runner owns `.g.dart` and `.freezed.dart` files; they are not included in
- nullable / anyOf / wrapper 规则分散在多个模板与解析阶段。 this generator's stale-file manifest.
- 很难为未来 `koi_blueprint_flutter` 抽出复用 IR。
所以这里直接重新收口: ## Response wrappers
- parser core 面向 OpenAPI 语义 The parser does not hard-code `BaseResult`:
- Koi 输出作为 renderer/preset
- 兼容旧配置,但不延续旧架构负担
## 6. 近期路线 - `passthrough` returns the OpenAPI response schema;
- `external_generic` detects configured success envelopes and returns
`ExternalWrapper<Inner>`.
- 第一阶段:打通 Koi/FastAPI 场景(已完成) A `default` response remains an error response. `response_mode:
- 第二阶段:补齐 multipart、discriminator union、preset 基础层(当前版本) http_response` retains Dio status and headers. Error response models remain in
- 第三阶段:扩更强 union fallback、更多 preset,并服务 `koi_blueprint_flutter` the IR and generated model set.
## Extension boundaries
The IR/preset/renderer split leaves room for a versioned renderer interface,
template overrides, semantic-diff tooling, and optional runtime adapters.
Those are design directions, not features of the current public release. The
current implementation boundaries are documented in
[Compatibility](COMPATIBILITY.md).
Additional ecosystem comparisons are kept as internal project research and
are not part of the published package.
+119
View File
@@ -0,0 +1,119 @@
# Compatibility
[English](COMPATIBILITY.md) | [Chinese (Simplified)](zh-CN/COMPATIBILITY.md)
This matrix describes behavior covered by the current implementation and
automated tests.
- **Supported**: implemented and tested.
- **Partial**: only the boundary stated in the notes is supported.
- **Unsupported**: diagnosed before generation, or explicitly ignored with a
warning.
## Documents, references, and merging
| Capability | Status | Boundary |
|---|---|---|
| OpenAPI 3.0.x / 3.1.x | Supported | JSON and YAML; common Schema Object subset needed by client generation |
| Swagger/OpenAPI 2.0 | Unsupported | `openapi.unsupported_version` error |
| Local `$ref` | Supported | JSON Pointer across component groups |
| Relative file / file URL `$ref` | Supported | Resolved relative to the referring document |
| HTTP(S) `$ref` | Supported | Per-run cache, dependency tracking, and cycle diagnostics |
| External component name conflicts | Supported | Deterministic rename with warning |
| Multiple root sources | Supported | Deterministic merge; component and operation conflict diagnostics |
| Unresolved `$ref` | Diagnosed | Error; never silently converted to `dynamic` |
| Callbacks / webhooks | Unsupported | Warning; no reverse-call client is emitted |
| Link Object | Unsupported | `openapi.links_not_supported` warning |
This is not a complete JSON Schema 2020-12 implementation. `$defs` as
generation roots, `if`/`then`/`else`, `not`, `contains`,
`dependentSchemas`, and unevaluated-property/item semantics are outside the
current contract.
## Schemas and models
| Capability | Status | Boundary |
|---|---|---|
| Object / array / map | Supported | Recursive containers and inline schemas |
| `properties` + `additionalProperties` | Supported | Extra entries round-trip at the JSON root |
| String/number/integer/bool/date/date-time | Supported | Formats can be overridden by type mappings |
| Enum | Supported | String, number, and boolean wire values; inline or component |
| Enum member-name mappings | Supported | Configuration and common `x-enum` extensions |
| Enum unknown fallback | Supported | Configurable `JsonKey.unknownEnumValue` output |
| Required / nullable / absent | Supported | Deserialization distinguishes missing from disallowed null; ordinary Freezed fields do not retain presence history |
| Read-only / write-only | Supported | Directional request/response variants when needed |
| `allOf` | Supported | Object properties, required fields, and additional properties are merged |
| Discriminator `oneOf`/`anyOf` | Supported | Local or normalized object branches |
| Non-discriminator `oneOf`/`anyOf` | Partial | Primitive, container, and object branches; overlapping `oneOf` matches are rejected |
| Custom schema/type/format | Supported | External imports; host code owns the JSON conversion contract |
| Constraint metadata | Partial | Descriptions/doc comments retained; no generated runtime validator |
| Presence/dirty tracking | Unsupported | No Kiota-style backing store |
## HTTP wire semantics
| Capability | Status | Boundary |
|---|---|---|
| Path/query/header/cookie parameters | Supported | Type, required/default, and wire names |
| Matrix / label / simple | Supported | Validated location/style combinations |
| Form / spaceDelimited / pipeDelimited / deepObject | Supported | Validated query/cookie combinations |
| Explode / `allowReserved` | Supported | A transport adapter is emitted when Retrofit cannot express the behavior |
| Parameter Object `content` | Partial | Exactly one media type is required |
| JSON / `+json` request body | Supported | Required bodies and null-to-absent behavior |
| Text request body | Supported | Typed `@Body` |
| `multipart/form-data` | Supported | Binary, primitive, array, and structured JSON parts |
| `application/x-www-form-urlencoded` | Supported | Typed fields; complex encoding uses an adapter |
| Binary upload | Supported | `Uint8List` |
| Binary download | Supported | `Uint8List` or Dio `ResponseBody` stream |
| XML / Protobuf / arbitrary custom codec | Unsupported | Content-type error; no unsafe decoder is emitted |
## Responses, servers, and security
| Capability | Status | Boundary |
|---|---|---|
| Multiple 2xx / 204 / `2XX` | Supported | Deterministic primary response; differing JSON types may become `Object?` with a warning |
| `default` / 4xx / 5xx | Supported | Error model/IR metadata retained; never selected as success |
| Response headers | Supported | Status, type, required flag, and description |
| Raw response metadata | Supported | `response_mode: http_response` |
| Mixed binary and JSON success media | Diagnosed | Error when one Retrofit method cannot decode the union safely |
| Root/path/operation servers | Supported | Server variable default/enum validation; preferred server becomes client default |
| Runtime switching among servers | Partial | Metadata retained; no environment-selector UI |
| API key / Bearer / Basic | Metadata supported | Scheme and operation requirements generated; credentials supplied by host interceptor/provider |
| OAuth2 / OpenID / mutual TLS | Partial | Scheme/scope metadata only; no complete authorization flow |
| Security OR/AND requirements | Supported | List entries are OR; schemes inside one map entry are AND |
## Generation and lifecycle
| Capability | Status | Boundary |
|---|---|---|
| Retrofit + Freezed | Supported | The only complete renderer in this release |
| Passthrough / external generic wrapper | Supported | Configurable standard-envelope detection |
| Model/property/parameter/operation mappings | Supported | Collision checks and safe identifiers |
| Schema/type/import mappings | Supported | Recursive Dart `List`/`Map` type expressions |
| Tag/path/operation/model filters | Supported | Exact, glob, and `regex:` patterns |
| Dry run | Supported | Reports would-write and would-delete sets |
| Dart fix / format | Supported | Changed primary generated files only |
| build_runner build/watch | Supported | Runs in the configured host-project directory |
| Unchanged skip | Supported | Spec/config/content and actual-file SHA-256 |
| Stale cleanup | Supported | Manifest ownership, root containment, and user-modification protection |
| Concurrent generation lock | Supported | Operating-system exclusive lock beside the manifest |
| External templates / plugin renderer | Unsupported | IR/preset are separated, but there is no public renderer SPI |
| Complete SDK package scaffold | Unsupported | Generates APIs/models inside an existing project |
## Verification coverage
The repository test suite covers:
- parser, IR, renderer, and lifecycle stages;
- OpenAPI 3.0 YAML and OpenAPI 3.1 JSON/FastAPI fixtures;
- enum and naming mappings, parameter wire styles, responses, schema
semantics, security, and external/cyclic references;
- reviewed SHA-256 golden output;
- generation into a temporary Dart package followed by build_runner, analysis,
and runtime tests.
Coverage of a feature in this matrix is not a claim of full OpenAPI or JSON
Schema conformance outside the stated boundary. Use a minimal fixture and
dry-run when adopting a new schema corpus.
Diagnostic meanings and remediation are documented in
[Diagnostics](DIAGNOSTICS.md).
+269
View File
@@ -0,0 +1,269 @@
# Configuration reference
[English](CONFIGURATION.md) | [Chinese (Simplified)](zh-CN/CONFIGURATION.md)
`koi_swagger_parser` reads YAML configuration. The complete commented example
is [koi_swagger_parser.example.yaml](../koi_swagger_parser.example.yaml).
Unknown keys produce `config.unknown_key`; invalid scalar types and invalid
combinations produce errors before OpenAPI parsing starts.
## Discovery and path semantics
Without `--config`, the loader searches the working directory in this order:
1. `koi_swagger_parser.yaml`
2. `koi_swagger_parser.yml`
3. `generator_config.yaml`
4. `generator_config.yml`
All relative OpenAPI file sources, output directories, and explicit manifest
paths are resolved against the directory containing the selected
configuration file. If no configuration file is found, they are resolved
against the invocation directory.
When an explicit configuration file is supplied and `--cwd` is omitted, its
directory is also the host-project working directory used for build_runner.
`--cwd` overrides that command working directory but does not rebase relative
configuration paths. HTTP(S) source URLs remain URLs.
## Top-level sections
| Field | Type | Required | Default |
|---|---|---:|---|
| `input` | map | yes | none |
| `output` | map | no | defaults below |
| `generation` | map | no | defaults below |
| `formatting` | map | no | defaults below |
At least one enabled `input.swagger_urls` entry is required.
## `input`
### `input.swagger_urls`
Type: list of strings or source objects. Default: empty, which is an error.
```yaml
input:
swagger_urls:
- ./openapi.yaml
- url: https://example.com/openapi.json
enabled: true
- url: ./experimental.yaml
enabled: false
```
Each source object accepts:
| Field | Type | Required | Default | Meaning |
|---|---|---:|---|---|
| `url` | string | yes | none | Local path, file URL, or HTTP(S) URL |
| `enabled` | boolean | no | `true` | Whether the source participates |
Sources are processed in configuration order. Multiple roots are merged
deterministically; conflicting operations are errors and conflicting
components are renamed with diagnostics.
## `output`
| Field | Type | Default | Meaning |
|---|---|---|---|
| `base_dir` | string path | `./lib/src` | Common generated-code base |
| `api_dir` | string path | `<base_dir>/api` | Generated API root |
| `models_dir` | string path | `<base_dir>/api_models` | Generated model root |
| `included_tags` | string list | unset | Include matching operation tags |
| `excluded_tags` | string list | unset | Exclude matching operation tags |
| `included_paths` | string list | unset | Include matching OpenAPI paths |
| `excluded_paths` | string list | unset | Exclude matching OpenAPI paths |
| `included_operations` | string list | unset | Include matching operations |
| `excluded_operations` | string list | unset | Exclude matching operations |
| `included_models` | string list | unset | Add matching schemas as roots |
| `excluded_models` | string list | unset | Exclude matching models |
| `split_by_tags` | boolean | `true` | Split API files by operation tag |
| `extract_version_from_url` | boolean | `false` | Try to derive grouping version from a source URL |
| `clean_stale_files` | boolean | `true` | Safely remove obsolete owned files |
| `skip_if_unchanged` | boolean | `true` | Skip writes when spec, config, and content are unchanged |
| `manifest_path` | string path | `<project>/.dart_tool/koi_swagger_parser/manifest.json` | Ownership/hash manifest |
Filter entries accept:
- an exact string;
- `*`, `?`, and `**` glob syntax;
- `regex:<expression>`, using Dart regular-expression syntax.
An exclusion wins when the same value matches include and exclude rules.
Putting the same literal in both lists produces a warning. If an included
operation requires an excluded model, generation fails instead of emitting an
uncompilable client.
Stale cleanup is limited to files recorded in the previous manifest, inside
the current API/model roots, with content that still matches the previous
hash. Keep the manifest outside the generated API/model roots.
## `generation.preset`
| Field | Type | Default | Allowed values |
|---|---|---|---|
| `name` | string | `koi_flutter` | `koi_flutter` |
The current release has one renderer preset: Dio + Retrofit APIs and
Freezed/json_serializable models.
## `generation.api`
| Field | Type | Default | Allowed values / meaning |
|---|---|---|---|
| `enabled` | boolean | `true` | Generate API files |
| `use_retrofit` | boolean | `true` | Must remain `true` when API generation is enabled |
| `cancel_token_support` | boolean | `false` | Add Dio `CancelToken` parameters |
| `response_mode` | string | `body` | `body`, `http_response` |
| `binary_response_mode` | string | `bytes` | `bytes`, `stream` |
`generation.api.enabled: true` requires
`generation.models.enabled: true`, because the current Retrofit renderer
imports the generated model index.
### `generation.api.client`
| Field | Type | Default | Meaning |
|---|---|---|---|
| `class_name` | string | `ApiClient` | Generated client class name |
| `file_name` | string | `api_client` | Generated client file stem |
Values are converted to safe Dart identifiers/file names.
### `generation.api.response_wrapper`
| Field | Type | Default | Allowed values / meaning |
|---|---|---|---|
| `mode` | string | `passthrough` | `passthrough`, `external_generic` |
| `success_data_field` | string | `data` | Envelope field containing the success payload |
| `detect_standard_envelope` | boolean | `true` | Detect configured code/message/data-style envelopes |
| `external_generic.import` | string | none | Required in `external_generic` mode |
| `external_generic.class_name` | string | `BaseResult` | External generic wrapper type |
Example:
```yaml
generation:
api:
response_wrapper:
mode: external_generic
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:your_network/your_network.dart
class_name: BaseResult
```
The legacy `generation.api.base_result_import` key selects
`external_generic` with class name `BaseResult` when no explicit mode is set.
Prefer the structured form for new configurations.
## `generation.models`
| Field | Type | Default | Meaning |
|---|---|---|---|
| `enabled` | boolean | `true` | Generate model files |
| `class_prefix` | string | `Koi` | Prefix for generated model names |
| `default_non_nullable_list_to_empty` | boolean | `true` | Default eligible non-nullable list values to an empty list |
| `enum_unknown_fallback` | boolean | `false` | Generate and use an `unknown` enum member |
| `enum_unknown_default_case` | boolean | `false` | Legacy alias for `enum_unknown_fallback` |
### `generation.models.json_serializable`
| Field | Type | Default |
|---|---|---|
| `checked` | boolean | `true` |
| `explicit_to_json` | boolean | `true` |
| `include_if_null` | boolean | `false` |
These values are forwarded to generated json_serializable annotations where
applicable.
### Enum mappings
Both list and map forms are accepted:
```yaml
generation:
models:
enum_mappings:
OrderStatus:
- value: pending
name: waitingForPayment
description: Waiting for payment
- value: done
name: completed
Priority:
1: low
2:
name: high
description: Requires attention
```
The outer key is the OpenAPI enum/schema name. A wire `value` may be a string,
number, or boolean; `name` must be a non-empty Dart member name.
`description` is optional.
`enum_key_mappings` accepts the same shape for compatibility. When both fields
map the same enum value, `enum_mappings` wins and a warning is emitted. See
[Enum mappings in the README](../README.md#enum-mappings) for vendor-extension
precedence.
## `generation.mappings`
All mapping sections default to an empty map.
| Field | Shape | Meaning |
|---|---|---|
| `models` | `schema: DartName` | Rename generated component models |
| `properties` | `schema: {wireProperty: dartField}` | Rename properties within one schema |
| `parameters` | `operation: {wireParameter: dartParameter}` | Rename parameters within one operation |
| `operations` | `operation: dartMethod` | Rename operations |
| `schemas` | `schema: type-or-object` | Reuse an external Dart type instead of generating the schema |
| `types` | `openapiType[:format]: type-or-object` | Map an OpenAPI scalar/type pattern |
| `imports` | `DartType: packageImport` | Supply imports for mapped Dart types |
Operation keys may be an `operationId` or `METHOD /path`. Type mappings try
`type:format` before `type`.
A type-or-object value is either a Dart type string or:
```yaml
type: UuidValue
import: package:uuid/uuid_value.dart
```
Within one mapping scope, two source names cannot target the same Dart name.
The same schema cannot appear in both `models` and `schemas`. A Dart type
cannot resolve to conflicting imports. These combinations are configuration
errors.
## `formatting`
| Field | Type | Default | Allowed values / meaning |
|---|---|---|---|
| `run_dart_fix` | boolean | `false` | Run `dart fix --apply` on changed primary generated files |
| `run_dart_format` | boolean | `true` | Format changed primary generated files |
| `run_build_runner` | boolean | `false` | Run build_runner after the primary write |
| `build_runner_mode` | string | `build` | `build`, `watch` |
| `delete_conflicting_outputs` | boolean | `true` | Pass `--delete-conflicting-outputs` to build_runner |
The CLI `--watch` flag starts build_runner watch after a non-dry generation,
regardless of the configured build_runner mode. Watch mode is long-running and
is intended for interactive development rather than one-shot CI jobs.
## Combination rules
The loader enforces these relationships:
- API and model generation cannot both be disabled.
- API generation currently requires model generation.
- API generation currently requires `use_retrofit: true`.
- `external_generic` response wrapping requires a non-empty import.
- Unknown preset and enum/response/build-runner mode values are errors.
- Invalid filter regular expressions are errors.
- Mapping target/import conflicts are errors.
See [Diagnostics](DIAGNOSTICS.md) for stable codes and remediation guidance.
+158
View File
@@ -0,0 +1,158 @@
# Diagnostics
[English](DIAGNOSTICS.md) | [Chinese (Simplified)](zh-CN/DIAGNOSTICS.md)
Configuration, loading, parsing, rendering, and lifecycle issues are reported
as `KoiDiagnostic` values:
```dart
class KoiDiagnostic {
final String code;
final DiagnosticSeverity severity; // info | warning | error
final String message;
final String? source;
final String? location;
}
```
`code` is the stable machine-readable identifier. Message wording may improve
between releases. `source` identifies a configuration path, document
URL/file, or generated file. `location` usually contains a JSONPath,
JSON Pointer, or other logical location inside the source.
## Severity behavior
- `info`: a normal decision, such as skipping an unchanged generation.
- `warning`: output remains reliable, but a rename, safe degradation, or
explicitly omitted non-blocking capability requires attention.
- `error`: continuing could produce an invalid client; generation throws
`KoiDiagnosticException`.
The CLI prints warnings and errors by default. `--verbose` also prints
informational diagnostics and failure stack traces.
Library callers can inspect diagnostics:
```dart
try {
final result = await const KoiSwaggerParser().generate();
for (final item in result.diagnostics) {
print(item.format());
}
} on KoiDiagnosticException catch (error) {
for (final item in error.diagnostics) {
print(item.format());
}
}
```
Automation should match `code`, not message text. Assert `source` or
`location` as well when the distinction matters.
## Common codes
### Configuration
| Code | Meaning and remediation |
|---|---|
| `config.missing_sources` | Add at least one enabled `input.swagger_urls` entry. |
| `config.unknown_key` | Correct the spelling or remove a field not recognized by this version; warning. |
| `config.invalid_section` | Replace a scalar/list with the expected YAML map. |
| `config.invalid_string` / `config.invalid_boolean` | Correct the YAML scalar type. |
| `config.invalid_string_list` | Use a list of strings for include/exclude filters. |
| `config.invalid_filter_regex` | Correct the Dart expression after `regex:`. |
| `config.no_outputs_enabled` | API and model generation cannot both be disabled. |
| `config.api_requires_models` | The current API renderer requires generated models. |
| `config.unsupported_api_renderer` | Keep `use_retrofit: true`; no other API renderer is public yet. |
| `config.external_wrapper_import_required` | Add `external_generic.import`. |
| `config.mapping_target_collision` | Give mapping sources distinct target Dart names. |
| `config.import_mapping_conflict` | Use one import for a mapped Dart type. |
| `config.conflicting_schema_mapping` | Do not both rename and externally replace the same schema. |
| `config.ambiguous_enum_mapping` | Use unambiguous typed enum wire values. |
### Documents and references
| Code | Meaning and remediation |
|---|---|
| `openapi.missing_version` / `openapi.unsupported_version` | Supply an OpenAPI 3.0.x or 3.1.x document. |
| `openapi.invalid_info` / `openapi.invalid_paths` | Correct the corresponding core document structure. |
| `openapi.source_load_failed` / `openapi.source_http_error` | Check the file path, URL, access, and response status. |
| `openapi.unresolved_ref` | The local JSON Pointer does not exist. |
| `openapi.unresolved_external_ref` | The external resource or fragment does not exist or cannot be loaded. |
| `openapi.external_ref_not_bundled` | Reference normalization failed; inspect preceding source/resolver diagnostics. |
| `openapi.external_component_renamed` | An external component conflict was renamed deterministically; warning. |
| `openapi.multisource_component_renamed` | A component conflict across roots was renamed deterministically; warning. |
| `openapi.multisource_operation_conflict` | Two roots define the same method/path; remove or separate one definition. |
| `openapi.callbacks_not_supported` / `openapi.webhooks_not_supported` | Reverse-call clients are not generated; warning. |
| `openapi.links_not_supported` | Link Object call relationships are not generated; warning. |
### Parameters, requests, and responses
| Code | Meaning and remediation |
|---|---|
| `openapi.invalid_parameter_style` | Use a style valid for the parameter location. |
| `openapi.deep_object_requires_object` | A deepObject parameter needs an object schema. |
| `openapi.delimited_parameter_requires_array` | A space/pipe-delimited parameter needs an array schema. |
| `openapi.parameter_content_count` | Parameter Object `content` must contain exactly one media type. |
| `openapi.request_body_schema_missing` | Add a schema to the selected request media type. |
| `openapi.unsupported_request_content_type` | Use a supported request codec or map the endpoint manually. |
| `openapi.unsupported_response_content_type` | Use a supported response codec or map the endpoint manually. |
| `openapi.success_response_missing` | Add an explicit 2xx response; `default` is not success. |
| `openapi.multiple_success_response_types` | Multiple successful JSON body types converge on `Object?`; warning. |
| `openapi.mixed_success_media_types` | Binary/stream and JSON successes cannot be decoded safely by one generated method. |
| `openapi.path_parameter_forced_required` | A path parameter declared optional is forced to required; warning. |
### Schemas and names
| Code | Meaning and remediation |
|---|---|
| `openapi.property_read_write_only` | A property cannot be both read-only and write-only. |
| `openapi.invalid_additional_properties` | Use a boolean or Schema Object. |
| `openapi.property_name_collision` | An automatic suffix prevented a Dart field collision; warning. |
| `openapi.operation_name_collision` | An automatic suffix prevented a method collision; warning. |
| `openapi.operation_argument_name_collision` | A parameter conflicts with a body, cancel token, or another argument. |
| `openapi.duplicate_enum_member_name` | Multiple wire values map to one name; a suffix was added. |
| `openapi.enum_member_name_sanitized` | An enum member name was changed into a valid Dart identifier. |
| `filter.required_model_excluded` | A selected API needs an excluded model; include it or exclude its consumers. |
| `filter.model_pattern_unmatched` | An `included_models` pattern matched no schema. |
### Servers and security
| Code | Meaning and remediation |
|---|---|
| `openapi.server_url_missing` | Add a non-empty server URL. |
| `openapi.server_variable_default_missing` | Give every server variable a default value. |
| `openapi.server_variable_default_invalid` | Make the default one of the declared enum values. |
| `openapi.server_variable_unresolved` | Define every variable used by the server URL. |
| `openapi.unknown_security_scheme` | Define the referenced scheme in components. |
| `openapi.unsupported_security_scheme` | The scheme is retained only where documented or rejected. |
| `openapi.http_auth_requires_custom_interceptor` | Implement credential injection in the host Dio layer; warning. |
### Generation lifecycle
| Code | Meaning and remediation |
|---|---|
| `lifecycle.unchanged_skipped` | Hashes and files match; generation was safely skipped. |
| `lifecycle.manifest_invalid` | The old manifest is corrupt; output is rebuilt without trusting its deletion list. |
| `lifecycle.manifest_version_ignored` | The manifest schema is incompatible; its deletion list is ignored. |
| `lifecycle.output_outside_managed_roots` | Renderer output escaped the API/model roots and was rejected. |
| `lifecycle.duplicate_output_path` | Two rendered files target the same path with different content. |
| `lifecycle.stale_path_outside_roots` | A former path is outside current roots and is preserved. |
| `lifecycle.stale_file_modified` | A stale generated file was edited and is preserved for manual review. |
| `lifecycle.generated_file_missing` | A primary generated file disappeared during fix/format/build processing. |
| `lifecycle.control_file_not_stale` | A lifecycle control file is never treated as stale output. |
## Design principles
1. Unresolved references, invalid wire styles, and unsupported codecs do not
silently become `dynamic`.
2. Safe, compilable automatic renaming can continue with a warning.
3. Unsupported constructs that do not invalidate other output are explicitly
omitted with a warning.
4. Stale cleanup preserves files whenever containment or hash ownership is
uncertain.
5. Diagnostics include component, operation, parameter, or file context when
available.
New codes can be added in minor releases. Existing automation should treat
unknown warning codes as reviewable and unknown error codes as blocking.
+237
View File
@@ -0,0 +1,237 @@
# Migrating from swagger_generator_flutter
[English](MIGRATION.md) | [Chinese (Simplified)](zh-CN/MIGRATION.md)
This guide covers migration from the `generator_config.yaml` conventions used
by `swagger_generator_flutter`. Perform the migration in the consuming
Dart/Flutter repository; no local machine path is assumed.
## Recommended sequence
1. Preserve the old generated output on a separate branch.
2. Copy
[koi_swagger_parser.example.yaml](../koi_swagger_parser.example.yaml) and
migrate only sources, output roots, and response wrapping first.
3. Set `clean_stale_files: false` for the first run and use `--dry-run`.
4. Generate into temporary directories and compare API signatures, JSON keys,
and imports.
5. Migrate enum, naming, and custom type mappings.
6. Run build_runner, `dart analyze`, and the host project's tests.
7. After reviewing the output, switch to the final directories and enable
`clean_stale_files: true`.
## Settings that carry over
| Previous field | New field or behavior |
|---|---|
| `input.swagger_urls` | Same name; strings/objects, `enabled`, local paths, and HTTP(S) sources are supported |
| `output.base_dir/api_dir/models_dir` | Same names |
| `output.included_tags/excluded_tags` | Same names, with glob and regular-expression support |
| `output.split_by_tags` | Same name |
| `generation.api.client.class_name/file_name` | Same names |
| `generation.api.cancel_token_support` | Same name |
| `generation.api.base_result_import` | Compatibility input; selects `external_generic` with `BaseResult` |
| `generation.models.class_prefix` | Same name |
| `generation.models.json_serializable.*` | Same names |
| `generation.models.enum_key_mappings` | Still accepted; migrate toward `enum_mappings` |
| `formatting.run_dart_format` | Same name |
An existing enum mapping does not need to be rewritten immediately:
```yaml
generation:
models:
enum_key_mappings:
TaskType:
- value: 1
name: spotCheck
description: Spot check
```
When both fields map the same wire value, `enum_mappings` wins. The generator
also recognizes `x-enumNames`, `x-enum-varnames`, and `x-enum-values`.
## Settings that need rewriting
### Response wrapper
Previous form:
```yaml
generation:
api:
base_result_type: BaseResult
base_result_import: package:app/network/base_result.dart
```
Recommended form:
```yaml
generation:
api:
response_wrapper:
mode: external_generic
detect_standard_envelope: true
success_data_field: data
external_generic:
import: package:app/network/base_result.dart
class_name: BaseResult
```
There is no automatic one-to-one migration for
`base_page_result_type/base_page_result_import`. Prefer an accurate pagination
schema in OpenAPI. To reuse an existing type, use
`generation.mappings.schemas`, or keep the server envelope in `passthrough`
mode.
### Type and import mappings
Previous top-level `type_mapping` and `imports` values were global. The new
configuration groups validated mappings under `generation.mappings`:
```yaml
generation:
mappings:
schemas:
Money:
type: Money
import: package:money2/money2.dart
types:
string:uuid:
type: UuidValue
import: package:uuid/uuid_value.dart
string:date-time: DateTime
imports:
UuidValue: package:uuid/uuid_value.dart
```
Type lookup tries `type:format` before `type`. A `schemas` entry means that the
schema is completely supplied by an external type and no Dart model is
generated for it.
### Ignored directories and files
The previous `ignored_directories` and `ignored_files` settings skipped files
after rendering and could leave APIs referencing missing models. Replace them
with semantic source filters:
```yaml
output:
excluded_paths:
- /api/v1/legacy/**
excluded_operations:
- regex:^internal
excluded_models:
- Legacy*
```
If a retained operation depends on an excluded model, generation fails with
`filter.required_model_excluded`. There is intentionally no setting that
arbitrarily drops a rendered file.
### Version extraction
Paths such as `/api/v1/` and `/v2/` are grouped automatically. To also try
extracting a version from the source URL:
```yaml
output:
extract_version_from_url: true
```
The previous custom
`generation.api.version_extraction.pattern/default_version` setting has no
one-to-one equivalent. A URL extraction failure produces a diagnostic and
falls back to path-based grouping.
### build_runner
The previous workflow commonly ran build_runner separately. It can now be
part of generation:
```yaml
formatting:
run_dart_format: true
run_build_runner: true
build_runner_mode: build
delete_conflicting_outputs: true
```
For interactive development:
```bash
dart run koi_swagger_parser generate --watch
```
Watch mode is long-running and should not be used for a one-shot CI job.
## Previous settings that are not read
These fields produce a `config.unknown_key` warning:
- `generator.name/version/author/copyright`;
- `input.validate_schema/strict_mode`;
- `output.api_file_suffix/model_file_suffix`;
- `generation.api.use_dio/parser/method_naming`;
- `generation.models.class_naming/field_naming/use_const_constructor`;
- top-level `validation.on_error/on_warning`;
- top-level generic import ordering/grouping settings.
The new generator uses structured errors for cases that would break wire
semantics or compilation. It does not provide a warn/ignore override for those
errors. Unsupported constructs that can be omitted safely, such as callbacks
and links, produce warnings.
## Generated-code differences to review
- A schema with read-only/write-only fields can become `FooRequest` and
`FooResponse`.
- An optional field whose schema disallows null still uses a nullable Dart
argument to represent absence, together with `disallowNullValue`; explicit
JSON null is rejected.
- A root object with `properties` plus `additionalProperties` flattens extras
into the JSON root.
- A `default` response is never treated as successful.
- Complex parameter styles can introduce a public transport adapter and a
private Retrofit delegate.
- API methods can return `HttpResponse<T>`; binary output can use bytes or a
stream.
- Generated enums include wire conversion, and unknown fallback uses an
internal sentinel wire value.
- Stale files are removed only through manifest ownership; a user-modified
stale file is preserved.
## CLI comparison
Previous:
```bash
dart run swagger_generator_flutter generate --all
```
New:
```bash
dart run koi_swagger_parser generate --all
dart run koi_swagger_parser generate --dry-run --verbose
```
Previous `--api`, `--models`, `--docs`, and CLI tag overrides are not retained.
Use `generation.*.enabled` and output filters. The current generator does not
emit a separate API documentation site.
## Acceptance checklist
- The dry run has no error diagnostics.
- Enum wire values and Dart member names match the previous contract.
- Request/response directional models contain the expected fields.
- Cookie, query, and path serialization produces equivalent requests.
- Error and default responses are not decoded as successful responses.
- External custom types have correct imports and JSON conversion.
- `dart run build_runner build --delete-conflicting-outputs` succeeds.
- `dart analyze` and host-project tests pass.
- The second generation reports `lifecycle.unchanged_skipped`.
- Removing an operation deletes only unmodified files owned by the manifest.
For every available backend fixture, compare runtime serialization in addition
to generated source text.
+109
View File
@@ -0,0 +1,109 @@
# Comparison with mature open-source generators
Internal research reviewed on 2026-07-31 against official documentation:
- [OpenAPI Generator dart-dio](https://openapi-generator.tech/docs/generators/dart-dio/)
- [OpenAPI Generator customization](https://openapi-generator.tech/docs/customization/)
- [Swagger Codegen](https://swagger.io/docs/open-source-tools/swagger-codegen/codegen-v3/about/)
- [Swagger Codegen generator customization](https://swagger.io/docs/open-source-tools/swagger-codegen/codegen-v3/generators/)
- [Microsoft Kiota](https://learn.microsoft.com/en-us/openapi/kiota/)
- [Kiota design](https://learn.microsoft.com/en-us/openapi/kiota/design)
- [Kiota authentication](https://learn.microsoft.com/en-us/openapi/kiota/authentication)
- [Kiota backing store](https://learn.microsoft.com/en-us/openapi/kiota/backing-store)
This document is a planning comparison, not a public compatibility promise.
See [Compatibility](../COMPATIBILITY.md) for verified behavior.
## Positioning
| Dimension | Mature community tools | `koi_swagger_parser` |
|---|---|---|
| Output scope | Many client languages, server stubs, and documentation | Dart/Flutter code inside an existing Retrofit + Freezed project |
| Specification parsing | Mature OpenAPI models/validation and large corpora | Purpose-built loader/parser/IR for a documented OpenAPI 3.0/3.1 subset |
| Extension model | Templates, custom generators, language backends, plugins | Separated preset/IR/renderer, but no stable external renderer interface |
| Naming/type reuse | Model/property/parameter/operation/type/import mappings | Comparable mapping categories with early collision checks |
| HTTP runtime | Kiota request adapter, authentication, and middleware abstractions | Dio/Retrofit, with generated adapters for supported wire styles |
| Authentication | Generated schemes or provider abstractions | Server/security metadata; credentials supplied by host Dio code |
| Model state | Optional backing stores and explicit presence tracking | Immutable Freezed models; absent/null validation without dirty tracking |
| Deliverable | Can scaffold a standalone SDK package | Generates APIs/models into an existing package |
| Lifecycle | Established CLI/plugin/build-system integrations | Local manifest, locking, hashes, safe stale cleanup, and build_runner |
## Current differentiators
The official OpenAPI Generator dart-dio feature table marks multi-server,
parameterized-server, and parameter-styling support as unavailable. Within its
documented subset, `koi_swagger_parser` implements root/path/operation server
variables and path/query/header/cookie style/explode handling, using a typed
adapter when Retrofit annotations cannot express the wire format.
Other project-specific strengths include:
- OpenAPI 3.1 nullable versus required/absent behavior for
json_serializable;
- root `properties` plus `additionalProperties` round trips;
- directional read-only/write-only models;
- discriminator and structural union matching;
- external generic Koi response envelopes;
- multiple enum vendor extensions plus configuration precedence;
- hash-protected cleanup limited to generator-owned files;
- generated-package build_runner/analyze/test verification.
These are useful differentiators for Koi-style Dart clients; they do not imply
broader specification coverage than mature general-purpose generators.
## Gaps compared with mature ecosystems
### Specification and JSON Schema breadth
The implementation is not a complete JSON Schema 2020-12 validator. Broader
third-party document coverage would require selected `$defs`/JSON Pointer
cases, conditional and unevaluated schemas, richer constraint interaction,
larger official/community corpora, fuzz/property tests, and resource/performance
benchmarks.
### Renderer and template extensibility
OpenAPI Generator and Swagger Codegen allow template replacement and custom
language generators. A comparable extension boundary would require a
versioned IR contract, renderer interface, template overrides, alternative
Dart stacks, layout/header hooks, and generator compatibility checks.
### Runtime abstraction
Kiota separates requests, serialization, authentication, and transport. A
future Dio-oriented optional runtime layer could provide typed authentication
providers, retry/telemetry hooks, error mapping, pagination/long-running
operation helpers, download progress, and presence tracking for PATCH.
### Standalone SDK delivery
Generating a publishable SDK rather than files inside an application would
also require package scaffolding, dependency-version policy, generated
documentation/examples, semantic breaking-change reports, runtime/generator
version pinning, and reproducible release archives.
## Explicitly deferred scope
- Swagger/OpenAPI 2.0.
- Callbacks, webhooks, and Link Object execution.
- XML, Protobuf, and arbitrary codecs.
- Complete OAuth login and mutual-TLS runtime flows.
- Server stubs and mock servers.
- Non-Dart code generation.
These do not block the current Koi/FastAPI client goal, but encountering them
must produce documented diagnostics rather than plausible-looking output.
## Suggested priority
1. Expand fixture/fuzz coverage and add large-document performance benchmarks.
2. Version the IR and define a renderer/template extension boundary.
3. Add semantic diff and CI-check modes.
4. Add optional authentication/error/middleware hooks.
5. Add PATCH presence and dirty tracking if demanded by consumer APIs.
6. Expand the JSON Schema subset only for concrete production fixtures.
7. Add Swagger 2, XML, or callbacks only when a confirmed consumer requires
them.
This order preserves the focused Dart/Koi value while adopting engineering
features with demonstrated reuse value.
+75
View File
@@ -0,0 +1,75 @@
# Product goal
This is an internal planning record for the 0.2.0 development milestone.
User-facing support commitments are defined by
[Compatibility](../COMPATIBILITY.md).
## Objective
Evolve `koi_swagger_parser` from a Koi/FastAPI happy-path script into a
reliable, diagnosable, and extensible OpenAPI 3.0/3.1 Dart/Flutter client
generator. Preserve the loader -> resolver/merger -> parser/IR ->
preset/renderer -> lifecycle architecture and the Koi-oriented
Retrofit/Freezed output style.
## Definition of done
1. **Diagnostics**: versions, invalid structures, unsupported constructs,
conflicts, and degradations have stable codes and source/location context.
2. **References**: local, relative-file, and remote references cover component
groups with caching, cycle detection, multi-source conflict handling, and
dependency tracking.
3. **Mappings**: enum configuration and vendor extensions have deterministic
precedence and unknown fallback; model, property, parameter, operation,
schema, type, and import mappings are validated.
4. **HTTP**: path/query/header/cookie serialization and supported
style/explode/allowReserved combinations are correct; JSON, multipart,
form-urlencoded, text, and binary transfers work.
5. **Responses**: multiple 2xx, 204, `2XX`, `default`, error models, headers,
raw `HttpResponse`, and byte/stream output are represented.
6. **Schemas**: required/nullable/absent, read-only/write-only,
additional-properties, composition/union, custom-scalar, and constraint
metadata enter the IR and output within documented boundaries.
7. **Client metadata**: servers/base URLs and security requirements are
available while remaining compatible with host Dio interceptors.
8. **Lifecycle**: configuration is effective; manifest locking/hashing,
unchanged skips, safe stale cleanup, filters, and build_runner integration
are complete.
9. **Quality gate**: layered tests, conflict/error cases, OpenAPI 3.0/3.1
fixtures, golden output, and a temporary generated-package
build/analyze/test verification pass.
10. **Documentation**: README, configuration, compatibility, diagnostics, and
migration documentation match the implementation in English and
Simplified Chinese.
## Non-goals for this milestone
- Swagger/OpenAPI 2.0.
- Callback, webhook, and Link Object call generation.
- XML, Protobuf, and arbitrary codecs.
- Complete JSON Schema 2020-12.
- Server stubs, mock servers, and non-Dart output.
- Independent generated-SDK publishing.
Non-goals must still produce an explicit warning or error when encountered.
## Evidence
```bash
dart format .
dart analyze
dart test
dart run tool/verify_generated_package.dart
```
The end-to-end verifier must let the generator invoke build_runner and then
run analysis and runtime tests in the generated package. String-fragment checks
alone are insufficient.
## Next horizon
Potential follow-up work includes a larger fixture/fuzz/performance corpus,
versioned IR and renderer interfaces, semantic-diff/CI mode, optional
authentication/error/middleware adapters, and PATCH presence tracking.
JSON Schema or Swagger 2 scope should expand only in response to concrete
consumer fixtures.
+154
View File
@@ -0,0 +1,154 @@
# 架构
[English](../ARCHITECTURE.md) | [简体中文](ARCHITECTURE.md)
生成流水线有意拆分为多个阶段:
```text
配置
-> source loader
-> external/local $ref resolver
-> multi-source merger
-> validation + parser
-> transport/schema IR
-> preset + Dart renderer
-> manifest lifecycle
-> fix / format / build_runner
```
核心不变量是:加载阶段产生自包含且依赖可追踪的文档;parser 表达 OpenAPI
语义;renderer 不重新解释规范;lifecycle 只管理 manifest 中声明的文件。
## 配置
`lib/src/config/config.dart`:
- 查找 `koi_swagger_parser.yaml` 和兼容的 `generator_config.yaml` 名称;
- 相对 source/output 路径以配置文件目录解析;
- 从 `--cwd` 或显式配置目录确定宿主项目命令目录;
- 在转换 YAML scalar 前完成类型校验;
- 解析 enum、命名、type/import mapping 和 filter;
- 校验不支持的组合、mapping collision、正则和 enum mapping;
- 支持从旧 `base_result_import` 迁移。
未知 key 产生 warning;非法类型和不完整组合产生 error。详见
[配置参考](CONFIGURATION.md)。
## Loader、resolver 与 merger
对每个 root source,`OpenApiReferenceResolver`:
1. 规范化本地文件、file URL 或 HTTP(S) source;
2. 缓存每个加载的文档;
3. 解析 local、relative 和 remote `$ref`;
4. 把外部 component bundle 到根文档;
5. 为 component 冲突分配确定性名称;
6. 记录依赖并诊断无法支持的跨文档循环。
`OpenApiDocumentMerger` 再按配置顺序合并 root:
- 重复的 operation method/path 是 error;
- 同名但内容不同的 component 会确定性改名,并重写该 source 的 ref;
- 后输入不会静默覆盖冲突;
- 版本、info 和 path parameter 冲突会被报告。
parser 原则上只应收到根文档 JSON Pointer。仍然存在的 external ref 会被视为
resolver error。
## Validation 与 parser
`OpenApiDocumentParser` 负责:
- OpenAPI 3.0/3.1 版本和关键结构校验;
- operation、path、tag 和 model filter;
- parameter location、style、explode、`allowReserved` 和 `content`;
- request media type,以及 response status/content/header;
- server、security scheme 和 security requirement;
- required、nullable、readOnly/writeOnly 和 `additionalProperties` 语义;
- enum 配置/vendor extension 优先级;
- `allOf`、discriminator union 和已支持的 generic union;
- custom mapping 和 Dart 名称冲突。
无法用可靠 wire 语义渲染的结构不能静默退化为 `dynamic`。安全但有信息损失的
继续处理会产生 warning,例如不同成功 JSON body type 可能统一为 `Object?`。
XML、Protobuf 等没有已支持 decoder 的 media type 会产生 error。
## 中间表示
`lib/src/ir/ir.dart` 是 parser/renderer 契约,主要节点包括:
- `ApiOperation`:method、path、tag、version、server 和 security;
- `ApiParameter`:Dart/wire name、location、style、explode 和
`allowReserved`;
- `ApiRequestBody`:JSON、multipart、form-urlencoded 和 binary request;
- `ApiResponse`:status/default、body kind/type、content type 和 header;
- `ModelSpec`:object、enum、union model,以及 usage category 和 extras;
- `ModelField`:required/nullability/read-write flag 和 constraint;
- `DartType`:named、list、map、object、dynamic 和 void type graph。
model 按 request、result、shared、enum 分类。包含 readOnly/writeOnly 属性的
component 可投影为 request/response variant,避免 request 强制只读值,也避免
response 暴露 write-only secret。
generic `oneOf` 要求恰好一个 decoder 匹配。generic `anyOf` 保留 raw value 和
全部匹配 case。object 分支在 decode 前会用 required key 和 enum constraint
预筛。
## Preset 与 renderer
当前 `koi_flutter` preset 面向 Dio、Retrofit、Freezed 和 json_serializable。
renderer:
- 对 Retrofit 可表达的 parameter 形式使用 Retrofit annotation;
- 当已支持的 style、cookie 或 `allowReserved` 组合需要自定义序列化时,生成
typed public adapter 和 private Retrofit delegate;
- 渲染 Freezed object、enum 和 union model;
- 为根级 `properties` + `additionalProperties` 渲染自定义 map 读写 helper;
- 收集 model dependency 和 external type import;
- 按 version/tag 分组 API,并输出 client/index 文件。
renderer 不加载 ref,也不判断 OpenAPI 结构是否合法。
## 生成生命周期
落盘前,`GenerationLifecycle` 计算:
- canonical merged spec SHA-256;
- normalized config SHA-256;
- 每个渲染 path/content SHA-256。
manifest 记录 schema/generator version、source root、dependency 和每个主生成
文件的实际 hash。写入使用操作系统 exclusive lock 和同目录临时文件 rename。
安全不变量:
1. renderer 输出必须位于配置的 API/model 根;
2. stale candidate 必须来自旧 manifest;
3. stale path 仍必须位于当前 managed root;
4. 删除前当前 hash 必须等于旧 manifest hash;
5. 损坏或未知版本 manifest 不参与删除;
6. fix/format/build 失败时不写 success manifest;
7. watch 在释放生成锁后启动。
`.g.dart` 和 `.freezed.dart` 由 build_runner 管理,不进入本生成器的 stale
manifest。
## Response wrapper
parser 不硬编码 `BaseResult`:
- `passthrough` 返回 OpenAPI response schema;
- `external_generic` 检测配置的成功 envelope,并返回
`ExternalWrapper<Inner>`。
`default` response 仍是错误响应。`response_mode: http_response` 保留 Dio
status/header。错误 response model 仍保留在 IR 和生成 model 集合中。
## 扩展边界
IR/preset/renderer 分层为未来的版本化 renderer interface、template override、
semantic diff 和可选 runtime adapter 留出空间。这些是设计方向,不是当前
公开版本的功能。当前实现边界见[兼容性](COMPATIBILITY.md)。
更多生态对比属于项目内部调研,不随发布 package 交付。
+67
View File
@@ -0,0 +1,67 @@
# 更新日志
本文件记录此 package 的重要变更。
[English](../../CHANGELOG.md) | [简体中文](CHANGELOG.md)
## 0.2.0 - 2026-07-31
### 破坏性变更
- 主 library 不再导出 `DartType`、`ModelSpec`、`ApiOperation`、
`GeneratedFile`、`GeneratedBundle` 等 parser IR 实现类型;请改用
`KoiSwaggerParser` 及其文档化的 `GenerationResult`;
- 不再导出 `KoiSwaggerConfigLoader`;程序化加载配置请改用
`KoiSwaggerConfig.load()`。
### 新增
- 结构化配置、OpenAPI、过滤和生命周期诊断;error 不再静默生成已知错误代码;
- local、relative、file 和 HTTP(S) `$ref` 解析,包括缓存、循环检测、依赖
追踪、外部 component bundling 和确定性多 source 冲突处理;
- `enum_mappings`、兼容字段 `enum_key_mappings`、常见 `x-enum` 扩展、描述和
可选 unknown enum fallback;
- model、property、parameter、operation、schema、type 和 import 映射及冲突校验;
- OpenAPI path、query、header、cookie 参数序列化,包括已支持的 matrix、label、
form、simple、spaceDelimited、pipeDelimited、deepObject、explode 和
`allowReserved` 组合;
- JSON、text、multipart、form-urlencoded 和 binary upload,以及 bytes/stream
download;
- 多成功响应、204、`2XX`、`default`、错误模型、响应头和 `HttpResponse<T>` 模式;
- required、nullable、absent 语义、readOnly/writeOnly 投影,以及根级
`properties` + `additionalProperties` round trip;
- `allOf`、discriminator union,以及已支持的 primitive、container、object
`oneOf`/`anyOf`;
- server variables、security schemes 和 operation OR/AND security metadata;
- tag、path、operation、model 的精确、glob 和正则过滤;
- 带锁 manifest、spec/config/content SHA-256、未变化跳过、安全 stale cleanup、
原子写入和 dry-run 变更计划;
- 内置 build_runner build/watch 集成、reviewed golden、OpenAPI 3.0/3.1 fixture
和临时生成包验证;
- 英文 canonical 文档与完整简体中文镜像,以及配置、兼容性、诊断和迁移参考。
### 变更
- `GenerationResult.writtenFiles` 现在只包含实际写入文件;`generatedFiles` 包含
完整目标集合;dry-run 使用 `wouldWriteFiles` 和 `wouldDeleteFiles`;
- 显式传入配置文件且未设置 `workingDirectory`/`--cwd` 时,配置文件目录成为
宿主项目工作目录;
- `default` response 不再视为成功;XML、Protobuf 和没有安全 decoder 的其他
response media type 会产生诊断;
- `dart fix` 和 `dart format` 仅处理本轮改变的主生成文件。
- 所有非测试 Dart 声明均在不使用文件级排除的情况下满足
`public_member_api_docs`,包括内部 IR 和 lifecycle 类型。
### 修复
- 使 `formatting.run_dart_fix` 和 `output.extract_version_from_url` 真正生效;
- 避免 Dio/Retrofit `Headers` import 冲突、Freezed custom `toJson` 缺失,以及
parameter、field、method mapping 碰撞。
- lifecycle manifest 记录的生成器版本已与 package `0.2.0` 同步。
## 0.1.0 - 2026-06-02
- 首次发布 `koi_swagger_parser`;
- 支持常见 OpenAPI 3.1/FastAPI 解析、Koi 风格 Retrofit API 生成和 Freezed
模型生成;
- 支持兼容读取旧版 `generator_config.yaml`。
+113
View File
@@ -0,0 +1,113 @@
# 兼容性
[English](../COMPATIBILITY.md) | [简体中文](COMPATIBILITY.md)
本矩阵只描述当前实现和自动化测试已覆盖的行为。
- **支持**:已经实现并测试;
- **部分支持**:仅支持“边界”列出的子集;
- **不支持**:生成前产生诊断,或 warning 后明确忽略。
## 文档、引用与合并
| 能力 | 状态 | 边界 |
|---|---|---|
| OpenAPI 3.0.x / 3.1.x | 支持 | JSON/YAML;客户端生成所需常见 Schema Object 子集 |
| Swagger/OpenAPI 2.0 | 不支持 | `openapi.unsupported_version` error |
| 本地 `$ref` | 支持 | 各 component 分组的 JSON Pointer |
| 相对文件 / file URL `$ref` | 支持 | 相对当前引用文档解析 |
| HTTP(S) `$ref` | 支持 | 单次运行缓存、依赖追踪和循环诊断 |
| 外部 component 重名 | 支持 | 确定性改名并 warning |
| 多 root source | 支持 | 确定性 merge;component/operation 冲突诊断 |
| unresolved `$ref` | 支持诊断 | error,不静默转换成 `dynamic` |
| callbacks / webhooks | 不支持 | warning,不生成反向调用 client |
| Link Object | 不支持 | `openapi.links_not_supported` warning |
当前不是完整 JSON Schema 2020-12 实现。`$defs` 作为生成根、
`if`/`then`/`else`、`not`、`contains`、`dependentSchemas` 和 unevaluated
property/item 语义不在当前契约中。
## Schema 与 model
| 能力 | 状态 | 边界 |
|---|---|---|
| object / array / map | 支持 | 递归容器和 inline schema |
| `properties` + `additionalProperties` | 支持 | extra entry 在 JSON 根 round trip |
| string/number/integer/bool/date/date-time | 支持 | format 可由 type mapping 覆盖 |
| enum | 支持 | string、number、boolean wire value;inline/component |
| enum 成员名映射 | 支持 | 配置及常见 `x-enum` extension |
| enum unknown fallback | 支持 | 可配置 `JsonKey.unknownEnumValue` 输出 |
| required / nullable / absent | 支持 | 反序列化区分缺失和不允许 null;普通 Freezed 字段不保留 presence 历史 |
| readOnly / writeOnly | 支持 | 必要时生成 request/response directional variant |
| `allOf` | 支持 | 合并 object property、required 和 additional property |
| discriminator `oneOf`/`anyOf` | 支持 | local 或已归一化 object branch |
| 无 discriminator `oneOf`/`anyOf` | 部分支持 | primitive/container/object;重叠 `oneOf` 匹配会拒绝 |
| custom schema/type/format | 支持 | external import;宿主代码负责 JSON 转换契约 |
| constraint metadata | 部分支持 | 保留 description/doc comment;不生成 runtime validator |
| presence/dirty tracking | 不支持 | 无 Kiota 风格 backing store |
## HTTP wire 语义
| 能力 | 状态 | 边界 |
|---|---|---|
| path/query/header/cookie parameter | 支持 | type、required/default 和 wire name |
| matrix / label / simple | 支持 | 校验 location/style 组合 |
| form / spaceDelimited / pipeDelimited / deepObject | 支持 | 校验 query/cookie 组合 |
| explode / `allowReserved` | 支持 | Retrofit 无法表达时生成 transport adapter |
| Parameter Object `content` | 部分支持 | 必须恰有一个 media type |
| JSON / `+json` request body | 支持 | required body 和 null-to-absent |
| text request body | 支持 | typed `@Body` |
| `multipart/form-data` | 支持 | binary、primitive、array 和 structured JSON part |
| `application/x-www-form-urlencoded` | 支持 | typed field;复杂 encoding 使用 adapter |
| binary upload | 支持 | `Uint8List` |
| binary download | 支持 | `Uint8List` 或 Dio `ResponseBody` stream |
| XML / Protobuf / 任意 custom codec | 不支持 | content-type error,不生成不安全 decoder |
## Response、server 与 security
| 能力 | 状态 | 边界 |
|---|---|---|
| 多个 2xx / 204 / `2XX` | 支持 | 确定性 primary response;不同 JSON type 可 warning 后变成 `Object?` |
| `default` / 4xx / 5xx | 支持 | 保留 error model/IR metadata;不作为 success |
| response header | 支持 | status、type、required flag 和 description |
| raw response metadata | 支持 | `response_mode: http_response` |
| binary + JSON 混合 success media | 支持诊断 | 单个 Retrofit 方法无法安全 decode 时 error |
| root/path/operation server | 支持 | server variable default/enum 校验;preferred server 成为 client default |
| 多 server runtime 切换 | 部分支持 | 保留 metadata;不生成环境选择 UI |
| API key / Bearer / Basic | 支持 metadata | 生成 scheme/operation requirement;凭证由宿主 interceptor/provider 提供 |
| OAuth2 / OpenID / mutual TLS | 部分支持 | 仅 scheme/scope metadata;不生成完整授权流程 |
| security OR/AND requirement | 支持 | list 项是 OR;同一 map 项中的 scheme 是 AND |
## 生成与生命周期
| 能力 | 状态 | 边界 |
|---|---|---|
| Retrofit + Freezed | 支持 | 当前版本唯一完整 renderer |
| passthrough / external generic wrapper | 支持 | 可配置标准 envelope 检测 |
| model/property/parameter/operation mapping | 支持 | collision 校验和安全 identifier |
| schema/type/import mapping | 支持 | 递归 Dart `List`/`Map` type expression |
| tag/path/operation/model filter | 支持 | exact、glob 和 `regex:` |
| dry-run | 支持 | 报告 would-write/would-delete |
| Dart fix / format | 支持 | 仅本轮改变的主生成文件 |
| build_runner build/watch | 支持 | 在配置的宿主项目目录执行 |
| unchanged skip | 支持 | spec/config/content 和实际文件 SHA-256 |
| stale cleanup | 支持 | manifest ownership、root containment、用户修改保护 |
| concurrent generation lock | 支持 | manifest 旁的操作系统 exclusive lock |
| external template / plugin renderer | 不支持 | IR/preset 已分层,但无公开 renderer SPI |
| 完整 SDK package scaffold | 不支持 | 在已有项目内生成 API/model |
## 验证覆盖
仓库测试覆盖:
- parser、IR、renderer 和 lifecycle 阶段;
- OpenAPI 3.0 YAML、OpenAPI 3.1 JSON/FastAPI fixture;
- enum/命名 mapping、parameter wire style、response、schema 语义、security
和 external/cyclic ref;
- reviewed SHA-256 golden;
- 生成到临时 Dart package,随后运行 build_runner、analysis 和 runtime test。
矩阵中的“支持”不代表超出所列边界的完整 OpenAPI/JSON Schema conformance。
引入新的 schema corpus 时,应先用最小 fixture 和 dry-run 验证。
诊断含义和处理方式见[诊断参考](DIAGNOSTICS.md)。
+254
View File
@@ -0,0 +1,254 @@
# 配置参考
[English](../CONFIGURATION.md) | [简体中文](CONFIGURATION.md)
`koi_swagger_parser` 使用 YAML 配置。完整注释示例见
[koi_swagger_parser.example.yaml](../../koi_swagger_parser.example.yaml)。
未知字段会产生 `config.unknown_key`;非法 scalar 类型和非法组合会在 OpenAPI
解析前报错。
## 查找顺序与路径语义
未传入 `--config` 时,加载器按以下顺序在工作目录查找:
1. `koi_swagger_parser.yaml`
2. `koi_swagger_parser.yml`
3. `generator_config.yaml`
4. `generator_config.yml`
所有相对 OpenAPI 文件 source、输出目录和显式 manifest 路径都以所选配置文件的
目录为基准。未找到配置文件时,以命令调用目录为基准。
显式传入配置文件且未指定 `--cwd` 时,其目录也会成为 build_runner 使用的宿主
项目工作目录。`--cwd` 可覆盖命令工作目录,但不会重新解释配置中的相对路径。
HTTP(S) source URL 保持 URL 语义。
## 顶层字段
| 字段 | 类型 | 必填 | 默认值 |
|---|---|---:|---|
| `input` | map | 是 | 无 |
| `output` | map | 否 | 见下文 |
| `generation` | map | 否 | 见下文 |
| `formatting` | map | 否 | 见下文 |
必须至少配置一个启用的 `input.swagger_urls` 项。
## `input`
### `input.swagger_urls`
类型:字符串或 source 对象的列表。默认:空,会报错。
```yaml
input:
swagger_urls:
- ./openapi.yaml
- url: https://example.com/openapi.json
enabled: true
- url: ./experimental.yaml
enabled: false
```
source 对象字段:
| 字段 | 类型 | 必填 | 默认值 | 含义 |
|---|---|---:|---|---|
| `url` | string | 是 | 无 | 本地路径、file URL 或 HTTP(S) URL |
| `enabled` | boolean | 否 | `true` | 是否参与生成 |
source 按配置顺序处理。多个根文档会确定性合并;operation 冲突报错,component
冲突会确定性改名并产生诊断。
## `output`
| 字段 | 类型 | 默认值 | 含义 |
|---|---|---|---|
| `base_dir` | string path | `./lib/src` | 生成代码公共根 |
| `api_dir` | string path | `<base_dir>/api` | API 输出根 |
| `models_dir` | string path | `<base_dir>/api_models` | model 输出根 |
| `included_tags` | string list | 未设置 | 包含匹配 tag 的 operation |
| `excluded_tags` | string list | 未设置 | 排除匹配 tag 的 operation |
| `included_paths` | string list | 未设置 | 包含匹配的 OpenAPI path |
| `excluded_paths` | string list | 未设置 | 排除匹配的 OpenAPI path |
| `included_operations` | string list | 未设置 | 包含匹配 operation |
| `excluded_operations` | string list | 未设置 | 排除匹配 operation |
| `included_models` | string list | 未设置 | 把匹配 schema 加为生成根 |
| `excluded_models` | string list | 未设置 | 排除匹配 model |
| `split_by_tags` | boolean | `true` | 按 operation tag 拆分 API 文件 |
| `extract_version_from_url` | boolean | `false` | 尝试从 source URL 提取分组版本 |
| `clean_stale_files` | boolean | `true` | 安全删除不再生成的自有文件 |
| `skip_if_unchanged` | boolean | `true` | spec、config 和内容未变时跳过写入 |
| `manifest_path` | string path | `<project>/.dart_tool/koi_swagger_parser/manifest.json` | 所有权/hash manifest |
过滤项支持:
- 精确字符串;
- `*`、`?`、`**` glob;
- `regex:<expression>`,使用 Dart 正则语法。
同一值同时匹配 include/exclude 时,exclude 优先;相同字面量同时出现在两边会
产生 warning。若保留的 operation 依赖被排除 model,生成会失败,不会输出无法
编译的客户端。
stale cleanup 仅处理旧 manifest 记录、仍位于当前 API/model 根、且内容仍匹配
旧 hash 的文件。manifest 应放在生成的 API/model 根之外。
## `generation.preset`
| 字段 | 类型 | 默认值 | 可选值 |
|---|---|---|---|
| `name` | string | `koi_flutter` | `koi_flutter` |
当前版本只有一个 renderer preset:Dio + Retrofit API 和
Freezed/json_serializable model。
## `generation.api`
| 字段 | 类型 | 默认值 | 可选值/含义 |
|---|---|---|---|
| `enabled` | boolean | `true` | 生成 API |
| `use_retrofit` | boolean | `true` | 启用 API 时必须为 `true` |
| `cancel_token_support` | boolean | `false` | 添加 Dio `CancelToken` 参数 |
| `response_mode` | string | `body` | `body`、`http_response` |
| `binary_response_mode` | string | `bytes` | `bytes`、`stream` |
`generation.api.enabled: true` 要求 `generation.models.enabled: true`,因为
当前 Retrofit renderer 会导入生成的 model index。
### `generation.api.client`
| 字段 | 类型 | 默认值 | 含义 |
|---|---|---|---|
| `class_name` | string | `ApiClient` | 生成的 client class 名 |
| `file_name` | string | `api_client` | 生成的 client 文件名 |
值会转换为安全的 Dart identifier/file name。
### `generation.api.response_wrapper`
| 字段 | 类型 | 默认值 | 可选值/含义 |
|---|---|---|---|
| `mode` | string | `passthrough` | `passthrough`、`external_generic` |
| `success_data_field` | string | `data` | envelope 中的成功 payload 字段 |
| `detect_standard_envelope` | boolean | `true` | 检测配置的 code/message/data 风格 envelope |
| `external_generic.import` | string | 无 | `external_generic` 模式必填 |
| `external_generic.class_name` | string | `BaseResult` | 外部 generic wrapper 类型 |
```yaml
generation:
api:
response_wrapper:
mode: external_generic
success_data_field: data
detect_standard_envelope: true
external_generic:
import: package:your_network/your_network.dart
class_name: BaseResult
```
兼容字段 `generation.api.base_result_import` 会在未显式设置 mode 时选择
`external_generic`,class name 为 `BaseResult`。新配置应优先使用结构化写法。
## `generation.models`
| 字段 | 类型 | 默认值 | 含义 |
|---|---|---|---|
| `enabled` | boolean | `true` | 生成 model |
| `class_prefix` | string | `Koi` | 生成 model 名称前缀 |
| `default_non_nullable_list_to_empty` | boolean | `true` | 符合条件的非空 list 默认使用空 list |
| `enum_unknown_fallback` | boolean | `false` | 生成并使用 `unknown` enum 成员 |
| `enum_unknown_default_case` | boolean | `false` | 上一字段的兼容别名 |
### `generation.models.json_serializable`
| 字段 | 类型 | 默认值 |
|---|---|---|
| `checked` | boolean | `true` |
| `explicit_to_json` | boolean | `true` |
| `include_if_null` | boolean | `false` |
这些值会在适用场景传给生成的 json_serializable annotation。
### Enum 映射
支持 list 和 map 两种写法:
```yaml
generation:
models:
enum_mappings:
OrderStatus:
- value: pending
name: waitingForPayment
description: 等待付款
- value: done
name: completed
Priority:
1: low
2:
name: high
description: 需要关注
```
外层 key 是 OpenAPI enum/schema 名称。wire `value` 可以是 string、number 或
boolean;`name` 必须是非空 Dart 成员名;`description` 可选。
兼容字段 `enum_key_mappings` 接受同样结构。两个字段映射同一 enum value 时,
`enum_mappings` 优先并产生 warning。vendor extension 优先级见
[README 的 Enum 映射](../../README.zh-CN.md#enum-映射)。
## `generation.mappings`
所有 mapping 段默认都是空 map。
| 字段 | 结构 | 含义 |
|---|---|---|
| `models` | `schema: DartName` | 重命名生成的 component model |
| `properties` | `schema: {wireProperty: dartField}` | 重命名指定 schema 的属性 |
| `parameters` | `operation: {wireParameter: dartParameter}` | 重命名指定 operation 的参数 |
| `operations` | `operation: dartMethod` | 重命名 operation |
| `schemas` | `schema: type-or-object` | 使用外部 Dart 类型,不生成该 schema |
| `types` | `openapiType[:format]: type-or-object` | 映射 OpenAPI scalar/type pattern |
| `imports` | `DartType: packageImport` | 为映射后的 Dart 类型提供 import |
operation key 可以是 `operationId` 或 `METHOD /path`。type mapping 先尝试
`type:format`,再尝试 `type`。
type-or-object 可以是 Dart 类型字符串,也可以是:
```yaml
type: UuidValue
import: package:uuid/uuid_value.dart
```
同一 mapping scope 内,两个 source name 不能指向同一个 Dart 名。相同 schema
不能同时出现在 `models` 和 `schemas`。同一个 Dart 类型不能解析出冲突 import。
这些组合都会报配置错误。
## `formatting`
| 字段 | 类型 | 默认值 | 可选值/含义 |
|---|---|---|---|
| `run_dart_fix` | boolean | `false` | 对本轮改变的主生成文件执行 `dart fix --apply` |
| `run_dart_format` | boolean | `true` | 格式化本轮改变的主生成文件 |
| `run_build_runner` | boolean | `false` | 主文件写入后运行 build_runner |
| `build_runner_mode` | string | `build` | `build`、`watch` |
| `delete_conflicting_outputs` | boolean | `true` | 向 build_runner 传入 `--delete-conflicting-outputs` |
CLI `--watch` 会在非 dry-run 生成后启动 build_runner watch,不受配置 mode
限制。watch 是长驻流程,适合交互式开发,不适合一次性 CI job。
## 组合规则
加载器会强制以下关系:
- API 和 model 不能同时关闭;
- API 当前依赖 model;
- API 当前要求 `use_retrofit: true`;
- `external_generic` response wrapper 需要非空 import;
- 未知 preset、enum/response/build-runner mode 会报错;
- 非法 filter 正则会报错;
- mapping target/import 冲突会报错。
稳定 code 和处理方式见[诊断参考](DIAGNOSTICS.md)。
+150
View File
@@ -0,0 +1,150 @@
# 诊断
[English](../DIAGNOSTICS.md) | [简体中文](DIAGNOSTICS.md)
配置、加载、解析、渲染和生命周期问题统一表示为 `KoiDiagnostic`:
```dart
class KoiDiagnostic {
final String code;
final DiagnosticSeverity severity; // info | warning | error
final String message;
final String? source;
final String? location;
}
```
`code` 是稳定的机器可读标识;message 文案可能随版本改进。`source` 表示配置
路径、文档 URL/文件或生成文件;`location` 通常是 source 内的 JSONPath、
JSON Pointer 或其他逻辑位置。
## Severity 行为
- `info`:正常决策,例如跳过未变化的生成;
- `warning`:输出仍然可靠,但发生改名、安全降级或明确省略的非阻塞能力;
- `error`:继续执行可能产生无效客户端,生成器抛出
`KoiDiagnosticException`。
CLI 默认输出 warning/error;`--verbose` 还会输出 info 和失败堆栈。
library 调用方可检查诊断:
```dart
try {
final result = await const KoiSwaggerParser().generate();
for (final item in result.diagnostics) {
print(item.format());
}
} on KoiDiagnosticException catch (error) {
for (final item in error.diagnostics) {
print(item.format());
}
}
```
自动化应匹配 `code`,不要匹配 message 文本。需要区分上下文时再断言 `source`
或 `location`。
## 常见 code
### 配置
| Code | 含义和处理 |
|---|---|
| `config.missing_sources` | 添加至少一个启用的 `input.swagger_urls`。 |
| `config.unknown_key` | 修正拼写或删除当前版本不认识的字段;warning。 |
| `config.invalid_section` | 把 scalar/list 改成预期 YAML map。 |
| `config.invalid_string` / `config.invalid_boolean` | 修正 YAML scalar 类型。 |
| `config.invalid_string_list` | include/exclude filter 必须是字符串列表。 |
| `config.invalid_filter_regex` | 修正 `regex:` 后的 Dart 表达式。 |
| `config.no_outputs_enabled` | API/model 不能同时关闭。 |
| `config.api_requires_models` | 当前 API renderer 依赖生成 model。 |
| `config.unsupported_api_renderer` | 保持 `use_retrofit: true`;目前没有其他公开 API renderer。 |
| `config.external_wrapper_import_required` | 添加 `external_generic.import`。 |
| `config.mapping_target_collision` | 为 mapping source 设置不同的 Dart 目标名。 |
| `config.import_mapping_conflict` | 同一映射 Dart 类型只能使用一个 import。 |
| `config.conflicting_schema_mapping` | 同一 schema 不能既重命名又由外部类型替换。 |
| `config.ambiguous_enum_mapping` | 使用类型明确、无歧义的 enum wire value。 |
### 文档与引用
| Code | 含义和处理 |
|---|---|
| `openapi.missing_version` / `openapi.unsupported_version` | 提供 OpenAPI 3.0.x/3.1.x 文档。 |
| `openapi.invalid_info` / `openapi.invalid_paths` | 修正文档对应关键结构。 |
| `openapi.source_load_failed` / `openapi.source_http_error` | 检查文件路径、URL、访问权限和响应状态。 |
| `openapi.unresolved_ref` | 本地 JSON Pointer 不存在。 |
| `openapi.unresolved_external_ref` | 外部资源/fragment 不存在或无法加载。 |
| `openapi.external_ref_not_bundled` | ref 归一化失败;检查前序 source/resolver 诊断。 |
| `openapi.external_component_renamed` | 外部 component 冲突已确定性改名;warning。 |
| `openapi.multisource_component_renamed` | root 间 component 冲突已确定性改名;warning。 |
| `openapi.multisource_operation_conflict` | 两个 root 定义相同 method/path;删除或拆分其中一个。 |
| `openapi.callbacks_not_supported` / `openapi.webhooks_not_supported` | 不生成反向调用 client;warning。 |
| `openapi.links_not_supported` | 不生成 Link Object 调用关系;warning。 |
### Parameter、request 与 response
| Code | 含义和处理 |
|---|---|
| `openapi.invalid_parameter_style` | 使用与 parameter location 匹配的 style。 |
| `openapi.deep_object_requires_object` | deepObject parameter 必须使用 object schema。 |
| `openapi.delimited_parameter_requires_array` | space/pipe-delimited parameter 必须使用 array schema。 |
| `openapi.parameter_content_count` | Parameter Object `content` 必须恰有一个 media type。 |
| `openapi.request_body_schema_missing` | 为选中的 request media type 添加 schema。 |
| `openapi.unsupported_request_content_type` | 使用已支持 request codec 或手写 endpoint。 |
| `openapi.unsupported_response_content_type` | 使用已支持 response codec 或手写 endpoint。 |
| `openapi.success_response_missing` | 添加显式 2xx response;`default` 不是 success。 |
| `openapi.multiple_success_response_types` | 多个成功 JSON body type 统一为 `Object?`;warning。 |
| `openapi.mixed_success_media_types` | 单个生成方法无法安全 decode binary/stream + JSON success。 |
| `openapi.path_parameter_forced_required` | 声明为 optional 的 path parameter 被强制为 required;warning。 |
### Schema 与命名
| Code | 含义和处理 |
|---|---|
| `openapi.property_read_write_only` | property 不能同时 read-only/write-only。 |
| `openapi.invalid_additional_properties` | 使用 boolean 或 Schema Object。 |
| `openapi.property_name_collision` | 自动 suffix 避免了 Dart field collision;warning。 |
| `openapi.operation_name_collision` | 自动 suffix 避免了 method collision;warning。 |
| `openapi.operation_argument_name_collision` | parameter 与 body、cancel token 或其他参数冲突。 |
| `openapi.duplicate_enum_member_name` | 多个 wire value 映射到同名,已添加 suffix。 |
| `openapi.enum_member_name_sanitized` | enum member name 已转换为合法 Dart identifier。 |
| `filter.required_model_excluded` | 选中 API 依赖被排除 model;包含该 model 或排除调用方。 |
| `filter.model_pattern_unmatched` | `included_models` pattern 没有匹配 schema。 |
### Server 与 security
| Code | 含义和处理 |
|---|---|
| `openapi.server_url_missing` | 添加非空 server URL。 |
| `openapi.server_variable_default_missing` | 为每个 server variable 提供 default。 |
| `openapi.server_variable_default_invalid` | 让 default 属于声明的 enum value。 |
| `openapi.server_variable_unresolved` | 定义 server URL 中的每个 variable。 |
| `openapi.unknown_security_scheme` | 在 components 中定义被引用 scheme。 |
| `openapi.unsupported_security_scheme` | scheme 只在文档边界内保留或被拒绝。 |
| `openapi.http_auth_requires_custom_interceptor` | 在宿主 Dio 层实现凭证注入;warning。 |
### 生成生命周期
| Code | 含义和处理 |
|---|---|
| `lifecycle.unchanged_skipped` | hash/file 一致,已安全跳过生成。 |
| `lifecycle.manifest_invalid` | 旧 manifest 损坏;重建输出但不信任其删除列表。 |
| `lifecycle.manifest_version_ignored` | manifest schema 不兼容,其删除列表被忽略。 |
| `lifecycle.output_outside_managed_roots` | renderer 输出越过 API/model 根,已拒绝。 |
| `lifecycle.duplicate_output_path` | 两个渲染文件以不同内容指向同一路径。 |
| `lifecycle.stale_path_outside_roots` | 旧路径位于当前 root 外,因此保留。 |
| `lifecycle.stale_file_modified` | stale 生成文件已被编辑,保留供人工处理。 |
| `lifecycle.generated_file_missing` | 主生成文件在 fix/format/build 期间消失。 |
| `lifecycle.control_file_not_stale` | lifecycle 控制文件永不作为 stale 输出处理。 |
## 设计原则
1. unresolved ref、非法 wire style 和不支持 codec 不会静默变成 `dynamic`;
2. 安全且可编译的自动改名可以 warning 后继续;
3. 不会破坏其他输出的不支持结构会 warning 后明确省略;
4. stale cleanup 在 path containment 或 hash ownership 不确定时保留文件;
5. 可用时诊断会带 component、operation、parameter 或文件上下文。
minor release 可以增加新 code。自动化应把未知 warning 视为需复核,把未知 error
视为阻塞。
+215
View File
@@ -0,0 +1,215 @@
# 从 swagger_generator_flutter 迁移
[English](../MIGRATION.md) | [简体中文](MIGRATION.md)
本指南面向使用 `swagger_generator_flutter` 的 `generator_config.yaml` 惯例。
迁移应在消费方 Dart/Flutter 仓库中进行,不依赖任何本机绝对路径。
## 建议顺序
1. 在独立分支保留旧生成结果;
2. 复制 [koi_swagger_parser.example.yaml](../../koi_swagger_parser.example.yaml),
第一轮只迁移 source、输出根和 response wrapper;
3. 第一次设置 `clean_stale_files: false` 并执行 `--dry-run`;
4. 生成到临时目录,对比 API signature、JSON key 和 import;
5. 迁移 enum、命名和 custom type mapping;
6. 在宿主项目运行 build_runner、`dart analyze` 和测试;
7. 审核输出后切换正式目录,并启用 `clean_stale_files: true`。
## 可直接沿用
| 旧字段 | 新字段或行为 |
|---|---|
| `input.swagger_urls` | 同名;支持 string/object、`enabled`、本地路径和 HTTP(S) source |
| `output.base_dir/api_dir/models_dir` | 同名 |
| `output.included_tags/excluded_tags` | 同名,并支持 glob/正则 |
| `output.split_by_tags` | 同名 |
| `generation.api.client.class_name/file_name` | 同名 |
| `generation.api.cancel_token_support` | 同名 |
| `generation.api.base_result_import` | 兼容输入;选择 `external_generic` + `BaseResult` |
| `generation.models.class_prefix` | 同名 |
| `generation.models.json_serializable.*` | 同名 |
| `generation.models.enum_key_mappings` | 仍然支持;建议迁移到 `enum_mappings` |
| `formatting.run_dart_format` | 同名 |
已有 enum mapping 不需要立即重写:
```yaml
generation:
models:
enum_key_mappings:
TaskType:
- value: 1
name: spotCheck
description: 抽查
```
两个字段映射同一 wire value 时,`enum_mappings` 优先。生成器也识别
`x-enumNames`、`x-enum-varnames` 和 `x-enum-values`。
## 需要改写
### Response wrapper
旧写法:
```yaml
generation:
api:
base_result_type: BaseResult
base_result_import: package:app/network/base_result.dart
```
推荐写法:
```yaml
generation:
api:
response_wrapper:
mode: external_generic
detect_standard_envelope: true
success_data_field: data
external_generic:
import: package:app/network/base_result.dart
class_name: BaseResult
```
`base_page_result_type/base_page_result_import` 没有自动一对一迁移。优先在
OpenAPI 中准确描述分页 schema;复用已有类型时使用
`generation.mappings.schemas`,或用 `passthrough` 保留服务端 envelope。
### 类型与 import mapping
旧顶层 `type_mapping` 和 `imports` 是全局配置。新配置把可校验 mapping 集中到
`generation.mappings`:
```yaml
generation:
mappings:
schemas:
Money:
type: Money
import: package:money2/money2.dart
types:
string:uuid:
type: UuidValue
import: package:uuid/uuid_value.dart
string:date-time: DateTime
imports:
UuidValue: package:uuid/uuid_value.dart
```
type 查找先尝试 `type:format`,再尝试 `type`。`schemas` 表示 schema 完全由
external type 提供,不再生成对应 Dart model。
### ignored directory/file
旧 `ignored_directories`/`ignored_files` 在渲染后跳过文件,可能留下引用缺失
model 的 API。应改为源语义 filter:
```yaml
output:
excluded_paths:
- /api/v1/legacy/**
excluded_operations:
- regex:^internal
excluded_models:
- Legacy*
```
保留的 operation 若依赖被排除 model,生成会以
`filter.required_model_excluded` 失败。没有任意丢弃渲染文件的设置。
### 版本提取
`/api/v1/`、`/v2/` 等 path 会自动分组。若还要尝试从 source URL 提取:
```yaml
output:
extract_version_from_url: true
```
旧 `generation.api.version_extraction.pattern/default_version` 没有一对一配置。
URL 提取失败会产生诊断并回到基于 path 的分组。
### build_runner
旧流程通常单独执行 build_runner,现在可以并入生成:
```yaml
formatting:
run_dart_format: true
run_build_runner: true
build_runner_mode: build
delete_conflicting_outputs: true
```
交互式开发可用:
```bash
dart run koi_swagger_parser generate --watch
```
watch 是长驻模式,不适合一次性 CI job。
## 不再读取的旧设置
这些字段会产生 `config.unknown_key` warning:
- `generator.name/version/author/copyright`;
- `input.validate_schema/strict_mode`;
- `output.api_file_suffix/model_file_suffix`;
- `generation.api.use_dio/parser/method_naming`;
- `generation.models.class_naming/field_naming/use_const_constructor`;
- 顶层 `validation.on_error/on_warning`;
- 顶层通用 import 排序/分组设置。
新生成器对会破坏 wire semantics 或编译的情况使用结构化 error,不提供
warn/ignore override。callbacks、links 等可安全省略的不支持结构使用 warning。
## 需审核的生成代码差异
- readOnly/writeOnly schema 可拆为 `FooRequest`/`FooResponse`;
- optional 但 schema 不允许 null 的字段仍用 nullable Dart argument 表示 absent,
同时使用 `disallowNullValue`;显式 JSON null 会被拒绝;
- 根 object 同时有 `properties` 和 `additionalProperties` 时,extra 会 flatten
到 JSON 根;
- `default` response 永不作为 success;
- 复杂 parameter style 可产生 public transport adapter + private Retrofit
delegate;
- API method 可返回 `HttpResponse<T>`,binary 输出可使用 bytes/stream;
- enum 包含 wire conversion,unknown fallback 使用内部 sentinel wire value;
- stale file 只通过 manifest ownership 删除;用户修改的 stale file 会保留。
## CLI 对照
旧:
```bash
dart run swagger_generator_flutter generate --all
```
新:
```bash
dart run koi_swagger_parser generate --all
dart run koi_swagger_parser generate --dry-run --verbose
```
旧 `--api`、`--models`、`--docs` 和 CLI tag override 不再保留。使用
`generation.*.enabled` 和 output filter。当前生成器不输出独立 API 文档站点。
## 验收清单
- dry-run 无 error 诊断;
- enum wire value 和 Dart member name 与旧契约一致;
- request/response directional model 包含预期字段;
- cookie、query、path 序列化产生等价请求;
- error/default response 不被 decode 成 success;
- external custom type 的 import 和 JSON conversion 正确;
- `dart run build_runner build --delete-conflicting-outputs` 成功;
- `dart analyze` 和宿主项目测试通过;
- 第二次生成报告 `lifecycle.unchanged_skipped`;
- 移除 operation 后只删除 manifest 拥有且未修改的文件。
对每个可用后端 fixture,除了对比生成源码,还应对比 runtime serialization。
+33
View File
@@ -0,0 +1,33 @@
# Koi Swagger Parser example
This directory contains a small, self-contained OpenAPI 3.0 document and two
ways to generate Dart sources from it.
## Use the Dart API
From the package root, run:
```shell
dart run example/koi_swagger_parser_example.dart
```
The program writes generated files to a temporary directory, prints a summary,
and removes the directory in a `finally` block.
## Use the command-line interface
The included configuration writes output to `example/generated`:
```shell
dart run koi_swagger_parser generate \
--config example/koi_swagger_parser.yaml
```
Applications that compile the generated sources must add the packages used by
their selected generator preset, such as Dio, Retrofit, Freezed, and their
associated code generators. Run `build_runner` in the consuming application
after generation when Freezed or JSON serialization output is required.
The example configuration disables `build_runner` so that the generator itself
can be demonstrated without adding those application dependencies to this
package.
+26
View File
@@ -0,0 +1,26 @@
input:
swagger_urls:
- ./openapi.yaml
output:
base_dir: ./generated
api_dir: ./generated/api
models_dir: ./generated/models
split_by_tags: true
clean_stale_files: true
generation:
preset:
name: koi_flutter
models:
enum_unknown_fallback: true
enum_mappings:
PetStatus:
- value: pending
name: awaitingReview
description: Waiting for a store review
formatting:
run_dart_fix: false
run_dart_format: true
run_build_runner: false
+42 -18
View File
@@ -4,22 +4,24 @@ import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
Future<void> main() async { Future<void> main() async {
final packageRoot = Directory.current.path;
final tempDir = await Directory.systemTemp.createTemp( final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_example_', 'koi_swagger_parser_example_',
); );
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); try {
await configFile.writeAsString(''' final exampleDirectory = _findExampleDirectory();
final openApiPath = p.join(exampleDirectory.path, 'openapi.yaml');
final outputDirectory = p.join(tempDir.path, 'generated');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input: input:
swagger_urls: swagger_urls:
- url: ${p.join(packageRoot, 'test', 'fixtures', 'dba_openapi.json')} - ${_yamlString(openApiPath)}
enabled: true
output: output:
base_dir: ${p.join(tempDir.path, 'lib', 'src')} base_dir: ${_yamlString(outputDirectory)}
included_tags: api_dir: ${_yamlString(p.join(outputDirectory, 'api'))}
- stores models_dir: ${_yamlString(p.join(outputDirectory, 'models'))}
- materials manifest_path: ${_yamlString(p.join(tempDir.path, 'manifest.json'))}
generation: generation:
api: api:
@@ -27,17 +29,39 @@ generation:
mode: passthrough mode: passthrough
formatting: formatting:
run_dart_format: false run_dart_format: true
'''); ''');
final result = await const KoiSwaggerParser().generate( final result = await const KoiSwaggerParser().generate(
configPath: configFile.path, configPath: configFile.path,
dryRun: true, );
);
print( print(
'loaded ${result.sourceDocuments.length} source(s), ' 'Loaded ${result.sourceDocuments.length} source(s), '
'parsed ${result.operationsCount} operation(s), ' 'parsed ${result.operationsCount} operation(s), and '
'rendered ${result.modelsCount} model(s).', 'generated ${result.writtenFiles.length} file(s) for '
'${result.modelsCount} model(s):',
);
for (final file in result.writtenFiles) {
print(' ${p.relative(file, from: outputDirectory)}');
}
} finally {
await tempDir.delete(recursive: true);
}
}
Directory _findExampleDirectory() {
final scriptDirectory = File.fromUri(Platform.script).parent;
final workingDirectory = Directory(p.join(Directory.current.path, 'example'));
for (final directory in <Directory>[scriptDirectory, workingDirectory]) {
if (File(p.join(directory.path, 'openapi.yaml')).existsSync()) {
return directory;
}
}
throw StateError(
'Could not find example/openapi.yaml. Run this example from the package '
'root with "dart run example/koi_swagger_parser_example.dart".',
); );
} }
String _yamlString(String value) => "'${value.replaceAll("'", "''")}'";
+78
View File
@@ -0,0 +1,78 @@
openapi: 3.0.3
info:
title: Pet Store example
version: 1.0.0
servers:
- url: https://api.example.com/v1
tags:
- name: Pets
paths:
/pets:
get:
tags:
- Pets
operationId: listPets
summary: List pets
parameters:
- name: limit
in: query
required: false
schema:
type: integer
format: int32
minimum: 1
responses:
'200':
description: A list of pets
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Pet'
/pets/{petId}:
get:
tags:
- Pets
operationId: getPet
summary: Get a pet by ID
parameters:
- name: petId
in: path
required: true
schema:
type: string
responses:
'200':
description: The requested pet
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
'404':
description: Pet not found
components:
schemas:
Pet:
type: object
required:
- id
- name
- status
properties:
id:
type: string
name:
type: string
status:
$ref: '#/components/schemas/PetStatus'
PetStatus:
type: string
enum:
- available
- pending
- sold
x-enumNames:
- available
- pendingReview
- sold
+101
View File
@@ -0,0 +1,101 @@
input:
swagger_urls:
# String and object entries are supported. Sources may be local paths,
# file:// URLs, or HTTP(S) URLs.
- url: ./openapi.json
enabled: true
# - ./openapi-extra.yaml
output:
base_dir: ./lib/src
api_dir: ./lib/src/api
models_dir: ./lib/src/api_models
# Each filter accepts exact values, globs (* ? **), or regex:<expression>.
included_tags: []
excluded_tags: []
included_paths: []
excluded_paths:
# - /internal/**
included_operations: []
excluded_operations: []
included_models: []
excluded_models: []
split_by_tags: true
extract_version_from_url: false
# Delete only unchanged files owned by the previous manifest and located
# under the configured API or model output roots.
clean_stale_files: true
skip_if_unchanged: true
manifest_path: ./.dart_tool/koi_swagger_parser/manifest.json
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: true
response_mode: body # body | http_response
binary_response_mode: bytes # bytes | stream
client:
class_name: ApiClient
file_name: api_client
response_wrapper:
mode: passthrough # passthrough | external_generic
success_data_field: data
detect_standard_envelope: true
# external_generic:
# import: package:your_network/your_network.dart
# class_name: BaseResult
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
enum_unknown_fallback: true
enum_mappings:
# OrderStatus:
# - value: pending
# name: waitingForPayment
# description: Waiting for payment
# The legacy enum_key_mappings key remains supported. When both keys are
# present, enum_mappings takes precedence.
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
mappings:
models:
# UserDto: User
properties:
# UserDto:
# user_id: id
parameters:
# getUser:
# user_id: id
operations:
# getUser: fetchUser
# "DELETE /api/v1/users/{id}": removeUser
schemas:
# Money:
# type: Money
# import: package:money2/money2.dart
types:
# string:uuid:
# type: UuidValue
# import: package:uuid/uuid_value.dart
# integer:int64: int
imports:
# UuidValue: package:uuid/uuid_value.dart
formatting:
run_dart_fix: false
run_dart_format: true
run_build_runner: false
build_runner_mode: build # build | watch
delete_conflicting_outputs: true
+26 -4
View File
@@ -1,6 +1,28 @@
/// Generates Retrofit and Freezed Dart/Flutter clients from OpenAPI documents.
library; library;
export 'src/config/config.dart'; export 'src/config/config.dart'
export 'src/ir/ir.dart'; show
export 'src/koi_swagger_parser.dart'; ApiGenerationConfig,
export 'src/preset/preset.dart'; BinaryResponseMode,
BuildRunnerMode,
ClientConfig,
DartTypeMapping,
EnumValueMapping,
FormattingConfig,
GenerationConfig,
GenerationMappingsConfig,
InputConfig,
JsonSerializableConfig,
KoiSwaggerConfig,
ModelGenerationConfig,
OutputConfig,
ResponseMetadataMode,
ResponseWrapperConfig,
ResponseWrapperMode;
export 'src/diagnostic/diagnostic.dart'
show DiagnosticSeverity, KoiDiagnostic, KoiDiagnosticException;
export 'src/ir/ir.dart' show GenerationResult;
export 'src/koi_swagger_parser.dart' show KoiSwaggerParser;
export 'src/preset/preset.dart'
show BinaryTransportStyle, GeneratorPreset, GeneratorPresetRegistry;
+1493 -32
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
/// The impact of a parser or generator diagnostic.
enum DiagnosticSeverity {
/// Informational context that does not require corrective action.
info,
/// A recoverable issue that may require user attention.
warning,
/// An issue that prevents the requested operation from completing.
error,
}
/// A structured issue reported while loading, parsing, or generating code.
class KoiDiagnostic {
/// Creates a diagnostic with an optional source and logical location.
const KoiDiagnostic({
required this.code,
required this.severity,
required this.message,
this.source,
this.location,
});
/// A stable machine-readable identifier for this issue.
final String code;
/// The impact of this issue.
final DiagnosticSeverity severity;
/// A human-readable English explanation of this issue.
final String message;
/// The source document or configuration file associated with this issue.
final String? source;
/// A JSON path, reference, or other logical location within [source].
final String? location;
/// Formats this diagnostic as one human-readable line.
String format() {
final context = <String>[
if (source != null && source!.isNotEmpty) source!,
if (location != null && location!.isNotEmpty) location!,
].join(' ');
final suffix = context.isEmpty ? '' : ' ($context)';
return '[${severity.name}] $code: $message$suffix';
}
@override
String toString() => format();
}
/// Collects diagnostics while a multi-stage operation is in progress.
///
/// This implementation utility is intentionally not exported from the
/// package's primary library.
class KoiDiagnosticBag {
final List<KoiDiagnostic> _diagnostics = <KoiDiagnostic>[];
/// An immutable snapshot of all diagnostics added so far.
List<KoiDiagnostic> get diagnostics =>
List<KoiDiagnostic>.unmodifiable(_diagnostics);
/// Whether at least one error diagnostic has been added.
bool get hasErrors => _diagnostics.any(
(diagnostic) => diagnostic.severity == DiagnosticSeverity.error,
);
/// Adds [diagnostic] to this bag.
void add(KoiDiagnostic diagnostic) {
_diagnostics.add(diagnostic);
}
/// Adds an informational diagnostic.
void info(String code, String message, {String? source, String? location}) {
add(
KoiDiagnostic(
code: code,
severity: DiagnosticSeverity.info,
message: message,
source: source,
location: location,
),
);
}
/// Adds a warning diagnostic.
void warning(
String code,
String message, {
String? source,
String? location,
}) {
add(
KoiDiagnostic(
code: code,
severity: DiagnosticSeverity.warning,
message: message,
source: source,
location: location,
),
);
}
/// Adds an error diagnostic.
void error(String code, String message, {String? source, String? location}) {
add(
KoiDiagnostic(
code: code,
severity: DiagnosticSeverity.error,
message: message,
source: source,
location: location,
),
);
}
/// Throws [KoiDiagnosticException] when this bag contains any errors.
void throwIfErrors({
String message = 'OpenAPI pre-generation validation failed.',
}) {
if (hasErrors) {
throw KoiDiagnosticException(message, diagnostics);
}
}
}
/// An exception that preserves the structured diagnostics for a failed run.
class KoiDiagnosticException implements Exception {
/// Creates an exception with a summary [message] and related [diagnostics].
const KoiDiagnosticException(this.message, this.diagnostics);
/// A concise summary of the failure.
final String message;
/// The diagnostics collected before the operation failed.
final List<KoiDiagnostic> diagnostics;
@override
String toString() {
final details = diagnostics
.where((diagnostic) => diagnostic.severity == DiagnosticSeverity.error)
.map((diagnostic) => diagnostic.format())
.join('\n');
return details.isEmpty ? message : '$message\n$details';
}
}
File diff suppressed because it is too large Load Diff
+686
View File
@@ -0,0 +1,686 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:path/path.dart' as p;
import '../config/config.dart';
import '../diagnostic/diagnostic.dart';
import '../ir/ir.dart';
/// Generator version stored in generated-file lifecycle manifests.
const String koiSwaggerParserGeneratorVersion = '0.2.0';
/// Schema version used to validate generated-file lifecycle manifests.
const int koiSwaggerParserManifestSchemaVersion = 1;
/// Describes files changed, removed, or planned by a lifecycle pass.
class GenerationLifecycleOutcome {
/// Creates an immutable lifecycle result.
const GenerationLifecycleOutcome({
required this.generatedFiles,
required this.writtenFiles,
required this.deletedFiles,
required this.wouldWriteFiles,
required this.wouldDeleteFiles,
required this.skippedUnchanged,
required this.manifestPath,
this.diagnostics = const <KoiDiagnostic>[],
});
/// Complete set of files owned by the current generated bundle.
final List<String> generatedFiles;
/// Files written or updated by this lifecycle pass.
final List<String> writtenFiles;
/// Stale generated files deleted by this lifecycle pass.
final List<String> deletedFiles;
/// Files that a dry run would write or update.
final List<String> wouldWriteFiles;
/// Stale files that a dry run would delete.
final List<String> wouldDeleteFiles;
/// Whether unchanged inputs and outputs allowed all writes to be skipped.
final bool skippedUnchanged;
/// Absolute path of the manifest controlling generated-file ownership.
final String manifestPath;
/// Structured informational and warning diagnostics from the lifecycle pass.
final List<KoiDiagnostic> diagnostics;
}
/// Applies safe, manifest-backed writes and stale generated-file cleanup.
class GenerationLifecycle {
/// Creates a lifecycle manager configured by [config].
const GenerationLifecycle(this.config);
/// Generation settings and managed output roots.
final KoiSwaggerConfig config;
/// Applies [bundle], or calculates its changes when [dryRun] is `true`.
///
/// [postWrite] runs after primary files are written and before their final
/// hashes are recorded, allowing formatting and build steps to participate
/// in manifest validation.
Future<GenerationLifecycleOutcome> apply({
required GeneratedBundle bundle,
required Map<String, dynamic> sourceDocument,
required List<String> sources,
required List<String> dependencies,
required bool dryRun,
required Future<void> Function(List<String> changedFiles) postWrite,
}) async {
final diagnostics = KoiDiagnosticBag();
final files = _normalizeGeneratedFiles(bundle.files, diagnostics);
diagnostics.throwIfErrors(
message: 'Generated-file lifecycle validation failed.',
);
final manifestFile = File(
_absoluteConfiguredPath(config.output.manifestPath),
);
final previous = await _readManifest(manifestFile, diagnostics);
final hashes = _LifecycleHashes(
specHash: _hashCanonical(sourceDocument),
configHash: _hashCanonical(_configFingerprint()),
contentHash: _hashCanonical(<Object?>[
for (final file in files)
<String, Object?>{
'path': _portablePath(file.path),
'content': file.content,
},
]),
);
final desiredPaths = files.map((file) => file.path).toSet();
final previousEntries = _manifestEntries(previous);
final staleEntries = previousEntries
.where((entry) => !desiredPaths.contains(entry.path))
.toList(growable: false);
final canSkip =
config.output.skipIfUnchanged &&
_manifestHashesMatch(previous, hashes) &&
await _manifestFilesMatch(previous, files);
if (canSkip) {
diagnostics.info(
'lifecycle.unchanged_skipped',
'The OpenAPI document, configuration, and generated files are '
'unchanged; writing and post-processing were skipped.',
source: manifestFile.path,
);
return GenerationLifecycleOutcome(
generatedFiles: files.map((file) => file.path).toList(growable: false),
writtenFiles: const <String>[],
deletedFiles: const <String>[],
wouldWriteFiles: const <String>[],
wouldDeleteFiles: const <String>[],
skippedUnchanged: true,
manifestPath: manifestFile.path,
diagnostics: diagnostics.diagnostics,
);
}
final wouldWrite = <String>[
for (final file in files)
if (!await _fileHasContent(file.path, file.content)) file.path,
];
final wouldDelete = <String>[];
if (config.output.cleanStaleFiles) {
for (final entry in staleEntries) {
if (await _canDeleteStale(entry, diagnostics, reportModified: false)) {
wouldDelete.add(entry.path);
}
}
}
if (dryRun) {
return GenerationLifecycleOutcome(
generatedFiles: files.map((file) => file.path).toList(growable: false),
writtenFiles: const <String>[],
deletedFiles: const <String>[],
wouldWriteFiles: wouldWrite,
wouldDeleteFiles: wouldDelete,
skippedUnchanged: false,
manifestPath: manifestFile.path,
diagnostics: diagnostics.diagnostics,
);
}
await manifestFile.parent.create(recursive: true);
final lockFile = File('${manifestFile.path}.lock');
final lock = await lockFile.open(mode: FileMode.append);
try {
await lock.lock(FileLock.exclusive);
// Another process may have completed while this process waited.
final lockedPrevious = await _readManifest(manifestFile, diagnostics);
if (config.output.skipIfUnchanged &&
_manifestHashesMatch(lockedPrevious, hashes) &&
await _manifestFilesMatch(lockedPrevious, files)) {
diagnostics.info(
'lifecycle.unchanged_skipped_after_lock',
'Another process completed the same output while this process was '
'waiting for the generation lock; duplicate writing was skipped.',
source: manifestFile.path,
);
return GenerationLifecycleOutcome(
generatedFiles: files
.map((file) => file.path)
.toList(growable: false),
writtenFiles: const <String>[],
deletedFiles: const <String>[],
wouldWriteFiles: const <String>[],
wouldDeleteFiles: const <String>[],
skippedUnchanged: true,
manifestPath: manifestFile.path,
diagnostics: diagnostics.diagnostics,
);
}
final lockedEntries = _manifestEntries(lockedPrevious);
final lockedStaleEntries = lockedEntries
.where((entry) => !desiredPaths.contains(entry.path))
.toList(growable: false);
final writtenFiles = <String>[];
for (final file in files) {
if (await _fileHasContent(file.path, file.content)) {
continue;
}
await _atomicWrite(File(file.path), file.content);
writtenFiles.add(file.path);
}
final deletedFiles = <String>[];
final retainedStaleEntries = <_ManifestFileEntry>[];
for (final entry in lockedStaleEntries) {
if (!config.output.cleanStaleFiles) {
retainedStaleEntries.add(entry);
continue;
}
if (!await _canDeleteStale(entry, diagnostics)) {
continue;
}
final staleFile = File(entry.path);
if (await staleFile.exists()) {
await staleFile.delete();
deletedFiles.add(entry.path);
await _deleteEmptyGeneratedParents(entry.path);
}
}
await postWrite(List<String>.unmodifiable(writtenFiles));
final manifestEntries = <Map<String, Object?>>[];
for (final file in files) {
final ioFile = File(file.path);
if (!await ioFile.exists()) {
diagnostics.error(
'lifecycle.generated_file_missing',
'A generated file is missing after post-processing.',
source: file.path,
);
continue;
}
manifestEntries.add(<String, Object?>{
'path': _portablePath(file.path),
'sha256': await _hashFile(ioFile),
});
}
diagnostics.throwIfErrors(
message: 'Post-generation file validation failed.',
);
final manifest = <String, Object?>{
'schemaVersion': koiSwaggerParserManifestSchemaVersion,
'generator': <String, Object?>{
'name': 'koi_swagger_parser',
'version': koiSwaggerParserGeneratorVersion,
},
'specHash': hashes.specHash,
'configHash': hashes.configHash,
'contentHash': hashes.contentHash,
'sources': sources,
'dependencies': dependencies,
'generatedFiles': manifestEntries,
if (retainedStaleEntries.isNotEmpty)
'retainedStaleFiles': <Map<String, Object?>>[
for (final entry in retainedStaleEntries)
<String, Object?>{
'path': _portablePath(entry.path),
'sha256': entry.sha256,
},
],
};
await _atomicWrite(
manifestFile,
'${const JsonEncoder.withIndent(' ').convert(manifest)}\n',
);
return GenerationLifecycleOutcome(
generatedFiles: files.map((file) => file.path).toList(growable: false),
writtenFiles: List<String>.unmodifiable(writtenFiles),
deletedFiles: List<String>.unmodifiable(deletedFiles),
wouldWriteFiles: const <String>[],
wouldDeleteFiles: const <String>[],
skippedUnchanged: false,
manifestPath: manifestFile.path,
diagnostics: diagnostics.diagnostics,
);
} finally {
try {
await lock.unlock();
} on FileSystemException {
// The descriptor may already be unlocked after an earlier I/O error.
}
await lock.close();
}
}
List<GeneratedFile> _normalizeGeneratedFiles(
List<GeneratedFile> input,
KoiDiagnosticBag diagnostics,
) {
final byPath = <String, GeneratedFile>{};
for (final file in input) {
final path = _absoluteConfiguredPath(file.path);
if (!_isWithinOutputRoots(path)) {
diagnostics.error(
'lifecycle.output_outside_managed_roots',
'A renderer attempted to write outside the configured '
'api_dir/models_dir roots.',
source: path,
);
continue;
}
final previous = byPath[path];
if (previous != null && previous.content != file.content) {
diagnostics.error(
'lifecycle.duplicate_output_path',
'Multiple renderers generated different content for the same path.',
source: path,
);
continue;
}
byPath[path] = GeneratedFile(path: path, content: file.content);
}
return byPath.values.toList(growable: false)
..sort((left, right) => left.path.compareTo(right.path));
}
Future<Map<String, dynamic>?> _readManifest(
File file,
KoiDiagnosticBag diagnostics,
) async {
if (!await file.exists()) {
return null;
}
try {
final decoded = jsonDecode(await file.readAsString());
if (decoded is! Map) {
throw const FormatException('manifest root is not an object');
}
final manifest = decoded.cast<String, dynamic>();
if (manifest['schemaVersion'] != koiSwaggerParserManifestSchemaVersion) {
diagnostics.warning(
'lifecycle.manifest_version_ignored',
'The manifest schema version is incompatible. A full generation '
'will run without trusting the old manifest for file deletion.',
source: file.path,
);
return null;
}
return manifest;
} on Object catch (error) {
diagnostics.warning(
'lifecycle.manifest_invalid',
'Unable to read the generation manifest. A full generation will run: '
'$error',
source: file.path,
);
return null;
}
}
List<_ManifestFileEntry> _manifestEntries(Map<String, dynamic>? manifest) {
if (manifest == null) {
return const <_ManifestFileEntry>[];
}
final entries = <_ManifestFileEntry>[];
for (final key in const <String>['generatedFiles', 'retainedStaleFiles']) {
final rawEntries = manifest[key];
if (rawEntries is! List) {
continue;
}
for (final rawEntry in rawEntries) {
if (rawEntry is! Map) {
continue;
}
final rawPath = rawEntry['path']?.toString();
final hash = rawEntry['sha256']?.toString();
if (rawPath == null ||
rawPath.isEmpty ||
hash == null ||
hash.isEmpty) {
continue;
}
entries.add(
_ManifestFileEntry(
path: _absoluteManifestPath(rawPath),
sha256: hash,
),
);
}
}
return entries;
}
bool _manifestHashesMatch(
Map<String, dynamic>? manifest,
_LifecycleHashes hashes,
) {
if (manifest == null) {
return false;
}
final generator = manifest['generator'];
return generator is Map &&
generator['version'] == koiSwaggerParserGeneratorVersion &&
manifest['specHash'] == hashes.specHash &&
manifest['configHash'] == hashes.configHash &&
manifest['contentHash'] == hashes.contentHash;
}
Future<bool> _manifestFilesMatch(
Map<String, dynamic>? manifest,
List<GeneratedFile> files,
) async {
if (manifest == null) {
return false;
}
final rawEntries = manifest['generatedFiles'];
if (rawEntries is! List || rawEntries.length != files.length) {
return false;
}
final entries = _manifestEntries(<String, dynamic>{
'generatedFiles': rawEntries,
});
if (entries.length != files.length) {
return false;
}
final expectedPaths = files.map((file) => file.path).toSet();
if (!entries.every((entry) => expectedPaths.contains(entry.path))) {
return false;
}
for (final entry in entries) {
final file = File(entry.path);
if (!await file.exists() || await _hashFile(file) != entry.sha256) {
return false;
}
}
return true;
}
Future<bool> _canDeleteStale(
_ManifestFileEntry entry,
KoiDiagnosticBag diagnostics, {
bool reportModified = true,
}) async {
final manifestPath = _absoluteConfiguredPath(config.output.manifestPath);
if (p.equals(entry.path, manifestPath) ||
p.equals(entry.path, '$manifestPath.lock')) {
if (reportModified) {
diagnostics.warning(
'lifecycle.control_file_not_stale',
'The old manifest lists a lifecycle control file as generated; '
'deletion was refused.',
source: entry.path,
);
}
return false;
}
if (!_isWithinOutputRoots(entry.path)) {
if (reportModified) {
diagnostics.warning(
'lifecycle.stale_path_outside_roots',
'A path in the old manifest is outside the currently managed output '
'roots; deletion was refused.',
source: entry.path,
);
}
return false;
}
final file = File(entry.path);
if (!await file.exists()) {
return false;
}
final currentHash = await _hashFile(file);
if (currentHash != entry.sha256) {
if (reportModified) {
diagnostics.warning(
'lifecycle.stale_file_modified',
'A stale generated file was modified after the previous generation '
'and was preserved to avoid data loss. Handle it manually.',
source: entry.path,
);
}
return false;
}
return true;
}
bool _isWithinOutputRoots(String path) {
final normalized = _absoluteConfiguredPath(path);
return <String>[config.output.apiDir, config.output.modelsDir].any((root) {
final absoluteRoot = _absoluteConfiguredPath(root);
return p.equals(normalized, absoluteRoot) ||
p.isWithin(absoluteRoot, normalized);
});
}
Future<void> _deleteEmptyGeneratedParents(String filePath) async {
final roots = <String>[
_absoluteConfiguredPath(config.output.apiDir),
_absoluteConfiguredPath(config.output.modelsDir),
];
var directory = Directory(p.dirname(filePath));
while (true) {
final path = _absoluteConfiguredPath(directory.path);
final root = roots.where(
(candidate) => p.equals(candidate, path) || p.isWithin(candidate, path),
);
if (root.isEmpty || roots.any((candidate) => p.equals(candidate, path))) {
return;
}
if (!await directory.exists() || !await directory.list().isEmpty) {
return;
}
await directory.delete();
directory = directory.parent;
}
}
String _portablePath(String path) {
final absolute = _absoluteConfiguredPath(path);
final root = _absoluteConfiguredPath(config.workingDirectory);
if (p.equals(absolute, root) || p.isWithin(root, absolute)) {
return p.relative(absolute, from: root);
}
return absolute;
}
String _absoluteManifestPath(String path) {
return _absoluteConfiguredPath(path);
}
String _absoluteConfiguredPath(String path) {
if (p.isAbsolute(path)) {
return p.normalize(path);
}
return p.normalize(p.join(config.workingDirectory, path));
}
Map<String, Object?> _configFingerprint() {
final mappings = config.generation.mappings;
return <String, Object?>{
'output': <String, Object?>{
'baseDir': p.normalize(config.output.baseDir),
'apiDir': p.normalize(config.output.apiDir),
'modelsDir': p.normalize(config.output.modelsDir),
'includedTags': config.output.includedTags,
'excludedTags': config.output.excludedTags,
'includedPaths': config.output.includedPaths,
'excludedPaths': config.output.excludedPaths,
'includedOperations': config.output.includedOperations,
'excludedOperations': config.output.excludedOperations,
'includedModels': config.output.includedModels,
'excludedModels': config.output.excludedModels,
'splitByTags': config.output.splitByTags,
'extractVersionFromUrl': config.output.extractVersionFromUrl,
'cleanStaleFiles': config.output.cleanStaleFiles,
},
'generation': <String, Object?>{
'preset': config.generation.preset.name,
'api': <String, Object?>{
'enabled': config.generation.api.enabled,
'useRetrofit': config.generation.api.useRetrofit,
'cancelTokenSupport': config.generation.api.cancelTokenSupport,
'responseMetadataMode':
config.generation.api.responseMetadataMode.name,
'binaryResponseMode': config.generation.api.binaryResponseMode.name,
'clientClassName': config.generation.api.client.className,
'clientFileName': config.generation.api.client.fileName,
'responseWrapperMode':
config.generation.api.responseWrapper.mode.name,
'successDataField':
config.generation.api.responseWrapper.successDataField,
'detectStandardEnvelope':
config.generation.api.responseWrapper.detectStandardEnvelope,
'externalImport':
config.generation.api.responseWrapper.externalImport,
'externalClassName':
config.generation.api.responseWrapper.externalClassName,
},
'models': <String, Object?>{
'enabled': config.generation.models.enabled,
'classPrefix': config.generation.models.classPrefix,
'defaultNonNullableListToEmpty':
config.generation.models.defaultNonNullableListToEmpty,
'enumUnknownFallback': config.generation.models.enumUnknownFallback,
'enumMappings': <String, Object?>{
for (final enumEntry
in config.generation.models.enumMappings.entries)
enumEntry.key: <String, Object?>{
for (final valueEntry in enumEntry.value.entries)
'${valueEntry.key.runtimeType}:${valueEntry.key}':
<String, Object?>{
'name': valueEntry.value.name,
'description': valueEntry.value.description,
},
},
},
'jsonSerializable': <String, Object?>{
'checked': config.generation.models.jsonSerializable.checked,
'explicitToJson':
config.generation.models.jsonSerializable.explicitToJson,
'includeIfNull':
config.generation.models.jsonSerializable.includeIfNull,
},
},
'mappings': <String, Object?>{
'models': mappings.models,
'properties': mappings.properties,
'parameters': mappings.parameters,
'operations': mappings.operations,
'schemas': <String, Object?>{
for (final entry in mappings.schemas.entries)
entry.key: <String, Object?>{
'type': entry.value.dartType,
'import': entry.value.import,
},
},
'types': <String, Object?>{
for (final entry in mappings.types.entries)
entry.key: <String, Object?>{
'type': entry.value.dartType,
'import': entry.value.import,
},
},
'imports': mappings.imports,
},
},
'formatting': <String, Object?>{
'runDartFormat': config.generation.formatting.runDartFormat,
'runDartFix': config.generation.formatting.runDartFix,
'runBuildRunner': config.generation.formatting.runBuildRunner,
'buildRunnerMode': config.generation.formatting.buildRunnerMode.name,
'deleteConflictingOutputs':
config.generation.formatting.deleteConflictingOutputs,
},
};
}
}
class _LifecycleHashes {
const _LifecycleHashes({
required this.specHash,
required this.configHash,
required this.contentHash,
});
final String specHash;
final String configHash;
final String contentHash;
}
class _ManifestFileEntry {
const _ManifestFileEntry({required this.path, required this.sha256});
final String path;
final String sha256;
}
Future<bool> _fileHasContent(String path, String content) async {
final file = File(path);
return await file.exists() && await file.readAsString() == content;
}
Future<void> _atomicWrite(File file, String content) async {
await file.parent.create(recursive: true);
final tempFile = File(
'${file.path}.${pid}_${DateTime.now().microsecondsSinceEpoch}.tmp',
);
try {
await tempFile.writeAsString(content, flush: true);
await tempFile.rename(file.path);
} finally {
if (await tempFile.exists()) {
await tempFile.delete();
}
}
}
Future<String> _hashFile(File file) async {
return sha256.convert(await file.readAsBytes()).toString();
}
String _hashCanonical(Object? value) {
return sha256
.convert(utf8.encode(jsonEncode(_canonicalize(value))))
.toString();
}
Object? _canonicalize(Object? value) {
if (value is Map) {
final keys = value.keys.map((key) => key.toString()).toList(growable: false)
..sort();
return <String, Object?>{
for (final key in keys) key: _canonicalize(value[key]),
};
}
if (value is Iterable) {
return <Object?>[for (final item in value) _canonicalize(item)];
}
return value;
}
+668 -6
View File
@@ -1,51 +1,211 @@
enum ModelKind { object, enumType, unionType } import '../diagnostic/diagnostic.dart';
enum ModelCategory { request, result, shared, enums } /// Structural form used to render a parsed OpenAPI schema.
enum ModelKind {
/// A class-like schema with named fields.
object,
enum UsageKind { request, result, shared } /// A finite set of serialized values.
enumType,
enum TypeKind { named, list, map, dynamicType, objectType, voidType } /// A wrapper selecting one of several schema branches.
unionType,
}
enum ApiRequestBodyKind { json, multipart } /// Strategy used to select and decode union branches.
enum UnionMode {
/// Selects branches using an OpenAPI discriminator property.
discriminated,
/// Requires exactly one branch to match.
oneOf,
/// Accepts any structurally compatible branch.
anyOf,
}
/// Output directory group assigned to a generated model.
enum ModelCategory {
/// Models used only in request data.
request,
/// Models used only in response data.
result,
/// Models shared by request and response data.
shared,
/// Generated enum declarations.
enums,
}
/// Direction in which an OpenAPI schema is used.
enum UsageKind {
/// The schema participates in request serialization.
request,
/// The schema participates in response deserialization.
result,
/// The schema is explicitly shared across both directions.
shared,
}
/// Canonical Dart type shape represented in the generator IR.
enum TypeKind {
/// A named Dart type, optionally with generic arguments.
named,
/// A Dart `List` type.
list,
/// A string-keyed Dart `Map` type.
map,
/// The Dart `dynamic` type.
dynamicType,
/// The Dart `Object` type.
objectType,
/// The Dart `void` type.
voidType,
}
/// Wire encoding selected for an OpenAPI request body.
enum ApiRequestBodyKind {
/// JSON or another JSON-compatible media type.
json,
/// A `multipart/form-data` body.
multipart,
/// An `application/x-www-form-urlencoded` body.
formUrlEncoded,
/// A binary request body.
binary,
}
/// OpenAPI parameter serialization style retained by the transport IR.
enum ApiParameterStyle {
/// Path-segment parameters prefixed with a semicolon.
matrix,
/// Dot-prefixed path-segment parameters.
label,
/// Form-style query or cookie parameters.
form,
/// Comma-delimited path or header parameters.
simple,
/// Space-delimited query arrays.
spaceDelimited,
/// Pipe-delimited query arrays.
pipeDelimited,
/// Object-shaped query parameters encoded as nested keys.
deepObject,
}
/// Decoder category selected for an OpenAPI response body.
enum ApiResponseBodyKind {
/// The response has no body.
none,
/// The body uses JSON or a JSON-compatible media type.
json,
/// The body is decoded as text.
text,
/// The body is decoded as an in-memory byte list.
bytes,
/// The body is exposed as a byte stream.
stream,
/// The media type is retained without a specialized decoder.
other,
}
/// Supported OpenAPI security-scheme categories.
enum ApiSecuritySchemeType {
/// HTTP authentication such as Basic or Bearer.
http,
/// An API key supplied through a header, query, or cookie.
apiKey,
/// An OAuth 2.0 flow declaration.
oauth2,
/// An OpenID Connect discovery-based scheme.
openIdConnect,
/// Mutual TLS authentication.
mutualTls,
/// An unrecognized scheme retained only as metadata.
unknown,
}
/// A renderable Dart type with nullability and generic arguments.
class DartType { class DartType {
/// Creates a named type such as `User` or `Future<User>`.
const DartType.named( const DartType.named(
this.name, { this.name, {
this.nullable = false, this.nullable = false,
this.arguments = const <DartType>[], this.arguments = const <DartType>[],
}) : kind = TypeKind.named; }) : kind = TypeKind.named;
/// Creates a `List` whose element type is [itemType].
DartType.list(DartType itemType, {this.nullable = false}) DartType.list(DartType itemType, {this.nullable = false})
: kind = TypeKind.list, : kind = TypeKind.list,
name = 'List', name = 'List',
arguments = <DartType>[itemType]; arguments = <DartType>[itemType];
/// Creates a string-keyed `Map` whose value type is [valueType].
DartType.map(DartType valueType, {this.nullable = false}) DartType.map(DartType valueType, {this.nullable = false})
: kind = TypeKind.map, : kind = TypeKind.map,
name = 'Map', name = 'Map',
arguments = <DartType>[const DartType.named('String'), valueType]; arguments = <DartType>[const DartType.named('String'), valueType];
/// Creates the Dart `dynamic` type.
const DartType.dynamicType({this.nullable = false}) const DartType.dynamicType({this.nullable = false})
: kind = TypeKind.dynamicType, : kind = TypeKind.dynamicType,
name = 'dynamic', name = 'dynamic',
arguments = const <DartType>[]; arguments = const <DartType>[];
/// Creates the Dart `Object` type.
const DartType.objectType({this.nullable = false}) const DartType.objectType({this.nullable = false})
: kind = TypeKind.objectType, : kind = TypeKind.objectType,
name = 'Object', name = 'Object',
arguments = const <DartType>[]; arguments = const <DartType>[];
/// Creates the non-nullable Dart `void` type.
const DartType.voidType() const DartType.voidType()
: kind = TypeKind.voidType, : kind = TypeKind.voidType,
name = 'void', name = 'void',
arguments = const <DartType>[], arguments = const <DartType>[],
nullable = false; nullable = false;
/// Structural category of this type.
final TypeKind kind; final TypeKind kind;
/// Base Dart type name before generic arguments and nullability.
final String name; final String name;
/// Generic type arguments in source order.
final List<DartType> arguments; final List<DartType> arguments;
/// Whether rendered values may be `null`.
final bool nullable; final bool nullable;
/// Returns an equivalent nullable type when nullability is meaningful.
DartType asNullable() { DartType asNullable() {
if (nullable || kind == TypeKind.voidType) { if (nullable || kind == TypeKind.voidType) {
return this; return this;
@@ -66,8 +226,10 @@ class DartType {
} }
} }
/// Whether this type is a list or map collection.
bool get isCollection => kind == TypeKind.list || kind == TypeKind.map; bool get isCollection => kind == TypeKind.list || kind == TypeKind.map;
/// Renders this type as valid Dart source.
String render() { String render() {
final suffix = nullable ? '?' : ''; final suffix = nullable ? '?' : '';
return switch (kind) { return switch (kind) {
@@ -83,6 +245,7 @@ class DartType {
}; };
} }
/// Returns named types shared with [candidates], including nested arguments.
Set<String> collectReferencedNames(Set<String> candidates) { Set<String> collectReferencedNames(Set<String> candidates) {
final result = <String>{}; final result = <String>{};
if (kind == TypeKind.named && candidates.contains(name)) { if (kind == TypeKind.named && candidates.contains(name)) {
@@ -93,48 +256,158 @@ class DartType {
} }
return result; return result;
} }
/// Returns every named type used by this type and its generic arguments.
Set<String> collectNamedNames() {
final result = <String>{};
if (kind == TypeKind.named) {
result.add(name);
}
for (final argument in arguments) {
result.addAll(argument.collectNamedNames());
}
return result;
}
} }
/// A generated model property and its serialization metadata.
class ModelField { class ModelField {
/// Creates a model field parsed from an OpenAPI property.
const ModelField({ const ModelField({
required this.name, required this.name,
required this.wireName, required this.wireName,
required this.type, required this.type,
required this.required, required this.required,
this.schemaNullable = false,
this.readOnly = false,
this.writeOnly = false,
this.constraints = const <String, Object?>{},
this.description, this.description,
this.defaultValue, this.defaultValue,
}); });
/// Sanitized Dart field name.
final String name; final String name;
/// Serialized property name from the OpenAPI document.
final String wireName; final String wireName;
/// Dart type used by generated source.
final DartType type; final DartType type;
/// Whether the containing schema lists this property as required.
final bool required; final bool required;
/// Whether the source schema explicitly permits `null`.
final bool schemaNullable;
/// Whether the property is present only during deserialization.
final bool readOnly;
/// Whether the property is present only during serialization.
final bool writeOnly;
/// Validation keywords retained for generation or downstream inspection.
final Map<String, Object?> constraints;
/// Human-readable OpenAPI property description.
final String? description; final String? description;
/// Default value declared by the source schema.
final Object? defaultValue; final Object? defaultValue;
/// Whether serialized input may omit this field.
bool get allowsAbsent => !required;
} }
/// A generated enum member and its serialized wire value.
class EnumValue { class EnumValue {
const EnumValue({required this.name, required this.rawValue}); /// Creates an enum member.
const EnumValue({
required this.name,
required this.rawValue,
this.description,
this.isFallback = false,
});
/// Sanitized Dart member name.
final String name; final String name;
/// Value serialized to and deserialized from the wire format.
final Object rawValue; final Object rawValue;
/// Human-readable description for generated API documentation.
final String? description;
/// Whether this member receives unknown wire values.
final bool isFallback;
} }
/// One branch of a generated discriminated or structural union.
class UnionCase { class UnionCase {
/// Creates a union branch.
const UnionCase({ const UnionCase({
required this.caseName, required this.caseName,
required this.wireValue, required this.wireValue,
required this.type, required this.type,
required this.originalName, required this.originalName,
this.matchKind = UnionMatchKind.object,
this.requiredProperties = const <String>[],
this.allowedValues = const <Object>[],
}); });
/// Sanitized case name used by the generated wrapper.
final String caseName; final String caseName;
/// Discriminator value selecting this branch when applicable.
final String wireValue; final String wireValue;
/// Dart payload type represented by this branch.
final DartType type; final DartType type;
/// Original component or inline schema name.
final String originalName; final String originalName;
/// Runtime value shape used for structural matching.
final UnionMatchKind matchKind;
/// Object properties required for this branch to match.
final List<String> requiredProperties;
/// Literal values accepted by this branch.
final List<Object> allowedValues;
} }
/// Runtime JSON shape used to match a union branch.
enum UnionMatchKind {
/// A JSON string.
string,
/// A JSON integer.
integer,
/// Any JSON number.
number,
/// A JSON boolean.
boolean,
/// A JSON object.
object,
/// A JSON array.
array,
/// The JSON `null` value.
nullValue,
/// Any JSON value.
any,
}
/// Complete generator metadata for one model declaration.
class ModelSpec { class ModelSpec {
/// Creates a model specification with mutable usage classification.
ModelSpec({ ModelSpec({
required this.name, required this.name,
required this.originalName, required this.originalName,
@@ -143,22 +416,48 @@ class ModelSpec {
List<EnumValue>? enumValues, List<EnumValue>? enumValues,
List<UnionCase>? unionCases, List<UnionCase>? unionCases,
this.discriminatorKey, this.discriminatorKey,
this.unionMode = UnionMode.discriminated,
this.description, this.description,
this.additionalPropertiesType,
}) : fields = fields ?? <ModelField>[], }) : fields = fields ?? <ModelField>[],
enumValues = enumValues ?? <EnumValue>[], enumValues = enumValues ?? <EnumValue>[],
unionCases = unionCases ?? <UnionCase>[], unionCases = unionCases ?? <UnionCase>[],
usages = <UsageKind>{}; usages = <UsageKind>{};
/// Sanitized generated Dart type name.
final String name; final String name;
/// Original component or inline schema name.
final String originalName; final String originalName;
/// Structural form of this model.
final ModelKind kind; final ModelKind kind;
/// Object properties when [kind] is [ModelKind.object].
final List<ModelField> fields; final List<ModelField> fields;
/// Enum members when [kind] is [ModelKind.enumType].
final List<EnumValue> enumValues; final List<EnumValue> enumValues;
/// Union branches when [kind] is [ModelKind.unionType].
final List<UnionCase> unionCases; final List<UnionCase> unionCases;
/// Serialized property used to select a discriminated union branch.
final String? discriminatorKey; final String? discriminatorKey;
/// Branch-selection semantics for a union model.
final UnionMode unionMode;
/// Human-readable schema description.
final String? description; final String? description;
/// Value type for undeclared object properties, when supported.
DartType? additionalPropertiesType;
/// Request and response directions that reference this model.
final Set<UsageKind> usages; final Set<UsageKind> usages;
/// Output group derived from [kind] and [usages].
ModelCategory get category { ModelCategory get category {
if (kind == ModelKind.enumType) { if (kind == ModelKind.enumType) {
return ModelCategory.enums; return ModelCategory.enums;
@@ -178,27 +477,77 @@ class ModelSpec {
} }
} }
/// A path, query, header, or cookie parameter for a generated operation.
class ApiParameter { class ApiParameter {
/// Creates an operation parameter with resolved serialization semantics.
const ApiParameter({ const ApiParameter({
required this.name, required this.name,
required this.wireName, required this.wireName,
required this.location, required this.location,
required this.type, required this.type,
required this.required, required this.required,
required this.style,
required this.explode,
this.allowReserved = false,
this.contentType,
this.description, this.description,
this.defaultValue, this.defaultValue,
}); });
/// Sanitized Dart argument name.
final String name; final String name;
/// Parameter name used on the wire.
final String wireName; final String wireName;
/// OpenAPI parameter location such as `path` or `query`.
final String location; final String location;
/// Generated Dart argument type.
final DartType type; final DartType type;
/// Whether callers must provide the parameter.
final bool required; final bool required;
/// OpenAPI serialization style.
final ApiParameterStyle style;
/// Whether arrays and objects expand into separate wire values.
final bool explode;
/// Whether reserved characters remain unescaped for query parameters.
final bool allowReserved;
/// Media type used by a content-based parameter.
final String? contentType;
/// Human-readable OpenAPI parameter description.
final String? description; final String? description;
/// Default value declared by the parameter schema.
final Object? defaultValue; final Object? defaultValue;
/// Returns a copy with Dart argument name [value].
ApiParameter withName(String value) {
return ApiParameter(
name: value,
wireName: wireName,
location: location,
type: type,
required: required,
style: style,
explode: explode,
allowReserved: allowReserved,
contentType: contentType,
description: description,
defaultValue: defaultValue,
);
}
} }
/// One field in a multipart or form-url-encoded request body.
class ApiMultipartField { class ApiMultipartField {
/// Creates a structured request-body field.
const ApiMultipartField({ const ApiMultipartField({
required this.name, required this.name,
required this.wireName, required this.wireName,
@@ -208,15 +557,28 @@ class ApiMultipartField {
this.contentType, this.contentType,
}); });
/// Sanitized Dart argument or local variable name.
final String name; final String name;
/// Field name used on the wire.
final String wireName; final String wireName;
/// Generated Dart value type.
final DartType type; final DartType type;
/// Whether callers must provide this field.
final bool required; final bool required;
/// Human-readable schema description.
final String? description; final String? description;
/// Optional per-field media type.
final String? contentType; final String? contentType;
} }
/// Parsed request body for a generated operation.
class ApiRequestBody { class ApiRequestBody {
/// Creates a request body with its selected wire encoding.
const ApiRequestBody({ const ApiRequestBody({
required this.kind, required this.kind,
required this.required, required this.required,
@@ -224,17 +586,173 @@ class ApiRequestBody {
this.type, this.type,
this.contentType, this.contentType,
this.multipartFields = const <ApiMultipartField>[], this.multipartFields = const <ApiMultipartField>[],
this.formFields = const <ApiMultipartField>[],
}); });
/// Wire encoding used by the request body.
final ApiRequestBodyKind kind; final ApiRequestBodyKind kind;
/// Dart payload type for non-structured request bodies.
final DartType? type; final DartType? type;
/// Whether callers must supply the body.
final bool required; final bool required;
/// Sanitized Dart parameter name.
final String parameterName; final String parameterName;
/// Selected request media type.
final String? contentType; final String? contentType;
/// Fields emitted for a multipart request.
final List<ApiMultipartField> multipartFields; final List<ApiMultipartField> multipartFields;
/// Fields emitted for a form-url-encoded request.
final List<ApiMultipartField> formFields;
} }
/// One response header retained in operation metadata.
class ApiResponseHeader {
/// Creates a parsed response header.
const ApiResponseHeader({
required this.name,
required this.type,
required this.required,
this.description,
});
/// Header name used on the wire.
final String name;
/// Generated Dart value type.
final DartType type;
/// Whether the response declares this header as required.
final bool required;
/// Human-readable OpenAPI header description.
final String? description;
}
/// One declared response for a generated operation.
class ApiResponse {
/// Creates a parsed operation response.
const ApiResponse({
required this.statusCode,
required this.isSuccess,
required this.isDefault,
required this.bodyKind,
required this.bodyType,
this.contentType,
this.description,
this.headers = const <ApiResponseHeader>[],
});
/// OpenAPI response key such as `200`, `2XX`, or `default`.
final String statusCode;
/// Whether this response is an explicit successful status.
final bool isSuccess;
/// Whether this is the OpenAPI `default` response.
final bool isDefault;
/// Decoder category selected for the response body.
final ApiResponseBodyKind bodyKind;
/// Generated Dart type of the decoded response body.
final DartType bodyType;
/// Selected response media type.
final String? contentType;
/// Human-readable OpenAPI response description.
final String? description;
/// Parsed response headers.
final List<ApiResponseHeader> headers;
/// Whether this response declares a decodable body.
bool get hasBody => bodyKind != ApiResponseBodyKind.none;
}
/// A resolved OpenAPI server URL and its substituted variables.
class ApiServer {
/// Creates a server metadata entry.
const ApiServer({
required this.url,
this.description,
this.variables = const <String, String>{},
});
/// Server URL after applying declared variable defaults.
final String url;
/// Human-readable server description.
final String? description;
/// Resolved server-variable values keyed by variable name.
final Map<String, String> variables;
}
/// A parsed OpenAPI security scheme retained for client integration.
class ApiSecurityScheme {
/// Creates a security-scheme metadata entry.
const ApiSecurityScheme({
required this.name,
required this.type,
this.scheme,
this.bearerFormat,
this.parameterName,
this.location,
this.description,
this.openIdConnectUrl,
this.oauthScopes = const <String>[],
});
/// Component name of the security scheme.
final String name;
/// Normalized security-scheme category.
final ApiSecuritySchemeType type;
/// HTTP authentication scheme such as `basic` or `bearer`.
final String? scheme;
/// Optional bearer token format hint.
final String? bearerFormat;
/// Wire parameter name for an API-key scheme.
final String? parameterName;
/// Wire location for an API-key scheme.
final String? location;
/// Human-readable security-scheme description.
final String? description;
/// Discovery URL for an OpenID Connect scheme.
final String? openIdConnectUrl;
/// OAuth scopes declared across supported flows.
final List<String> oauthScopes;
}
/// One AND-group inside an OpenAPI security requirement list.
class ApiSecurityRequirement {
/// Creates a requirement mapping scheme names to requested scopes.
const ApiSecurityRequirement({required this.schemes});
/// Required schemes and their OAuth scopes.
final Map<String, List<String>> schemes;
/// Whether this requirement permits a request without credentials.
bool get allowsAnonymous => schemes.isEmpty;
}
/// Complete transport and type metadata for one generated API operation.
class ApiOperation { class ApiOperation {
/// Creates a parsed API operation.
const ApiOperation({ const ApiOperation({
required this.path, required this.path,
required this.method, required this.method,
@@ -243,57 +761,158 @@ class ApiOperation {
required this.methodName, required this.methodName,
required this.returnType, required this.returnType,
required this.parameters, required this.parameters,
this.responses = const <ApiResponse>[],
this.primaryResponseStatusCode,
this.serverUrl,
this.security = const <ApiSecurityRequirement>[],
this.requestBody, this.requestBody,
this.summary, this.summary,
this.description, this.description,
this.deprecated = false, this.deprecated = false,
}); });
/// OpenAPI path template.
final String path; final String path;
/// Lowercase HTTP method.
final String method; final String method;
/// Selected OpenAPI tag used for grouping.
final String tag; final String tag;
/// API version used in generated output paths.
final String version; final String version;
/// Sanitized generated Dart method name.
final String methodName; final String methodName;
/// Generated method return type.
final DartType returnType; final DartType returnType;
/// Path, query, header, and cookie parameters.
final List<ApiParameter> parameters; final List<ApiParameter> parameters;
/// All declared operation responses.
final List<ApiResponse> responses;
/// Status key selected as the primary successful response.
final String? primaryResponseStatusCode;
/// Operation- or path-level server override.
final String? serverUrl;
/// Alternative security requirements; list entries are combined with OR.
final List<ApiSecurityRequirement> security;
/// Parsed request body, when present.
final ApiRequestBody? requestBody; final ApiRequestBody? requestBody;
/// Short OpenAPI operation summary.
final String? summary; final String? summary;
/// Detailed OpenAPI operation description.
final String? description; final String? description;
/// Whether the OpenAPI operation is deprecated.
final bool deprecated; final bool deprecated;
/// Selected successful response, preferring [primaryResponseStatusCode].
ApiResponse? get primarySuccessResponse {
final selectedStatusCode = primaryResponseStatusCode;
if (selectedStatusCode != null) {
for (final response in responses) {
if (response.statusCode == selectedStatusCode) {
return response;
}
}
}
for (final response in responses) {
if (response.isSuccess && response.hasBody) {
return response;
}
}
for (final response in responses) {
if (response.isSuccess) {
return response;
}
}
return null;
}
} }
/// Deterministic intermediate representation of all selected API content.
class ParsedApiDocument { class ParsedApiDocument {
/// Creates a parsed document ready for Dart rendering.
const ParsedApiDocument({ const ParsedApiDocument({
required this.models, required this.models,
required this.operations, required this.operations,
required this.sourceDocuments, required this.sourceDocuments,
this.servers = const <ApiServer>[],
this.securitySchemes = const <String, ApiSecurityScheme>{},
this.globalSecurity = const <ApiSecurityRequirement>[],
this.dependencyDocuments = const <String>[],
this.diagnostics = const <KoiDiagnostic>[],
}); });
/// Generated models keyed by sanitized Dart type name.
final Map<String, ModelSpec> models; final Map<String, ModelSpec> models;
/// Selected operations in deterministic output order.
final List<ApiOperation> operations; final List<ApiOperation> operations;
/// Canonical root OpenAPI documents.
final List<String> sourceDocuments; final List<String> sourceDocuments;
/// Root-level resolved servers.
final List<ApiServer> servers;
/// Parsed security schemes keyed by component name.
final Map<String, ApiSecurityScheme> securitySchemes;
/// Root-level alternative security requirements.
final List<ApiSecurityRequirement> globalSecurity;
/// External documents loaded through references.
final List<String> dependencyDocuments;
/// Informational and warning diagnostics retained during parsing.
final List<KoiDiagnostic> diagnostics;
} }
/// One rendered source file before lifecycle writes are applied.
class GeneratedFile { class GeneratedFile {
/// Creates a rendered file at [path] with [content].
const GeneratedFile({required this.path, required this.content}); const GeneratedFile({required this.path, required this.content});
/// Absolute destination path.
final String path; final String path;
/// Complete generated Dart source.
final String content; final String content;
} }
/// Rendered files and aggregate counts from one generation pass.
class GeneratedBundle { class GeneratedBundle {
/// Creates a generated bundle.
const GeneratedBundle({ const GeneratedBundle({
required this.files, required this.files,
required this.operationsCount, required this.operationsCount,
required this.modelsCount, required this.modelsCount,
}); });
/// Rendered files ready for lifecycle processing.
final List<GeneratedFile> files; final List<GeneratedFile> files;
/// Number of operations included in this bundle.
final int operationsCount; final int operationsCount;
/// Number of models included in this bundle.
final int modelsCount; final int modelsCount;
} }
/// Summarizes the output and diagnostics of one generation run.
class GenerationResult { class GenerationResult {
/// Creates a generation result.
const GenerationResult({ const GenerationResult({
required this.configPath, required this.configPath,
required this.sourceDocuments, required this.sourceDocuments,
@@ -301,12 +920,55 @@ class GenerationResult {
required this.operationsCount, required this.operationsCount,
required this.modelsCount, required this.modelsCount,
required this.dryRun, required this.dryRun,
this.generatedFiles = const <String>[],
this.deletedFiles = const <String>[],
this.wouldWriteFiles = const <String>[],
this.wouldDeleteFiles = const <String>[],
this.skippedUnchanged = false,
this.manifestPath,
this.dependencyDocuments = const <String>[],
this.diagnostics = const <KoiDiagnostic>[],
}); });
/// The resolved configuration file path, or `null` when defaults were used.
final String? configPath; final String? configPath;
/// Root OpenAPI documents loaded for this run.
final List<String> sourceDocuments; final List<String> sourceDocuments;
/// All files represented by the generated output bundle.
final List<String> generatedFiles;
/// Files written or updated during this run.
final List<String> writtenFiles; final List<String> writtenFiles;
/// Stale generated files deleted during this run.
final List<String> deletedFiles;
/// Files that would be written when [dryRun] is `true`.
final List<String> wouldWriteFiles;
/// Files that would be deleted when [dryRun] is `true`.
final List<String> wouldDeleteFiles;
/// Whether generation skipped writing because all output was unchanged.
final bool skippedUnchanged;
/// Path to the generated-file lifecycle manifest, when one is configured.
final String? manifestPath;
/// Number of OpenAPI operations included in generated output.
final int operationsCount; final int operationsCount;
/// Number of OpenAPI models included in generated output.
final int modelsCount; final int modelsCount;
/// Whether this run only calculated changes without modifying files.
final bool dryRun; final bool dryRun;
/// Referenced OpenAPI documents loaded in addition to [sourceDocuments].
final List<String> dependencyDocuments;
/// Informational and warning diagnostics retained for this run.
final List<KoiDiagnostic> diagnostics;
} }
+139 -20
View File
@@ -1,32 +1,55 @@
import 'dart:io'; import 'dart:io';
import 'config/config.dart'; import 'config/config.dart';
import 'diagnostic/diagnostic.dart';
import 'generator/generator.dart'; import 'generator/generator.dart';
import 'generator/lifecycle.dart';
import 'ir/ir.dart'; import 'ir/ir.dart';
import 'openapi/openapi.dart'; import 'openapi/openapi.dart';
/// Generates Dart API clients and models from configured OpenAPI documents.
class KoiSwaggerParser { class KoiSwaggerParser {
/// Creates a stateless parser and generator facade.
const KoiSwaggerParser(); const KoiSwaggerParser();
/// Loads configuration, parses OpenAPI inputs, and generates Dart sources.
///
/// [configPath] selects a YAML configuration file. When omitted, the parser
/// searches [workingDirectory], or the current directory, for a supported
/// default filename.
///
/// When [dryRun] is `true`, no generated file is written or deleted, and the
/// proposed changes are returned in the [GenerationResult]. Set [verbose] to
/// print progress to standard output. Set [buildRunnerWatch] to start
/// `build_runner watch` after a successful non-dry generation.
///
/// Throws [KoiDiagnosticException] when configuration or OpenAPI validation
/// reports errors. File, network, formatting, and build failures retain their
/// underlying exception types.
Future<GenerationResult> generate({ Future<GenerationResult> generate({
String? configPath, String? configPath,
String? workingDirectory, String? workingDirectory,
bool dryRun = false, bool dryRun = false,
bool verbose = false, bool verbose = false,
bool buildRunnerWatch = false,
}) async { }) async {
final config = await KoiSwaggerConfig.load( final config = await KoiSwaggerConfig.load(
configPath: configPath, configPath: configPath,
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
); );
if (config.input.swaggerUrls.isEmpty) { if (config.input.swaggerUrls.isEmpty) {
throw StateError('没有可用的 OpenAPI 文档源,请检查配置文件的 input.swagger_urls。'); throw StateError(
'No OpenAPI document source is available. Check '
'input.swagger_urls in the configuration file.',
);
} }
final loader = OpenApiDocumentLoader(); final loader = OpenApiDocumentLoader();
final loaded = await loader.load(config.input.swaggerUrls); final loaded = await loader.load(config.input.swaggerUrls);
if (verbose) { if (verbose) {
stdout.writeln( stdout.writeln(
'[koi_swagger_parser] loaded ${loaded.sources.length} source(s).', '[koi_swagger_parser] loaded ${loaded.sources.length} root source(s) '
'and ${loaded.dependencies.length} referenced source(s).',
); );
} }
@@ -42,37 +65,67 @@ class KoiSwaggerParser {
final generator = DartGenerator(config); final generator = DartGenerator(config);
final bundle = generator.generate(document); final bundle = generator.generate(document);
final filePaths = bundle.files final formatting = config.generation.formatting;
.map((file) => file.path) final lifecycle = GenerationLifecycle(config);
.toList(growable: false); final lifecycleResult = await lifecycle.apply(
bundle: bundle,
sourceDocument: loaded.document,
sources: loaded.sources,
dependencies: loaded.dependencies,
dryRun: dryRun,
postWrite: (changedFiles) async {
if (changedFiles.isNotEmpty && formatting.runDartFix) {
await _runFixer(changedFiles);
}
if (changedFiles.isNotEmpty && formatting.runDartFormat) {
await _runFormatter(changedFiles);
}
if (formatting.runBuildRunner &&
formatting.buildRunnerMode == BuildRunnerMode.build) {
await _runBuildRunner(config, mode: BuildRunnerMode.build);
}
},
);
if (!dryRun) { final shouldWatch =
for (final file in bundle.files) { !dryRun &&
final ioFile = File(file.path); (buildRunnerWatch ||
ioFile.parent.createSync(recursive: true); (formatting.runBuildRunner &&
ioFile.writeAsStringSync(file.content); formatting.buildRunnerMode == BuildRunnerMode.watch));
} if (shouldWatch) {
await _runBuildRunner(config, mode: BuildRunnerMode.watch);
if (config.generation.formatting.runDartFormat) {
await _runFormatter(<String>[
config.output.apiDir,
config.output.modelsDir,
]);
}
} }
return GenerationResult( return GenerationResult(
configPath: config.configPath, configPath: config.configPath,
sourceDocuments: loaded.sources, sourceDocuments: loaded.sources,
writtenFiles: filePaths, generatedFiles: lifecycleResult.generatedFiles,
writtenFiles: lifecycleResult.writtenFiles,
deletedFiles: lifecycleResult.deletedFiles,
wouldWriteFiles: lifecycleResult.wouldWriteFiles,
wouldDeleteFiles: lifecycleResult.wouldDeleteFiles,
skippedUnchanged: lifecycleResult.skippedUnchanged,
manifestPath: lifecycleResult.manifestPath,
operationsCount: bundle.operationsCount, operationsCount: bundle.operationsCount,
modelsCount: bundle.modelsCount, modelsCount: bundle.modelsCount,
dryRun: dryRun, dryRun: dryRun,
dependencyDocuments: document.dependencyDocuments,
diagnostics: <KoiDiagnostic>[
...config.diagnostics,
...document.diagnostics,
...lifecycleResult.diagnostics,
],
); );
} }
Future<void> _runFormatter(List<String> targets) async { Future<void> _runFormatter(List<String> targets) async {
final uniqueTargets = targets.toSet().toList(growable: false); final uniqueTargets = targets
.toSet()
.where((target) => File(target).existsSync())
.toList(growable: false);
if (uniqueTargets.isEmpty) {
return;
}
final result = await Process.run(Platform.resolvedExecutable, <String>[ final result = await Process.run(Platform.resolvedExecutable, <String>[
'format', 'format',
...uniqueTargets, ...uniqueTargets,
@@ -86,4 +139,70 @@ class KoiSwaggerParser {
); );
} }
} }
Future<void> _runFixer(List<String> targets) async {
final uniqueTargets = targets.toSet().where(
(target) => File(target).existsSync() || Directory(target).existsSync(),
);
for (final target in uniqueTargets) {
final result = await Process.run(Platform.resolvedExecutable, <String>[
'fix',
'--apply',
target,
]);
if (result.exitCode != 0) {
throw ProcessException(
Platform.resolvedExecutable,
<String>['fix', '--apply', target],
result.stderr.toString(),
result.exitCode,
);
}
}
}
Future<void> _runBuildRunner(
KoiSwaggerConfig config, {
required BuildRunnerMode mode,
}) async {
final arguments = <String>[
'run',
'build_runner',
mode.name,
if (config.generation.formatting.deleteConflictingOutputs)
'--delete-conflicting-outputs',
];
if (mode == BuildRunnerMode.watch) {
final process = await Process.start(
Platform.resolvedExecutable,
arguments,
workingDirectory: config.workingDirectory,
mode: ProcessStartMode.inheritStdio,
);
final exitCode = await process.exitCode;
if (exitCode != 0) {
throw ProcessException(
Platform.resolvedExecutable,
arguments,
'build_runner watch exited with a non-zero status.',
exitCode,
);
}
return;
}
final result = await Process.run(
Platform.resolvedExecutable,
arguments,
workingDirectory: config.workingDirectory,
);
if (result.exitCode != 0) {
throw ProcessException(
Platform.resolvedExecutable,
arguments,
'${result.stdout}\n${result.stderr}',
result.exitCode,
);
}
}
} }
+268
View File
@@ -0,0 +1,268 @@
import 'dart:convert';
import 'package:path/path.dart' as p;
import '../diagnostic/diagnostic.dart';
import '../util/utils.dart';
/// Merges resolved OpenAPI roots while diagnosing semantic conflicts.
class OpenApiDocumentMerger {
/// Creates a merger that reports issues to [diagnostics].
OpenApiDocumentMerger(this.diagnostics);
static const Set<String> _httpMethods = <String>{
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
};
/// Destination for merge warnings and errors.
final KoiDiagnosticBag diagnostics;
/// Merges [incoming] into [base] using deterministic conflict handling.
Map<String, dynamic> merge(
Map<String, dynamic> base,
Map<String, dynamic> incoming, {
required String source,
}) {
final prepared = _deepCopyMap(incoming);
_validateVersions(base, prepared, source);
_renameConflictingComponents(base, prepared, source);
_validatePathConflicts(base, prepared, source);
final baseInfo = base['info'];
if (baseInfo != null &&
prepared['info'] != null &&
!_structurallyEqual(baseInfo, prepared['info'])) {
diagnostics.warning(
'openapi.multisource_info_ignored',
'The source documents define different info objects; the merged '
'document keeps the info object from the first root document.',
source: source,
location: r'$.info',
);
}
final result = deepMergeMaps(base, prepared);
if (baseInfo != null) {
result['info'] = baseInfo;
}
if (base['openapi'] != null) {
result['openapi'] = base['openapi'];
}
return result;
}
void _validateVersions(
Map<String, dynamic> base,
Map<String, dynamic> incoming,
String source,
) {
final baseVersion = base['openapi']?.toString();
final incomingVersion = incoming['openapi']?.toString();
if (baseVersion == null || incomingVersion == null) {
return;
}
String family(String version) {
final parts = version.split('.');
return parts.length < 2 ? version : '${parts[0]}.${parts[1]}';
}
if (family(baseVersion) != family(incomingVersion)) {
diagnostics.error(
'openapi.multisource_version_conflict',
'Cannot merge OpenAPI $baseVersion and OpenAPI $incomingVersion '
'documents.',
source: source,
location: r'$.openapi',
);
}
}
void _renameConflictingComponents(
Map<String, dynamic> base,
Map<String, dynamic> incoming,
String source,
) {
final baseComponents = base['components'];
final incomingComponents = incoming['components'];
if (baseComponents is! Map || incomingComponents is! Map) {
return;
}
for (final groupEntry in incomingComponents.entries.toList()) {
final kind = groupEntry.key.toString();
final incomingGroup = groupEntry.value;
final baseGroup = baseComponents[kind];
if (incomingGroup is! Map || baseGroup is! Map) {
continue;
}
for (final componentEntry in incomingGroup.entries.toList()) {
final name = componentEntry.key.toString();
if (!baseGroup.containsKey(name)) {
continue;
}
if (_structurallyEqual(baseGroup[name], componentEntry.value)) {
incomingGroup.remove(componentEntry.key);
continue;
}
final renamed = _uniqueComponentName(
source,
name,
baseGroup.keys,
incomingGroup.keys,
);
incomingGroup
..remove(componentEntry.key)
..[renamed] = componentEntry.value;
final oldRef =
'#/components/${_escapePointerSegment(kind)}/'
'${_escapePointerSegment(name)}';
final newRef =
'#/components/${_escapePointerSegment(kind)}/'
'${_escapePointerSegment(renamed)}';
_rewriteRefs(incoming, oldRef, newRef);
diagnostics.warning(
'openapi.multisource_component_renamed',
'Component `$kind/$name` conflicts with an earlier source and was '
'renamed to `$renamed`.',
source: source,
location: '#/components/$kind/$name',
);
}
}
}
void _validatePathConflicts(
Map<String, dynamic> base,
Map<String, dynamic> incoming,
String source,
) {
final basePaths = base['paths'];
final incomingPaths = incoming['paths'];
if (basePaths is! Map || incomingPaths is! Map) {
return;
}
for (final pathEntry in incomingPaths.entries) {
final path = pathEntry.key.toString();
final incomingPathItem = pathEntry.value;
final basePathItem = basePaths[path];
if (incomingPathItem is! Map || basePathItem is! Map) {
continue;
}
for (final method in _httpMethods) {
if (basePathItem[method] != null && incomingPathItem[method] != null) {
diagnostics.error(
'openapi.multisource_operation_conflict',
'Multiple source documents define '
'`${method.toUpperCase()} $path`.',
source: source,
location: '\$.paths.$path.$method',
);
}
}
final baseParameters = basePathItem['parameters'];
final incomingParameters = incomingPathItem['parameters'];
if (baseParameters != null &&
incomingParameters != null &&
!_structurallyEqual(baseParameters, incomingParameters)) {
diagnostics.error(
'openapi.multisource_path_parameters_conflict',
'Multiple source documents define conflicting shared parameters '
'for path `$path`.',
source: source,
location: '\$.paths.$path.parameters',
);
}
}
}
String _uniqueComponentName(
String source,
String originalName,
Iterable<dynamic> baseNames,
Iterable<dynamic> incomingNames,
) {
final used = <String>{
...baseNames.map((name) => name.toString()),
...incomingNames.map((name) => name.toString()),
};
final path = isHttpUrl(source) ? Uri.parse(source).path : source;
final stem = toSnakeCase(p.basenameWithoutExtension(path));
final prefix = stem.isEmpty ? 'source' : stem;
final baseName = '${prefix}_$originalName';
var candidate = baseName;
var suffix = 1;
while (used.contains(candidate)) {
suffix += 1;
candidate = '$baseName$suffix';
}
return candidate;
}
void _rewriteRefs(dynamic value, String oldRef, String newRef) {
if (value is Map) {
for (final entry in value.entries.toList()) {
if (entry.key.toString() == r'$ref' && entry.value == oldRef) {
value[entry.key] = newRef;
} else {
_rewriteRefs(entry.value, oldRef, newRef);
}
}
return;
}
if (value is List) {
for (final item in value) {
_rewriteRefs(item, oldRef, newRef);
}
}
}
bool _structurallyEqual(dynamic left, dynamic right) {
return jsonEncode(_canonicalize(left)) == jsonEncode(_canonicalize(right));
}
dynamic _canonicalize(dynamic value) {
if (value is Map) {
final keys = value.keys.map((key) => key.toString()).toList()..sort();
return <String, dynamic>{
for (final key in keys) key: _canonicalize(value[key]),
};
}
if (value is List) {
return value.map(_canonicalize).toList(growable: false);
}
return value;
}
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> value) {
return _deepCopy(value) as Map<String, dynamic>;
}
dynamic _deepCopy(dynamic value) {
if (value is Map) {
return value.map(
(key, dynamic item) => MapEntry(key.toString(), _deepCopy(item)),
);
}
if (value is List) {
return value.map(_deepCopy).toList(growable: true);
}
return value;
}
String _escapePointerSegment(String value) {
return value.replaceAll('~', '~0').replaceAll('/', '~1');
}
}
+2337 -170
View File
File diff suppressed because it is too large Load Diff
+539
View File
@@ -0,0 +1,539 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import '../diagnostic/diagnostic.dart';
import '../util/utils.dart';
/// A root OpenAPI document with normalized references and dependency metadata.
class ResolvedOpenApiSource {
/// Creates a resolved source result.
const ResolvedOpenApiSource({
required this.source,
required this.document,
required this.dependencies,
});
/// Canonical path or URL of the root source.
final String source;
/// Resolved root document with external components bundled locally.
final Map<String, dynamic> document;
/// Canonical external documents loaded while resolving references.
final List<String> dependencies;
}
/// Loads OpenAPI documents and resolves local and external `$ref` values.
class OpenApiReferenceResolver {
/// Creates a resolver that reports issues to [diagnostics].
OpenApiReferenceResolver(this.diagnostics);
/// Destination for loading and reference-resolution diagnostics.
final KoiDiagnosticBag diagnostics;
final Map<String, Map<String, dynamic>> _documentCache =
<String, Map<String, dynamic>>{};
final Set<String> _loadedDependencies = <String>{};
final Map<String, String> _bundledRefs = <String, String>{};
final Map<String, Map<String, dynamic>> _bundledComponents =
<String, Map<String, dynamic>>{};
final Set<String> _activeInlineRefs = <String>{};
late String _rootSource;
late Map<String, dynamic> _rootDocument;
/// Loads [source], resolves its references, and returns dependency metadata.
Future<ResolvedOpenApiSource> resolve(String source) async {
_rootSource = _canonicalSource(source);
_loadedDependencies
..clear()
..add(_rootSource);
_bundledRefs.clear();
_bundledComponents.clear();
_activeInlineRefs.clear();
final rawDocument = await _loadDocument(_rootSource);
_rootDocument = _deepCopyMap(rawDocument);
final resolvedValue = await _resolveNode(
_rootDocument,
baseSource: _rootSource,
location: r'$',
);
final resolvedDocument = resolvedValue is Map
? resolvedValue.cast<String, dynamic>()
: <String, dynamic>{};
_mergeBundledComponents(resolvedDocument);
return ResolvedOpenApiSource(
source: _rootSource,
document: resolvedDocument,
dependencies:
_loadedDependencies
.where((dependency) => dependency != _rootSource)
.toList(growable: false)
..sort(),
);
}
Future<dynamic> _resolveNode(
dynamic value, {
required String baseSource,
required String location,
}) async {
if (value is List) {
final result = <dynamic>[];
for (var index = 0; index < value.length; index += 1) {
result.add(
await _resolveNode(
value[index],
baseSource: baseSource,
location: '$location[$index]',
),
);
}
return result;
}
if (value is! Map) {
return value;
}
final map = value.cast<String, dynamic>();
final ref = map[r'$ref'];
if (ref is String) {
final resolvedRef = await _resolveReference(
ref,
baseSource: baseSource,
location: '$location.\$ref',
);
final siblings = <String, dynamic>{};
for (final entry in map.entries.toList(growable: false)) {
if (entry.key == r'$ref') {
continue;
}
siblings[entry.key] = await _resolveNode(
entry.value,
baseSource: baseSource,
location: '$location.${entry.key}',
);
}
if (resolvedRef is Map) {
return deepMergeMaps(resolvedRef.cast<String, dynamic>(), siblings);
}
if (siblings.isNotEmpty) {
diagnostics.error(
'openapi.ref_siblings_on_non_object',
'Reference `$ref` resolves to a non-object value, so sibling fields '
'cannot be merged.',
source: baseSource,
location: location,
);
}
return resolvedRef;
}
final result = <String, dynamic>{};
for (final entry in map.entries.toList(growable: false)) {
result[entry.key] = await _resolveNode(
entry.value,
baseSource: baseSource,
location: '$location.${entry.key}',
);
}
return result;
}
Future<dynamic> _resolveReference(
String ref, {
required String baseSource,
required String location,
}) async {
final target = _resolveLocation(baseSource, ref);
if (target == null) {
diagnostics.error(
'openapi.invalid_ref',
'Unable to resolve reference URI `$ref`.',
source: baseSource,
location: location,
);
return <String, dynamic>{};
}
if (target.source == _rootSource) {
if (target.fragment.isEmpty) {
diagnostics.error(
'openapi.root_document_ref_not_supported',
'Referencing an entire root OpenAPI document is not supported.',
source: baseSource,
location: location,
);
return <String, dynamic>{};
}
return <String, dynamic>{r'$ref': '#${target.fragment}'};
}
_loadedDependencies.add(target.source);
final component = _componentPointer(target.fragment);
if (component != null) {
return _bundleExternalComponent(
target,
component: component,
location: location,
);
}
final absoluteRef = '${target.source}#${target.fragment}';
if (!_activeInlineRefs.add(absoluteRef)) {
diagnostics.error(
'openapi.unrepresentable_external_ref_cycle',
'External cyclic reference `$absoluteRef` does not target a component '
'and cannot be inlined safely.',
source: baseSource,
location: location,
);
return <String, dynamic>{};
}
try {
final document = await _loadDocument(target.source);
final targetValue = _resolveJsonPointer(document, target.fragment);
if (targetValue == null) {
diagnostics.error(
'openapi.unresolved_external_ref',
'Reference target `$ref` does not exist in the external document.',
source: target.source,
location: '#${target.fragment}',
);
return <String, dynamic>{};
}
return _resolveNode(
_deepCopy(targetValue),
baseSource: target.source,
location: '${target.source}#${target.fragment}',
);
} finally {
_activeInlineRefs.remove(absoluteRef);
}
}
Future<Map<String, dynamic>> _bundleExternalComponent(
_ReferenceTarget target, {
required _ComponentPointer component,
required String location,
}) async {
final absoluteRef = '${target.source}#${target.fragment}';
final existingRef = _bundledRefs[absoluteRef];
if (existingRef != null) {
return <String, dynamic>{r'$ref': existingRef};
}
final localName = _allocateComponentName(
component.kind,
component.name,
target.source,
);
final localRef =
'#/components/${_escapePointerSegment(component.kind)}/'
'${_escapePointerSegment(localName)}';
_bundledRefs[absoluteRef] = localRef;
final group = _bundledComponents.putIfAbsent(
component.kind,
() => <String, dynamic>{},
);
group[localName] = <String, dynamic>{};
final document = await _loadDocument(target.source);
final targetValue = _resolveJsonPointer(document, target.fragment);
if (targetValue == null) {
diagnostics.error(
'openapi.unresolved_external_ref',
'Component `${component.kind}/${component.name}` does not exist in '
'the external document.',
source: target.source,
location: '#${target.fragment}',
);
return <String, dynamic>{r'$ref': localRef};
}
final resolvedValue = await _resolveNode(
_deepCopy(targetValue),
baseSource: target.source,
location: '${target.source}#${target.fragment}',
);
if (resolvedValue is! Map) {
diagnostics.error(
'openapi.invalid_external_component',
'External component `${component.kind}/${component.name}` must be an '
'object.',
source: target.source,
location: location,
);
} else {
group[localName] = resolvedValue.cast<String, dynamic>();
}
return <String, dynamic>{r'$ref': localRef};
}
String _allocateComponentName(
String kind,
String preferredName,
String source,
) {
final rootComponents = _rootDocument['components'];
final rootGroup = rootComponents is Map ? rootComponents[kind] : null;
final existingNames = <String>{
if (rootGroup is Map) ...rootGroup.keys.map((key) => key.toString()),
...?_bundledComponents[kind]?.keys,
};
if (!existingNames.contains(preferredName)) {
return preferredName;
}
final sourceStem = _sourceStem(source);
final baseName = '${sourceStem}_$preferredName';
var candidate = baseName;
var suffix = 1;
while (existingNames.contains(candidate)) {
suffix += 1;
candidate = '$baseName$suffix';
}
diagnostics.warning(
'openapi.external_component_renamed',
'External component `$preferredName` conflicts with the root document '
'and was renamed to `$candidate`.',
source: source,
location: '#/components/$kind/$preferredName',
);
return candidate;
}
void _mergeBundledComponents(Map<String, dynamic> document) {
if (_bundledComponents.isEmpty) {
return;
}
final components = document.putIfAbsent(
'components',
() => <String, dynamic>{},
);
if (components is! Map) {
diagnostics.error(
'openapi.invalid_components',
'Unable to add the external reference because the root document '
'components value is not an object.',
source: _rootSource,
location: r'$.components',
);
return;
}
final componentMap = components.cast<String, dynamic>();
for (final entry in _bundledComponents.entries) {
final rawGroup = componentMap.putIfAbsent(
entry.key,
() => <String, dynamic>{},
);
if (rawGroup is! Map) {
diagnostics.error(
'openapi.invalid_component_group',
'components.${entry.key} must be an object.',
source: _rootSource,
location: '\$.components.${entry.key}',
);
continue;
}
rawGroup.cast<String, dynamic>().addAll(entry.value);
}
}
Future<Map<String, dynamic>> _loadDocument(String source) async {
final cached = _documentCache[source];
if (cached != null) {
return cached;
}
try {
final String content;
if (isHttpUrl(source)) {
final response = await http.get(Uri.parse(source));
if (response.statusCode < 200 || response.statusCode >= 300) {
diagnostics.error(
'openapi.source_http_error',
'Failed to fetch the OpenAPI document: HTTP '
'${response.statusCode}.',
source: source,
);
return <String, dynamic>{};
}
content = response.body;
} else {
final filePath = source.startsWith('file://')
? Uri.parse(source).toFilePath()
: source;
content = await File(filePath).readAsString();
}
final document = decodeStructuredDocument(content);
_documentCache[source] = document;
return document;
} on Object catch (error) {
diagnostics.error(
'openapi.source_load_failed',
'Failed to read or parse the OpenAPI document: $error',
source: source,
);
return <String, dynamic>{};
}
}
_ReferenceTarget? _resolveLocation(String baseSource, String ref) {
final hashIndex = ref.indexOf('#');
final rawPath = hashIndex < 0 ? ref : ref.substring(0, hashIndex);
final fragment = hashIndex < 0 ? '' : ref.substring(hashIndex + 1);
try {
if (rawPath.isEmpty) {
return _ReferenceTarget(
source: _canonicalSource(baseSource),
fragment: fragment,
);
}
if (isHttpUrl(rawPath) || rawPath.startsWith('file://')) {
return _ReferenceTarget(
source: _canonicalSource(rawPath),
fragment: fragment,
);
}
if (isHttpUrl(baseSource)) {
final resolved = Uri.parse(baseSource).resolve(rawPath);
return _ReferenceTarget(
source: _canonicalSource(resolved.toString()),
fragment: fragment,
);
}
final basePath = baseSource.startsWith('file://')
? Uri.parse(baseSource).toFilePath()
: baseSource;
final targetPath = p.isAbsolute(rawPath)
? rawPath
: p.join(p.dirname(basePath), Uri.decodeComponent(rawPath));
return _ReferenceTarget(
source: p.normalize(p.absolute(targetPath)),
fragment: fragment,
);
} on FormatException {
return null;
}
}
String _canonicalSource(String source) {
if (isHttpUrl(source)) {
final fragmentIndex = source.indexOf('#');
final withoutFragment = fragmentIndex < 0
? source
: source.substring(0, fragmentIndex);
return Uri.parse(withoutFragment).toString();
}
if (source.startsWith('file://')) {
return Uri.file(p.normalize(Uri.parse(source).toFilePath())).toString();
}
return p.normalize(p.absolute(source));
}
_ComponentPointer? _componentPointer(String fragment) {
if (!fragment.startsWith('/')) {
return null;
}
final segments = fragment
.substring(1)
.split('/')
.map(_decodePointerSegment)
.toList(growable: false);
if (segments.length != 3 || segments.first != 'components') {
return null;
}
return _ComponentPointer(kind: segments[1], name: segments[2]);
}
dynamic _resolveJsonPointer(Map<String, dynamic> document, String fragment) {
if (fragment.isEmpty) {
return document;
}
if (!fragment.startsWith('/')) {
return null;
}
dynamic current = document;
for (final rawSegment in fragment.substring(1).split('/')) {
final segment = _decodePointerSegment(rawSegment);
if (current is Map) {
if (!current.containsKey(segment)) {
return null;
}
current = current[segment];
continue;
}
if (current is List) {
final index = int.tryParse(segment);
if (index == null || index < 0 || index >= current.length) {
return null;
}
current = current[index];
continue;
}
return null;
}
return current;
}
String _decodePointerSegment(String value) {
return Uri.decodeComponent(
value.replaceAll('~1', '/').replaceAll('~0', '~'),
);
}
String _escapePointerSegment(String value) {
return value.replaceAll('~', '~0').replaceAll('/', '~1');
}
String _sourceStem(String source) {
final path = isHttpUrl(source)
? Uri.parse(source).path
: source.startsWith('file://')
? Uri.parse(source).toFilePath()
: source;
final stem = p.basenameWithoutExtension(path);
return stem.isEmpty ? 'external' : toSnakeCase(stem);
}
Map<String, dynamic> _deepCopyMap(Map<String, dynamic> value) {
return _deepCopy(value) as Map<String, dynamic>;
}
dynamic _deepCopy(dynamic value) {
if (value is Map) {
return value.map(
(key, dynamic item) => MapEntry(key.toString(), _deepCopy(item)),
);
}
if (value is List) {
return value.map(_deepCopy).toList(growable: true);
}
return value;
}
}
class _ReferenceTarget {
const _ReferenceTarget({required this.source, required this.fragment});
final String source;
final String fragment;
}
class _ComponentPointer {
const _ComponentPointer({required this.kind, required this.name});
final String kind;
final String name;
}
+36 -4
View File
@@ -1,32 +1,64 @@
enum BinaryTransportStyle { multipartFile, file, bytes, string } /// The Dart representation generated for multipart binary fields.
enum BinaryTransportStyle {
/// Uses Dio's `MultipartFile`.
multipartFile,
/// Uses `dart:io`'s `File`.
file,
/// Uses a byte list.
bytes,
/// Uses a string value.
string,
}
/// A named set of compatibility defaults for generated Dart code.
class GeneratorPreset { class GeneratorPreset {
/// Creates a generator preset.
const GeneratorPreset({ const GeneratorPreset({
required this.name, required this.name,
required this.multipartBinaryStyle, required this.multipartBinaryStyle,
required this.description, required this.description,
}); });
/// The stable configuration name of this preset.
final String name; final String name;
/// The Dart representation used for multipart binary fields.
final BinaryTransportStyle multipartBinaryStyle; final BinaryTransportStyle multipartBinaryStyle;
/// A human-readable English description of this preset.
final String description; final String description;
} }
/// Resolves the built-in generator presets by name.
class GeneratorPresetRegistry { class GeneratorPresetRegistry {
GeneratorPresetRegistry._();
/// The Retrofit, Dio, and Freezed preset used by Koi Flutter projects.
static const GeneratorPreset koiFlutter = GeneratorPreset( static const GeneratorPreset koiFlutter = GeneratorPreset(
name: 'koi_flutter', name: 'koi_flutter',
multipartBinaryStyle: BinaryTransportStyle.multipartFile, multipartBinaryStyle: BinaryTransportStyle.multipartFile,
description: description:
'Koi Flutter preset,面向 Retrofit + Dio + Freezed 场景,' 'Koi Flutter preset for Retrofit, Dio, and Freezed projects. '
'multipart 二进制字段默认生成为 MultipartFile。', 'Multipart binary fields use MultipartFile by default.',
); );
/// Resolves [rawName] to a built-in preset.
///
/// An omitted or empty name resolves to [koiFlutter]. Throws
/// [ArgumentError] when the requested preset is unknown.
static GeneratorPreset resolve(String? rawName) { static GeneratorPreset resolve(String? rawName) {
final name = rawName?.trim(); final name = rawName?.trim();
if (name == null || name.isEmpty || name == koiFlutter.name) { if (name == null || name.isEmpty || name == koiFlutter.name) {
return koiFlutter; return koiFlutter;
} }
throw ArgumentError.value(name, 'generation.preset.name', '未知的 preset。'); throw ArgumentError.value(
name,
'generation.preset.name',
'Unknown preset.',
);
} }
} }
+51 -2
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
/// Dart reserved words and built-in identifiers unsafe for generated names.
const Set<String> dartKeywords = { const Set<String> dartKeywords = {
'abstract', 'abstract',
'as', 'as',
@@ -88,6 +89,7 @@ const List<String> _knownSnakeCaseAcronyms = <String>[
'uuid', 'uuid',
]; ];
/// Splits mixed-case, snake-case, kebab-case, and punctuated input into words.
List<String> splitWords(String input) { List<String> splitWords(String input) {
final normalized = input final normalized = input
.replaceAllMapped( .replaceAllMapped(
@@ -111,6 +113,7 @@ List<String> splitWords(String input) {
.toList(growable: false); .toList(growable: false);
} }
/// Converts [input] to a valid UpperCamelCase Dart identifier.
String toPascalCase(String input) { String toPascalCase(String input) {
final words = splitWords(input); final words = splitWords(input);
if (words.isEmpty) { if (words.isEmpty) {
@@ -127,6 +130,7 @@ String toPascalCase(String input) {
return sanitizeIdentifier(value, fallback: 'GeneratedValue'); return sanitizeIdentifier(value, fallback: 'GeneratedValue');
} }
/// Converts [input] to a valid lowerCamelCase Dart identifier.
String toCamelCase(String input) { String toCamelCase(String input) {
final pascal = toPascalCase(input); final pascal = toPascalCase(input);
if (pascal.isEmpty) { if (pascal.isEmpty) {
@@ -136,6 +140,7 @@ String toCamelCase(String input) {
return sanitizeIdentifier(value, fallback: 'generatedValue'); return sanitizeIdentifier(value, fallback: 'generatedValue');
} }
/// Converts [input] to a lowercase snake_case file or directory name.
String toSnakeCase(String input) { String toSnakeCase(String input) {
final words = _mergeSnakeCaseAcronyms(splitWords(input)); final words = _mergeSnakeCaseAcronyms(splitWords(input));
if (words.isEmpty) { if (words.isEmpty) {
@@ -187,6 +192,7 @@ class _SnakeCaseAcronymMatch {
final int endIndex; final int endIndex;
} }
/// Removes invalid Dart identifier characters and applies [fallback] as needed.
String sanitizeIdentifier(String raw, {required String fallback}) { String sanitizeIdentifier(String raw, {required String fallback}) {
var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), ''); var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), '');
if (value.isEmpty) { if (value.isEmpty) {
@@ -201,6 +207,7 @@ String sanitizeIdentifier(String raw, {required String fallback}) {
return value; return value;
} }
/// Escapes [value] for inclusion in a single-quoted generated Dart literal.
String escapeDartString(String value) { String escapeDartString(String value) {
return value return value
.replaceAll('\\', r'\\') .replaceAll('\\', r'\\')
@@ -208,6 +215,7 @@ String escapeDartString(String value) {
.replaceAll('\n', r'\n'); .replaceAll('\n', r'\n');
} }
/// Converts a YAML or ordinary map into a string-keyed Dart map.
Map<String, dynamic> yamlToMap(dynamic value) { Map<String, dynamic> yamlToMap(dynamic value) {
if (value is YamlMap) { if (value is YamlMap) {
return value.map( return value.map(
@@ -224,6 +232,7 @@ Map<String, dynamic> yamlToMap(dynamic value) {
return <String, dynamic>{}; return <String, dynamic>{};
} }
/// Recursively converts YAML collection nodes into ordinary Dart values.
dynamic yamlValueToDynamic(dynamic value) { dynamic yamlValueToDynamic(dynamic value) {
if (value is YamlMap || value is Map) { if (value is YamlMap || value is Map) {
return yamlToMap(value); return yamlToMap(value);
@@ -236,6 +245,7 @@ dynamic yamlValueToDynamic(dynamic value) {
return value; return value;
} }
/// Recursively overlays [override] values on a copy of [base].
Map<String, dynamic> deepMergeMaps( Map<String, dynamic> deepMergeMaps(
Map<String, dynamic> base, Map<String, dynamic> base,
Map<String, dynamic> override, Map<String, dynamic> override,
@@ -253,10 +263,49 @@ Map<String, dynamic> deepMergeMaps(
return result; return result;
} }
/// Whether [value] is an absolute HTTP or HTTPS URL.
bool isHttpUrl(String value) { bool isHttpUrl(String value) {
return value.startsWith('http://') || value.startsWith('https://'); return value.startsWith('http://') || value.startsWith('https://');
} }
/// Matches an exact value, a shell-style glob (`*`, `?`, `**`), or a regular
/// expression prefixed with `regex:`.
bool matchesPattern(String value, String pattern) {
if (pattern.startsWith('regex:')) {
return RegExp(pattern.substring('regex:'.length)).hasMatch(value);
}
if (!pattern.contains('*') && !pattern.contains('?')) {
return value == pattern;
}
final buffer = StringBuffer('^');
var index = 0;
while (index < pattern.length) {
final character = pattern[index];
if (character == '*') {
final isDouble = index + 1 < pattern.length && pattern[index + 1] == '*';
buffer.write(isDouble ? '.*' : r'[^/]*');
index += isDouble ? 2 : 1;
continue;
}
if (character == '?') {
buffer.write(r'[^/]');
index += 1;
continue;
}
buffer.write(RegExp.escape(character));
index += 1;
}
buffer.write(r'$');
return RegExp(buffer.toString()).hasMatch(value);
}
/// Whether [value] matches at least one entry in [patterns].
bool matchesAnyPattern(String value, List<String>? patterns) {
return patterns?.any((pattern) => matchesPattern(value, pattern)) ?? false;
}
/// Decodes a JSON or YAML document whose root must be an object.
Map<String, dynamic> decodeStructuredDocument(String content) { Map<String, dynamic> decodeStructuredDocument(String content) {
final trimmed = content.trimLeft(); final trimmed = content.trimLeft();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) { if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
@@ -264,13 +313,13 @@ Map<String, dynamic> decodeStructuredDocument(String content) {
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
return decoded; return decoded;
} }
throw const FormatException('OpenAPI 根节点必须是对象。'); throw const FormatException('The OpenAPI document root must be an object.');
} }
final yaml = loadYaml(content); final yaml = loadYaml(content);
final mapped = yamlToMap(yaml); final mapped = yamlToMap(yaml);
if (mapped.isEmpty) { if (mapped.isEmpty) {
throw const FormatException('无法解析 OpenAPI 文档内容。'); throw const FormatException('Unable to parse the OpenAPI document.');
} }
return mapped; return mapped;
} }
+7 -2
View File
@@ -1,9 +1,13 @@
name: koi_swagger_parser name: koi_swagger_parser
description: OpenAPI 3.1 parser and Koi-style Dart API generator for Retrofit + Freezed projects. description: Generates Retrofit and Freezed Dart/Flutter clients from OpenAPI 3.0 and 3.1 documents.
version: 0.1.0 version: 0.2.0
homepage: https://github.com/mrkoi-is/koi_swagger_parser homepage: https://github.com/mrkoi-is/koi_swagger_parser
repository: 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 issue_tracker: https://github.com/mrkoi-is/koi_swagger_parser/issues
documentation: https://pub.dev/documentation/koi_swagger_parser/latest/
executables:
koi_swagger_parser:
topics: topics:
- openapi - openapi
@@ -17,6 +21,7 @@ environment:
dependencies: dependencies:
args: ^2.7.0 args: ^2.7.0
crypto: ^3.0.7
http: ^1.5.0 http: ^1.5.0
path: ^1.9.1 path: ^1.9.1
yaml: ^3.1.3 yaml: ^3.1.3
+120
View File
@@ -0,0 +1,120 @@
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
test('top-level help is in English', () async {
final result = await _runCli(<String>['--help']);
expect(result.exitCode, 0, reason: result.stderr.toString());
expect(result.stdout.toString(), contains('Usage:'));
expect(result.stdout.toString(), contains('Available commands:'));
expect(result.stdout.toString(), contains('generate'));
expect(result.stdout.toString(), isNot(contains('用法')));
});
test('generate help documents command options in English', () async {
final result = await _runCli(<String>['generate', '--help']);
expect(result.exitCode, 0, reason: result.stderr.toString());
expect(
result.stdout.toString(),
contains('Generate API clients and models.'),
);
expect(result.stdout.toString(), contains('--config'));
expect(result.stdout.toString(), contains('--dry-run'));
expect(result.stdout.toString(), contains('--watch'));
expect(result.stdout.toString(), isNot(contains('显示帮助')));
});
test('invalid arguments report an English usage error', () async {
final result = await _runCli(<String>['--unknown']);
expect(result.exitCode, 64);
expect(result.stderr.toString(), startsWith('Error:'));
expect(result.stderr.toString(), contains('Usage:'));
});
test('generation failures are in English by default', () async {
final directory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_cli_missing_',
);
addTearDown(() => directory.delete(recursive: true));
final missingConfig = p.join(directory.path, 'missing.yaml');
final result = await _runCli(<String>[
'generate',
'--config',
missingConfig,
]);
expect(result.exitCode, 1);
expect(result.stderr.toString(), contains('Generation failed.'));
expect(
result.stderr.toString(),
contains('Run with --verbose to see the underlying error.'),
);
expect(result.stderr.toString(), isNot(contains('生成失败')));
});
test(
'generate --dry-run reports planned writes without touching output',
() async {
final directory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_cli_',
);
addTearDown(() => directory.delete(recursive: true));
final source = File(p.join(directory.path, 'openapi.json'));
await source.writeAsString(
jsonEncode(<String, Object?>{
'openapi': '3.1.0',
'info': <String, Object?>{'title': 'CLI', 'version': '1'},
'paths': <String, Object?>{
'/health': <String, Object?>{
'get': <String, Object?>{
'operationId': 'health',
'responses': <String, Object?>{
'204': <String, Object?>{'description': 'healthy'},
},
},
},
},
}),
);
final output = p.join(directory.path, 'lib', 'src');
final config = File(p.join(directory.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- ${source.path}
output:
base_dir: $output
api_dir: ${p.join(output, 'api')}
models_dir: ${p.join(output, 'models')}
formatting:
run_dart_format: false
''');
final result = await _runCli(<String>[
'generate',
'--config',
config.path,
'--dry-run',
]);
expect(result.exitCode, 0, reason: result.stderr.toString());
expect(result.stdout.toString(), contains('dry-run:'));
expect(result.stdout.toString(), contains('would be written'));
expect(Directory(output).existsSync(), isFalse);
},
);
}
Future<ProcessResult> _runCli(List<String> arguments) {
return Process.run(Platform.resolvedExecutable, <String>[
'run',
'koi_swagger_parser',
...arguments,
], workingDirectory: Directory.current.path);
}
+122
View File
@@ -72,5 +72,127 @@ formatting:
expect(config.generation.models.classPrefix, 'Koi'); expect(config.generation.models.classPrefix, 'Koi');
expect(config.generation.formatting.runDartFormat, isFalse); expect(config.generation.formatting.runDartFormat, isFalse);
}); });
test('解析生命周期、过滤与 build_runner 配置', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_config_lifecycle_',
);
addTearDown(() => tempDir.delete(recursive: true));
await File(p.join(tempDir.path, 'koi_swagger_parser.yaml')).writeAsString(
'''
input:
swagger_urls:
- ./openapi.json
output:
included_paths:
- /api/**
excluded_operations:
- regex:^DELETE\\s
included_models:
- '*Response'
clean_stale_files: false
skip_if_unchanged: false
manifest_path: ./.generated/koi.json
generation:
api:
enabled: true
models:
enabled: true
formatting:
run_dart_format: false
run_build_runner: true
build_runner_mode: watch
delete_conflicting_outputs: false
''',
);
final config = await KoiSwaggerConfig.load(
workingDirectory: tempDir.path,
);
expect(config.output.includedPaths, <String>['/api/**']);
expect(config.output.excludedOperations, <String>[r'regex:^DELETE\s']);
expect(config.output.includedModels, <String>['*Response']);
expect(config.output.cleanStaleFiles, isFalse);
expect(config.output.skipIfUnchanged, isFalse);
expect(
config.output.manifestPath,
p.join(tempDir.path, '.generated', 'koi.json'),
);
expect(config.generation.formatting.runBuildRunner, isTrue);
expect(
config.generation.formatting.buildRunnerMode,
BuildRunnerMode.watch,
);
expect(config.generation.formatting.deleteConflictingOutputs, isFalse);
});
test('错误标量类型和无效 regex 产生结构化诊断', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_config_invalid_',
);
addTearDown(() => tempDir.delete(recursive: true));
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ./openapi.json
enabled: yes
output:
split_by_tags: wrong
included_paths:
- regex:[
generation:
api:
enabled: true
models:
enabled: true
''');
await expectLater(
KoiSwaggerConfig.load(workingDirectory: tempDir.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'diagnostic codes',
containsAll(<String>[
'config.invalid_boolean',
'config.invalid_filter_regex',
]),
),
),
);
});
test('API enabled 时明确拒绝未实现的非 Retrofit renderer', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_config_renderer_',
);
addTearDown(() => tempDir.delete(recursive: true));
await File(p.join(tempDir.path, 'koi_swagger_parser.yaml')).writeAsString(
'''
input:
swagger_urls:
- ./openapi.json
generation:
api:
enabled: true
use_retrofit: false
models:
enabled: true
''',
);
await expectLater(
KoiSwaggerConfig.load(workingDirectory: tempDir.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'diagnostic codes',
contains('config.unsupported_api_renderer'),
),
),
);
});
}); });
} }
@@ -0,0 +1,312 @@
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('enum mappings', () {
test('按配置和扩展字段优先级生成名称、描述与 unknown fallback', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_enum_mappings_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Enum mappings",
"version": "1.0.0"
},
"paths": {
"/api/v1/deliveries": {
"get": {
"tags": ["deliveries"],
"operationId": "list_deliveries",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeliveryResponse"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"DeliveryResponse": {
"type": "object",
"required": ["state"],
"properties": {
"state": {
"$ref": "#/components/schemas/DeliveryState"
}
}
},
"DeliveryState": {
"type": "integer",
"enum": [0, 1, 2, 3, 4],
"x-enumNames": [null, "ONE_FROM_NAMES"],
"x-enum-varnames": [
"ZERO_FROM_VARNAMES",
"ONE_FROM_VARNAMES",
"TWO_FROM_VARNAMES"
],
"x-enumDescriptions": [null, "来自 x-enumDescriptions"],
"x-enum-descriptions": [
"来自 x-enum-descriptions",
"旧描述",
"第二项描述"
],
"x-enum-values": [
null,
null,
null,
{
"value": 3,
"name": "THREE_FROM_VALUES",
"description": "来自 x-enum-values"
}
]
}
}
}
}
''');
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')}
generation:
api:
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
enum_unknown_fallback: true
future_option: true
enum_key_mappings:
DeliveryState:
- value: 0
name: OLD_ZERO
enum_mappings:
DeliveryState:
- value: 0
name: CONFIG_ZERO
description: 来自 enum_mappings
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
final enumFile = File(
p.join(outputDir, 'api_models', 'enums', 'delivery_state.dart'),
).readAsStringSync();
expect(enumFile, contains('CONFIG_ZERO,'));
expect(enumFile, isNot(contains('OLD_ZERO,')));
expect(enumFile, contains('ONE_FROM_NAMES,'));
expect(enumFile, contains('TWO_FROM_VARNAMES,'));
expect(enumFile, contains('THREE_FROM_VALUES,'));
expect(enumFile, contains('value4,'));
expect(enumFile, contains('unknown,'));
expect(enumFile, contains('/// 来自 enum_mappings'));
expect(enumFile, contains('/// 来自 x-enumDescriptions'));
expect(enumFile, contains('/// 第二项描述'));
expect(enumFile, contains('/// 来自 x-enum-values'));
final responseFile = File(
p.join(outputDir, 'api_models', 'result', 'delivery_response.dart'),
).readAsStringSync();
expect(
responseFile,
contains('unknownEnumValue: KoiDeliveryState.unknown'),
);
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
containsAll(<String>[
'config.unknown_key',
'config.enum_mapping_overridden',
]),
);
});
test('无效映射通过结构化配置错误拒绝', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_invalid_enum_mapping_',
);
addTearDown(() => tempDir.delete(recursive: true));
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ./openapi.json
generation:
api:
response_wrapper:
mode: passthrough
models:
enum_mappings:
Status:
- value: 1
''');
await expectLater(
KoiSwaggerConfig.load(configPath: configFile.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
'diagnostic codes',
contains('config.invalid_enum_mapping'),
),
),
);
});
});
group('OpenAPI diagnostics', () {
test('extract_version_from_url 从单一 source 文件名提取版本', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_source_version_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'catalog-v4.openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Source version",
"version": "1.0.0"
},
"paths": {
"/catalog": {
"get": {
"responses": {
"204": {
"description": "no content"
}
}
}
}
}
}
''');
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}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
extract_version_from_url: true
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(
File(p.join(outputDir, 'api', 'v4', 'default_api.dart')).existsSync(),
isTrue,
);
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
isNot(contains('config.source_version_not_found')),
);
});
test('unresolved ref 不再静默退化为 dynamic', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_unresolved_ref_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Broken refs",
"version": "1.0.0"
},
"paths": {
"/api/v1/items": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MissingModel"
}
}
}
}
}
}
}
},
"components": {
"schemas": {}
}
}
''');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
output:
base_dir: ${p.join(tempDir.path, 'lib', 'src')}
generation:
api:
response_wrapper:
mode: passthrough
''');
await expectLater(
const KoiSwaggerParser().generate(configPath: configFile.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
'diagnostic codes',
contains('openapi.unresolved_ref'),
),
),
);
});
});
}
+65
View File
@@ -0,0 +1,65 @@
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() {
final fixtures = <String>[
'openapi_30_compat.yaml',
'e2e_openapi.json',
'dba_openapi.json',
];
for (final fixtureName in fixtures) {
test('fixture corpus parses and renders $fixtureName', () async {
final tempDirectory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_corpus_',
);
addTearDown(() => tempDirectory.delete(recursive: true));
final source = p.join(
Directory.current.path,
'test',
'fixtures',
fixtureName,
);
final output = p.join(tempDirectory.path, 'lib', 'src');
final config = File(
p.join(tempDirectory.path, 'koi_swagger_parser.yaml'),
);
await config.writeAsString('''
input:
swagger_urls:
- url: $source
output:
base_dir: $output
api_dir: ${p.join(output, 'api')}
models_dir: ${p.join(output, 'models')}
manifest_path: ${p.join(tempDirectory.path, '.generated', 'manifest.json')}
generation:
api:
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: config.path,
dryRun: true,
);
expect(result.operationsCount, greaterThan(0));
expect(result.modelsCount, greaterThan(0));
expect(result.generatedFiles, isNotEmpty);
expect(
result.diagnostics.where(
(diagnostic) => diagnostic.severity == DiagnosticSeverity.error,
),
isEmpty,
);
});
}
}
+228
View File
@@ -0,0 +1,228 @@
{
"openapi": "3.1.0",
"info": {
"title": "Koi generated package verification",
"version": "1.0.0"
},
"paths": {
"/api/v1/accounts/{segments}": {
"post": {
"tags": ["accounts"],
"operationId": "create_account",
"parameters": [
{
"name": "segments",
"in": "path",
"required": true,
"style": "label",
"explode": true,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "fields",
"in": "query",
"style": "pipeDelimited",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "session",
"in": "cookie",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Account"
}
}
}
},
"responses": {
"200": {
"description": "created",
"headers": {
"X-Request-Id": {
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Account"
}
}
}
}
}
}
},
"/api/v1/unions": {
"get": {
"tags": ["unions"],
"operationId": "get_union",
"responses": {
"200": {
"description": "union",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnionEnvelope"
}
}
}
}
}
}
},
"/api/v1/download": {
"get": {
"tags": ["files"],
"operationId": "download_file",
"responses": {
"200": {
"description": "bytes",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Account": {
"type": "object",
"required": [
"id",
"secret",
"display_name",
"settings"
],
"properties": {
"id": {
"type": "integer",
"readOnly": true
},
"secret": {
"type": "string",
"writeOnly": true,
"minLength": 8
},
"display_name": {
"type": [
"string",
"null"
]
},
"state": {
"$ref": "#/components/schemas/AccountState"
},
"settings": {
"$ref": "#/components/schemas/FlexibleSettings"
}
}
},
"AccountState": {
"type": "string",
"enum": [
"active",
"disabled"
],
"x-enumNames": [
"active",
"disabled"
]
},
"FlexibleSettings": {
"type": "object",
"required": [
"theme"
],
"properties": {
"theme": {
"type": "string"
}
},
"additionalProperties": {
"type": "integer"
}
},
"UnionEnvelope": {
"type": "object",
"required": [
"value",
"criteria"
],
"properties": {
"value": {
"$ref": "#/components/schemas/StringOrInteger"
},
"criteria": {
"$ref": "#/components/schemas/SearchCriteria"
}
}
},
"StringOrInteger": {
"oneOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"SearchCriteria": {
"anyOf": [
{
"title": "ByName",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
},
{
"title": "ById",
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "integer"
}
}
}
]
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
openapi: 3.0.3
info:
title: OpenAPI 3.0 compatibility corpus
version: 1.0.0
servers:
- url: https://{environment}.example.test
variables:
environment:
default: api
enum:
- api
- staging
security:
- clientKey: []
paths:
/v1/animals/{id}:
get:
operationId: getAnimal
tags:
- animals
parameters:
- name: id
in: path
required: true
schema:
type: integer
format: int64
responses:
"200":
description: Animal
headers:
X-Request-Id:
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
"404":
description: Missing
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
components:
securitySchemes:
clientKey:
type: apiKey
in: header
name: X-Client-Key
schemas:
Animal:
type: object
required:
- kind
properties:
kind:
type: string
nickname:
type: string
nullable: true
Cat:
allOf:
- $ref: "#/components/schemas/Animal"
- type: object
required:
- lives
properties:
kind:
type: string
enum:
- cat
lives:
type: integer
minimum: 1
maximum: 9
Dog:
allOf:
- $ref: "#/components/schemas/Animal"
- type: object
required:
- trained
properties:
kind:
type: string
enum:
- dog
trained:
type: boolean
Pet:
oneOf:
- $ref: "#/components/schemas/Cat"
- $ref: "#/components/schemas/Dog"
discriminator:
propertyName: kind
mapping:
cat: "#/components/schemas/Cat"
dog: "#/components/schemas/Dog"
Problem:
type: object
required:
- code
properties:
code:
type: string
enum:
- not_found
- forbidden
x-enum-varnames:
- notFound
- forbidden
detail:
type: string
+1 -1
View File
@@ -97,7 +97,7 @@ formatting:
final materialResponse = File( final materialResponse = File(
p.join(outputDir, 'api_models', 'result', 'material_response.dart'), p.join(outputDir, 'api_models', 'result', 'material_response.dart'),
).readAsStringSync(); ).readAsStringSync();
expect(materialResponse, contains("@JsonKey(name: 'store_id')")); expect(materialResponse, contains("@JsonKey(name: 'store_id',"));
expect(materialResponse, contains('required int storeId')); expect(materialResponse, contains('required int storeId'));
final sharedError = File( final sharedError = File(
@@ -0,0 +1,349 @@
import 'dart:convert';
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('generation lifecycle', () {
test(
'writes a manifest, skips unchanged work, and detects local edits',
() async {
final fixture = await _LifecycleFixture.create();
addTearDown(fixture.dispose);
await fixture.writeDocument(includeAdmin: false);
final first = await fixture.generate();
expect(first.skippedUnchanged, isFalse);
expect(first.writtenFiles, isNotEmpty);
expect(File(first.manifestPath!).existsSync(), isTrue);
final second = await fixture.generate();
expect(second.skippedUnchanged, isTrue);
expect(second.writtenFiles, isEmpty);
expect(
second.diagnostics.map((diagnostic) => diagnostic.code),
contains('lifecycle.unchanged_skipped'),
);
final petsApi = File(
p.join(fixture.outputDir, 'api', 'v1', 'pets_api.dart'),
);
await petsApi.writeAsString(
'${await petsApi.readAsString()}\n// edited\n',
);
final repaired = await fixture.generate();
expect(repaired.skippedUnchanged, isFalse);
expect(repaired.writtenFiles, contains(p.normalize(petsApi.path)));
expect(await petsApi.readAsString(), isNot(contains('// edited')));
},
);
test(
'deletes only stale files that still match their manifest hash',
() async {
final fixture = await _LifecycleFixture.create();
addTearDown(fixture.dispose);
await fixture.writeDocument(includeAdmin: true);
await fixture.generate();
final adminApi = File(
p.join(fixture.outputDir, 'api', 'v1', 'admin_api.dart'),
);
final adminModel = File(
p.join(
fixture.outputDir,
'api_models',
'result',
'admin_record.dart',
),
);
expect(adminApi.existsSync(), isTrue);
expect(adminModel.existsSync(), isTrue);
await adminApi.writeAsString(
'${await adminApi.readAsString()}\n// keep my local edit\n',
);
await fixture.writeDocument(includeAdmin: false);
final result = await fixture.generate();
expect(adminApi.existsSync(), isTrue);
expect(adminModel.existsSync(), isFalse);
expect(result.deletedFiles, contains(p.normalize(adminModel.path)));
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
contains('lifecycle.stale_file_modified'),
);
},
);
test('dry-run reports changes without writing files', () async {
final fixture = await _LifecycleFixture.create();
addTearDown(fixture.dispose);
await fixture.writeDocument(includeAdmin: false);
final result = await fixture.generate(dryRun: true);
expect(result.writtenFiles, isEmpty);
expect(result.wouldWriteFiles, isNotEmpty);
expect(result.generatedFiles, isNotEmpty);
expect(File(result.manifestPath!).existsSync(), isFalse);
});
});
group('path, operation, and model filters', () {
test(
'filters operations and can include an unreferenced component',
() async {
final fixture = await _LifecycleFixture.create(
outputOptions: '''
included_paths:
- /api/v1/pets/**
excluded_operations:
- getPetSecret
included_models:
- Standalone
''',
);
addTearDown(fixture.dispose);
await fixture.writeDocument(
includeAdmin: true,
includeSecretOperation: true,
);
final result = await fixture.generate();
expect(result.operationsCount, 1);
expect(
result.generatedFiles.any((path) => path.endsWith('standalone.dart')),
isTrue,
);
expect(
result.generatedFiles.any((path) => path.endsWith('admin_api.dart')),
isFalse,
);
},
);
test(
'rejects excluding a model required by a selected operation',
() async {
final fixture = await _LifecycleFixture.create(
outputOptions: '''
excluded_models:
- Pet
''',
);
addTearDown(fixture.dispose);
await fixture.writeDocument(includeAdmin: false);
await expectLater(
fixture.generate(),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'diagnostic codes',
contains('filter.required_model_excluded'),
),
),
);
},
);
test('models-only mode generates unreferenced component schemas', () async {
final directory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_models_only_',
);
addTearDown(() => directory.delete(recursive: true));
final document = File(p.join(directory.path, 'openapi.json'));
await document.writeAsString(
jsonEncode(<String, Object?>{
'openapi': '3.1.0',
'info': <String, Object?>{'title': 'models', 'version': '1'},
'paths': <String, Object?>{},
'components': <String, Object?>{
'schemas': <String, Object?>{'Standalone': _objectSchema('value')},
},
}),
);
final output = p.join(directory.path, 'lib', 'src');
final config = File(p.join(directory.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- ${document.path}
output:
base_dir: $output
api_dir: ${p.join(output, 'api')}
models_dir: ${p.join(output, 'models')}
generation:
api:
enabled: false
models:
enabled: true
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: config.path,
);
expect(result.operationsCount, 0);
expect(result.modelsCount, 1);
expect(
result.generatedFiles.any((path) => path.endsWith('standalone.dart')),
isTrue,
);
expect(
result.generatedFiles.any(
(path) => path.contains('${p.separator}api${p.separator}'),
),
isFalse,
);
});
});
}
class _LifecycleFixture {
_LifecycleFixture({
required this.directory,
required this.documentPath,
required this.configPath,
required this.outputDir,
});
final Directory directory;
final String documentPath;
final String configPath;
final String outputDir;
static Future<_LifecycleFixture> create({String outputOptions = ''}) async {
final directory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_lifecycle_',
);
final documentPath = p.join(directory.path, 'openapi.json');
final configPath = p.join(directory.path, 'koi_swagger_parser.yaml');
final outputDir = p.join(directory.path, 'lib', 'src');
await File(configPath).writeAsString('''
input:
swagger_urls:
- url: $documentPath
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
manifest_path: ${p.join(directory.path, '.generated', 'manifest.json')}
clean_stale_files: true
skip_if_unchanged: true
split_by_tags: true
$outputOptions
generation:
api:
enabled: true
use_retrofit: true
models:
enabled: true
class_prefix: Koi
formatting:
run_dart_fix: false
run_dart_format: false
''');
return _LifecycleFixture(
directory: directory,
documentPath: documentPath,
configPath: configPath,
outputDir: outputDir,
);
}
Future<void> dispose() => directory.delete(recursive: true);
Future<GenerationResult> generate({bool dryRun = false}) {
return const KoiSwaggerParser().generate(
configPath: configPath,
workingDirectory: directory.path,
dryRun: dryRun,
);
}
Future<void> writeDocument({
required bool includeAdmin,
bool includeSecretOperation = false,
}) async {
final paths = <String, Object?>{
'/api/v1/pets/{id}': <String, Object?>{
'get': _operation(
operationId: 'getPet',
tag: 'pets',
schemaName: 'Pet',
),
},
if (includeSecretOperation)
'/api/v1/pets/{id}/secret': <String, Object?>{
'get': _operation(
operationId: 'getPetSecret',
tag: 'pets',
schemaName: 'Pet',
),
},
if (includeAdmin)
'/api/v1/admin': <String, Object?>{
'get': _operation(
operationId: 'getAdmin',
tag: 'admin',
schemaName: 'AdminRecord',
),
},
};
final document = <String, Object?>{
'openapi': '3.1.0',
'info': <String, Object?>{'title': 'Lifecycle', 'version': '1.0.0'},
'paths': paths,
'components': <String, Object?>{
'schemas': <String, Object?>{
'Pet': _objectSchema('name'),
'AdminRecord': _objectSchema('role'),
'Standalone': _objectSchema('value'),
},
},
};
await File(documentPath).writeAsString(jsonEncode(document));
}
}
Map<String, Object?> _operation({
required String operationId,
required String tag,
required String schemaName,
}) {
return <String, Object?>{
'operationId': operationId,
'tags': <String>[tag],
'responses': <String, Object?>{
'200': <String, Object?>{
'description': 'OK',
'content': <String, Object?>{
'application/json': <String, Object?>{
'schema': <String, Object?>{
r'$ref': '#/components/schemas/$schemaName',
},
},
},
},
},
};
}
Map<String, Object?> _objectSchema(String property) {
return <String, Object?>{
'type': 'object',
'required': <String>[property],
'properties': <String, Object?>{
property: <String, Object?>{'type': 'string'},
},
};
}
+69
View File
@@ -0,0 +1,69 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
test('e2e fixture generated file hashes match the reviewed golden', () async {
final tempDirectory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_golden_',
);
addTearDown(() => tempDirectory.delete(recursive: true));
final root = Directory.current.path;
final fixture = p.join(root, 'test', 'fixtures', 'e2e_openapi.json');
final output = p.join(tempDirectory.path, 'lib', 'src');
final config = File(p.join(tempDirectory.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: $fixture
output:
base_dir: $output
api_dir: ${p.join(output, 'api')}
models_dir: ${p.join(output, 'models')}
manifest_path: ${p.join(tempDirectory.path, '.generated', 'manifest.json')}
generation:
api:
response_mode: http_response
binary_response_mode: bytes
cancel_token_support: true
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
enum_unknown_fallback: true
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: config.path,
);
final actual = <String, String>{};
for (final path in result.generatedFiles) {
final relative = p.relative(path, from: output).replaceAll('\\', '/');
actual[relative] = sha256
.convert(await File(path).readAsBytes())
.toString();
}
final sortedActual = <String, String>{
for (final key in (actual.keys.toList()..sort())) key: actual[key]!,
};
final goldenFile = File(
p.join(root, 'test', 'goldens', 'e2e_generated_files.sha256.json'),
);
final expected = (jsonDecode(await goldenFile.readAsString()) as Map)
.cast<String, dynamic>();
expect(
sortedActual,
expected,
reason:
'If this output change is intentional, review generated Dart first '
'and then update the golden hashes.',
);
});
}
@@ -0,0 +1,23 @@
{
"api/api_client.dart": "0e4bc00c8854d3f380739955f64df058308536ac70c950d94380fdfa346f742d",
"api/index.dart": "10b58e9eaacf0e36c28b9d59332d70f1bf33811abdcc1820cdc31b7d6ccd213f",
"api/v1/accounts_api.dart": "f1d2d742486c9a98326ede91fb2c6a4259830fdcf25cad96d18cea3bd7a2a79f",
"api/v1/files_api.dart": "a667efe796d9f67e7aa5eaac47d7029fceeaf032d0f1f7ef7d77241afb289990",
"api/v1/index.dart": "b7b00d8385488c99cfbd605b402092ee88495f81213f93dea3656d5c30a7ee3b",
"api/v1/unions_api.dart": "9a9ec25401d58d186da4e26725bedc7dbce81926582261f117b9df0d82b78773",
"models/api_models.dart": "93b32a1869cf4e04268b1988ea6f89ca074efb0aa36bc9bbd399b2e21778c644",
"models/enums/account_state.dart": "9a013aca5a2467d83d9e15675fb3e1db3bf08b73ff67964170566f4140d426c1",
"models/enums/index.dart": "c07bcc9aae88d3075441505822e4818b1ec54f4992fb95cdf61d3657404cf431",
"models/index.dart": "adfef74c3b0d6b3f37785531612a6e87ccf84c40fa0ca45c29106a3c265efefb",
"models/request/account_request.dart": "fb3bfd99b8cdd6431e5772513209d12e2f2bd267492b9fc8d54906e5f0aa9387",
"models/request/index.dart": "b184b1c90150386d6eb2f34ac9d5b0444f86b01021687f9397cc4ca62a41cce3",
"models/result/account_response.dart": "37efb0dde5c50d048502b09c5a2df6cb92b8a8bd78b9d54e4600bb8031565d26",
"models/result/index.dart": "ce8f83b6bea6dc08310fc3a7a27b64ac24390febfb0fd357ee25ca4bad2da786",
"models/result/search_criteria.dart": "44b954e24346c564abcff2acba4b5448efc3c80fbc43df1ceedb621b8ae6c25f",
"models/result/search_criteria_by_id_variant.dart": "18ad15d0e1d4bd2993ab3412643435b679a1c20eab55d90f87652db3a36d23b2",
"models/result/search_criteria_by_name_variant.dart": "4c4c2b03ddbedea9233ded04570f5ec71d9bab4893a0d962688a2124bb123abd",
"models/result/string_or_integer.dart": "39b4cc3e011196b6556469792020588d27e669ed39363d587ef6e9f5fc0272bd",
"models/result/union_envelope.dart": "0224c40b31b3ee0825ae74716bf6ca272922071897b5ed315cd56fa593fee9f9",
"models/shared/flexible_settings.dart": "bf79e5b64d35330ffebc62cc73409f80000aa1b740dcc0ea2a22c87aac3fcb26",
"models/shared/index.dart": "876999aa6d55bb50e861e09c8b387aec1e374cb923c640f7b7d1aaf13741a1da"
}
+267
View File
@@ -0,0 +1,267 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('HTTP wire semantics', () {
test(
'生成 style/explode/cookie/allowReserved transport adapter 与表单',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_http_semantics_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "HTTP Semantics", "version": "1.0.0"},
"paths": {
"/api/v1/items/{coordinates}": {
"post": {
"tags": ["items"],
"operationId": "search_items",
"parameters": [
{
"name": "coordinates",
"in": "path",
"required": true,
"style": "matrix",
"explode": false,
"schema": {"type": "array", "items": {"type": "integer"}}
},
{
"name": "filter",
"in": "query",
"style": "deepObject",
"explode": true,
"schema": {
"type": "object",
"properties": {
"role": {"type": "string"},
"active": {"type": "boolean"}
}
}
},
{
"name": "sort",
"in": "query",
"style": "form",
"explode": false,
"schema": {"type": "array", "items": {"type": "string"}}
},
{
"name": "fields",
"in": "query",
"style": "pipeDelimited",
"schema": {"type": "array", "items": {"type": "string"}}
},
{
"name": "redirect",
"in": "query",
"allowReserved": true,
"schema": {"type": "string"}
},
{
"name": "X-Flags",
"in": "header",
"schema": {"type": "array", "items": {"type": "string"}}
},
{
"name": "session",
"in": "cookie",
"required": true,
"schema": {"type": "string"}
},
{
"name": "features",
"in": "cookie",
"explode": true,
"schema": {"type": "array", "items": {"type": "string"}}
}
],
"requestBody": {
"required": true,
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"type": "object",
"required": ["name", "state"],
"properties": {
"name": {"type": "string"},
"state": {
"type": "string",
"enum": ["draft", "published"]
},
"labels": {
"type": "array",
"items": {"type": "string"}
}
}
}
}
}
},
"responses": {"204": {"description": "updated"}}
}
}
}
}
''');
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}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
generation:
api:
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(p.join(outputDir, 'api', 'v1', 'items_api.dart'));
final api = await apiFile.readAsString();
expect(api, contains('class ItemsApi {'));
expect(api, contains('final _ItemsApiRetrofit _delegate;'));
expect(api, contains('required List<int> coordinates'));
expect(api, contains('@RestApi(parser: Parser.JsonSerializable)'));
expect(api, contains('abstract class _ItemsApiRetrofit'));
expect(
api,
contains("@POST('/api/v1/items/{coordinates}{reservedQuery}')"),
);
expect(
api,
contains("@Path('coordinates') required String coordinates"),
);
expect(api, contains("@Query('filter') required"));
expect(api, contains("@Query('sort') required String? sort"));
expect(api, contains("@Header('X-Flags') required String? xFlags"));
expect(
api,
contains("@Header('Cookie') required String? cookieHeader"),
);
expect(
api,
contains("@Path('reservedQuery') required String reservedQuery"),
);
expect(api, contains('@FormUrlEncoded()'));
expect(api, contains("@Field('state') required Object? state"));
expect(api, contains('_openApiCookieHeader('));
expect(api, contains('_openApiReservedQuery('));
expect(api, contains("case 'matrix':"));
expect(api, contains("'pipeDelimited' => '|'"));
final format = await Process.run(Platform.resolvedExecutable, <String>[
'format',
'--output=none',
apiFile.path,
]);
expect(
format.exitCode,
0,
reason: '${format.stdout}\n${format.stderr}',
);
},
);
test('可选 JSON body 生成 nullable 类型与 nullToAbsent', () async {
final config = KoiSwaggerConfig(
configPath: null,
workingDirectory: Directory.current.path,
input: const InputConfig(swaggerUrls: <String>[]),
output: OutputConfig(
baseDir: Directory.current.path,
apiDir: p.join(Directory.current.path, 'api'),
modelsDir: p.join(Directory.current.path, 'models'),
splitByTags: true,
extractVersionFromUrl: false,
),
generation: GenerationConfig(
preset: GeneratorPresetRegistry.koiFlutter,
api: const ApiGenerationConfig(
enabled: true,
useRetrofit: true,
cancelTokenSupport: false,
client: ClientConfig(
className: 'ApiClient',
fileName: 'api_client',
),
responseWrapper: ResponseWrapperConfig(
mode: ResponseWrapperMode.passthrough,
successDataField: 'data',
detectStandardEnvelope: false,
),
),
models: const ModelGenerationConfig(
enabled: true,
classPrefix: 'Koi',
defaultNonNullableListToEmpty: true,
jsonSerializable: JsonSerializableConfig(
checked: true,
explicitToJson: true,
includeIfNull: false,
),
),
formatting: const FormattingConfig(
runDartFormat: false,
runDartFix: false,
),
),
);
final parsed = OpenApiDocumentParser(config).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{'title': 'optional body', 'version': '1'},
'paths': <String, dynamic>{
'/api/v1/items': <String, dynamic>{
'post': <String, dynamic>{
'tags': <String>['items'],
'requestBody': <String, dynamic>{
'required': false,
'content': <String, dynamic>{
'application/json': <String, dynamic>{
'schema': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'name': <String, dynamic>{'type': 'string'},
},
},
},
},
},
'responses': <String, dynamic>{
'204': <String, dynamic>{'description': 'ok'},
},
},
},
},
},
sources: const <String>['memory://optional-body'],
),
);
expect(parsed.operations.single.requestBody!.type!.nullable, isTrue);
});
});
}
+1 -1
View File
@@ -149,7 +149,7 @@ formatting:
p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'), p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'),
).readAsStringSync(); ).readAsStringSync();
expect(metaFile, contains('class KoiUploadMeta')); expect(metaFile, contains('class KoiUploadMeta'));
expect(metaFile, contains("@JsonKey(name: 'album_id')")); expect(metaFile, contains("@JsonKey(name: 'album_id',"));
}); });
}); });
+179
View File
@@ -0,0 +1,179 @@
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('naming and type mappings', () {
test(
'应用 model/property/parameter/operation/schema/type/import 映射',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_all_mappings_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "mappings", "version": "1"},
"paths": {
"/api/v1/pets/{pet_id}": {
"get": {
"tags": ["pets"],
"operationId": "get_pet",
"parameters": [
{
"name": "pet_id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
},
{
"name": "include_details",
"in": "query",
"schema": {"type": "boolean"}
}
],
"responses": {
"200": {
"description": "pet",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Pet"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Pet": {
"type": "object",
"required": ["pet_id", "price", "trace_id"],
"properties": {
"pet_id": {"type": "integer"},
"price": {"$ref": "#/components/schemas/Money"},
"trace_id": {"type": "string", "format": "uuid"}
}
},
"Money": {
"type": "object",
"required": ["amount"],
"properties": {
"amount": {"type": "number"}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
mappings:
models:
Pet: Creature
properties:
Pet:
pet_id: identifier
parameters:
get_pet:
pet_id: identifier
include_details: withDetails
operations:
get_pet: fetchCreature
schemas:
Money:
type: ExternalMoney
import: package:mapping_sample/external_types.dart
types:
"string:uuid":
type: UuidValue
import: package:mapping_sample/external_types.dart
imports:
UuidValue: package:mapping_sample/external_types.dart
api:
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: config.path,
);
final model = File(
p.join(outputDir, 'models', 'result', 'creature.dart'),
).readAsStringSync();
expect(model, contains('class KoiCreature'));
expect(model, contains('required int identifier'));
expect(model, contains('required ExternalMoney price'));
expect(model, contains('required UuidValue traceId'));
expect(
model,
contains("import 'package:mapping_sample/external_types.dart';"),
);
expect(
result.writtenFiles.any((path) => path.endsWith('money.dart')),
isFalse,
);
final api = File(
p.join(outputDir, 'api', 'v1', 'pets_api.dart'),
).readAsStringSync();
expect(api, contains('Future<KoiCreature> fetchCreature'));
expect(api, contains("@Path('pet_id') required int identifier"));
expect(api, contains("@Query('include_details') bool? withDetails"));
},
);
test('显式映射目标冲突在加载配置时拒绝', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_mapping_collision_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(
'{"openapi":"3.1.0","info":{"title":"x","version":"1"},'
'"paths":{"/x":{"get":{"responses":{"204":{"description":"ok"}}}}}}',
);
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
generation:
mappings:
operations:
first: duplicateName
second: duplicateName
''');
expect(
() => KoiSwaggerConfig.load(configPath: config.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'codes',
contains('config.mapping_target_collision'),
),
),
);
});
});
}
+453
View File
@@ -0,0 +1,453 @@
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(r'$ref resolver', () {
test('解析相对文件、跨组件引用、循环引用与名称冲突', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_relative_refs_',
);
addTearDown(() => tempDir.delete(recursive: true));
final commonFile = File(p.join(tempDir.path, 'common.yaml'));
await commonFile.writeAsString(r'''
components:
parameters:
Limit:
name: limit
in: query
required: false
schema:
type: integer
requestBodies:
PayloadBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Payload'
responses:
ExternalResponse:
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/ExternalResponseModel'
schemas:
Payload:
type: object
required: [name]
properties:
name:
type: string
ExternalResponseModel:
type: object
required: [item]
properties:
item:
$ref: '#/components/schemas/ExternalItem'
ExternalItem:
type: object
required: [id]
properties:
id:
type: integer
parent:
$ref: '#/components/schemas/ExternalResponseModel'
''');
final rootFile = File(p.join(tempDir.path, 'openapi.yaml'));
await rootFile.writeAsString(r'''
openapi: 3.1.0
info:
title: Relative refs
version: 1.0.0
paths:
/api/v1/external:
post:
tags: [external]
parameters:
- $ref: './common.yaml#/components/parameters/Limit'
requestBody:
$ref: './common.yaml#/components/requestBodies/PayloadBody'
responses:
'200':
$ref: './common.yaml#/components/responses/ExternalResponse'
components:
schemas:
ExternalItem:
type: object
properties:
localOnly:
type: boolean
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = await _writeConfig(
tempDir.path,
source: rootFile.path,
outputDir: outputDir,
);
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(result.dependencyDocuments, contains(commonFile.path));
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
contains('openapi.external_component_renamed'),
);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'external_api.dart'),
).readAsStringSync();
expect(apiFile, contains("@Query('limit') int? limit"));
expect(apiFile, contains('@Body() required KoiPayload body'));
expect(
apiFile,
contains('Future<KoiExternalResponseModel> postExternal'),
);
final responseFile = File(
p.join(
outputDir,
'api_models',
'result',
'external_response_model.dart',
),
).readAsStringSync();
expect(responseFile, contains('required KoiCommonExternalItem item'));
final itemFile = File(
p.join(outputDir, 'api_models', 'result', 'common_external_item.dart'),
).readAsStringSync();
expect(itemFile, contains('KoiExternalResponseModel? parent'));
});
test('解析远程根文档中的相对远程引用', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_remote_refs_',
);
addTearDown(() => tempDir.delete(recursive: true));
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((request) async {
request.response.headers.contentType = ContentType.json;
if (request.uri.path == '/root.json') {
request.response.write(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Remote refs",
"version": "1.0.0"
},
"paths": {
"/api/v1/remote": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "./models.json#/components/schemas/RemoteModel"
}
}
}
}
}
}
}
}
}
''');
} else if (request.uri.path == '/models.json') {
request.response.write(r'''
{
"components": {
"schemas": {
"RemoteModel": {
"type": "object",
"required": ["id"],
"properties": {
"id": {
"type": "integer"
}
}
}
}
}
}
''');
} else {
request.response.statusCode = HttpStatus.notFound;
}
await request.response.close();
});
final rootUrl = 'http://${server.address.host}:${server.port}/root.json';
final modelsUrl =
'http://${server.address.host}:${server.port}/models.json';
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = await _writeConfig(
tempDir.path,
source: rootUrl,
outputDir: outputDir,
);
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(result.dependencyDocuments, contains(modelsUrl));
expect(
File(
p.join(outputDir, 'api_models', 'result', 'remote_model.dart'),
).existsSync(),
isTrue,
);
});
test('外部文档中不存在的 pointer 产生结构化错误', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_missing_external_ref_',
);
addTearDown(() => tempDir.delete(recursive: true));
final commonFile = File(p.join(tempDir.path, 'common.json'));
await commonFile.writeAsString('{"components":{"schemas":{}}}');
final rootFile = File(p.join(tempDir.path, 'openapi.json'));
await rootFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Missing external pointer",
"version": "1.0.0"
},
"paths": {
"/api/v1/items": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"$ref": "./common.json#/components/schemas/Missing"
}
}
}
}
}
}
}
}
}
''');
final configFile = await _writeConfig(
tempDir.path,
source: rootFile.path,
outputDir: p.join(tempDir.path, 'lib', 'src'),
);
await expectLater(
const KoiSwaggerParser().generate(configPath: configFile.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
'diagnostic codes',
contains('openapi.unresolved_external_ref'),
),
),
);
});
test('多 source component 冲突会确定性重命名并改写引用', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_multisource_components_',
);
addTearDown(() => tempDir.delete(recursive: true));
final firstFile = File(p.join(tempDir.path, 'one.json'));
await firstFile.writeAsString(
_singleModelDocument(
title: 'First',
path: '/api/v1/first',
propertyName: 'id',
propertyType: 'integer',
),
);
final secondFile = File(p.join(tempDir.path, 'two.json'));
await secondFile.writeAsString(
_singleModelDocument(
title: 'Second',
path: '/api/v1/second',
propertyName: 'name',
propertyType: '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: ${firstFile.path}
- url: ${secondFile.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
contains('openapi.multisource_component_renamed'),
);
expect(
File(
p.join(outputDir, 'api_models', 'result', 'thing.dart'),
).readAsStringSync(),
contains('required int id'),
);
expect(
File(
p.join(outputDir, 'api_models', 'result', 'two_thing.dart'),
).readAsStringSync(),
contains('required String name'),
);
});
test('多 source 重复 operation 会阻止生成', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_multisource_operations_',
);
addTearDown(() => tempDir.delete(recursive: true));
final firstFile = File(p.join(tempDir.path, 'one.json'));
final secondFile = File(p.join(tempDir.path, 'two.json'));
final document = _singleModelDocument(
title: 'Duplicate',
path: '/api/v1/duplicate',
propertyName: 'id',
propertyType: 'integer',
);
await firstFile.writeAsString(document);
await secondFile.writeAsString(document);
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: ${firstFile.path}
- url: ${secondFile.path}
output:
base_dir: $outputDir
generation:
api:
response_wrapper:
mode: passthrough
''');
await expectLater(
const KoiSwaggerParser().generate(configPath: configFile.path),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
'diagnostic codes',
contains('openapi.multisource_operation_conflict'),
),
),
);
});
});
}
Future<File> _writeConfig(
String directory, {
required String source,
required String outputDir,
}) async {
final configFile = File(p.join(directory, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: $source
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
return configFile;
}
String _singleModelDocument({
required String title,
required String path,
required String propertyName,
required String propertyType,
}) {
return '''
{
"openapi": "3.1.0",
"info": {
"title": "$title",
"version": "1.0.0"
},
"paths": {
"$path": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"\$ref": "#/components/schemas/Thing"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Thing": {
"type": "object",
"required": ["$propertyName"],
"properties": {
"$propertyName": {
"type": "$propertyType"
}
}
}
}
}
}
''';
}
+326
View File
@@ -0,0 +1,326 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('response semantics', () {
test('default 不作为成功响应,并保留多个 2xx 与响应头元数据', () {
final parsed = OpenApiDocumentParser(_testConfig()).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{'title': 'responses', 'version': '1'},
'paths': <String, dynamic>{
'/api/v1/items': <String, dynamic>{
'get': <String, dynamic>{
'tags': <String>['items'],
'operationId': 'list_items',
'responses': <String, dynamic>{
'200': <String, dynamic>{
'description': 'items',
'headers': <String, dynamic>{
'X-Rate-Limit': <String, dynamic>{
'description': 'remaining requests',
'schema': <String, dynamic>{'type': 'integer'},
},
},
'content': <String, dynamic>{
'application/json': <String, dynamic>{
'schema': <String, dynamic>{
'type': 'array',
'items': <String, dynamic>{'type': 'string'},
},
},
},
},
'204': <String, dynamic>{'description': 'empty'},
'default': <String, dynamic>{
'description': 'error',
'content': <String, dynamic>{
'application/problem+json': <String, dynamic>{
'schema': <String, dynamic>{
'type': 'object',
'properties': <String, dynamic>{
'detail': <String, dynamic>{'type': 'string'},
},
},
},
},
},
},
},
},
},
},
sources: const <String>['memory://responses'],
),
);
final operation = parsed.operations.single;
expect(operation.returnType.render(), 'List<String>');
expect(operation.primaryResponseStatusCode, '200');
expect(operation.responses, hasLength(3));
expect(
operation.responses
.singleWhere((response) => response.statusCode == 'default')
.isSuccess,
isFalse,
);
final success = operation.responses.singleWhere(
(response) => response.statusCode == '200',
);
expect(success.headers.single.name, 'X-Rate-Limit');
expect(success.headers.single.type.render(), 'int?');
expect(
parsed.models.values.any(
(model) => model.originalName.contains('defaultError'),
),
isTrue,
);
});
test('binary response 可生成 bytes + HttpResponse + Accept 元数据', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_binary_response_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "download", "version": "1"},
"paths": {
"/api/v1/files/{id}": {
"get": {
"tags": ["files"],
"operationId": "download_file",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "string"}
}
],
"responses": {
"200": {
"description": "file",
"headers": {
"Content-Disposition": {
"schema": {"type": "string"}
}
},
"content": {
"application/octet-stream": {
"schema": {"type": "string", "format": "binary"}
}
}
}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
api:
response_mode: http_response
binary_response_mode: bytes
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: config.path);
final apiFile = File(p.join(outputDir, 'api', 'v1', 'files_api.dart'));
final api = await apiFile.readAsString();
expect(api, contains("import 'dart:typed_data';"));
expect(api, contains("@Headers(<String, dynamic>{'Accept':"));
expect(api, contains("'application/octet-stream'"));
expect(api, contains('@DioResponseType(ResponseType.bytes)'));
expect(api, contains('Future<HttpResponse<Uint8List>> downloadFile'));
expect(api, contains('Header Content-Disposition: String?'));
});
test('binary 与 JSON 成功响应混用时结构化拒绝', () {
expect(
() => OpenApiDocumentParser(_testConfig()).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{'title': 'mixed', 'version': '1'},
'paths': <String, dynamic>{
'/api/v1/export': <String, dynamic>{
'get': <String, dynamic>{
'tags': <String>['export'],
'responses': <String, dynamic>{
'200': <String, dynamic>{
'description': 'json',
'content': <String, dynamic>{
'application/json': <String, dynamic>{
'schema': <String, dynamic>{'type': 'string'},
},
},
},
'206': <String, dynamic>{
'description': 'bytes',
'content': <String, dynamic>{
'application/octet-stream': <String, dynamic>{
'schema': <String, dynamic>{
'type': 'string',
'format': 'binary',
},
},
},
},
},
},
},
},
},
sources: const <String>['memory://mixed'],
),
),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'codes',
contains('openapi.mixed_success_media_types'),
),
),
);
});
test('XML/Protobuf 等未实现 decoder 的 media type 会明确拒绝', () {
expect(
() => OpenApiDocumentParser(_testConfig()).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{'title': 'xml', 'version': '1'},
'paths': <String, dynamic>{
'/legacy': <String, dynamic>{
'get': <String, dynamic>{
'responses': <String, dynamic>{
'200': <String, dynamic>{
'description': 'xml',
'content': <String, dynamic>{
'application/xml': <String, dynamic>{
'schema': <String, dynamic>{'type': 'object'},
},
},
},
},
},
},
},
},
sources: const <String>['memory://xml'],
),
),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'codes',
contains('openapi.unsupported_response_content_type'),
),
),
);
});
test('Link Object 不静默丢失,产生明确 warning', () {
final parsed = OpenApiDocumentParser(_testConfig()).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{'title': 'links', 'version': '1'},
'paths': <String, dynamic>{
'/orders': <String, dynamic>{
'get': <String, dynamic>{
'responses': <String, dynamic>{
'200': <String, dynamic>{
'description': 'order',
'content': <String, dynamic>{
'application/json': <String, dynamic>{
'schema': <String, dynamic>{'type': 'string'},
},
},
'links': <String, dynamic>{
'customer': <String, dynamic>{
'operationId': 'getCustomer',
},
},
},
},
},
},
},
},
sources: const <String>['memory://links'],
),
);
expect(
parsed.diagnostics.map((item) => item.code),
contains('openapi.links_not_supported'),
);
});
});
}
KoiSwaggerConfig _testConfig() {
return KoiSwaggerConfig(
configPath: null,
workingDirectory: Directory.current.path,
input: const InputConfig(swaggerUrls: <String>[]),
output: OutputConfig(
baseDir: Directory.current.path,
apiDir: p.join(Directory.current.path, 'api'),
modelsDir: p.join(Directory.current.path, 'models'),
splitByTags: true,
extractVersionFromUrl: false,
),
generation: GenerationConfig(
preset: GeneratorPresetRegistry.koiFlutter,
api: const ApiGenerationConfig(
enabled: true,
useRetrofit: true,
cancelTokenSupport: false,
client: ClientConfig(className: 'ApiClient', fileName: 'api_client'),
responseWrapper: ResponseWrapperConfig(
mode: ResponseWrapperMode.passthrough,
successDataField: 'data',
detectStandardEnvelope: false,
),
),
models: const ModelGenerationConfig(
enabled: true,
classPrefix: 'Koi',
defaultNonNullableListToEmpty: true,
jsonSerializable: JsonSerializableConfig(
checked: true,
explicitToJson: true,
includeIfNull: false,
),
),
formatting: const FormattingConfig(
runDartFormat: false,
runDartFix: false,
),
),
);
}
+198
View File
@@ -0,0 +1,198 @@
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('schema semantics', () {
test(
'区分 required/nullable/absent,按方向拆分 readOnly/writeOnly,并保留 additionalProperties',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_schema_semantics_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "schema semantics", "version": "1"},
"paths": {
"/api/v1/accounts": {
"post": {
"tags": ["accounts"],
"operationId": "create_account",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Account"}
}
}
},
"responses": {
"200": {
"description": "created",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Account"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Account": {
"type": "object",
"required": ["id", "secret", "display_name", "settings"],
"properties": {
"id": {"type": "integer", "readOnly": true},
"secret": {"type": "string", "writeOnly": true},
"display_name": {"type": ["string", "null"]},
"nickname": {"type": "string"},
"settings": {"$ref": "#/components/schemas/FlexibleSettings"}
}
},
"FlexibleSettings": {
"type": "object",
"required": ["theme"],
"properties": {
"theme": {"type": "string"}
},
"additionalProperties": {"type": "integer"}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
api:
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: config.path);
final request = File(
p.join(outputDir, 'models', 'request', 'account_request.dart'),
).readAsStringSync();
final response = File(
p.join(outputDir, 'models', 'result', 'account_response.dart'),
).readAsStringSync();
expect(request, contains('class KoiAccountRequest'));
expect(request, isNot(contains('required int id')));
expect(request, contains('required String secret'));
expect(request, contains('required String? displayName'));
expect(request, contains('includeIfNull: true'));
expect(request, contains('String? nickname'));
expect(request, contains('disallowNullValue: true'));
expect(response, contains('class KoiAccountResponse'));
expect(response, contains('required int id'));
expect(response, isNot(contains('required String secret')));
expect(response, contains('required String? displayName'));
final settings = File(
p.join(outputDir, 'models', 'shared', 'flexible_settings.dart'),
).readAsStringSync();
expect(settings, contains('Map<String, int> additionalProperties'));
expect(settings, contains('ReadAdditionalProperties'));
expect(settings, contains('createToJson: false'));
expect(settings, contains('result[entry.key]'));
expect(settings, contains("result['theme']"));
final api = File(
p.join(outputDir, 'api', 'v1', 'accounts_api.dart'),
).readAsStringSync();
expect(api, contains('required KoiAccountRequest body'));
expect(api, contains('Future<KoiAccountResponse> createAccount'));
},
);
test('同一模型属性名冲突时确定性后缀并给出诊断', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_property_collision_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "collision", "version": "1"},
"paths": {
"/api/v1/value": {
"get": {
"tags": ["value"],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"some-value": {"type": "string"},
"some_value": {"type": "string"}
}
}
}
}
}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: config.path,
);
expect(
result.diagnostics.map((diagnostic) => diagnostic.code),
contains('openapi.property_name_collision'),
);
final modelPath = result.writtenFiles.singleWhere(
(path) => path.endsWith('response.dart'),
);
final model = File(modelPath).readAsStringSync();
expect(model, contains('String? someValue'));
expect(model, contains('String? someValue2'));
});
});
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('servers and security metadata', () {
test('解析 server variables、operation override 与鉴权元数据', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_server_security_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "servers and auth", "version": "1"},
"servers": [
{
"url": "https://{environment}.example.com/{version}",
"variables": {
"environment": {
"default": "api",
"enum": ["api", "staging"]
},
"version": {"default": "v1"}
}
}
],
"security": [
{"bearerAuth": []},
{"apiKeyAuth": []}
],
"paths": {
"/accounts": {
"get": {
"tags": ["accounts"],
"operationId": "list_accounts",
"responses": {"204": {"description": "ok"}}
}
},
"/upload": {
"post": {
"tags": ["accounts"],
"operationId": "upload_account",
"servers": [{"url": "https://upload.example.com"}],
"security": [],
"responses": {"204": {"description": "ok"}}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
},
"basicAuth": {
"type": "http",
"scheme": "basic"
},
"apiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key"
},
"oauth": {
"type": "oauth2",
"flows": {
"clientCredentials": {
"tokenUrl": "https://auth.example.com/token",
"scopes": {
"accounts:read": "read accounts"
}
}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: config.path);
final api = File(
p.join(outputDir, 'api', 'v1', 'accounts_api.dart'),
).readAsStringSync();
expect(
api,
contains(
"@RestApi(baseUrl: 'https://api.example.com/v1', "
'parser: Parser.JsonSerializable)',
),
);
expect(api, contains('Security: bearerAuth OR apiKeyAuth'));
expect(api, contains("@POST('https://upload.example.com/upload')"));
final client = File(
p.join(outputDir, 'api', 'api_client.dart'),
).readAsStringSync();
expect(
client,
contains("baseUrl = baseUrl ?? 'https://api.example.com/v1'"),
);
expect(client, contains("'bearerAuth': <String, Object?>{"));
expect(client, contains("'scheme': 'bearer'"));
expect(client, contains("'apiKeyAuth': <String, Object?>{"));
expect(client, contains("'in': 'header'"));
expect(client, contains("'scopes': <String>['accounts:read']"));
expect(client, contains("'GET /accounts':"));
expect(client, contains("'POST /upload': <Map<String, List<String>>>[]"));
});
test('不存在的 security scheme 会结构化拒绝', () {
final config = _testConfig();
expect(
() => OpenApiDocumentParser(config).parse(
LoadedOpenApiDocument(
document: <String, dynamic>{
'openapi': '3.1.0',
'info': <String, dynamic>{
'title': 'invalid auth',
'version': '1',
},
'security': <dynamic>[
<String, dynamic>{'missing': <String>[]},
],
'paths': <String, dynamic>{
'/value': <String, dynamic>{
'get': <String, dynamic>{
'responses': <String, dynamic>{
'204': <String, dynamic>{'description': 'ok'},
},
},
},
},
},
sources: const <String>['memory://invalid-auth'],
),
),
throwsA(
isA<KoiDiagnosticException>().having(
(error) => error.diagnostics.map((item) => item.code),
'codes',
contains('openapi.unknown_security_scheme'),
),
),
);
});
});
}
KoiSwaggerConfig _testConfig() {
return KoiSwaggerConfig(
configPath: null,
workingDirectory: Directory.current.path,
input: const InputConfig(swaggerUrls: <String>[]),
output: OutputConfig(
baseDir: Directory.current.path,
apiDir: p.join(Directory.current.path, 'api'),
modelsDir: p.join(Directory.current.path, 'models'),
splitByTags: true,
extractVersionFromUrl: false,
),
generation: GenerationConfig(
preset: GeneratorPresetRegistry.koiFlutter,
api: const ApiGenerationConfig(
enabled: true,
useRetrofit: true,
cancelTokenSupport: false,
client: ClientConfig(className: 'ApiClient', fileName: 'api_client'),
responseWrapper: ResponseWrapperConfig(
mode: ResponseWrapperMode.passthrough,
successDataField: 'data',
detectStandardEnvelope: false,
),
),
models: const ModelGenerationConfig(
enabled: true,
classPrefix: 'Koi',
defaultNonNullableListToEmpty: true,
jsonSerializable: JsonSerializableConfig(
checked: true,
explicitToJson: true,
includeIfNull: false,
),
),
formatting: const FormattingConfig(
runDartFormat: false,
runDartFix: false,
),
),
);
}
+139
View File
@@ -0,0 +1,139 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('generic union generation', () {
test('生成 primitive oneOf 与可多分支匹配的 anyOf wrapper', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_generic_union_',
);
addTearDown(() => tempDir.delete(recursive: true));
final spec = File(p.join(tempDir.path, 'openapi.json'));
await spec.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {"title": "generic unions", "version": "1"},
"paths": {
"/api/v1/value": {
"get": {
"tags": ["value"],
"operationId": "get_value",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/UnionEnvelope"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"UnionEnvelope": {
"type": "object",
"required": ["value", "criteria"],
"properties": {
"value": {"$ref": "#/components/schemas/StringOrInteger"},
"criteria": {"$ref": "#/components/schemas/SearchCriteria"}
}
},
"StringOrInteger": {
"oneOf": [
{"type": "string"},
{"type": "integer"}
]
},
"SearchCriteria": {
"anyOf": [
{
"title": "ByName",
"type": "object",
"required": ["name"],
"properties": {"name": {"type": "string"}}
},
{
"title": "ById",
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "integer"}}
}
]
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await config.writeAsString('''
input:
swagger_urls:
- url: ${spec.path}
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'models')}
generation:
api:
response_wrapper:
mode: passthrough
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: config.path);
final oneOfFile = File(
p.join(outputDir, 'models', 'result', 'string_or_integer.dart'),
);
final oneOf = oneOfFile.readAsStringSync();
expect(
oneOf,
contains('factory KoiStringOrInteger.fromJson(Object? json)'),
);
expect(oneOf, contains('if (json is String)'));
expect(oneOf, contains('if (json is int)'));
expect(oneOf, contains('if (matches.length != 1)'));
expect(
oneOf,
contains(
'final class KoiStringOrIntegerString1 extends KoiStringOrInteger',
),
);
final anyOfFile = File(
p.join(outputDir, 'models', 'result', 'search_criteria.dart'),
);
final anyOf = anyOfFile.readAsStringSync();
expect(anyOf, contains("json.containsKey('name')"));
expect(anyOf, contains("json.containsKey('id')"));
expect(
anyOf,
contains(
'final class KoiSearchCriteriaAnyOf extends KoiSearchCriteria',
),
);
expect(anyOf, contains('final List<KoiSearchCriteria> matches'));
for (final file in <File>[oneOfFile, anyOfFile]) {
final format = await Process.run(Platform.resolvedExecutable, <String>[
'format',
'--output=none',
file.path,
]);
expect(
format.exitCode,
0,
reason: '${format.stdout}\n${format.stderr}',
);
}
});
});
}
+177
View File
@@ -0,0 +1,177 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
Future<void> main(List<String> arguments) async {
final keep = arguments.contains('--keep');
final root = Directory.current.path;
final fixture = p.join(root, 'test', 'fixtures', 'e2e_openapi.json');
final tempDirectory = await Directory.systemTemp.createTemp(
'koi_swagger_parser_generated_package_',
);
try {
await _writePackageFiles(tempDirectory, fixture);
await _run(Platform.resolvedExecutable, const <String>[
'pub',
'get',
], workingDirectory: tempDirectory.path);
final configPath = p.join(tempDirectory.path, 'koi_swagger_parser.yaml');
final result = await const KoiSwaggerParser().generate(
configPath: configPath,
);
stdout.writeln(
'Generated ${result.generatedFiles.length} files '
'(${result.modelsCount} models, ${result.operationsCount} operations).',
);
await _run(Platform.resolvedExecutable, const <String>[
'analyze',
], workingDirectory: tempDirectory.path);
await _run(Platform.resolvedExecutable, const <String>[
'test',
], workingDirectory: tempDirectory.path);
stdout.writeln('Generated package verification passed.');
} finally {
if (keep) {
stdout.writeln('Kept generated package at ${tempDirectory.path}');
} else if (tempDirectory.existsSync()) {
await tempDirectory.delete(recursive: true);
}
}
}
Future<void> _writePackageFiles(Directory directory, String fixture) async {
await File(p.join(directory.path, 'pubspec.yaml')).writeAsString(r'''
name: koi_generated_e2e
publish_to: none
environment:
sdk: ^3.11.0
dependencies:
dio: ^5.9.0
freezed_annotation: ^3.1.0
json_annotation: ^4.12.0
retrofit: ^4.9.0
dev_dependencies:
build_runner: ^2.10.0
freezed: ^3.2.0
json_serializable: ^6.11.0
retrofit_generator: ^10.2.0
test: ^1.26.0
''');
final output = p.join(directory.path, 'lib', 'src');
await File(p.join(directory.path, 'koi_swagger_parser.yaml')).writeAsString(
'''
input:
swagger_urls:
- url: $fixture
output:
base_dir: $output
api_dir: ${p.join(output, 'api')}
models_dir: ${p.join(output, 'models')}
generation:
api:
response_mode: http_response
binary_response_mode: bytes
cancel_token_support: true
response_wrapper:
mode: passthrough
models:
class_prefix: Koi
enum_unknown_fallback: true
formatting:
run_dart_format: true
run_build_runner: true
build_runner_mode: build
delete_conflicting_outputs: true
''',
);
final testDirectory = Directory(p.join(directory.path, 'test'));
await testDirectory.create(recursive: true);
await File(
p.join(testDirectory.path, 'generated_smoke_test.dart'),
).writeAsString(r'''
import 'package:koi_generated_e2e/src/api/v1/accounts_api.dart';
import 'package:koi_generated_e2e/src/models/request/account_request.dart';
import 'package:koi_generated_e2e/src/models/result/account_response.dart';
import 'package:koi_generated_e2e/src/models/result/search_criteria.dart';
import 'package:koi_generated_e2e/src/models/result/string_or_integer.dart';
import 'package:koi_generated_e2e/src/models/shared/flexible_settings.dart';
import 'package:test/test.dart';
void main() {
test('additional properties round-trip at the object root', () {
const settings = KoiFlexibleSettings(
theme: 'dark',
additionalProperties: <String, int>{'retries': 3},
);
expect(settings.toJson(), <String, dynamic>{
'theme': 'dark',
'retries': 3,
});
final decoded = KoiFlexibleSettings.fromJson(<String, dynamic>{
'theme': 'light',
'retries': 5,
});
expect(decoded.theme, 'light');
expect(decoded.additionalProperties, <String, int>{'retries': 5});
});
test('directional and generic union models are usable', () {
const request = KoiAccountRequest(
secret: 'long-secret',
displayName: null,
settings: KoiFlexibleSettings(theme: 'dark'),
);
expect(request.secret, 'long-secret');
const response = KoiAccountResponse(
id: 1,
displayName: null,
settings: KoiFlexibleSettings(theme: 'dark'),
);
expect(response.id, 1);
final primitive = KoiStringOrInteger.fromJson('text');
expect(primitive, isA<KoiStringOrIntegerString1>());
final criteria = KoiSearchCriteria.fromJson(<String, dynamic>{
'name': 'Koi',
'id': 7,
});
expect(criteria, isA<KoiSearchCriteriaAnyOf>());
expect((criteria as KoiSearchCriteriaAnyOf).matches, hasLength(2));
});
test('Retrofit transport adapter has a concrete generated implementation', () {
expect(AccountsApi, isNotNull);
});
}
''');
}
Future<void> _run(
String executable,
List<String> arguments, {
required String workingDirectory,
}) async {
stdout.writeln('> $executable ${arguments.join(' ')}');
final process = await Process.start(
executable,
arguments,
workingDirectory: workingDirectory,
mode: ProcessStartMode.inheritStdio,
);
final exitCode = await process.exitCode;
if (exitCode != 0) {
throw ProcessException(
executable,
arguments,
'Command failed with exit code $exitCode.',
exitCode,
);
}
}