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:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.omx/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
test/
|
||||||
|
tool/
|
||||||
|
doc/api/
|
||||||
|
doc/internal/
|
||||||
|
pubspec.lock
|
||||||
|
example/generated/
|
||||||
|
*.log
|
||||||
+79
-4
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 交付。
|
||||||
@@ -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`。
|
||||||
@@ -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)。
|
||||||
@@ -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)。
|
||||||
@@ -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
|
||||||
|
视为阻塞。
|
||||||
@@ -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。
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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("'", "''")}'";
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+1547
-49
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+228
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+113
@@ -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
|
||||||
@@ -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'},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user