From 454e6c51d73ed8577b2243bb1ce1d7f74a0239dc Mon Sep 17 00:00:00 2001 From: "mr.koi" Date: Fri, 31 Jul 2026 16:47:39 +0800 Subject: [PATCH] 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. --- .pubignore | 12 + CHANGELOG.md | 83 +- README.md | 397 ++- README.zh-CN.md | 350 +++ analysis_options.yaml | 2 +- bin/koi_swagger_parser.dart | 138 +- doc/ARCHITECTURE.md | 206 +- doc/COMPATIBILITY.md | 119 + doc/CONFIGURATION.md | 269 ++ doc/DIAGNOSTICS.md | 158 ++ doc/MIGRATION.md | 237 ++ doc/internal/COMMUNITY_COMPARISON.md | 109 + doc/internal/GOAL.md | 75 + doc/zh-CN/ARCHITECTURE.md | 154 + doc/zh-CN/CHANGELOG.md | 67 + doc/zh-CN/COMPATIBILITY.md | 113 + doc/zh-CN/CONFIGURATION.md | 254 ++ doc/zh-CN/DIAGNOSTICS.md | 150 + doc/zh-CN/MIGRATION.md | 215 ++ example/README.md | 33 + example/koi_swagger_parser.yaml | 26 + example/koi_swagger_parser_example.dart | 60 +- example/openapi.yaml | 78 + koi_swagger_parser.example.yaml | 101 + lib/koi_swagger_parser.dart | 30 +- lib/src/config/config.dart | 1525 +++++++++- lib/src/diagnostic/diagnostic.dart | 147 + lib/src/generator/generator.dart | 1596 ++++++++++- lib/src/generator/lifecycle.dart | 686 +++++ lib/src/ir/ir.dart | 674 ++++- lib/src/koi_swagger_parser.dart | 159 +- lib/src/openapi/document_merger.dart | 268 ++ lib/src/openapi/openapi.dart | 2507 +++++++++++++++-- lib/src/openapi/reference_resolver.dart | 539 ++++ lib/src/preset/preset.dart | 40 +- lib/src/util/utils.dart | 53 +- pubspec.yaml | 9 +- test/cli_test.dart | 120 + test/config_loader_test.dart | 122 + test/enum_mappings_and_diagnostics_test.dart | 312 ++ test/fixture_corpus_test.dart | 65 + test/fixtures/e2e_openapi.json | 228 ++ test/fixtures/openapi_30_compat.yaml | 113 + test/generation_integration_test.dart | 2 +- ...generation_lifecycle_and_filters_test.dart | 349 +++ test/golden_generation_test.dart | 69 + test/goldens/e2e_generated_files.sha256.json | 23 + test/http_semantics_test.dart | 267 ++ test/multipart_and_union_test.dart | 2 +- test/naming_type_mappings_test.dart | 179 ++ test/reference_resolver_test.dart | 453 +++ test/response_semantics_test.dart | 326 +++ test/schema_semantics_test.dart | 198 ++ test/server_security_test.dart | 214 ++ test/union_semantics_test.dart | 139 + tool/verify_generated_package.dart | 177 ++ 56 files changed, 14506 insertions(+), 491 deletions(-) create mode 100644 .pubignore create mode 100644 README.zh-CN.md create mode 100644 doc/COMPATIBILITY.md create mode 100644 doc/CONFIGURATION.md create mode 100644 doc/DIAGNOSTICS.md create mode 100644 doc/MIGRATION.md create mode 100644 doc/internal/COMMUNITY_COMPARISON.md create mode 100644 doc/internal/GOAL.md create mode 100644 doc/zh-CN/ARCHITECTURE.md create mode 100644 doc/zh-CN/CHANGELOG.md create mode 100644 doc/zh-CN/COMPATIBILITY.md create mode 100644 doc/zh-CN/CONFIGURATION.md create mode 100644 doc/zh-CN/DIAGNOSTICS.md create mode 100644 doc/zh-CN/MIGRATION.md create mode 100644 example/README.md create mode 100644 example/koi_swagger_parser.yaml create mode 100644 example/openapi.yaml create mode 100644 koi_swagger_parser.example.yaml create mode 100644 lib/src/diagnostic/diagnostic.dart create mode 100644 lib/src/generator/lifecycle.dart create mode 100644 lib/src/openapi/document_merger.dart create mode 100644 lib/src/openapi/reference_resolver.dart create mode 100644 test/cli_test.dart create mode 100644 test/enum_mappings_and_diagnostics_test.dart create mode 100644 test/fixture_corpus_test.dart create mode 100644 test/fixtures/e2e_openapi.json create mode 100644 test/fixtures/openapi_30_compat.yaml create mode 100644 test/generation_lifecycle_and_filters_test.dart create mode 100644 test/golden_generation_test.dart create mode 100644 test/goldens/e2e_generated_files.sha256.json create mode 100644 test/http_semantics_test.dart create mode 100644 test/naming_type_mappings_test.dart create mode 100644 test/reference_resolver_test.dart create mode 100644 test/response_semantics_test.dart create mode 100644 test/schema_semantics_test.dart create mode 100644 test/server_security_test.dart create mode 100644 test/union_semantics_test.dart create mode 100644 tool/verify_generated_package.dart diff --git a/.pubignore b/.pubignore new file mode 100644 index 0000000..fcd8302 --- /dev/null +++ b/.pubignore @@ -0,0 +1,12 @@ +.dart_tool/ +.omx/ +.DS_Store +build/ +coverage/ +test/ +tool/ +doc/api/ +doc/internal/ +pubspec.lock +example/generated/ +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c051ea..5b90cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ -## 0.1.0 +# Changelog -- 初始化 `koi_swagger_parser`。 -- 支持 OpenAPI 3.1/FastAPI 常见结构解析、Koi 风格 Retrofit API 生成、Freezed 模型生成。 -- 支持兼容读取旧版 `generator_config.yaml`。 +All notable changes to this package are documented in this file. + +[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` 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. diff --git a/README.md b/README.md index 6557d43..c9530f6 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,95 @@ # 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 解析。 -- 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`。 +Generated code follows a Koi-oriented layout: -## 设计目标 +```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`。 -- 先产出中间表示(IR),再做 Koi 风格渲染,方便未来扩展到 `koi_blueprint_flutter`。 -- 默认以 Retrofit + Freezed 为输出基线,但 response wrapper 可配置。 -- 对 FastAPI/SQLAlchemy 风格服务端有良好适配,同时不把服务端补丁写死在生成器里。 +## Features -## 配置文件 +- 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` 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 input: @@ -32,12 +101,6 @@ output: base_dir: ./lib/src api_dir: ./lib/src/api models_dir: ./lib/src/api_models - included_tags: - - auth - - stores - - materials - split_by_tags: true - extract_version_from_url: false generation: preset: @@ -45,93 +108,265 @@ generation: api: enabled: true use_retrofit: true - cancel_token_support: true - client: - class_name: DbaApiClient - file_name: dba_api_client response_wrapper: - mode: external_generic - success_data_field: data - detect_standard_envelope: true - external_generic: - import: package:dba_network/dba_network.dart - class_name: BaseResult + mode: passthrough models: enabled: true class_prefix: Koi - default_non_nullable_list_to_empty: true - json_serializable: - checked: true - explicit_to_json: true - include_if_null: false + formatting: run_dart_format: true + 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`。 -- `output.included_tags`、`generation.models.class_prefix` 等旧字段会继续生效。 +```bash +dart run koi_swagger_parser generate --all --dry-run +``` -## 命令行 +4. Generate the client: ```bash dart run koi_swagger_parser generate --all ``` -常用参数: +When `formatting.run_build_runner` is `false`, run build_runner separately: -- `--config`:指定配置文件路径。 -- `--cwd`:指定工作目录。 -- `--dry-run`:只解析与渲染,不落盘。 -- `--verbose`:打印更多诊断信息。 - -## 输出结构 - -默认会生成: - -```text -lib/src/ - api/ - dba_api_client.dart - index.dart - v1/ - index.dart - auth_api.dart - stores_api.dart - api_models/ - api_models.dart - request/ - result/ - shared/ - enums/ +```bash +dart run build_runner build --delete-conflicting-outputs ``` -## 当前能力 +## CLI usage -当前版本已经覆盖 Koi 当前最需要的能力: +```text +dart run koi_swagger_parser generate [options] -- OpenAPI 3.1 文档加载(本地 JSON/YAML、远程 URL、多 source merge)。 -- `object` / `array` / `enum` / `additionalProperties` / `allOf` 基础支持。 -- nullable query/path/body 字段解析。 -- FastAPI envelope 检测与 `BaseResult` 外部包装。 -- `multipart/form-data` 生成 `MultipartFile` / `File` / `Uint8List` / `String` 等 preset 驱动参数。 -- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper,并可被其他模型继续引用。 -- Retrofit API 文件、Freezed 模型文件、分组 index 文件生成。 +--all, -a Generate all enabled resources. Kept for command compatibility. +--config, -c Configuration file path. +--cwd Host project working directory. +--dry-run Parse and render without writing or deleting files. +--watch Start build_runner watch after generation. +--verbose, -v Print informational diagnostics and failure stack traces. +``` -下一阶段可以继续往前加: +The loader searches the working directory for, in order: -- 更丰富的 multipart metadata(如 encoding / runtime fileName / contentType preset)。 -- 无 discriminator 的更强联合类型兜底策略。 -- 更多 preset(例如 Chopper / blueprint preset / 网络层集成 preset)。 -- 与 `koi_blueprint_flutter` 的一键联动模板。 +1. `koi_swagger_parser.yaml` +2. `koi_swagger_parser.yml` +3. `generator_config.yaml` +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 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 dart pub get dart format . +dart analyze 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) diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..81db764 --- /dev/null +++ b/README.zh-CN.md @@ -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` 返回值; +- 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 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) diff --git a/analysis_options.yaml b/analysis_options.yaml index bd9b06e..055fa4b 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,6 @@ analyzer: linter: rules: - public_member_api_docs: false + public_member_api_docs: true lines_longer_than_80_chars: false prefer_single_quotes: true diff --git a/bin/koi_swagger_parser.dart b/bin/koi_swagger_parser.dart index f7d8c69..844af14 100644 --- a/bin/koi_swagger_parser.dart +++ b/bin/koi_swagger_parser.dart @@ -5,22 +5,59 @@ import 'package:koi_swagger_parser/koi_swagger_parser.dart'; Future main(List arguments) async { final parser = ArgParser() - ..addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息。'); + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show command help.'); final generateParser = ArgParser() - ..addFlag('all', abbr: 'a', negatable: false, help: '兼容旧命令格式,当前等价于生成全部资源。') - ..addOption('config', abbr: 'c', help: '配置文件路径。') - ..addOption('cwd', help: '工作目录。') - ..addFlag('dry-run', negatable: false, help: '只解析与渲染,不落盘。') - ..addFlag('verbose', abbr: 'v', negatable: false, help: '打印更多诊断信息。'); + ..addFlag( + 'help', + abbr: 'h', + negatable: false, + 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); late final ArgResults result; try { result = parser.parse(arguments); } on FormatException catch (error) { - stderr.writeln(error.message); - stderr.writeln(parser.usage); + stderr.writeln('Error: ${error.message}'); + stderr.writeln(); + stderr.writeln(_usage(parser)); exitCode = 64; return; } @@ -31,9 +68,16 @@ Future main(List arguments) async { } final command = result.command; - if (command == null || command.name == 'generate') { - final commandArgs = command ?? result; - await _runGenerate(commandArgs, parser); + if (command == null) { + stdout.writeln(_usage(parser)); + return; + } + if (command.name == 'generate') { + if ((command['help'] as bool?) ?? false) { + stdout.writeln(_generateUsage(generateParser)); + return; + } + await _runGenerate(command, generateParser); return; } @@ -48,22 +92,50 @@ Future _runGenerate(ArgResults result, ArgParser parser) async { workingDirectory: result['cwd'] as String?, dryRun: result['dry-run'] as bool? ?? false, verbose: result['verbose'] as bool? ?? false, + buildRunnerWatch: result['watch'] as bool? ?? false, ); - final mode = summary.dryRun ? 'dry-run' : 'written'; - stdout.writeln( - '[koi_swagger_parser] $mode ${summary.writtenFiles.length} file(s), ' - '${summary.operationsCount} operation(s), ' - '${summary.modelsCount} model(s).', - ); + for (final diagnostic in summary.diagnostics) { + if (diagnostic.severity == DiagnosticSeverity.info && + !(result['verbose'] as bool? ?? false)) { + continue; + } + 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) { - stderr.writeln('[koi_swagger_parser] generation failed: $error'); + stderr.writeln('[koi_swagger_parser] Generation failed.'); if ((result['verbose'] as bool?) ?? false) { + stderr.writeln(error); stderr.writeln(stackTrace); } 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; } } @@ -72,9 +144,31 @@ String _usage(ArgParser parser) { return [ 'koi_swagger_parser', '', - '用法:', - ' dart run koi_swagger_parser generate --all', + 'Generate Retrofit and Freezed Dart sources from OpenAPI 3.0 or 3.1.', '', + 'Usage:', + ' koi_swagger_parser [arguments]', + ' dart run koi_swagger_parser [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, ].join('\n'); } diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 13e6e90..b99746d 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,114 +1,172 @@ # 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` -- 兼容配置:`generator_config.yaml` -- 路径归一化、默认值、旧字段兼容映射 -- preset 解析(当前内置 `koi_flutter`) +## Configuration -### 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. -- 本地/远程文档加载 -- 多 source 深合并 -- schema/ref 解析 -- OpenAPI 3.1 nullable / anyOf / oneOf / additionalProperties / allOf 归一化 -- multipart request body 与 discriminator union 识别 +Unknown keys produce warnings. Invalid types and incomplete combinations +produce errors. See [Configuration](CONFIGURATION.md). -### ir +## Loader, resolver, and merger -产出与 OpenAPI 原文解耦的中间表示: +For each root source, `OpenApiReferenceResolver`: -- API operation -- request body / parameter -- model / enum -- Dart type graph +1. normalizes a local file, file URL, or HTTP(S) source; +2. caches each loaded document; +3. resolves local, relative, and remote `$ref` values; +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 -- Freezed model -- enums -- index/client 聚合文件 +## Validation and parsing -这一层不直接处理 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 -- 当前主要控制 multipart 二进制字段该生成为 `MultipartFile`、`File`、`Uint8List` 还是 `String` -- 后续可以继续扩展 blueprint preset、网络层 preset、命名/包装策略 preset +## Intermediate representation -## 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` - - 返回真实 OpenAPI response schema - - 适合你希望保留服务端 envelope 模型的场景 -- `external_generic` - - 识别标准成功 envelope(默认 `code/message/data`) - - 将 `data` 提取为 inner type - - API 返回 `ExternalWrapper`,例如 `BaseResult` +A generic `oneOf` requires exactly one decoder match. A generic `anyOf` retains +the raw value and all matching cases. Object branches are prefiltered by +required keys and enum constraints before decoding. -这样做的意义是: +## Preset and renderer -- parser core 保持通用 -- Koi 网络层包裹策略可配置 -- 未来可以挂更多 wrapper preset,而不是继续堆服务端补丁 +The current `koi_flutter` preset targets Dio, Retrofit, Freezed, and +json_serializable. -## 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/`:只在请求体根节点使用 -- `result/`:只在成功响应根节点使用 -- `shared/`:被请求与响应共同依赖,或用于错误响应 -- `enums/`:枚举单独分组 +The renderer does not load references or decide whether an OpenAPI structure +is valid. -这比“按 swagger component 平铺”更符合 Koi 项目结构,也更利于未来蓝图模板整理。 +## Generation lifecycle -## 4. 命名原则 +Before writing, `GenerationLifecycle` calculates: -- 类名统一 PascalCase。 -- 字段统一 camelCase,并保留 `@JsonKey(name: ...)` 对应 wire name。 -- 文件名统一 snake_case。 -- API 默认按 `version + tag` 分文件。 +- the canonical merged-spec SHA-256; +- the normalized configuration SHA-256; +- each rendered path and content SHA-256. -命名转换统一收口到 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 解析和业务输出绑定太深。 -- nullable / anyOf / wrapper 规则分散在多个模板与解析阶段。 -- 很难为未来 `koi_blueprint_flutter` 抽出复用 IR。 +build_runner owns `.g.dart` and `.freezed.dart` files; they are not included in +this generator's stale-file manifest. -所以这里直接重新收口: +## Response wrappers -- parser core 面向 OpenAPI 语义 -- Koi 输出作为 renderer/preset -- 兼容旧配置,但不延续旧架构负担 +The parser does not hard-code `BaseResult`: -## 6. 近期路线 +- `passthrough` returns the OpenAPI response schema; +- `external_generic` detects configured success envelopes and returns + `ExternalWrapper`. -- 第一阶段:打通 Koi/FastAPI 场景(已完成) -- 第二阶段:补齐 multipart、discriminator union、preset 基础层(当前版本) -- 第三阶段:扩更强 union fallback、更多 preset,并服务 `koi_blueprint_flutter` +A `default` response remains an error response. `response_mode: +http_response` retains Dio status and headers. Error response models remain in +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. diff --git a/doc/COMPATIBILITY.md b/doc/COMPATIBILITY.md new file mode 100644 index 0000000..a27b18d --- /dev/null +++ b/doc/COMPATIBILITY.md @@ -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). diff --git a/doc/CONFIGURATION.md b/doc/CONFIGURATION.md new file mode 100644 index 0000000..61434a7 --- /dev/null +++ b/doc/CONFIGURATION.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 | `/api` | Generated API root | +| `models_dir` | string path | `/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 | `/.dart_tool/koi_swagger_parser/manifest.json` | Ownership/hash manifest | + +Filter entries accept: + +- an exact string; +- `*`, `?`, and `**` glob syntax; +- `regex:`, 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. diff --git a/doc/DIAGNOSTICS.md b/doc/DIAGNOSTICS.md new file mode 100644 index 0000000..b89c272 --- /dev/null +++ b/doc/DIAGNOSTICS.md @@ -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. diff --git a/doc/MIGRATION.md b/doc/MIGRATION.md new file mode 100644 index 0000000..1f9943d --- /dev/null +++ b/doc/MIGRATION.md @@ -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`; 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. diff --git a/doc/internal/COMMUNITY_COMPARISON.md b/doc/internal/COMMUNITY_COMPARISON.md new file mode 100644 index 0000000..29763a2 --- /dev/null +++ b/doc/internal/COMMUNITY_COMPARISON.md @@ -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. diff --git a/doc/internal/GOAL.md b/doc/internal/GOAL.md new file mode 100644 index 0000000..7582ac7 --- /dev/null +++ b/doc/internal/GOAL.md @@ -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. diff --git a/doc/zh-CN/ARCHITECTURE.md b/doc/zh-CN/ARCHITECTURE.md new file mode 100644 index 0000000..69b118d --- /dev/null +++ b/doc/zh-CN/ARCHITECTURE.md @@ -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`。 + +`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 交付。 diff --git a/doc/zh-CN/CHANGELOG.md b/doc/zh-CN/CHANGELOG.md new file mode 100644 index 0000000..34afa07 --- /dev/null +++ b/doc/zh-CN/CHANGELOG.md @@ -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` 模式; +- 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`。 diff --git a/doc/zh-CN/COMPATIBILITY.md b/doc/zh-CN/COMPATIBILITY.md new file mode 100644 index 0000000..c20bc5f --- /dev/null +++ b/doc/zh-CN/COMPATIBILITY.md @@ -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)。 diff --git a/doc/zh-CN/CONFIGURATION.md b/doc/zh-CN/CONFIGURATION.md new file mode 100644 index 0000000..787a347 --- /dev/null +++ b/doc/zh-CN/CONFIGURATION.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 | `/api` | API 输出根 | +| `models_dir` | string path | `/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 | `/.dart_tool/koi_swagger_parser/manifest.json` | 所有权/hash manifest | + +过滤项支持: + +- 精确字符串; +- `*`、`?`、`**` glob; +- `regex:`,使用 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)。 diff --git a/doc/zh-CN/DIAGNOSTICS.md b/doc/zh-CN/DIAGNOSTICS.md new file mode 100644 index 0000000..fdbf2e3 --- /dev/null +++ b/doc/zh-CN/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 +视为阻塞。 diff --git a/doc/zh-CN/MIGRATION.md b/doc/zh-CN/MIGRATION.md new file mode 100644 index 0000000..a7930c0 --- /dev/null +++ b/doc/zh-CN/MIGRATION.md @@ -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`,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。 diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..fa5719a --- /dev/null +++ b/example/README.md @@ -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. diff --git a/example/koi_swagger_parser.yaml b/example/koi_swagger_parser.yaml new file mode 100644 index 0000000..42d5371 --- /dev/null +++ b/example/koi_swagger_parser.yaml @@ -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 diff --git a/example/koi_swagger_parser_example.dart b/example/koi_swagger_parser_example.dart index b16efc0..3bd6d81 100644 --- a/example/koi_swagger_parser_example.dart +++ b/example/koi_swagger_parser_example.dart @@ -4,22 +4,24 @@ import 'package:koi_swagger_parser/koi_swagger_parser.dart'; import 'package:path/path.dart' as p; Future main() async { - final packageRoot = Directory.current.path; final tempDir = await Directory.systemTemp.createTemp( 'koi_swagger_parser_example_', ); - final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); - await configFile.writeAsString(''' + try { + 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: swagger_urls: - - url: ${p.join(packageRoot, 'test', 'fixtures', 'dba_openapi.json')} - enabled: true + - ${_yamlString(openApiPath)} output: - base_dir: ${p.join(tempDir.path, 'lib', 'src')} - included_tags: - - stores - - materials + base_dir: ${_yamlString(outputDirectory)} + api_dir: ${_yamlString(p.join(outputDirectory, 'api'))} + models_dir: ${_yamlString(p.join(outputDirectory, 'models'))} + manifest_path: ${_yamlString(p.join(tempDir.path, 'manifest.json'))} generation: api: @@ -27,17 +29,39 @@ generation: mode: passthrough formatting: - run_dart_format: false + run_dart_format: true '''); - final result = await const KoiSwaggerParser().generate( - configPath: configFile.path, - dryRun: true, - ); + final result = await const KoiSwaggerParser().generate( + configPath: configFile.path, + ); - print( - 'loaded ${result.sourceDocuments.length} source(s), ' - 'parsed ${result.operationsCount} operation(s), ' - 'rendered ${result.modelsCount} model(s).', + print( + 'Loaded ${result.sourceDocuments.length} source(s), ' + 'parsed ${result.operationsCount} operation(s), and ' + '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 [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("'", "''")}'"; diff --git a/example/openapi.yaml b/example/openapi.yaml new file mode 100644 index 0000000..fc586a8 --- /dev/null +++ b/example/openapi.yaml @@ -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 diff --git a/koi_swagger_parser.example.yaml b/koi_swagger_parser.example.yaml new file mode 100644 index 0000000..9406ef8 --- /dev/null +++ b/koi_swagger_parser.example.yaml @@ -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:. + 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 diff --git a/lib/koi_swagger_parser.dart b/lib/koi_swagger_parser.dart index 4371634..ef4b8b2 100644 --- a/lib/koi_swagger_parser.dart +++ b/lib/koi_swagger_parser.dart @@ -1,6 +1,28 @@ +/// Generates Retrofit and Freezed Dart/Flutter clients from OpenAPI documents. library; -export 'src/config/config.dart'; -export 'src/ir/ir.dart'; -export 'src/koi_swagger_parser.dart'; -export 'src/preset/preset.dart'; +export 'src/config/config.dart' + show + ApiGenerationConfig, + 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; diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index aed81e6..ab07cf9 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -3,26 +3,80 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; +import '../diagnostic/diagnostic.dart'; import '../preset/preset.dart'; import '../util/utils.dart'; -enum ResponseWrapperMode { passthrough, externalGeneric } +/// Determines how generated API methods expose successful response payloads. +enum ResponseWrapperMode { + /// Returns the payload type declared by the OpenAPI operation. + passthrough, + /// Wraps the payload in a configured external generic response type. + externalGeneric, +} + +/// Determines whether generated methods expose HTTP response metadata. +enum ResponseMetadataMode { + /// Returns only the decoded response body. + body, + + /// Returns Retrofit's `HttpResponse` with status and header metadata. + httpResponse, +} + +/// Determines the Dart type used for binary response bodies. +enum BinaryResponseMode { + /// Decodes binary response bodies as byte lists. + bytes, + + /// Exposes binary response bodies as byte streams. + stream, +} + +/// Selects how `build_runner` is invoked after generation. +enum BuildRunnerMode { + /// Runs one build and exits. + build, + + /// Watches the project and rebuilds after source changes. + watch, +} + +/// The resolved configuration used for one generation run. class KoiSwaggerConfig { + /// Creates a fully resolved generator configuration. KoiSwaggerConfig({ required this.configPath, required this.workingDirectory, required this.input, required this.output, required this.generation, + this.diagnostics = const [], }); + /// The resolved configuration file path, or `null` when defaults are used. final String? configPath; + + /// The project directory against which relative paths are resolved. final String workingDirectory; + + /// The configured OpenAPI inputs. final InputConfig input; + + /// The configured output locations and filters. final OutputConfig output; + + /// The configured code-generation behavior. final GenerationConfig generation; + /// Non-fatal diagnostics produced while loading this configuration. + final List diagnostics; + + /// Loads, validates, and resolves a configuration file. + /// + /// When [configPath] is omitted, the loader searches [workingDirectory], or + /// the current directory, for a supported default configuration filename. static Future load({ String? configPath, String? workingDirectory, @@ -34,70 +88,222 @@ class KoiSwaggerConfig { } } +/// Overrides the generated Dart name and description of one enum value. +class EnumValueMapping { + /// Creates an enum value override. + const EnumValueMapping({required this.name, this.description}); + + /// The Dart enum member name to generate. + final String name; + + /// An optional description used in generated Dart documentation. + final String? description; +} + +/// Declares the OpenAPI documents to load. class InputConfig { + /// Creates an input configuration from local paths or HTTP(S) URLs. const InputConfig({required this.swaggerUrls}); + /// Ordered OpenAPI document sources to merge and parse. final List swaggerUrls; } +/// Controls generated output locations, filtering, and file lifecycle. class OutputConfig { + /// Creates an output configuration. const OutputConfig({ required this.baseDir, required this.apiDir, required this.modelsDir, required this.splitByTags, required this.extractVersionFromUrl, + this.cleanStaleFiles = true, + this.skipIfUnchanged = true, + this.manifestPath = '.dart_tool/koi_swagger_parser/manifest.json', this.includedTags, this.excludedTags, + this.includedPaths, + this.excludedPaths, + this.includedOperations, + this.excludedOperations, + this.includedModels, + this.excludedModels, }); + /// Root directory for generated Dart source. final String baseDir; + + /// Directory for generated API client files. final String apiDir; + + /// Directory for generated model files. final String modelsDir; + + /// Tag patterns included in generation, or `null` to include every tag. final List? includedTags; + + /// Tag patterns excluded from generation. final List? excludedTags; + + /// OpenAPI path patterns included in generation. + final List? includedPaths; + + /// OpenAPI path patterns excluded from generation. + final List? excludedPaths; + + /// Operation IDs included in generation. + final List? includedOperations; + + /// Operation IDs excluded from generation. + final List? excludedOperations; + + /// Schema names included in generation. + final List? includedModels; + + /// Schema names excluded from generation. + final List? excludedModels; + + /// Whether API clients are split into separate files by OpenAPI tag. final bool splitByTags; + + /// Whether a version segment is inferred from each operation path. final bool extractVersionFromUrl; + + /// Whether files previously generated but no longer needed are removed. + final bool cleanStaleFiles; + + /// Whether files whose generated content is unchanged are left untouched. + final bool skipIfUnchanged; + + /// Path to the manifest used to track generated files. + final String manifestPath; } +/// Groups API, model, formatting, preset, and mapping configuration. class GenerationConfig { + /// Creates a generation configuration. const GenerationConfig({ required this.preset, required this.api, required this.models, required this.formatting, + this.mappings = const GenerationMappingsConfig(), }); + /// The compatibility preset applied to generated code. final GeneratorPreset preset; + + /// API client generation settings. final ApiGenerationConfig api; + + /// Model generation settings. final ModelGenerationConfig models; + + /// Post-generation formatting and build settings. final FormattingConfig formatting; + + /// Explicit naming and Dart type overrides. + final GenerationMappingsConfig mappings; } +/// Maps an OpenAPI schema or primitive to an existing Dart type. +class DartTypeMapping { + /// Creates a Dart type mapping with an optional import URI. + const DartTypeMapping({required this.dartType, this.import}); + + /// The Dart type expression used in generated declarations. + final String dartType; + + /// An optional import URI required by [dartType]. + final String? import; +} + +/// Explicit naming, schema, primitive type, and import mappings. +class GenerationMappingsConfig { + /// Creates mapping overrides. + const GenerationMappingsConfig({ + this.models = const {}, + this.properties = const >{}, + this.parameters = const >{}, + this.operations = const {}, + this.schemas = const {}, + this.types = const {}, + this.imports = const {}, + }); + + /// Maps OpenAPI schema names to generated Dart class names. + final Map models; + + /// Maps property names by their containing OpenAPI schema name. + final Map> properties; + + /// Maps parameter names by operation ID. + final Map> parameters; + + /// Maps operation IDs to generated Dart method names. + final Map operations; + + /// Maps OpenAPI schema names to existing Dart types. + final Map schemas; + + /// Maps OpenAPI primitive or format keys to Dart types. + final Map types; + + /// Adds explicit imports keyed by an arbitrary stable identifier. + final Map imports; +} + +/// Controls Retrofit API client generation. class ApiGenerationConfig { + /// Creates API client generation settings. const ApiGenerationConfig({ required this.enabled, required this.useRetrofit, required this.cancelTokenSupport, required this.client, required this.responseWrapper, + this.responseMetadataMode = ResponseMetadataMode.body, + this.binaryResponseMode = BinaryResponseMode.bytes, }); + /// Whether API client generation is enabled. final bool enabled; + + /// Whether generated clients use Retrofit annotations. final bool useRetrofit; + + /// Whether generated methods accept Dio `CancelToken` parameters. final bool cancelTokenSupport; + + /// Generated client class and filename settings. final ClientConfig client; + + /// Successful response wrapper settings. final ResponseWrapperConfig responseWrapper; + + /// Whether generated methods return bodies or HTTP response metadata. + final ResponseMetadataMode responseMetadataMode; + + /// How binary response bodies are represented in Dart. + final BinaryResponseMode binaryResponseMode; } +/// Names the generated API client class and file. class ClientConfig { + /// Creates generated API client naming settings. const ClientConfig({required this.className, required this.fileName}); + /// The generated Retrofit client class name. final String className; + + /// The generated Retrofit client filename. final String fileName; } +/// Configures successful response payload wrapping. class ResponseWrapperConfig { + /// Creates response wrapper settings. const ResponseWrapperConfig({ required this.mode, required this.successDataField, @@ -106,49 +312,103 @@ class ResponseWrapperConfig { this.externalClassName, }); + /// The response wrapping strategy. final ResponseWrapperMode mode; + + /// The envelope property containing the successful payload. final String successDataField; + + /// Whether common response envelopes are detected automatically. final bool detectStandardEnvelope; + + /// Import URI for the external generic wrapper, when configured. final String? externalImport; + + /// Class name of the external generic wrapper, when configured. final String? externalClassName; } +/// Controls Freezed and JSON-serializable model generation. class ModelGenerationConfig { + /// Creates model generation settings. const ModelGenerationConfig({ required this.enabled, required this.classPrefix, required this.defaultNonNullableListToEmpty, required this.jsonSerializable, + this.enumMappings = const >{}, + this.enumUnknownFallback = false, }); + /// Whether model generation is enabled. final bool enabled; + + /// Prefix added to generated model class names. final String classPrefix; + + /// Whether absent non-nullable list properties default to an empty list. final bool defaultNonNullableListToEmpty; + + /// JSON serialization annotation settings. final JsonSerializableConfig jsonSerializable; + + /// Per-schema mappings from wire enum values to generated enum members. + final Map> enumMappings; + + /// Whether generated enums contain a fallback for unknown wire values. + final bool enumUnknownFallback; } +/// Controls generated `JsonSerializable` annotation options. class JsonSerializableConfig { + /// Creates JSON serialization settings. const JsonSerializableConfig({ required this.checked, required this.explicitToJson, required this.includeIfNull, }); + /// Whether generated decoding uses checked conversion. final bool checked; + + /// Whether nested objects are serialized by calling their `toJson` methods. final bool explicitToJson; + + /// Whether null-valued fields are included in serialized JSON. final bool includeIfNull; } +/// Controls formatter, fixer, and `build_runner` execution. class FormattingConfig { + /// Creates post-generation tool settings. const FormattingConfig({ required this.runDartFormat, required this.runDartFix, + this.runBuildRunner = false, + this.buildRunnerMode = BuildRunnerMode.build, + this.deleteConflictingOutputs = true, }); + /// Whether `dart format` runs on changed generated files. final bool runDartFormat; + + /// Whether `dart fix --apply` runs on changed generated files. final bool runDartFix; + + /// Whether `build_runner` starts after generation. + final bool runBuildRunner; + + /// The `build_runner` command mode. + final BuildRunnerMode buildRunnerMode; + + /// Whether conflicting generated outputs may be deleted by `build_runner`. + final bool deleteConflictingOutputs; } +/// Loads YAML files into resolved [KoiSwaggerConfig] objects. +/// +/// Prefer [KoiSwaggerConfig.load] in application code. This implementation +/// class is intentionally not exported from the package's primary library. class KoiSwaggerConfigLoader { static const List _defaultNames = [ 'koi_swagger_parser.yaml', @@ -157,31 +417,117 @@ class KoiSwaggerConfigLoader { 'generator_config.yml', ]; + /// Loads and validates a configuration from [configPath]. static Future load({ String? configPath, String? workingDirectory, }) async { - final cwd = p.normalize(workingDirectory ?? Directory.current.path); + final invocationCwd = p.normalize( + workingDirectory ?? Directory.current.path, + ); final resolvedConfig = configPath == null - ? _findConfigFile(cwd) - : _resolvePath(configPath, cwd); - final configDir = resolvedConfig == null ? cwd : p.dirname(resolvedConfig); + ? _findConfigFile(invocationCwd) + : _resolvePath(configPath, invocationCwd); + final configDir = resolvedConfig == null + ? invocationCwd + : p.dirname(resolvedConfig); + final projectDir = workingDirectory == null && resolvedConfig != null + ? configDir + : invocationCwd; final rawConfig = resolvedConfig == null ? {} : await _readConfigFile(resolvedConfig); + final diagnostics = KoiDiagnosticBag(); + final diagnosticSource = resolvedConfig ?? ''; + _validateKnownConfigKeys(rawConfig, diagnostics, source: diagnosticSource); - final output = rawConfig['output'] as Map?; - final generation = rawConfig['generation'] as Map?; - final preset = generation?['preset'] as Map?; - final api = generation?['api'] as Map?; - final models = generation?['models'] as Map?; - final formatting = rawConfig['formatting'] as Map?; - final responseWrapper = api?['response_wrapper'] as Map?; - final externalGeneric = - responseWrapper?['external_generic'] as Map?; - final client = api?['client'] as Map?; - final jsonSerializable = - models?['json_serializable'] as Map?; + final output = _configSection( + rawConfig['output'], + diagnostics, + source: diagnosticSource, + location: r'$.output', + ); + final input = _configSection( + rawConfig['input'], + diagnostics, + source: diagnosticSource, + location: r'$.input', + ); + final generation = _configSection( + rawConfig['generation'], + diagnostics, + source: diagnosticSource, + location: r'$.generation', + ); + final preset = _configSection( + generation?['preset'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.preset', + ); + final api = _configSection( + generation?['api'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.api', + ); + final models = _configSection( + generation?['models'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.models', + ); + final formatting = _configSection( + rawConfig['formatting'], + diagnostics, + source: diagnosticSource, + location: r'$.formatting', + ); + final mappings = _configSection( + generation?['mappings'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.mappings', + ); + final responseWrapper = _configSection( + api?['response_wrapper'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.api.response_wrapper', + ); + final externalGeneric = _configSection( + responseWrapper?['external_generic'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.api.response_wrapper.external_generic', + ); + final client = _configSection( + api?['client'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.api.client', + ); + final jsonSerializable = _configSection( + models?['json_serializable'], + diagnostics, + source: diagnosticSource, + location: r'$.generation.models.json_serializable', + ); + _validateScalarConfigTypes( + output: output, + input: input, + preset: preset, + api: api, + models: models, + formatting: formatting, + responseWrapper: responseWrapper, + externalGeneric: externalGeneric, + client: client, + jsonSerializable: jsonSerializable, + diagnostics: diagnostics, + source: diagnosticSource, + ); + diagnostics.throwIfErrors(message: 'Configuration type validation failed.'); final baseDir = _resolvePath( output?['base_dir'] as String? ?? './lib/src', @@ -195,38 +541,122 @@ class KoiSwaggerConfigLoader { output?['models_dir'] as String? ?? p.join(baseDir, 'api_models'), configDir, ); + final manifestPath = _resolvePath( + output?['manifest_path'] as String? ?? + p.join( + projectDir, + '.dart_tool', + 'koi_swagger_parser', + 'manifest.json', + ), + configDir, + ); final legacyBaseResultImport = api?['base_result_import'] as String?; final wrapperMode = _parseWrapperMode( responseWrapper?['mode'] as String?, legacyBaseResultImport: legacyBaseResultImport, + diagnostics: diagnostics, + source: diagnosticSource, ); + final apiEnabled = api?['enabled'] as bool? ?? true; + final modelsEnabled = models?['enabled'] as bool? ?? true; + final enumMappings = _parseEnumMappings( + models, + diagnostics, + source: diagnosticSource, + ); + final enumUnknownFallback = + models?['enum_unknown_fallback'] as bool? ?? + models?['enum_unknown_default_case'] as bool? ?? + false; + final swaggerUrls = _parseSwaggerUrls(input, configDir); + if (swaggerUrls.isEmpty) { + diagnostics.error( + 'config.missing_sources', + 'input.swagger_urls must contain at least one enabled OpenAPI document ' + 'source.', + source: diagnosticSource, + location: r'$.input.swagger_urls', + ); + } + final resolvedPreset = _resolvePreset( + preset?['name'] as String?, + diagnostics, + source: diagnosticSource, + ); + final generationMappings = _parseGenerationMappings( + mappings, + diagnostics, + source: diagnosticSource, + ); + final responseMetadataMode = _parseResponseMetadataMode( + api?['response_mode'] as String?, + diagnostics: diagnostics, + source: diagnosticSource, + ); + final binaryResponseMode = _parseBinaryResponseMode( + api?['binary_response_mode'] as String?, + diagnostics: diagnostics, + source: diagnosticSource, + ); + final buildRunnerMode = _parseBuildRunnerMode( + formatting?['build_runner_mode'] as String?, + diagnostics: diagnostics, + source: diagnosticSource, + ); + + _validateConfigCombination( + apiEnabled: apiEnabled, + modelsEnabled: modelsEnabled, + useRetrofit: api?['use_retrofit'] as bool? ?? true, + wrapperMode: wrapperMode, + externalImport: + externalGeneric?['import'] as String? ?? legacyBaseResultImport, + includedTags: _stringList(output?['included_tags']), + excludedTags: _stringList(output?['excluded_tags']), + includedPaths: _stringList(output?['included_paths']), + excludedPaths: _stringList(output?['excluded_paths']), + includedOperations: _stringList(output?['included_operations']), + excludedOperations: _stringList(output?['excluded_operations']), + includedModels: _stringList(output?['included_models']), + excludedModels: _stringList(output?['excluded_models']), + diagnostics: diagnostics, + source: diagnosticSource, + ); + diagnostics.throwIfErrors(message: 'Configuration validation failed.'); return KoiSwaggerConfig( configPath: resolvedConfig, - workingDirectory: cwd, - input: InputConfig( - swaggerUrls: _parseSwaggerUrls( - rawConfig['input'] as Map?, - configDir, - ), - ), + workingDirectory: projectDir, + input: InputConfig(swaggerUrls: swaggerUrls), output: OutputConfig( baseDir: baseDir, apiDir: apiDir, modelsDir: modelsDir, includedTags: _stringList(output?['included_tags']), excludedTags: _stringList(output?['excluded_tags']), + includedPaths: _stringList(output?['included_paths']), + excludedPaths: _stringList(output?['excluded_paths']), + includedOperations: _stringList(output?['included_operations']), + excludedOperations: _stringList(output?['excluded_operations']), + includedModels: _stringList(output?['included_models']), + excludedModels: _stringList(output?['excluded_models']), splitByTags: output?['split_by_tags'] as bool? ?? true, extractVersionFromUrl: output?['extract_version_from_url'] as bool? ?? false, + cleanStaleFiles: output?['clean_stale_files'] as bool? ?? true, + skipIfUnchanged: output?['skip_if_unchanged'] as bool? ?? true, + manifestPath: manifestPath, ), generation: GenerationConfig( - preset: GeneratorPresetRegistry.resolve(preset?['name'] as String?), + preset: resolvedPreset, api: ApiGenerationConfig( - enabled: api?['enabled'] as bool? ?? true, + enabled: apiEnabled, useRetrofit: api?['use_retrofit'] as bool? ?? true, cancelTokenSupport: api?['cancel_token_support'] as bool? ?? false, + responseMetadataMode: responseMetadataMode, + binaryResponseMode: binaryResponseMode, client: ClientConfig( className: sanitizeIdentifier( client?['class_name'] as String? ?? 'ApiClient', @@ -250,7 +680,7 @@ class KoiSwaggerConfigLoader { ), ), models: ModelGenerationConfig( - enabled: models?['enabled'] as bool? ?? true, + enabled: modelsEnabled, classPrefix: sanitizeIdentifier( models?['class_prefix'] as String? ?? 'Koi', fallback: 'Koi', @@ -264,12 +694,20 @@ class KoiSwaggerConfigLoader { includeIfNull: jsonSerializable?['include_if_null'] as bool? ?? false, ), + enumMappings: enumMappings, + enumUnknownFallback: enumUnknownFallback, ), formatting: FormattingConfig( runDartFormat: formatting?['run_dart_format'] as bool? ?? true, runDartFix: formatting?['run_dart_fix'] as bool? ?? false, + runBuildRunner: formatting?['run_build_runner'] as bool? ?? false, + buildRunnerMode: buildRunnerMode, + deleteConflictingOutputs: + formatting?['delete_conflicting_outputs'] as bool? ?? true, ), + mappings: generationMappings, ), + diagnostics: diagnostics.diagnostics, ); } @@ -277,7 +715,7 @@ class KoiSwaggerConfigLoader { final file = File(path); final exists = file.existsSync(); if (exists == false) { - throw FileSystemException('配置文件不存在。', path); + throw FileSystemException('Configuration file does not exist.', path); } final yaml = loadYaml(await file.readAsString()); return yamlToMap(yaml); @@ -349,9 +787,961 @@ class KoiSwaggerConfigLoader { return null; } + static void _validateScalarConfigTypes({ + required Map? output, + required Map? input, + required Map? preset, + required Map? api, + required Map? models, + required Map? formatting, + required Map? responseWrapper, + required Map? externalGeneric, + required Map? client, + required Map? jsonSerializable, + required KoiDiagnosticBag diagnostics, + required String source, + }) { + void checkString( + Map? section, + String key, + String location, + ) { + final value = section?[key]; + if (value != null && value is! String) { + diagnostics.error( + 'config.invalid_string', + 'The configuration value must be a string.', + source: source, + location: '$location.$key', + ); + } + } + + void checkBool(Map? section, String key, String location) { + final value = section?[key]; + if (value != null && value is! bool) { + diagnostics.error( + 'config.invalid_boolean', + 'The configuration value must be a boolean.', + source: source, + location: '$location.$key', + ); + } + } + + void checkStringList( + Map? section, + String key, + String location, + ) { + final value = section?[key]; + if (value == null) { + return; + } + if (value is! List) { + diagnostics.error( + 'config.invalid_string_list', + 'The configuration value must be a list of strings.', + source: source, + location: '$location.$key', + ); + return; + } + for (var index = 0; index < value.length; index += 1) { + if (value[index] is! String) { + diagnostics.error( + 'config.invalid_string_list_item', + 'Each list item must be a string.', + source: source, + location: '$location.$key[$index]', + ); + } + } + } + + for (final key in const [ + 'base_dir', + 'api_dir', + 'models_dir', + 'manifest_path', + ]) { + checkString(output, key, r'$.output'); + } + for (final key in const [ + 'included_tags', + 'excluded_tags', + 'included_paths', + 'excluded_paths', + 'included_operations', + 'excluded_operations', + 'included_models', + 'excluded_models', + ]) { + checkStringList(output, key, r'$.output'); + final patterns = _stringList(output?[key]); + _validateFilterPatterns( + patterns, + diagnostics, + source: source, + location: '\$.output.$key', + ); + } + for (final key in const [ + 'split_by_tags', + 'extract_version_from_url', + 'clean_stale_files', + 'skip_if_unchanged', + ]) { + checkBool(output, key, r'$.output'); + } + checkString(preset, 'name', r'$.generation.preset'); + for (final key in const [ + 'base_result_import', + 'response_mode', + 'binary_response_mode', + ]) { + checkString(api, key, r'$.generation.api'); + } + for (final key in const [ + 'enabled', + 'use_retrofit', + 'cancel_token_support', + ]) { + checkBool(api, key, r'$.generation.api'); + } + checkString(client, 'class_name', r'$.generation.api.client'); + checkString(client, 'file_name', r'$.generation.api.client'); + checkString(responseWrapper, 'mode', r'$.generation.api.response_wrapper'); + checkString( + responseWrapper, + 'success_data_field', + r'$.generation.api.response_wrapper', + ); + checkBool( + responseWrapper, + 'detect_standard_envelope', + r'$.generation.api.response_wrapper', + ); + checkString( + externalGeneric, + 'import', + r'$.generation.api.response_wrapper.external_generic', + ); + checkString( + externalGeneric, + 'class_name', + r'$.generation.api.response_wrapper.external_generic', + ); + checkString(models, 'class_prefix', r'$.generation.models'); + for (final key in const [ + 'enabled', + 'default_non_nullable_list_to_empty', + 'enum_unknown_fallback', + 'enum_unknown_default_case', + ]) { + checkBool(models, key, r'$.generation.models'); + } + for (final key in const [ + 'checked', + 'explicit_to_json', + 'include_if_null', + ]) { + checkBool( + jsonSerializable, + key, + r'$.generation.models.json_serializable', + ); + } + checkString(formatting, 'build_runner_mode', r'$.formatting'); + for (final key in const [ + 'run_dart_format', + 'run_dart_fix', + 'run_build_runner', + 'delete_conflicting_outputs', + ]) { + checkBool(formatting, key, r'$.formatting'); + } + + final rawSources = input?['swagger_urls']; + if (rawSources != null && rawSources is! List) { + diagnostics.error( + 'config.invalid_sources', + 'input.swagger_urls must be a list of strings or source objects.', + source: source, + location: r'$.input.swagger_urls', + ); + } else if (rawSources is List) { + for (var index = 0; index < rawSources.length; index += 1) { + final item = rawSources[index]; + if (item is String) { + continue; + } + if (item is! Map) { + diagnostics.error( + 'config.invalid_source_entry', + 'A document source must be a path or URL string, or an object ' + 'containing a url field.', + source: source, + location: '\$.input.swagger_urls[$index]', + ); + continue; + } + if (item['url'] is! String) { + diagnostics.error( + 'config.invalid_source_url', + 'A document source object must contain a string url field.', + source: source, + location: '\$.input.swagger_urls[$index].url', + ); + } + if (item['enabled'] != null && item['enabled'] is! bool) { + diagnostics.error( + 'config.invalid_boolean', + 'The configuration value must be a boolean.', + source: source, + location: '\$.input.swagger_urls[$index].enabled', + ); + } + } + } + } + + static Map? _configSection( + dynamic value, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (value == null) { + return null; + } + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + diagnostics.error( + 'config.invalid_section', + 'The configuration section must be an object.', + source: source, + location: location, + ); + return null; + } + + static void _validateKnownConfigKeys( + Map rawConfig, + KoiDiagnosticBag diagnostics, { + required String source, + }) { + void check(dynamic value, Set known, String location) { + if (value is! Map) { + return; + } + for (final key in value.keys.map((key) => key.toString())) { + if (!known.contains(key)) { + diagnostics.warning( + 'config.unknown_key', + 'Unknown configuration key `$key`; this version ignores it.', + source: source, + location: '$location.$key', + ); + } + } + } + + check(rawConfig, const { + 'input', + 'output', + 'generation', + 'formatting', + }, r'$'); + check(rawConfig['input'], const {'swagger_urls'}, r'$.input'); + check(rawConfig['output'], const { + 'base_dir', + 'api_dir', + 'models_dir', + 'included_tags', + 'excluded_tags', + 'included_paths', + 'excluded_paths', + 'included_operations', + 'excluded_operations', + 'included_models', + 'excluded_models', + 'split_by_tags', + 'extract_version_from_url', + 'clean_stale_files', + 'skip_if_unchanged', + 'manifest_path', + }, r'$.output'); + check(rawConfig['generation'], const { + 'preset', + 'api', + 'models', + 'mappings', + }, r'$.generation'); + + final generation = rawConfig['generation']; + if (generation is Map) { + check(generation['preset'], const { + 'name', + }, r'$.generation.preset'); + check(generation['api'], const { + 'enabled', + 'use_retrofit', + 'cancel_token_support', + 'base_result_import', + 'response_mode', + 'binary_response_mode', + 'client', + 'response_wrapper', + }, r'$.generation.api'); + check(generation['models'], const { + 'enabled', + 'class_prefix', + 'default_non_nullable_list_to_empty', + 'json_serializable', + 'enum_mappings', + 'enum_key_mappings', + 'enum_unknown_fallback', + 'enum_unknown_default_case', + }, r'$.generation.models'); + + final api = generation['api']; + if (api is Map) { + check(api['client'], const { + 'class_name', + 'file_name', + }, r'$.generation.api.client'); + check(api['response_wrapper'], const { + 'mode', + 'success_data_field', + 'detect_standard_envelope', + 'external_generic', + }, r'$.generation.api.response_wrapper'); + final responseWrapper = api['response_wrapper']; + if (responseWrapper is Map) { + check( + responseWrapper['external_generic'], + const {'import', 'class_name'}, + r'$.generation.api.response_wrapper.external_generic', + ); + } + } + + final models = generation['models']; + if (models is Map) { + check(models['json_serializable'], const { + 'checked', + 'explicit_to_json', + 'include_if_null', + }, r'$.generation.models.json_serializable'); + } + check(generation['mappings'], const { + 'models', + 'properties', + 'parameters', + 'operations', + 'schemas', + 'types', + 'imports', + }, r'$.generation.mappings'); + } + check(rawConfig['formatting'], const { + 'run_dart_format', + 'run_dart_fix', + 'run_build_runner', + 'build_runner_mode', + 'delete_conflicting_outputs', + }, r'$.formatting'); + } + + static GenerationMappingsConfig _parseGenerationMappings( + Map? mappings, + KoiDiagnosticBag diagnostics, { + required String source, + }) { + final models = _parseStringMapping( + mappings?['models'], + diagnostics, + source: source, + location: r'$.generation.mappings.models', + ); + final properties = _parseNestedStringMapping( + mappings?['properties'], + diagnostics, + source: source, + location: r'$.generation.mappings.properties', + ); + final parameters = _parseNestedStringMapping( + mappings?['parameters'], + diagnostics, + source: source, + location: r'$.generation.mappings.parameters', + ); + final operations = _parseStringMapping( + mappings?['operations'], + diagnostics, + source: source, + location: r'$.generation.mappings.operations', + ); + final schemas = _parseDartTypeMappings( + mappings?['schemas'], + diagnostics, + source: source, + location: r'$.generation.mappings.schemas', + ); + final types = _parseDartTypeMappings( + mappings?['types'], + diagnostics, + source: source, + location: r'$.generation.mappings.types', + ); + final imports = _parseStringMapping( + mappings?['imports'], + diagnostics, + source: source, + location: r'$.generation.mappings.imports', + ); + + _validateUniqueMappingTargets( + models, + diagnostics, + source: source, + location: r'$.generation.mappings.models', + ); + _validateUniqueMappingTargets( + operations, + diagnostics, + source: source, + location: r'$.generation.mappings.operations', + ); + for (final entry in properties.entries) { + _validateUniqueMappingTargets( + entry.value, + diagnostics, + source: source, + location: '\$.generation.mappings.properties.${entry.key}', + ); + } + for (final entry in parameters.entries) { + _validateUniqueMappingTargets( + entry.value, + diagnostics, + source: source, + location: '\$.generation.mappings.parameters.${entry.key}', + ); + } + final overlap = models.keys.toSet().intersection(schemas.keys.toSet()); + for (final schemaName in overlap) { + diagnostics.error( + 'config.conflicting_schema_mapping', + 'Schema `$schemaName` cannot have both a model rename and an external ' + 'schema type mapping.', + source: source, + location: '\$.generation.mappings.schemas.$schemaName', + ); + } + final importsByDartType = {...imports}; + for (final mapping in [ + ...schemas.values, + ...types.values, + ]) { + final mappingImport = mapping.import; + if (mappingImport == null) { + continue; + } + final previous = importsByDartType[mapping.dartType]; + if (previous != null && previous != mappingImport) { + diagnostics.error( + 'config.import_mapping_conflict', + 'Dart type `${mapping.dartType}` is mapped from both `$previous` and ' + '`$mappingImport`.', + source: source, + location: r'$.generation.mappings', + ); + } else { + importsByDartType[mapping.dartType] = mappingImport; + } + } + + return GenerationMappingsConfig( + models: Map.unmodifiable(models), + properties: Map>.unmodifiable( + properties.map( + (key, value) => + MapEntry(key, Map.unmodifiable(value)), + ), + ), + parameters: Map>.unmodifiable( + parameters.map( + (key, value) => + MapEntry(key, Map.unmodifiable(value)), + ), + ), + operations: Map.unmodifiable(operations), + schemas: Map.unmodifiable(schemas), + types: Map.unmodifiable(types), + imports: Map.unmodifiable(imports), + ); + } + + static Map _parseStringMapping( + dynamic rawMapping, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (rawMapping == null) { + return {}; + } + if (rawMapping is! Map) { + diagnostics.error( + 'config.invalid_mapping', + 'The mapping configuration must be an object.', + source: source, + location: location, + ); + return {}; + } + final result = {}; + for (final entry in rawMapping.entries) { + final key = entry.key.toString().trim(); + final value = entry.value?.toString().trim(); + if (key.isEmpty || value == null || value.isEmpty) { + diagnostics.error( + 'config.invalid_mapping_entry', + 'Mapping keys and values must be non-empty strings.', + source: source, + location: '$location.${entry.key}', + ); + continue; + } + result[key] = value; + } + return result; + } + + static Map> _parseNestedStringMapping( + dynamic rawMapping, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (rawMapping == null) { + return >{}; + } + if (rawMapping is! Map) { + diagnostics.error( + 'config.invalid_nested_mapping', + 'The nested mapping configuration must be an object.', + source: source, + location: location, + ); + return >{}; + } + return >{ + for (final entry in rawMapping.entries) + entry.key.toString(): _parseStringMapping( + entry.value, + diagnostics, + source: source, + location: '$location.${entry.key}', + ), + }; + } + + static Map _parseDartTypeMappings( + dynamic rawMapping, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (rawMapping == null) { + return {}; + } + if (rawMapping is! Map) { + diagnostics.error( + 'config.invalid_type_mapping', + 'The type mapping must be an object.', + source: source, + location: location, + ); + return {}; + } + final result = {}; + for (final entry in rawMapping.entries) { + final key = entry.key.toString().trim(); + final rawValue = entry.value; + String? dartType; + String? import; + if (rawValue is String) { + dartType = rawValue.trim(); + } else if (rawValue is Map) { + dartType = + rawValue['type']?.toString().trim() ?? + rawValue['dart_type']?.toString().trim(); + import = rawValue['import']?.toString().trim(); + } + if (key.isEmpty || dartType == null || dartType.isEmpty) { + diagnostics.error( + 'config.invalid_type_mapping_entry', + 'The type mapping must provide a non-empty Dart `type`.', + source: source, + location: '$location.${entry.key}', + ); + continue; + } + result[key] = DartTypeMapping( + dartType: dartType, + import: import == null || import.isEmpty ? null : import, + ); + } + return result; + } + + static void _validateUniqueMappingTargets( + Map mappings, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + final owners = {}; + for (final entry in mappings.entries) { + final previous = owners[entry.value]; + if (previous != null && previous != entry.key) { + diagnostics.error( + 'config.mapping_target_collision', + 'Mappings `${entry.key}` and `$previous` both use target name ' + '`${entry.value}`.', + source: source, + location: '$location.${entry.key}', + ); + } else { + owners[entry.value] = entry.key; + } + } + } + + static Map> _parseEnumMappings( + Map? models, + KoiDiagnosticBag diagnostics, { + required String source, + }) { + final result = >{}; + + void mergeSource(String key) { + final rawSource = models?[key]; + if (rawSource == null) { + return; + } + if (rawSource is! Map) { + diagnostics.error( + 'config.invalid_enum_mappings', + '`$key` must be an object keyed by enum schema name.', + source: source, + location: r'$.generation.models.' + key, + ); + return; + } + + for (final entry in rawSource.entries) { + final enumName = entry.key.toString().trim(); + if (enumName.isEmpty) { + diagnostics.error( + 'config.invalid_enum_name', + 'An enum mapping name cannot be empty.', + source: source, + location: '\$.generation.models.$key', + ); + continue; + } + final location = '\$.generation.models.$key.${entry.key}'; + final parsed = _parseEnumValueMappings( + entry.value, + diagnostics, + source: source, + location: location, + ); + if (parsed.isEmpty) { + continue; + } + final target = result.putIfAbsent( + enumName, + () => {}, + ); + for (final mapping in parsed.entries) { + if (target.containsKey(mapping.key)) { + diagnostics.warning( + 'config.enum_mapping_overridden', + 'The `$key` mapping overrides the mapping for value ' + '`${mapping.key}` in enum `$enumName`.', + source: source, + location: location, + ); + } + target[mapping.key] = mapping.value; + } + } + } + + // Merge legacy fields first so newer fields predictably win conflicts. + mergeSource('enum_key_mappings'); + mergeSource('enum_mappings'); + + return Map>.unmodifiable( + result.map( + (name, mappings) => MapEntry( + name, + Map.unmodifiable(mappings), + ), + ), + ); + } + + static Map _parseEnumValueMappings( + dynamic rawMappings, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (rawMappings is Map && rawMappings['values'] != null) { + rawMappings = rawMappings['values']; + } + + final result = {}; + + void addMapping(dynamic value, dynamic rawName, dynamic rawDescription) { + final name = rawName?.toString().trim(); + if (value == null || name == null || name.isEmpty) { + diagnostics.error( + 'config.invalid_enum_mapping', + 'Each enum mapping must contain non-empty `value` and `name` fields.', + source: source, + location: location, + ); + return; + } + final key = value as Object; + if (result.containsKey(key)) { + diagnostics.warning( + 'config.duplicate_enum_mapping', + 'Enum value `$value` has duplicate mappings; the last mapping wins.', + source: source, + location: location, + ); + } + result[key] = EnumValueMapping( + name: name, + description: rawDescription?.toString(), + ); + } + + if (rawMappings is List) { + for (var index = 0; index < rawMappings.length; index += 1) { + final mapping = rawMappings[index]; + if (mapping is! Map) { + diagnostics.error( + 'config.invalid_enum_mapping', + 'Each enum mapping list item must be an object.', + source: source, + location: '$location[$index]', + ); + continue; + } + addMapping(mapping['value'], mapping['name'], mapping['description']); + } + return result; + } + + if (rawMappings is Map) { + for (final entry in rawMappings.entries) { + final mapping = entry.value; + if (mapping is String) { + addMapping(entry.key, mapping, null); + continue; + } + if (mapping is Map) { + addMapping( + mapping.containsKey('value') ? mapping['value'] : entry.key, + mapping['name'], + mapping['description'], + ); + continue; + } + diagnostics.error( + 'config.invalid_enum_mapping', + 'The mapping for enum value `${entry.key}` must be a name string or ' + 'an object.', + source: source, + location: '$location.${entry.key}', + ); + } + return result; + } + + diagnostics.error( + 'config.invalid_enum_mappings', + 'An enum mapping must be a list or an object.', + source: source, + location: location, + ); + return result; + } + + static void _validateConfigCombination({ + required bool apiEnabled, + required bool modelsEnabled, + required bool useRetrofit, + required ResponseWrapperMode wrapperMode, + required String? externalImport, + required List? includedTags, + required List? excludedTags, + required List? includedPaths, + required List? excludedPaths, + required List? includedOperations, + required List? excludedOperations, + required List? includedModels, + required List? excludedModels, + required KoiDiagnosticBag diagnostics, + required String source, + }) { + if (!apiEnabled && !modelsEnabled) { + diagnostics.error( + 'config.no_outputs_enabled', + 'generation.api.enabled and generation.models.enabled cannot both be ' + 'false.', + source: source, + location: r'$.generation', + ); + } + if (apiEnabled && !modelsEnabled) { + diagnostics.error( + 'config.api_requires_models', + 'The current Retrofit renderer depends on api_models.dart, so model ' + 'generation cannot be disabled while API generation is enabled.', + source: source, + location: r'$.generation.models.enabled', + ); + } + if (apiEnabled && !useRetrofit) { + diagnostics.error( + 'config.unsupported_api_renderer', + 'This version only provides a Retrofit API renderer; ' + 'generation.api.use_retrofit cannot be false.', + source: source, + location: r'$.generation.api.use_retrofit', + ); + } + if (apiEnabled && + wrapperMode == ResponseWrapperMode.externalGeneric && + (externalImport == null || externalImport.trim().isEmpty)) { + diagnostics.error( + 'config.external_wrapper_import_required', + 'The external_generic response wrapper requires ' + 'external_generic.import.', + source: source, + location: r'$.generation.api.response_wrapper.external_generic.import', + ); + } + + final filterPairs = <(String, List?, List?, String)>[ + ('tags', includedTags, excludedTags, 'tag'), + ('paths', includedPaths, excludedPaths, 'path'), + ('operations', includedOperations, excludedOperations, 'operation'), + ('models', includedModels, excludedModels, 'model'), + ]; + for (final pair in filterPairs) { + final (key, included, excluded, label) = pair; + _validateFilterPatterns( + included, + diagnostics, + source: source, + location: '\$.output.included_$key', + ); + _validateFilterPatterns( + excluded, + diagnostics, + source: source, + location: '\$.output.excluded_$key', + ); + final overlap = (included ?? const []).toSet().intersection( + (excluded ?? const []).toSet(), + ); + if (overlap.isNotEmpty) { + diagnostics.warning( + 'config.overlapping_$key', + 'The following $label filters appear in both include and exclude; ' + 'exclude takes precedence: ${overlap.toList()..sort()}.', + source: source, + location: r'$.output', + ); + } + } + } + + static void _validateFilterPatterns( + List? patterns, + KoiDiagnosticBag diagnostics, { + required String source, + required String location, + }) { + if (patterns == null) { + return; + } + for (var index = 0; index < patterns.length; index += 1) { + final pattern = patterns[index]; + if (!pattern.startsWith('regex:')) { + continue; + } + final expression = pattern.substring('regex:'.length); + if (expression.isEmpty) { + diagnostics.error( + 'config.empty_filter_regex', + 'A `regex:` filter must contain a regular expression.', + source: source, + location: '$location[$index]', + ); + continue; + } + try { + RegExp(expression); + } on FormatException catch (error) { + diagnostics.error( + 'config.invalid_filter_regex', + 'Invalid regular expression filter `$pattern`: ${error.message}.', + source: source, + location: '$location[$index]', + ); + } + } + } + + static GeneratorPreset _resolvePreset( + String? rawName, + KoiDiagnosticBag diagnostics, { + required String source, + }) { + try { + return GeneratorPresetRegistry.resolve(rawName); + } on ArgumentError { + diagnostics.error( + 'config.unknown_preset', + 'Unknown preset `${rawName ?? ''}`.', + source: source, + location: r'$.generation.preset.name', + ); + return GeneratorPresetRegistry.koiFlutter; + } + } + static ResponseWrapperMode _parseWrapperMode( String? rawMode, { String? legacyBaseResultImport, + required KoiDiagnosticBag diagnostics, + required String source, }) { if (rawMode == null || rawMode.trim().isEmpty) { return legacyBaseResultImport == null @@ -364,11 +1754,82 @@ class KoiSwaggerConfigLoader { case 'external_generic': return ResponseWrapperMode.externalGeneric; default: - throw ArgumentError.value( - rawMode, - 'response_wrapper.mode', - '未知的 wrapper 模式。', + diagnostics.error( + 'config.unknown_wrapper_mode', + 'Unknown response wrapper mode `$rawMode`.', + source: source, + location: r'$.generation.api.response_wrapper.mode', ); + return ResponseWrapperMode.passthrough; + } + } + + static ResponseMetadataMode _parseResponseMetadataMode( + String? rawMode, { + required KoiDiagnosticBag diagnostics, + required String source, + }) { + switch (rawMode?.trim()) { + case null: + case '': + case 'body': + return ResponseMetadataMode.body; + case 'http_response': + return ResponseMetadataMode.httpResponse; + default: + diagnostics.error( + 'config.unknown_response_mode', + 'Unknown response_mode `$rawMode`; expected body or http_response.', + source: source, + location: r'$.generation.api.response_mode', + ); + return ResponseMetadataMode.body; + } + } + + static BinaryResponseMode _parseBinaryResponseMode( + String? rawMode, { + required KoiDiagnosticBag diagnostics, + required String source, + }) { + switch (rawMode?.trim()) { + case null: + case '': + case 'bytes': + return BinaryResponseMode.bytes; + case 'stream': + return BinaryResponseMode.stream; + default: + diagnostics.error( + 'config.unknown_binary_response_mode', + 'Unknown binary_response_mode `$rawMode`; expected bytes or stream.', + source: source, + location: r'$.generation.api.binary_response_mode', + ); + return BinaryResponseMode.bytes; + } + } + + static BuildRunnerMode _parseBuildRunnerMode( + String? rawMode, { + required KoiDiagnosticBag diagnostics, + required String source, + }) { + switch (rawMode?.trim()) { + case null: + case '': + case 'build': + return BuildRunnerMode.build; + case 'watch': + return BuildRunnerMode.watch; + default: + diagnostics.error( + 'config.unknown_build_runner_mode', + 'Unknown build_runner_mode `$rawMode`; expected build or watch.', + source: source, + location: r'$.formatting.build_runner_mode', + ); + return BuildRunnerMode.build; } } } diff --git a/lib/src/diagnostic/diagnostic.dart b/lib/src/diagnostic/diagnostic.dart new file mode 100644 index 0000000..ffd721c --- /dev/null +++ b/lib/src/diagnostic/diagnostic.dart @@ -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 = [ + 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 _diagnostics = []; + + /// An immutable snapshot of all diagnostics added so far. + List get diagnostics => + List.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 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'; + } +} diff --git a/lib/src/generator/generator.dart b/lib/src/generator/generator.dart index c471d4a..6da26ac 100644 --- a/lib/src/generator/generator.dart +++ b/lib/src/generator/generator.dart @@ -4,12 +4,16 @@ import '../config/config.dart'; import '../ir/ir.dart'; import '../util/utils.dart'; +/// Renders the parsed OpenAPI intermediate representation as Dart sources. class DartGenerator { + /// Creates a renderer configured by [config]. DartGenerator(this.config); + /// Generation settings and output paths used for this render pass. final KoiSwaggerConfig config; late Map _modelIndex; + /// Renders [document] into a deterministic bundle of generated files. GeneratedBundle generate(ParsedApiDocument document) { _modelIndex = document.models; final files = []; @@ -42,7 +46,7 @@ class DartGenerator { } if (config.generation.api.enabled) { - files.addAll(_buildApiFiles(document.operations)); + files.addAll(_buildApiFiles(document)); } return GeneratedBundle( @@ -110,11 +114,15 @@ class DartGenerator { return files; } - List _buildApiFiles(List operations) { + List _buildApiFiles(ParsedApiDocument document) { if (!config.generation.api.useRetrofit) { - throw UnsupportedError('当前版本只支持 Retrofit API 生成。'); + throw UnsupportedError( + 'This version supports only Retrofit API generation.', + ); } + final operations = document.operations; + final defaultBaseUrl = document.servers.firstOrNull?.url; final grouped = >>{}; for (final operation in operations) { final versionGroup = grouped.putIfAbsent( @@ -157,6 +165,7 @@ class DartGenerator { version: version, className: apiClassName, fileName: apiFileName, + defaultBaseUrl: defaultBaseUrl, ), ), ); @@ -186,6 +195,9 @@ class DartGenerator { content: _renderClientFile( versionImports: versionImports, clientFields: clientFields, + defaultBaseUrl: defaultBaseUrl, + securitySchemes: document.securitySchemes, + operations: operations, ), ), ); @@ -219,6 +231,13 @@ class DartGenerator { modelFiles: modelFiles, ); case ModelKind.object: + if (model.additionalPropertiesType != null) { + return _renderExtensibleObjectModelFile( + model, + relativePath: relativePath, + modelFiles: modelFiles, + ); + } return _renderObjectModelFile( model, relativePath: relativePath, @@ -227,6 +246,234 @@ class DartGenerator { } } + String _renderExtensibleObjectModelFile( + ModelSpec model, { + required String relativePath, + required Map modelFiles, + }) { + final currentDir = p.dirname(relativePath); + final candidateNames = modelFiles.keys.toSet(); + final imports = {}; + final referencedTypes = [ + for (final field in model.fields) field.type, + model.additionalPropertiesType!, + ]; + for (final type in referencedTypes) { + final referencedNames = type.collectReferencedNames(candidateNames) + ..remove(model.name); + for (final referencedName in referencedNames) { + final targetPath = modelFiles[referencedName]!; + final importPath = p + .relative(targetPath, from: currentDir) + .replaceAll('\\', '/'); + imports.add("import '$importPath';"); + } + } + _addExternalTypeImports(imports, referencedTypes); + + final extrasName = _additionalPropertiesFieldName(model); + final extrasType = model.additionalPropertiesType!.render(); + final helperStem = '_${toCamelCase(model.name)}'; + final fieldLines = []; + for (final field in model.fields) { + if (field.description != null && field.description!.trim().isNotEmpty) { + fieldLines.addAll(_docComment(field.description!, prefix: ' ')); + } + if (field.constraints.isNotEmpty) { + fieldLines.add( + ' /// Constraints: ${_renderConstraints(field.constraints)}', + ); + } + final jsonKeyArguments = _jsonKeyArgumentsForField(field); + if (jsonKeyArguments.isNotEmpty) { + fieldLines.add(' @JsonKey(${jsonKeyArguments.join(', ')})'); + } + final defaultValue = _defaultExpressionFor(field); + if (defaultValue != null) { + fieldLines.add(' @Default($defaultValue)'); + } + final requiredPrefix = defaultValue == null && field.required + ? 'required ' + : ''; + fieldLines.add( + ' $requiredPrefix${field.type.render()} ${field.name},', + ); + } + fieldLines + ..add( + ' @JsonKey(' + 'readValue: ${helperStem}ReadAdditionalProperties, ' + 'includeToJson: false)', + ) + ..add( + ' @Default({}) ' + 'Map $extrasName,', + ); + + final jsonSerializable = config.generation.models.jsonSerializable; + final fileStem = p.basenameWithoutExtension(relativePath); + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln('// ignore_for_file: type=lint, invalid_annotation_target') + ..writeln() + ..writeln("import 'package:freezed_annotation/freezed_annotation.dart';"); + if (imports.isNotEmpty) { + buffer.writeln(); + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + } + buffer + ..writeln() + ..writeln("part '$fileStem.freezed.dart';") + ..writeln("part '$fileStem.g.dart';") + ..writeln(); + if (model.description != null && model.description!.trim().isNotEmpty) { + for (final line in _docComment(model.description!)) { + buffer.writeln(line); + } + } + buffer + ..writeln('@freezed') + ..writeln('abstract class ${model.name} with _\$${model.name} {') + ..writeln(' const ${model.name}._();') + ..writeln() + ..writeln( + ' @JsonSerializable(' + 'checked: ${jsonSerializable.checked}, ' + 'explicitToJson: ${jsonSerializable.explicitToJson}, ' + 'includeIfNull: ${jsonSerializable.includeIfNull}, ' + 'createToJson: false)', + ) + ..writeln(' const factory ${model.name}({'); + for (final line in fieldLines) { + buffer.writeln(line); + } + buffer + ..writeln(' }) = _${model.name};') + ..writeln() + ..writeln( + ' factory ${model.name}.fromJson(Map json) => ' + '_\$${model.name}FromJson(json);', + ) + ..writeln() + ..writeln( + ' Map toJson() => ' + '_\$${model.name}ToJson(this);', + ) + ..writeln('}') + ..writeln() + ..writeln( + 'Map _\$${model.name}ToJson(' + '${model.name} instance) {', + ) + ..writeln(' final result = {};') + ..writeln(' for (final entry in instance.$extrasName.entries) {') + ..writeln( + ' result[entry.key] = ${helperStem}ToJsonValue(entry.value);', + ) + ..writeln(' }'); + for (final field in model.fields) { + final shouldIncludeNull = + config.generation.models.jsonSerializable.includeIfNull || + (field.required && field.schemaNullable); + if (!shouldIncludeNull && field.type.nullable) { + buffer.writeln(' if (instance.${field.name} != null) {'); + buffer.writeln( + " result['${escapeDartString(field.wireName)}'] = " + '${helperStem}ToJsonValue(instance.${field.name});', + ); + buffer.writeln(' }'); + } else { + buffer.writeln( + " result['${escapeDartString(field.wireName)}'] = " + '${helperStem}ToJsonValue(instance.${field.name});', + ); + } + } + buffer + ..writeln(' return result;') + ..writeln('}') + ..writeln() + ..writeln( + 'Object? ${helperStem}ReadAdditionalProperties(' + 'Map json, String _) {', + ) + ..writeln(' const knownKeys = {'); + for (final field in model.fields) { + buffer.writeln(" '${escapeDartString(field.wireName)}',"); + } + buffer + ..writeln(' };') + ..writeln(' return {') + ..writeln(' for (final entry in json.entries)') + ..writeln(' if (!knownKeys.contains(entry.key.toString()))') + ..writeln(' entry.key.toString(): entry.value,') + ..writeln(' };') + ..writeln('}') + ..writeln() + ..writeln('Object? ${helperStem}ToJsonValue(Object? value) {') + ..writeln(' if (value == null || value is String ||') + ..writeln(' value is num || value is bool) {') + ..writeln(' return value;') + ..writeln(' }') + ..writeln(' if (value is DateTime) {') + ..writeln(' return value.toIso8601String();') + ..writeln(' }') + ..writeln(' if (value is Iterable) {') + ..writeln( + ' return value.map(${helperStem}ToJsonValue).toList(growable: false);', + ) + ..writeln(' }') + ..writeln(' if (value is Map) {') + ..writeln(' return value.map(') + ..writeln( + ' (key, item) => MapEntry(' + 'key.toString(), ${helperStem}ToJsonValue(item)),', + ) + ..writeln(' );') + ..writeln(' }') + ..writeln(' return (value as dynamic).toJson();') + ..writeln('}'); + return buffer.toString(); + } + + String _additionalPropertiesFieldName(ModelSpec model) { + final usedNames = model.fields.map((field) => field.name).toSet(); + var candidate = 'additionalProperties'; + var suffix = 1; + while (usedNames.contains(candidate)) { + suffix += 1; + candidate = 'additionalProperties$suffix'; + } + return candidate; + } + + List _jsonKeyArgumentsForField(ModelField field) { + final arguments = []; + if (field.name != field.wireName) { + arguments.add("name: '${escapeDartString(field.wireName)}'"); + } + if (field.required && field.defaultValue == null) { + arguments.add('required: true'); + } + if (!field.schemaNullable && field.defaultValue == null) { + arguments.add('disallowNullValue: true'); + } + if (field.required && field.schemaNullable) { + arguments.add('includeIfNull: true'); + } + final enumFallback = _enumFallbackForType(field.type); + if (enumFallback != null) { + arguments.add( + 'unknownEnumValue: ' + '${enumFallback.model.name}.${enumFallback.value.name}', + ); + } + return arguments; + } + String _renderObjectModelFile( ModelSpec model, { required String relativePath, @@ -246,6 +493,7 @@ class DartGenerator { imports.add("import '$importPath';"); } } + _addExternalTypeImports(imports, model.fields.map((field) => field.type)); final jsonSerializable = config.generation.models.jsonSerializable; final fieldLines = []; @@ -254,12 +502,16 @@ class DartGenerator { if (description != null && description.trim().isNotEmpty) { fieldLines.addAll(_docComment(description, prefix: ' ')); } + if (field.constraints.isNotEmpty) { + fieldLines.add( + ' /// Constraints: ${_renderConstraints(field.constraints)}', + ); + } final annotations = []; - if (field.name != field.wireName) { - annotations.add( - "@JsonKey(name: '${escapeDartString(field.wireName)}')", - ); + final jsonKeyArguments = _jsonKeyArgumentsForField(field); + if (jsonKeyArguments.isNotEmpty) { + annotations.add('@JsonKey(${jsonKeyArguments.join(', ')})'); } final defaultValue = _defaultExpressionFor(field); @@ -328,6 +580,8 @@ class DartGenerator { ' factory ${model.name}.fromJson(Map json) => ' '_\$${model.name}FromJson(json);', ) + ..writeln() + ..writeln(' Map toJson();') ..writeln('}'); return buffer.toString(); @@ -338,6 +592,13 @@ class DartGenerator { required String relativePath, required Map modelFiles, }) { + if (model.unionMode != UnionMode.discriminated) { + return _renderGenericUnionFile( + model, + relativePath: relativePath, + modelFiles: modelFiles, + ); + } final currentDir = p.dirname(relativePath); final candidateNames = modelFiles.keys.toSet(); final imports = {}; @@ -353,10 +614,15 @@ class DartGenerator { imports.add("import '$importPath';"); } } + _addExternalTypeImports( + imports, + model.unionCases.map((unionCase) => unionCase.type), + ); final discriminatorKey = model.discriminatorKey ?? 'type'; final buffer = StringBuffer() ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln('// ignore_for_file: type=lint, unnecessary_cast') ..writeln(); if (imports.isNotEmpty) { @@ -422,6 +688,225 @@ class DartGenerator { return '${buffer.toString().trimRight()}\n'; } + String _renderGenericUnionFile( + ModelSpec model, { + required String relativePath, + required Map modelFiles, + }) { + final currentDir = p.dirname(relativePath); + final candidateNames = modelFiles.keys.toSet(); + final imports = {}; + for (final unionCase in model.unionCases) { + final referencedNames = unionCase.type.collectReferencedNames( + candidateNames, + )..remove(model.name); + for (final referencedName in referencedNames) { + final targetPath = modelFiles[referencedName]!; + final importPath = p + .relative(targetPath, from: currentDir) + .replaceAll('\\', '/'); + imports.add("import '$importPath';"); + } + } + _addExternalTypeImports( + imports, + model.unionCases.map((unionCase) => unionCase.type), + ); + + final helperName = '_${toCamelCase(model.name)}UnionToJsonValue'; + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln('// ignore_for_file: type=lint, unnecessary_cast') + ..writeln(); + if (imports.isNotEmpty) { + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + buffer.writeln(); + } + if (model.description != null && model.description!.trim().isNotEmpty) { + for (final line in _docComment(model.description!)) { + buffer.writeln(line); + } + } + + buffer + ..writeln('sealed class ${model.name} {') + ..writeln(' const ${model.name}();') + ..writeln() + ..writeln(' factory ${model.name}.fromJson(Object? json) {') + ..writeln(' final matches = <${model.name}>[];'); + for (final unionCase in model.unionCases) { + final predicate = _unionCasePredicate(unionCase, 'json'); + final decode = _unionDecodeExpression(unionCase.type, 'json'); + buffer + ..writeln(' if ($predicate) {') + ..writeln(' try {') + ..writeln( + ' matches.add(' + '${model.name}${unionCase.caseName}($decode));', + ) + ..writeln(' } on Object {') + ..writeln( + ' // Structural validation failed; try the next union branch.', + ) + ..writeln(' }') + ..writeln(' }'); + } + if (model.unionMode == UnionMode.oneOf) { + buffer + ..writeln(' if (matches.length != 1) {') + ..writeln( + " throw FormatException('Expected exactly one ${model.name} " + "branch, matched \${matches.length}.', json);", + ) + ..writeln(' }') + ..writeln(' return matches.single;'); + } else { + buffer + ..writeln(' if (matches.isEmpty) {') + ..writeln( + " throw FormatException('No ${model.name} anyOf branch " + "matched.', json);", + ) + ..writeln(' }') + ..writeln( + ' return ${model.name}AnyOf(' + 'List<${model.name}>.unmodifiable(matches), json);', + ); + } + buffer + ..writeln(' }') + ..writeln() + ..writeln(' Object? toJson();') + ..writeln('}') + ..writeln(); + + for (final unionCase in model.unionCases) { + buffer + ..writeln( + 'final class ${model.name}${unionCase.caseName} ' + 'extends ${model.name} {', + ) + ..writeln(' const ${model.name}${unionCase.caseName}(this.value);') + ..writeln() + ..writeln(' final ${unionCase.type.render()} value;') + ..writeln() + ..writeln(' @override') + ..writeln(' Object? toJson() => $helperName(value);') + ..writeln('}') + ..writeln(); + } + if (model.unionMode == UnionMode.anyOf) { + buffer + ..writeln('final class ${model.name}AnyOf extends ${model.name} {') + ..writeln(' const ${model.name}AnyOf(this.matches, this.rawValue);') + ..writeln() + ..writeln(' final List<${model.name}> matches;') + ..writeln(' final Object? rawValue;') + ..writeln() + ..writeln(' @override') + ..writeln(' Object? toJson() => $helperName(rawValue);') + ..writeln('}') + ..writeln(); + } + + buffer + ..writeln('Object? $helperName(Object? value) {') + ..writeln(' if (value == null || value is String ||') + ..writeln(' value is num || value is bool) {') + ..writeln(' return value;') + ..writeln(' }') + ..writeln(' if (value is DateTime) {') + ..writeln(' return value.toIso8601String();') + ..writeln(' }') + ..writeln(' if (value is Iterable) {') + ..writeln(' return value.map($helperName).toList(growable: false);') + ..writeln(' }') + ..writeln(' if (value is Map) {') + ..writeln(' return value.map(') + ..writeln( + ' (key, item) => MapEntry(' + 'key.toString(), $helperName(item)),', + ) + ..writeln(' );') + ..writeln(' }') + ..writeln(' return (value as dynamic).toJson();') + ..writeln('}'); + return '${buffer.toString().trimRight()}\n'; + } + + String _unionCasePredicate(UnionCase unionCase, String value) { + if (unionCase.allowedValues.isNotEmpty) { + final values = unionCase.allowedValues.map(_dartLiteral).join(', '); + return '{$values}.contains($value)'; + } + return switch (unionCase.matchKind) { + UnionMatchKind.string => '$value is String', + UnionMatchKind.integer => '$value is int', + UnionMatchKind.number => '$value is num', + UnionMatchKind.boolean => '$value is bool', + UnionMatchKind.array => '$value is List', + UnionMatchKind.nullValue => '$value == null', + UnionMatchKind.any => 'true', + UnionMatchKind.object => + '$value is Map' + '${unionCase.requiredProperties.map((property) => " && $value.containsKey(${_dartLiteral(property)})").join()}', + }; + } + + String _unionDecodeExpression(DartType type, String value) { + if (type.kind == TypeKind.list) { + final itemType = type.arguments.first; + return '($value as List)' + '.map((item) => ${_unionDecodeExpression(itemType, 'item')})' + '.toList(growable: false)'; + } + if (type.kind == TypeKind.map) { + final valueType = type.arguments.last; + return 'Map.from($value as Map)' + '.map((key, item) => MapEntry(' + 'key, ${_unionDecodeExpression(valueType, 'item')}))'; + } + if (type.kind == TypeKind.dynamicType || type.kind == TypeKind.objectType) { + return value; + } + if (type.kind == TypeKind.voidType) { + return 'null'; + } + + final name = type.name; + switch (name) { + case 'Null': + return 'null'; + case 'String': + return '$value as String'; + case 'int': + return '($value as num).toInt()'; + case 'double': + return '($value as num).toDouble()'; + case 'num': + return '$value as num'; + case 'bool': + return '$value as bool'; + case 'DateTime': + return 'DateTime.parse($value as String)'; + } + final model = _modelIndex[name]; + if (model?.kind == ModelKind.enumType) { + final fallback = model!.enumValues + .where((enumValue) => enumValue.isFallback) + .firstOrNull; + final orElse = fallback == null + ? '' + : ', orElse: () => $name.${fallback.name}'; + return '$name.values.firstWhere(' + '(item) => item.toJson() == $value$orElse)'; + } + return '$name.fromJson(Map.from($value as Map))'; + } + String _renderEnumFile(ModelSpec model) { final buffer = StringBuffer() ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') @@ -437,10 +922,26 @@ class DartGenerator { buffer.writeln('enum ${model.name} {'); for (final value in model.enumValues) { + if (value.description != null && value.description!.trim().isNotEmpty) { + for (final line in _docComment(value.description!, prefix: ' ')) { + buffer.writeln(line); + } + } buffer.writeln(' @JsonValue(${_dartLiteral(value.rawValue)})'); buffer.writeln(' ${value.name},'); } - buffer.writeln('}'); + buffer + ..writeln(' ;') + ..writeln() + ..writeln(' Object toJson() => switch (this) {'); + for (final value in model.enumValues) { + buffer.writeln( + ' ${model.name}.${value.name} => ${_dartLiteral(value.rawValue)},', + ); + } + buffer + ..writeln(' };') + ..writeln('}'); return buffer.toString(); } @@ -449,8 +950,12 @@ class DartGenerator { required String version, required String className, required String fileName, + required String? defaultBaseUrl, }) { final currentDir = p.join(config.output.apiDir, version); + final useTransportAdapter = operations.any( + _operationRequiresTransportAdapter, + ); final modelsIndexPath = p .relative( p.join(config.output.modelsDir, 'api_models.dart'), @@ -459,6 +964,7 @@ class DartGenerator { .replaceAll('\\', '/'); final imports = [ + if (useTransportAdapter) "import 'dart:convert';", if (operations.any( (operation) => _operationUsesNamedType(operation, 'File'), )) @@ -467,10 +973,16 @@ class DartGenerator { (operation) => _operationUsesNamedType(operation, 'Uint8List'), )) "import 'dart:typed_data';", - "import 'package:dio/dio.dart';", + "import 'package:dio/dio.dart' hide Headers;", "import 'package:retrofit/retrofit.dart';", "import '$modelsIndexPath';", ]; + final externalApiImports = {}; + _addExternalTypeImports( + externalApiImports, + operations.expand(_operationDartTypes), + ); + imports.addAll(externalApiImports); final wrapperImport = config.generation.api.responseWrapper.externalImport; if (config.generation.api.responseWrapper.mode == @@ -482,6 +994,7 @@ class DartGenerator { final buffer = StringBuffer() ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln('// ignore_for_file: type=lint, unused_element, unused_import') ..writeln(); final sortedImports = imports.toList()..sort(); for (final import in sortedImports) { @@ -489,46 +1002,491 @@ class DartGenerator { } final partStem = p.basenameWithoutExtension(fileName); + final retrofitClassName = useTransportAdapter + ? '_${className}Retrofit' + : className; buffer ..writeln() - ..writeln("part '$partStem.g.dart';") + ..writeln("part '$partStem.g.dart';"); + + if (useTransportAdapter) { + buffer + ..writeln() + ..write( + _renderTransportAdapterClass( + operations: operations, + className: className, + retrofitClassName: retrofitClassName, + ), + ) + ..writeln(); + } + + final restApiArguments = [ + if (defaultBaseUrl != null) + "baseUrl: '${escapeDartString(defaultBaseUrl)}'", + 'parser: Parser.JsonSerializable', + ]; + buffer ..writeln() - ..writeln('@RestApi(parser: Parser.JsonSerializable)') - ..writeln('abstract class $className {') + ..writeln('@RestApi(${restApiArguments.join(', ')})') + ..writeln('abstract class $retrofitClassName {') ..writeln( - ' factory $className(Dio dio, {String? baseUrl}) = _$className;', + ' factory $retrofitClassName(Dio dio, {String? baseUrl}) = ' + '_$retrofitClassName;', ) ..writeln(); for (final operation in operations) { - if (operation.summary != null && operation.summary!.trim().isNotEmpty) { - for (final line in _docComment(operation.summary!)) { - buffer.writeln(' $line'); - } - } - if (operation.description != null && - operation.description!.trim().isNotEmpty && - operation.description != operation.summary) { - for (final line in _docComment(operation.description!)) { - buffer.writeln(' $line'); - } - } - if (operation.deprecated) { - buffer.writeln(" @Deprecated('Deprecated in OpenAPI spec.')"); - } - if (operation.requestBody?.kind == ApiRequestBodyKind.multipart) { - buffer.writeln(' @MultiPart()'); + if (!useTransportAdapter) { + _writeOperationDocumentation(buffer, operation); } + _writeOperationWireAnnotations(buffer, operation); + final baseOperationPath = + operation.serverUrl != null && operation.serverUrl != defaultBaseUrl + ? _joinServerAndOperationPath(operation.serverUrl!, operation.path) + : operation.path; + final operationPath = + useTransportAdapter && + operation.parameters.any((parameter) => parameter.allowReserved) + ? '$baseOperationPath{reservedQuery}' + : baseOperationPath; buffer - ..writeln(" @${operation.method}('${operation.path}')") + ..writeln( + " @${operation.method}('${escapeDartString(operationPath)}')", + ) ..writeln( ' Future<${operation.returnType.render()}> ' - '${operation.methodName}(${_renderApiParameters(operation)});', + '${operation.methodName}(' + '${useTransportAdapter ? _renderRawApiParameters(operation) : _renderApiParameters(operation)});', ) ..writeln(); } buffer.writeln('}'); + if (useTransportAdapter) { + buffer + ..writeln() + ..write(_renderTransportSerializationHelpers()); + } + return buffer.toString(); + } + + void _writeOperationDocumentation( + StringBuffer buffer, + ApiOperation operation, + ) { + if (operation.summary != null && operation.summary!.trim().isNotEmpty) { + for (final line in _docComment(operation.summary!)) { + buffer.writeln(' $line'); + } + } + if (operation.description != null && + operation.description!.trim().isNotEmpty && + operation.description != operation.summary) { + for (final line in _docComment(operation.description!)) { + buffer.writeln(' $line'); + } + } + for (final response in operation.responses) { + final content = response.contentType == null + ? '' + : ' ${response.contentType}'; + final body = response.hasBody ? ' ${response.bodyType.render()}' : ''; + buffer.writeln( + ' /// Response ${response.statusCode}:$content$body' + '${response.description == null ? '' : ' — ${response.description}'}', + ); + for (final header in response.headers) { + buffer.writeln( + ' /// Header ${header.name}: ${header.type.render()}' + '${header.description == null ? '' : ' — ${header.description}'}', + ); + } + } + if (operation.security.isNotEmpty) { + final alternatives = operation.security + .map((requirement) { + if (requirement.allowsAnonymous) { + return 'anonymous'; + } + return requirement.schemes.entries + .map( + (entry) => entry.value.isEmpty + ? entry.key + : '${entry.key}(${entry.value.join(', ')})', + ) + .join(' + '); + }) + .join(' OR '); + buffer.writeln(' /// Security: $alternatives'); + } + if (operation.deprecated) { + buffer.writeln(" @Deprecated('Deprecated in OpenAPI spec.')"); + } + } + + void _writeOperationWireAnnotations( + StringBuffer buffer, + ApiOperation operation, + ) { + final headers = {}; + final requestBody = operation.requestBody; + if (requestBody != null && + requestBody.contentType != null && + requestBody.kind != ApiRequestBodyKind.multipart && + requestBody.kind != ApiRequestBodyKind.formUrlEncoded) { + headers['Content-Type'] = requestBody.contentType!; + } + final primaryResponse = operation.primarySuccessResponse; + if (primaryResponse?.contentType != null) { + headers['Accept'] = primaryResponse!.contentType!; + } + if (headers.isNotEmpty) { + final values = headers.entries + .map( + (entry) => + '${_dartLiteral(entry.key)}: ${_dartLiteral(entry.value)}', + ) + .join(', '); + buffer.writeln(' @Headers({$values})'); + } + + switch (primaryResponse?.bodyKind) { + case ApiResponseBodyKind.bytes: + buffer.writeln(' @DioResponseType(ResponseType.bytes)'); + break; + case ApiResponseBodyKind.stream: + buffer.writeln(' @DioResponseType(ResponseType.stream)'); + break; + case ApiResponseBodyKind.text: + buffer.writeln(' @DioResponseType(ResponseType.plain)'); + break; + case ApiResponseBodyKind.none: + case ApiResponseBodyKind.json: + case ApiResponseBodyKind.other: + case null: + break; + } + + switch (requestBody?.kind) { + case ApiRequestBodyKind.multipart: + buffer.writeln(' @MultiPart()'); + break; + case ApiRequestBodyKind.formUrlEncoded: + buffer.writeln(' @FormUrlEncoded()'); + break; + case ApiRequestBodyKind.json: + case ApiRequestBodyKind.binary: + case null: + break; + } + } + + bool _operationRequiresTransportAdapter(ApiOperation operation) { + if (operation.requestBody?.kind == ApiRequestBodyKind.formUrlEncoded) { + return true; + } + return operation.parameters.any(_parameterRequiresSerialization); + } + + bool _parameterRequiresSerialization(ApiParameter parameter) { + if (parameter.allowReserved || + parameter.location == 'cookie' || + parameter.contentType != null) { + return true; + } + final isStructured = + parameter.type.isCollection || _isObjectParameterType(parameter.type); + final isEnum = _isEnumParameterType(parameter.type); + + return switch (parameter.location) { + 'path' => parameter.style != ApiParameterStyle.simple || isStructured, + 'header' => isStructured || isEnum, + 'query' => + parameter.style == ApiParameterStyle.spaceDelimited || + parameter.style == ApiParameterStyle.pipeDelimited || + (parameter.style == ApiParameterStyle.form && + !parameter.explode && + isStructured), + _ => false, + }; + } + + String _renderTransportAdapterClass({ + required List operations, + required String className, + required String retrofitClassName, + }) { + final buffer = StringBuffer() + ..writeln('class $className {') + ..writeln( + ' $className(Dio dio, {String? baseUrl}) ' + ': _delegate = $retrofitClassName(dio, baseUrl: baseUrl);', + ) + ..writeln() + ..writeln(' final $retrofitClassName _delegate;') + ..writeln(); + + for (final operation in operations) { + _writeOperationDocumentation(buffer, operation); + buffer + ..writeln( + ' Future<${operation.returnType.render()}> ' + '${operation.methodName}(${_renderPublicApiParameters(operation)}) {', + ) + ..writeln(' return _delegate.${operation.methodName}('); + for (final argument in _renderAdapterInvocationArguments(operation)) { + buffer.writeln(' $argument,'); + } + buffer + ..writeln(' );') + ..writeln(' }') + ..writeln(); + } + + buffer.writeln('}'); + return buffer.toString(); + } + + String _renderPublicApiParameters(ApiOperation operation) { + final parameters = []; + for (final parameter in operation.parameters) { + final defaultValue = _defaultExpressionForType( + parameter.defaultValue, + parameter.type, + ); + final requiredPrefix = parameter.required ? 'required ' : ''; + final defaultSuffix = defaultValue == null ? '' : ' = $defaultValue'; + parameters.add( + '$requiredPrefix${parameter.type.render()} ' + '${parameter.name}$defaultSuffix', + ); + } + + _addPublicRequestBodyParameters(parameters, operation.requestBody); + if (config.generation.api.cancelTokenSupport) { + parameters.add('CancelToken? cancelToken'); + } + return _renderNamedParameterList(parameters); + } + + void _addPublicRequestBodyParameters( + List parameters, + ApiRequestBody? requestBody, + ) { + if (requestBody == null) { + return; + } + switch (requestBody.kind) { + case ApiRequestBodyKind.multipart: + for (final field in requestBody.multipartFields) { + parameters.add(_plainBodyFieldParameter(field)); + } + break; + case ApiRequestBodyKind.formUrlEncoded: + for (final field in requestBody.formFields) { + parameters.add(_plainBodyFieldParameter(field)); + } + break; + case ApiRequestBodyKind.json: + case ApiRequestBodyKind.binary: + final requiredPrefix = requestBody.required ? 'required ' : ''; + parameters.add( + '$requiredPrefix${requestBody.type?.render() ?? 'Object?'} ' + '${requestBody.parameterName}', + ); + break; + } + } + + String _plainBodyFieldParameter(ApiMultipartField field) { + final requiredPrefix = field.required ? 'required ' : ''; + return '$requiredPrefix${field.type.render()} ${field.name}'; + } + + List _renderAdapterInvocationArguments(ApiOperation operation) { + final arguments = []; + final cookieParameters = []; + final reservedParameters = []; + + for (final parameter in operation.parameters) { + if (parameter.location == 'cookie') { + cookieParameters.add(parameter); + continue; + } + if (parameter.allowReserved) { + reservedParameters.add(parameter); + continue; + } + + var expression = parameter.name; + if (_parameterRequiresSerialization(parameter)) { + if (parameter.contentType != null) { + expression = + '_serializeOpenApiContentParameter(' + '${parameter.name}, ${_dartLiteral(parameter.contentType)})'; + } else { + expression = + '_serializeOpenApiParameter(' + 'name: ${_dartLiteral(parameter.wireName)}, ' + 'value: ${parameter.name}, ' + 'location: ${_dartLiteral(parameter.location)}, ' + 'style: ${_dartLiteral(parameter.style.name)}, ' + 'explode: ${parameter.explode})'; + } + if (parameter.required) { + expression = '$expression!'; + } + } + arguments.add('${parameter.name}: $expression'); + } + + if (cookieParameters.isNotEmpty) { + final records = cookieParameters + .map( + (parameter) => + '(name: ${_dartLiteral(parameter.wireName)}, ' + 'value: ${parameter.name}, explode: ${parameter.explode})', + ) + .join(', '); + arguments.add( + 'cookieHeader: _openApiCookieHeader(' + '<({String name, Object? value, bool explode})>[$records])', + ); + } + + if (reservedParameters.isNotEmpty) { + final records = reservedParameters + .map( + (parameter) => + '(name: ${_dartLiteral(parameter.wireName)}, ' + 'value: ${parameter.name}, ' + 'style: ${_dartLiteral(parameter.style.name)}, ' + 'explode: ${parameter.explode})', + ) + .join(', '); + arguments.add( + 'reservedQuery: _openApiReservedQuery(' + '<({String name, Object? value, String style, bool explode})>' + '[$records])', + ); + } + + final requestBody = operation.requestBody; + if (requestBody != null) { + switch (requestBody.kind) { + case ApiRequestBodyKind.multipart: + for (final field in requestBody.multipartFields) { + arguments.add('${field.name}: ${field.name}'); + } + break; + case ApiRequestBodyKind.formUrlEncoded: + for (final field in requestBody.formFields) { + arguments.add('${field.name}: _openApiJsonValue(${field.name})'); + } + break; + case ApiRequestBodyKind.json: + case ApiRequestBodyKind.binary: + arguments.add( + '${requestBody.parameterName}: ${requestBody.parameterName}', + ); + break; + } + } + if (config.generation.api.cancelTokenSupport) { + arguments.add('cancelToken: cancelToken'); + } + return arguments; + } + + String _renderRawApiParameters(ApiOperation operation) { + final parameters = []; + final cookieParameters = operation.parameters + .where((parameter) => parameter.location == 'cookie') + .toList(growable: false); + final hasReservedParameter = operation.parameters.any( + (parameter) => parameter.allowReserved, + ); + + for (final parameter in operation.parameters) { + if (parameter.location == 'cookie' || parameter.allowReserved) { + continue; + } + final annotation = _rawParameterAnnotation(parameter); + final type = _parameterRequiresSerialization(parameter) + ? (parameter.required ? 'String' : 'String?') + : parameter.type.render(); + parameters.add('$annotation required $type ${parameter.name}'); + } + + if (cookieParameters.isNotEmpty) { + parameters.add("@Header('Cookie') required String? cookieHeader"); + } + if (hasReservedParameter) { + parameters.add("@Path('reservedQuery') required String reservedQuery"); + } + + final requestBody = operation.requestBody; + if (requestBody != null) { + switch (requestBody.kind) { + case ApiRequestBodyKind.multipart: + for (final field in requestBody.multipartFields) { + parameters.add( + '${_multipartAnnotation(field)} required ' + '${field.type.render()} ${field.name}', + ); + } + break; + case ApiRequestBodyKind.formUrlEncoded: + for (final field in requestBody.formFields) { + parameters.add( + "@Field('${escapeDartString(field.wireName)}') " + 'required Object? ${field.name}', + ); + } + break; + case ApiRequestBodyKind.json: + case ApiRequestBodyKind.binary: + final bodyAnnotation = requestBody.required + ? '@Body()' + : '@Body(nullToAbsent: true)'; + parameters.add( + '$bodyAnnotation required ' + '${requestBody.type?.render() ?? 'Object?'} ' + '${requestBody.parameterName}', + ); + break; + } + } + if (config.generation.api.cancelTokenSupport) { + parameters.add('@CancelRequest() required CancelToken? cancelToken'); + } + return _renderNamedParameterList(parameters); + } + + String _rawParameterAnnotation(ApiParameter parameter) { + return switch (parameter.location) { + 'path' => "@Path('${escapeDartString(parameter.wireName)}')", + 'header' => "@Header('${escapeDartString(parameter.wireName)}')", + 'query' + when parameter.style == ApiParameterStyle.form && + parameter.explode && + _isObjectParameterType(parameter.type) => + '@Queries()', + _ => "@Query('${escapeDartString(parameter.wireName)}')", + }; + } + + String _renderNamedParameterList(List parameters) { + if (parameters.isEmpty) { + return ''; + } + final buffer = StringBuffer()..writeln('{'); + for (final parameter in parameters) { + buffer.writeln(' $parameter,'); + } + buffer.write(' }'); return buffer.toString(); } @@ -538,7 +1496,15 @@ class DartGenerator { final annotation = switch (parameter.location) { 'path' => "@Path('${escapeDartString(parameter.wireName)}')", 'header' => "@Header('${escapeDartString(parameter.wireName)}')", - _ => "@Query('${escapeDartString(parameter.wireName)}')", + 'cookie' => "@Header('Cookie')", + 'query' + when parameter.style == ApiParameterStyle.form && + parameter.explode && + _isObjectParameterType(parameter.type) => + '@Queries()', + _ => + "@Query('${escapeDartString(parameter.wireName)}'" + "${parameter.allowReserved ? ', encoded: true' : ''})", }; final defaultValue = _defaultExpressionForType( parameter.defaultValue, @@ -553,16 +1519,29 @@ class DartGenerator { final requestBody = operation.requestBody; if (requestBody != null) { - if (requestBody.kind == ApiRequestBodyKind.multipart) { - for (final field in requestBody.multipartFields) { - parameters.add(_renderMultipartParameter(field)); - } - } else { - final requiredPrefix = requestBody.required ? 'required ' : ''; - final bodyType = requestBody.type?.render() ?? 'Object'; - parameters.add( - '@Body() $requiredPrefix$bodyType ${requestBody.parameterName}', - ); + switch (requestBody.kind) { + case ApiRequestBodyKind.multipart: + for (final field in requestBody.multipartFields) { + parameters.add(_renderMultipartParameter(field)); + } + break; + case ApiRequestBodyKind.formUrlEncoded: + for (final field in requestBody.formFields) { + parameters.add(_renderFormParameter(field)); + } + break; + case ApiRequestBodyKind.json: + case ApiRequestBodyKind.binary: + final requiredPrefix = requestBody.required ? 'required ' : ''; + final bodyType = requestBody.type?.render() ?? 'Object'; + final bodyAnnotation = requestBody.required + ? '@Body()' + : '@Body(nullToAbsent: true)'; + parameters.add( + '$bodyAnnotation $requiredPrefix$bodyType ' + '${requestBody.parameterName}', + ); + break; } } @@ -583,15 +1562,82 @@ class DartGenerator { } String _renderMultipartParameter(ApiMultipartField field) { - final contentType = field.contentType; - final annotation = contentType == null - ? "@Part(name: '${escapeDartString(field.wireName)}')" - : "@Part(name: '${escapeDartString(field.wireName)}', contentType: '${escapeDartString(contentType)}')"; + final annotation = _multipartAnnotation(field); final requiredPrefix = field.required ? 'required ' : ''; return '$annotation $requiredPrefix${field.type.render()} ${field.name}'; } + String _multipartAnnotation(ApiMultipartField field) { + final contentType = field.contentType; + return contentType == null + ? "@Part(name: '${escapeDartString(field.wireName)}')" + : "@Part(name: '${escapeDartString(field.wireName)}', " + "contentType: '${escapeDartString(contentType)}')"; + } + + String _renderFormParameter(ApiMultipartField field) { + final annotation = "@Field('${escapeDartString(field.wireName)}')"; + final requiredPrefix = field.required ? 'required ' : ''; + return '$annotation $requiredPrefix${field.type.render()} ${field.name}'; + } + + Iterable _operationDartTypes(ApiOperation operation) sync* { + yield operation.returnType; + for (final parameter in operation.parameters) { + yield parameter.type; + } + final requestBody = operation.requestBody; + if (requestBody?.type != null) { + yield requestBody!.type!; + } + for (final field + in requestBody?.multipartFields ?? const []) { + yield field.type; + } + for (final field + in requestBody?.formFields ?? const []) { + yield field.type; + } + } + + void _addExternalTypeImports(Set imports, Iterable types) { + final names = {}; + for (final type in types) { + names.addAll(type.collectNamedNames()); + } + for (final name in names) { + final import = _externalImportForTypeName(name); + if (import != null && import.isNotEmpty) { + imports.add("import '${escapeDartString(import)}';"); + } + } + } + + String? _externalImportForTypeName(String typeName) { + final explicit = config.generation.mappings.imports[typeName]; + if (explicit != null) { + return explicit; + } + final mappings = [ + ...config.generation.mappings.schemas.values, + ...config.generation.mappings.types.values, + ]; + final typePattern = RegExp( + '(^|[^A-Za-z0-9_])${RegExp.escape(typeName)}([^A-Za-z0-9_]|' + r'$)', + ); + for (final mapping in mappings) { + if (mapping.import != null && typePattern.hasMatch(mapping.dartType)) { + return mapping.import; + } + } + return null; + } + bool _operationUsesNamedType(ApiOperation operation, String typeName) { + if (_typeUsesNamed(operation.returnType, typeName)) { + return true; + } for (final parameter in operation.parameters) { if (_typeUsesNamed(parameter.type, typeName)) { return true; @@ -613,10 +1659,338 @@ class DartGenerator { return true; } } + for (final field in requestBody.formFields) { + if (_typeUsesNamed(field.type, typeName)) { + return true; + } + } return false; } + bool _isObjectParameterType(DartType type) { + if (type.kind == TypeKind.map || type.kind == TypeKind.objectType) { + return true; + } + if (type.kind != TypeKind.named) { + return false; + } + final model = _modelIndex[type.name]; + return model != null && model.kind == ModelKind.object; + } + + bool _isEnumParameterType(DartType type) { + if (type.kind != TypeKind.named) { + return false; + } + return _modelIndex[type.name]?.kind == ModelKind.enumType; + } + + String _renderTransportSerializationHelpers() { + return r''' +Object? _openApiJsonValue(Object? value) { + if (value == null || + value is String || + value is num || + value is bool) { + return value; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is Iterable) { + return value.map(_openApiJsonValue).toList(growable: false); + } + if (value is Map) { + return value.map( + (key, item) => MapEntry(key.toString(), _openApiJsonValue(item)), + ); + } + try { + return (value as dynamic).toJson() as Object?; + } on NoSuchMethodError { + return value.toString(); + } +} + +String _openApiScalar(Object? value) { + final encoded = _openApiJsonValue(value); + if (encoded == null) { + return ''; + } + if (encoded is Map || encoded is Iterable) { + throw ArgumentError.value(value, 'value', 'Expected a scalar parameter.'); + } + return encoded.toString(); +} + +String? _serializeOpenApiContentParameter( + Object? value, + String contentType, +) { + if (value == null) { + return null; + } + final mediaType = contentType.split(';').first.trim().toLowerCase(); + if (mediaType == 'application/json' || mediaType.endsWith('+json')) { + return jsonEncode(_openApiJsonValue(value)); + } + if (mediaType.startsWith('text/')) { + return _openApiScalar(value); + } + throw ArgumentError.value( + contentType, + 'contentType', + 'Unsupported OpenAPI parameter content type.', + ); +} + +String? _serializeOpenApiParameter({ + required String name, + required Object? value, + required String location, + required String style, + required bool explode, +}) { + if (value == null) { + return null; + } + final encoded = _openApiJsonValue(value); + final list = encoded is Iterable + ? encoded.map(_openApiScalar).toList(growable: false) + : null; + final map = encoded is Map + ? encoded.map( + (key, item) => MapEntry(key.toString(), _openApiScalar(item)), + ) + : null; + final scalar = list == null && map == null ? _openApiScalar(encoded) : null; + + if (location == 'path') { + switch (style) { + case 'matrix': + if (list != null) { + return explode + ? list.map((item) => ';$name=$item').join() + : ';$name=${list.join(',')}'; + } + if (map != null) { + return explode + ? map.entries + .map((entry) => ';${entry.key}=${entry.value}') + .join() + : ';$name=${map.entries.expand( + (entry) => [entry.key, entry.value], + ).join(',')}'; + } + return ';$name=$scalar'; + case 'label': + if (list != null) { + return '.${list.join(explode ? '.' : ',')}'; + } + if (map != null) { + final values = explode + ? map.entries.map((entry) => '${entry.key}=${entry.value}') + : map.entries.expand( + (entry) => [entry.key, entry.value], + ); + return '.${values.join(explode ? '.' : ',')}'; + } + return '.$scalar'; + case 'simple': + if (list != null) { + return list.join(','); + } + if (map != null) { + return explode + ? map.entries + .map((entry) => '${entry.key}=${entry.value}') + .join(',') + : map.entries.expand( + (entry) => [entry.key, entry.value], + ).join(','); + } + return scalar; + } + } + + if (location == 'header') { + if (list != null) { + return list.join(','); + } + if (map != null) { + return explode + ? map.entries + .map((entry) => '${entry.key}=${entry.value}') + .join(',') + : map.entries.expand( + (entry) => [entry.key, entry.value], + ).join(','); + } + return scalar; + } + + if (location == 'query') { + if (list != null) { + return list.join( + switch (style) { + 'spaceDelimited' => ' ', + 'pipeDelimited' => '|', + _ => ',', + }, + ); + } + if (map != null) { + return map.entries.expand( + (entry) => [entry.key, entry.value], + ).join(','); + } + return scalar; + } + + throw ArgumentError.value(location, 'location', 'Unsupported location.'); +} + +String? _openApiCookieHeader( + List<({String name, Object? value, bool explode})> parameters, +) { + final cookies = []; + for (final parameter in parameters) { + if (parameter.value == null) { + continue; + } + final encoded = _openApiJsonValue(parameter.value); + if (encoded is Iterable) { + final values = encoded + .map(_openApiScalar) + .map(Uri.encodeQueryComponent) + .toList(growable: false); + cookies.add( + parameter.explode + ? values + .map( + (value) => + '${Uri.encodeQueryComponent(parameter.name)}=$value', + ) + .join('&') + : '${Uri.encodeQueryComponent(parameter.name)}=${values.join(',')}', + ); + continue; + } + if (encoded is Map) { + final values = encoded.entries + .map( + (entry) => MapEntry( + Uri.encodeQueryComponent(entry.key.toString()), + Uri.encodeQueryComponent(_openApiScalar(entry.value)), + ), + ) + .toList(growable: false); + cookies.add( + parameter.explode + ? values.map((entry) => '${entry.key}=${entry.value}').join('&') + : '${Uri.encodeQueryComponent(parameter.name)}=${values.expand( + (entry) => [entry.key, entry.value], + ).join(',')}', + ); + continue; + } + cookies.add( + '${Uri.encodeQueryComponent(parameter.name)}=' + '${Uri.encodeQueryComponent(_openApiScalar(encoded))}', + ); + } + return cookies.isEmpty ? null : cookies.join('; '); +} + +String _openApiReservedQuery( + List<({String name, Object? value, String style, bool explode})> parameters, +) { + final pairs = []; + for (final parameter in parameters) { + if (parameter.value == null) { + continue; + } + final name = Uri.encodeQueryComponent(parameter.name); + final encoded = _openApiJsonValue(parameter.value); + if (encoded is Iterable) { + final values = encoded + .map(_openApiScalar) + .map(_openApiAllowReserved) + .toList(growable: false); + if (parameter.explode && parameter.style == 'form') { + pairs.addAll(values.map((value) => '$name=$value')); + } else { + final separator = switch (parameter.style) { + 'spaceDelimited' => '%20', + 'pipeDelimited' => '|', + _ => ',', + }; + pairs.add('$name=${values.join(separator)}'); + } + continue; + } + if (encoded is Map) { + final entries = encoded.entries + .map( + (entry) => MapEntry( + Uri.encodeQueryComponent(entry.key.toString()), + _openApiAllowReserved(_openApiScalar(entry.value)), + ), + ) + .toList(growable: false); + if (parameter.style == 'deepObject') { + pairs.addAll( + entries.map( + (entry) => '$name[${entry.key}]=${entry.value}', + ), + ); + } else if (parameter.explode) { + pairs.addAll(entries.map((entry) => '${entry.key}=${entry.value}')); + } else { + pairs.add( + '$name=${entries.expand( + (entry) => [entry.key, entry.value], + ).join(',')}', + ); + } + continue; + } + pairs.add('$name=${_openApiAllowReserved(_openApiScalar(encoded))}'); + } + return pairs.isEmpty ? '' : '?${pairs.join('&')}'; +} + +String _openApiAllowReserved(String value) { + var encoded = Uri.encodeQueryComponent(value); + const reserved = { + '%3A': ':', + '%2F': '/', + '%3F': '?', + '%23': '#', + '%5B': '[', + '%5D': ']', + '%40': '@', + '%21': '!', + '%24': r'$', + '%26': '&', + '%27': "'", + '%28': '(', + '%29': ')', + '%2A': '*', + '%2B': '+', + '%2C': ',', + '%3B': ';', + '%3D': '=', + }; + for (final entry in reserved.entries) { + encoded = encoded.replaceAll(entry.key, entry.value); + } + return encoded; +} +'''; + } + bool _typeUsesNamed(DartType type, String typeName) { if (type.kind == TypeKind.named && type.name == typeName) { return true; @@ -629,9 +2003,32 @@ class DartGenerator { return false; } + ({ModelSpec model, EnumValue value})? _enumFallbackForType(DartType type) { + if (type.kind == TypeKind.named) { + final model = _modelIndex[type.name]; + if (model != null && model.kind == ModelKind.enumType) { + for (final value in model.enumValues) { + if (value.isFallback) { + return (model: model, value: value); + } + } + } + } + for (final argument in type.arguments) { + final fallback = _enumFallbackForType(argument); + if (fallback != null) { + return fallback; + } + } + return null; + } + String _renderClientFile({ required List versionImports, required List clientFields, + required String? defaultBaseUrl, + required Map securitySchemes, + required List operations, }) { final clientClassName = config.generation.api.client.className; final buffer = StringBuffer() @@ -647,13 +2044,27 @@ class DartGenerator { buffer ..writeln() - ..writeln('class $clientClassName {') - ..writeln(' $clientClassName(this._dio, {this.baseUrl});') + ..writeln('class $clientClassName {'); + if (defaultBaseUrl == null) { + buffer.writeln(' $clientClassName(this._dio, {this.baseUrl});'); + } else { + buffer.writeln( + ' $clientClassName(this._dio, {String? baseUrl}) ' + ": baseUrl = baseUrl ?? '${escapeDartString(defaultBaseUrl)}';", + ); + } + buffer ..writeln() ..writeln(' final Dio _dio;') ..writeln(' final String? baseUrl;') ..writeln(); + _writeSecurityMetadata( + buffer, + securitySchemes: securitySchemes, + operations: operations, + ); + final sortedFields = clientFields.toList()..sort(); for (final field in sortedFields) { buffer.writeln(field); @@ -663,6 +2074,74 @@ class DartGenerator { return buffer.toString(); } + void _writeSecurityMetadata( + StringBuffer buffer, { + required Map securitySchemes, + required List operations, + }) { + buffer.writeln( + ' /// OpenAPI security schemes for Dio interceptors or calling code.', + ); + buffer.writeln( + ' static const securitySchemes = ' + '>{', + ); + final schemeNames = securitySchemes.keys.toList()..sort(); + for (final name in schemeNames) { + final scheme = securitySchemes[name]!; + final values = { + 'type': _dartLiteral(scheme.type.name), + if (scheme.scheme != null) 'scheme': _dartLiteral(scheme.scheme), + if (scheme.bearerFormat != null) + 'bearerFormat': _dartLiteral(scheme.bearerFormat), + if (scheme.parameterName != null) + 'name': _dartLiteral(scheme.parameterName), + if (scheme.location != null) 'in': _dartLiteral(scheme.location), + if (scheme.openIdConnectUrl != null) + 'openIdConnectUrl': _dartLiteral(scheme.openIdConnectUrl), + if (scheme.oauthScopes.isNotEmpty) + 'scopes': + '[${scheme.oauthScopes.map(_dartLiteral).join(', ')}]', + }; + buffer.writeln( + ' ${_dartLiteral(name)}: {' + '${values.entries.map((entry) => '${_dartLiteral(entry.key)}: ${entry.value}').join(', ')}' + '},', + ); + } + buffer + ..writeln(' };') + ..writeln() + ..writeln( + ' /// Per-operation security: the outer list is OR; map entries are AND.', + ) + ..writeln( + ' static const operationSecurity = ' + '>>>{', + ); + final sortedOperations = operations.toList() + ..sort((left, right) => left.methodName.compareTo(right.methodName)); + for (final operation in sortedOperations) { + final requirements = operation.security + .map((requirement) { + final schemes = requirement.schemes.entries.map( + (entry) => + '${_dartLiteral(entry.key)}: ' + '[${entry.value.map(_dartLiteral).join(', ')}]', + ); + return '>{${schemes.join(', ')}}'; + }) + .join(', '); + buffer.writeln( + ' ${_dartLiteral('${operation.method} ${operation.path}')}: ' + '>>[$requirements],', + ); + } + buffer + ..writeln(' };') + ..writeln(); + } + String? _defaultExpressionFor(ModelField field) { final explicitDefault = _defaultExpressionForType( field.defaultValue, @@ -745,6 +2224,15 @@ class DartGenerator { .toList(growable: false); } + String _renderConstraints(Map constraints) { + return constraints.entries + .map( + (entry) => + '${entry.key}=${entry.value.toString().replaceAll(RegExp(r'\s+'), ' ')}', + ) + .join(', '); + } + String _modelCategoryDirectory(ModelCategory category) { return switch (category) { ModelCategory.request => 'request', @@ -763,6 +2251,16 @@ class DartGenerator { return toSnakeCase(candidate); } + String _joinServerAndOperationPath(String serverUrl, String operationPath) { + final left = serverUrl.endsWith('/') + ? serverUrl.substring(0, serverUrl.length - 1) + : serverUrl; + final right = operationPath.startsWith('/') + ? operationPath + : '/$operationPath'; + return '$left$right'; + } + String _apiFileName(String groupKey, String version) { if (config.output.splitByTags) { return '${toSnakeCase(groupKey)}_api.dart'; diff --git a/lib/src/generator/lifecycle.dart b/lib/src/generator/lifecycle.dart new file mode 100644 index 0000000..64dfd3c --- /dev/null +++ b/lib/src/generator/lifecycle.dart @@ -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 [], + }); + + /// Complete set of files owned by the current generated bundle. + final List generatedFiles; + + /// Files written or updated by this lifecycle pass. + final List writtenFiles; + + /// Stale generated files deleted by this lifecycle pass. + final List deletedFiles; + + /// Files that a dry run would write or update. + final List wouldWriteFiles; + + /// Stale files that a dry run would delete. + final List 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 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 apply({ + required GeneratedBundle bundle, + required Map sourceDocument, + required List sources, + required List dependencies, + required bool dryRun, + required Future Function(List 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([ + for (final file in files) + { + '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 [], + deletedFiles: const [], + wouldWriteFiles: const [], + wouldDeleteFiles: const [], + skippedUnchanged: true, + manifestPath: manifestFile.path, + diagnostics: diagnostics.diagnostics, + ); + } + + final wouldWrite = [ + for (final file in files) + if (!await _fileHasContent(file.path, file.content)) file.path, + ]; + final wouldDelete = []; + 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 [], + deletedFiles: const [], + 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 [], + deletedFiles: const [], + wouldWriteFiles: const [], + wouldDeleteFiles: const [], + 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 = []; + 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 = []; + 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.unmodifiable(writtenFiles)); + + final manifestEntries = >[]; + 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({ + 'path': _portablePath(file.path), + 'sha256': await _hashFile(ioFile), + }); + } + diagnostics.throwIfErrors( + message: 'Post-generation file validation failed.', + ); + + final manifest = { + 'schemaVersion': koiSwaggerParserManifestSchemaVersion, + 'generator': { + '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': >[ + for (final entry in retainedStaleEntries) + { + '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.unmodifiable(writtenFiles), + deletedFiles: List.unmodifiable(deletedFiles), + wouldWriteFiles: const [], + wouldDeleteFiles: const [], + 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 _normalizeGeneratedFiles( + List input, + KoiDiagnosticBag diagnostics, + ) { + final byPath = {}; + 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?> _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(); + 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? manifest) { + if (manifest == null) { + return const <_ManifestFileEntry>[]; + } + final entries = <_ManifestFileEntry>[]; + for (final key in const ['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? 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 _manifestFilesMatch( + Map? manifest, + List files, + ) async { + if (manifest == null) { + return false; + } + final rawEntries = manifest['generatedFiles']; + if (rawEntries is! List || rawEntries.length != files.length) { + return false; + } + final entries = _manifestEntries({ + '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 _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 [config.output.apiDir, config.output.modelsDir].any((root) { + final absoluteRoot = _absoluteConfiguredPath(root); + return p.equals(normalized, absoluteRoot) || + p.isWithin(absoluteRoot, normalized); + }); + } + + Future _deleteEmptyGeneratedParents(String filePath) async { + final roots = [ + _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 _configFingerprint() { + final mappings = config.generation.mappings; + return { + 'output': { + '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': { + 'preset': config.generation.preset.name, + 'api': { + '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': { + 'enabled': config.generation.models.enabled, + 'classPrefix': config.generation.models.classPrefix, + 'defaultNonNullableListToEmpty': + config.generation.models.defaultNonNullableListToEmpty, + 'enumUnknownFallback': config.generation.models.enumUnknownFallback, + 'enumMappings': { + for (final enumEntry + in config.generation.models.enumMappings.entries) + enumEntry.key: { + for (final valueEntry in enumEntry.value.entries) + '${valueEntry.key.runtimeType}:${valueEntry.key}': + { + 'name': valueEntry.value.name, + 'description': valueEntry.value.description, + }, + }, + }, + 'jsonSerializable': { + 'checked': config.generation.models.jsonSerializable.checked, + 'explicitToJson': + config.generation.models.jsonSerializable.explicitToJson, + 'includeIfNull': + config.generation.models.jsonSerializable.includeIfNull, + }, + }, + 'mappings': { + 'models': mappings.models, + 'properties': mappings.properties, + 'parameters': mappings.parameters, + 'operations': mappings.operations, + 'schemas': { + for (final entry in mappings.schemas.entries) + entry.key: { + 'type': entry.value.dartType, + 'import': entry.value.import, + }, + }, + 'types': { + for (final entry in mappings.types.entries) + entry.key: { + 'type': entry.value.dartType, + 'import': entry.value.import, + }, + }, + 'imports': mappings.imports, + }, + }, + 'formatting': { + '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 _fileHasContent(String path, String content) async { + final file = File(path); + return await file.exists() && await file.readAsString() == content; +} + +Future _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 _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 { + for (final key in keys) key: _canonicalize(value[key]), + }; + } + if (value is Iterable) { + return [for (final item in value) _canonicalize(item)]; + } + return value; +} diff --git a/lib/src/ir/ir.dart b/lib/src/ir/ir.dart index 828b847..db3ff85 100644 --- a/lib/src/ir/ir.dart +++ b/lib/src/ir/ir.dart @@ -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 { + /// Creates a named type such as `User` or `Future`. const DartType.named( this.name, { this.nullable = false, this.arguments = const [], }) : kind = TypeKind.named; + /// Creates a `List` whose element type is [itemType]. DartType.list(DartType itemType, {this.nullable = false}) : kind = TypeKind.list, name = 'List', arguments = [itemType]; + /// Creates a string-keyed `Map` whose value type is [valueType]. DartType.map(DartType valueType, {this.nullable = false}) : kind = TypeKind.map, name = 'Map', arguments = [const DartType.named('String'), valueType]; + /// Creates the Dart `dynamic` type. const DartType.dynamicType({this.nullable = false}) : kind = TypeKind.dynamicType, name = 'dynamic', arguments = const []; + /// Creates the Dart `Object` type. const DartType.objectType({this.nullable = false}) : kind = TypeKind.objectType, name = 'Object', arguments = const []; + /// Creates the non-nullable Dart `void` type. const DartType.voidType() : kind = TypeKind.voidType, name = 'void', arguments = const [], nullable = false; + /// Structural category of this type. final TypeKind kind; + + /// Base Dart type name before generic arguments and nullability. final String name; + + /// Generic type arguments in source order. final List arguments; + + /// Whether rendered values may be `null`. final bool nullable; + /// Returns an equivalent nullable type when nullability is meaningful. DartType asNullable() { if (nullable || kind == TypeKind.voidType) { 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; + /// Renders this type as valid Dart source. String render() { final suffix = nullable ? '?' : ''; return switch (kind) { @@ -83,6 +245,7 @@ class DartType { }; } + /// Returns named types shared with [candidates], including nested arguments. Set collectReferencedNames(Set candidates) { final result = {}; if (kind == TypeKind.named && candidates.contains(name)) { @@ -93,48 +256,158 @@ class DartType { } return result; } + + /// Returns every named type used by this type and its generic arguments. + Set collectNamedNames() { + final result = {}; + 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 { + /// Creates a model field parsed from an OpenAPI property. const ModelField({ required this.name, required this.wireName, required this.type, required this.required, + this.schemaNullable = false, + this.readOnly = false, + this.writeOnly = false, + this.constraints = const {}, this.description, this.defaultValue, }); + /// Sanitized Dart field name. final String name; + + /// Serialized property name from the OpenAPI document. final String wireName; + + /// Dart type used by generated source. final DartType type; + + /// Whether the containing schema lists this property as 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 constraints; + + /// Human-readable OpenAPI property description. final String? description; + + /// Default value declared by the source schema. 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 { - 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; + + /// Value serialized to and deserialized from the wire format. 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 { + /// Creates a union branch. const UnionCase({ required this.caseName, required this.wireValue, required this.type, required this.originalName, + this.matchKind = UnionMatchKind.object, + this.requiredProperties = const [], + this.allowedValues = const [], }); + /// Sanitized case name used by the generated wrapper. final String caseName; + + /// Discriminator value selecting this branch when applicable. final String wireValue; + + /// Dart payload type represented by this branch. final DartType type; + + /// Original component or inline schema name. final String originalName; + + /// Runtime value shape used for structural matching. + final UnionMatchKind matchKind; + + /// Object properties required for this branch to match. + final List requiredProperties; + + /// Literal values accepted by this branch. + final List 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 { + /// Creates a model specification with mutable usage classification. ModelSpec({ required this.name, required this.originalName, @@ -143,22 +416,48 @@ class ModelSpec { List? enumValues, List? unionCases, this.discriminatorKey, + this.unionMode = UnionMode.discriminated, this.description, + this.additionalPropertiesType, }) : fields = fields ?? [], enumValues = enumValues ?? [], unionCases = unionCases ?? [], usages = {}; + /// Sanitized generated Dart type name. final String name; + + /// Original component or inline schema name. final String originalName; + + /// Structural form of this model. final ModelKind kind; + + /// Object properties when [kind] is [ModelKind.object]. final List fields; + + /// Enum members when [kind] is [ModelKind.enumType]. final List enumValues; + + /// Union branches when [kind] is [ModelKind.unionType]. final List unionCases; + + /// Serialized property used to select a discriminated union branch. final String? discriminatorKey; + + /// Branch-selection semantics for a union model. + final UnionMode unionMode; + + /// Human-readable schema description. final String? description; + + /// Value type for undeclared object properties, when supported. + DartType? additionalPropertiesType; + + /// Request and response directions that reference this model. final Set usages; + /// Output group derived from [kind] and [usages]. ModelCategory get category { if (kind == ModelKind.enumType) { return ModelCategory.enums; @@ -178,27 +477,77 @@ class ModelSpec { } } +/// A path, query, header, or cookie parameter for a generated operation. class ApiParameter { + /// Creates an operation parameter with resolved serialization semantics. const ApiParameter({ required this.name, required this.wireName, required this.location, required this.type, required this.required, + required this.style, + required this.explode, + this.allowReserved = false, + this.contentType, this.description, this.defaultValue, }); + /// Sanitized Dart argument name. final String name; + + /// Parameter name used on the wire. final String wireName; + + /// OpenAPI parameter location such as `path` or `query`. final String location; + + /// Generated Dart argument type. final DartType type; + + /// Whether callers must provide the parameter. 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; + + /// Default value declared by the parameter schema. 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 { + /// Creates a structured request-body field. const ApiMultipartField({ required this.name, required this.wireName, @@ -208,15 +557,28 @@ class ApiMultipartField { this.contentType, }); + /// Sanitized Dart argument or local variable name. final String name; + + /// Field name used on the wire. final String wireName; + + /// Generated Dart value type. final DartType type; + + /// Whether callers must provide this field. final bool required; + + /// Human-readable schema description. final String? description; + + /// Optional per-field media type. final String? contentType; } +/// Parsed request body for a generated operation. class ApiRequestBody { + /// Creates a request body with its selected wire encoding. const ApiRequestBody({ required this.kind, required this.required, @@ -224,17 +586,173 @@ class ApiRequestBody { this.type, this.contentType, this.multipartFields = const [], + this.formFields = const [], }); + /// Wire encoding used by the request body. final ApiRequestBodyKind kind; + + /// Dart payload type for non-structured request bodies. final DartType? type; + + /// Whether callers must supply the body. final bool required; + + /// Sanitized Dart parameter name. final String parameterName; + + /// Selected request media type. final String? contentType; + + /// Fields emitted for a multipart request. final List multipartFields; + + /// Fields emitted for a form-url-encoded request. + final List 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 [], + }); + + /// 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 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 {}, + }); + + /// 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 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 [], + }); + + /// 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 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> 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 { + /// Creates a parsed API operation. const ApiOperation({ required this.path, required this.method, @@ -243,57 +761,158 @@ class ApiOperation { required this.methodName, required this.returnType, required this.parameters, + this.responses = const [], + this.primaryResponseStatusCode, + this.serverUrl, + this.security = const [], this.requestBody, this.summary, this.description, this.deprecated = false, }); + /// OpenAPI path template. final String path; + + /// Lowercase HTTP method. final String method; + + /// Selected OpenAPI tag used for grouping. final String tag; + + /// API version used in generated output paths. final String version; + + /// Sanitized generated Dart method name. final String methodName; + + /// Generated method return type. final DartType returnType; + + /// Path, query, header, and cookie parameters. final List parameters; + + /// All declared operation responses. + final List 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 security; + + /// Parsed request body, when present. final ApiRequestBody? requestBody; + + /// Short OpenAPI operation summary. final String? summary; + + /// Detailed OpenAPI operation description. final String? description; + + /// Whether the OpenAPI operation is 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 { + /// Creates a parsed document ready for Dart rendering. const ParsedApiDocument({ required this.models, required this.operations, required this.sourceDocuments, + this.servers = const [], + this.securitySchemes = const {}, + this.globalSecurity = const [], + this.dependencyDocuments = const [], + this.diagnostics = const [], }); + /// Generated models keyed by sanitized Dart type name. final Map models; + + /// Selected operations in deterministic output order. final List operations; + + /// Canonical root OpenAPI documents. final List sourceDocuments; + + /// Root-level resolved servers. + final List servers; + + /// Parsed security schemes keyed by component name. + final Map securitySchemes; + + /// Root-level alternative security requirements. + final List globalSecurity; + + /// External documents loaded through references. + final List dependencyDocuments; + + /// Informational and warning diagnostics retained during parsing. + final List diagnostics; } +/// One rendered source file before lifecycle writes are applied. class GeneratedFile { + /// Creates a rendered file at [path] with [content]. const GeneratedFile({required this.path, required this.content}); + /// Absolute destination path. final String path; + + /// Complete generated Dart source. final String content; } +/// Rendered files and aggregate counts from one generation pass. class GeneratedBundle { + /// Creates a generated bundle. const GeneratedBundle({ required this.files, required this.operationsCount, required this.modelsCount, }); + /// Rendered files ready for lifecycle processing. final List files; + + /// Number of operations included in this bundle. final int operationsCount; + + /// Number of models included in this bundle. final int modelsCount; } +/// Summarizes the output and diagnostics of one generation run. class GenerationResult { + /// Creates a generation result. const GenerationResult({ required this.configPath, required this.sourceDocuments, @@ -301,12 +920,55 @@ class GenerationResult { required this.operationsCount, required this.modelsCount, required this.dryRun, + this.generatedFiles = const [], + this.deletedFiles = const [], + this.wouldWriteFiles = const [], + this.wouldDeleteFiles = const [], + this.skippedUnchanged = false, + this.manifestPath, + this.dependencyDocuments = const [], + this.diagnostics = const [], }); + /// The resolved configuration file path, or `null` when defaults were used. final String? configPath; + + /// Root OpenAPI documents loaded for this run. final List sourceDocuments; + + /// All files represented by the generated output bundle. + final List generatedFiles; + + /// Files written or updated during this run. final List writtenFiles; + + /// Stale generated files deleted during this run. + final List deletedFiles; + + /// Files that would be written when [dryRun] is `true`. + final List wouldWriteFiles; + + /// Files that would be deleted when [dryRun] is `true`. + final List 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; + + /// Number of OpenAPI models included in generated output. final int modelsCount; + + /// Whether this run only calculated changes without modifying files. final bool dryRun; + + /// Referenced OpenAPI documents loaded in addition to [sourceDocuments]. + final List dependencyDocuments; + + /// Informational and warning diagnostics retained for this run. + final List diagnostics; } diff --git a/lib/src/koi_swagger_parser.dart b/lib/src/koi_swagger_parser.dart index 98f5d2a..25c868d 100644 --- a/lib/src/koi_swagger_parser.dart +++ b/lib/src/koi_swagger_parser.dart @@ -1,32 +1,55 @@ import 'dart:io'; import 'config/config.dart'; +import 'diagnostic/diagnostic.dart'; import 'generator/generator.dart'; +import 'generator/lifecycle.dart'; import 'ir/ir.dart'; import 'openapi/openapi.dart'; +/// Generates Dart API clients and models from configured OpenAPI documents. class KoiSwaggerParser { + /// Creates a stateless parser and generator facade. 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 generate({ String? configPath, String? workingDirectory, bool dryRun = false, bool verbose = false, + bool buildRunnerWatch = false, }) async { final config = await KoiSwaggerConfig.load( configPath: configPath, workingDirectory: workingDirectory, ); 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 loaded = await loader.load(config.input.swaggerUrls); if (verbose) { 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 bundle = generator.generate(document); - final filePaths = bundle.files - .map((file) => file.path) - .toList(growable: false); + final formatting = config.generation.formatting; + final lifecycle = GenerationLifecycle(config); + 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) { - for (final file in bundle.files) { - final ioFile = File(file.path); - ioFile.parent.createSync(recursive: true); - ioFile.writeAsStringSync(file.content); - } - - if (config.generation.formatting.runDartFormat) { - await _runFormatter([ - config.output.apiDir, - config.output.modelsDir, - ]); - } + final shouldWatch = + !dryRun && + (buildRunnerWatch || + (formatting.runBuildRunner && + formatting.buildRunnerMode == BuildRunnerMode.watch)); + if (shouldWatch) { + await _runBuildRunner(config, mode: BuildRunnerMode.watch); } return GenerationResult( configPath: config.configPath, 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, modelsCount: bundle.modelsCount, dryRun: dryRun, + dependencyDocuments: document.dependencyDocuments, + diagnostics: [ + ...config.diagnostics, + ...document.diagnostics, + ...lifecycleResult.diagnostics, + ], ); } Future _runFormatter(List 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, [ 'format', ...uniqueTargets, @@ -86,4 +139,70 @@ class KoiSwaggerParser { ); } } + + Future _runFixer(List 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, [ + 'fix', + '--apply', + target, + ]); + if (result.exitCode != 0) { + throw ProcessException( + Platform.resolvedExecutable, + ['fix', '--apply', target], + result.stderr.toString(), + result.exitCode, + ); + } + } + } + + Future _runBuildRunner( + KoiSwaggerConfig config, { + required BuildRunnerMode mode, + }) async { + final arguments = [ + '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, + ); + } + } } diff --git a/lib/src/openapi/document_merger.dart b/lib/src/openapi/document_merger.dart new file mode 100644 index 0000000..0ada4dc --- /dev/null +++ b/lib/src/openapi/document_merger.dart @@ -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 _httpMethods = { + '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 merge( + Map base, + Map 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 base, + Map 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 base, + Map 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 base, + Map 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 baseNames, + Iterable incomingNames, + ) { + final used = { + ...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 { + for (final key in keys) key: _canonicalize(value[key]), + }; + } + if (value is List) { + return value.map(_canonicalize).toList(growable: false); + } + return value; + } + + Map _deepCopyMap(Map value) { + return _deepCopy(value) as Map; + } + + 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'); + } +} diff --git a/lib/src/openapi/openapi.dart b/lib/src/openapi/openapi.dart index 55dbc71..d12b876 100644 --- a/lib/src/openapi/openapi.dart +++ b/lib/src/openapi/openapi.dart @@ -1,16 +1,32 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; import '../config/config.dart'; -import '../preset/preset.dart'; +import '../diagnostic/diagnostic.dart'; import '../ir/ir.dart'; +import '../preset/preset.dart'; import '../util/utils.dart'; +import 'document_merger.dart'; +import 'reference_resolver.dart'; +/// A normalized OpenAPI document assembled from one or more root sources. class LoadedOpenApiDocument { - const LoadedOpenApiDocument({required this.document, required this.sources}); + /// Creates a loaded document and its provenance metadata. + const LoadedOpenApiDocument({ + required this.document, + required this.sources, + this.dependencies = const [], + this.diagnostics = const [], + }); + /// Merged and reference-resolved OpenAPI document. final Map document; + + /// Canonical root source paths or URLs in loading order. final List sources; + + /// Canonical external documents referenced by the root sources. + final List dependencies; + + /// Loading, resolution, and merge diagnostics. + final List diagnostics; } class _DiscriminatedUnionDescriptor { @@ -33,43 +49,67 @@ class _DiscriminatedUnionBranch { final String wireValue; } +class _GenericUnionDescriptor { + const _GenericUnionDescriptor({required this.mode, required this.branches}); + + final UnionMode mode; + final List<_GenericUnionBranch> branches; +} + +class _GenericUnionBranch { + const _GenericUnionBranch({ + required this.schema, + required this.originalName, + required this.matchKind, + required this.requiredProperties, + required this.allowedValues, + }); + + final Map schema; + final String originalName; + final UnionMatchKind matchKind; + final List requiredProperties; + final List allowedValues; +} + +/// Loads, resolves, and merges configured OpenAPI root documents. class OpenApiDocumentLoader { + /// Creates a stateless OpenAPI document loader. const OpenApiDocumentLoader(); + /// Loads [sources] in order and merges them into one normalized document. Future load(List sources) async { var merged = {}; final loadedSources = []; + final dependencies = {}; + final diagnostics = KoiDiagnosticBag(); + final resolver = OpenApiReferenceResolver(diagnostics); + final merger = OpenApiDocumentMerger(diagnostics); for (final source in sources) { - final content = await _readSource(source); - final document = decodeStructuredDocument(content); - merged = merged.isEmpty ? document : deepMergeMaps(merged, document); - loadedSources.add(source); + final resolved = await resolver.resolve(source); + merged = merged.isEmpty + ? resolved.document + : merger.merge(merged, resolved.document, source: resolved.source); + loadedSources.add(resolved.source); + dependencies.addAll(resolved.dependencies); } + diagnostics.throwIfErrors( + message: 'OpenAPI document loading or reference resolution failed.', + ); - return LoadedOpenApiDocument(document: merged, sources: loadedSources); - } - - Future _readSource(String source) async { - if (isHttpUrl(source)) { - final response = await http.get(Uri.parse(source)); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpException( - '拉取 OpenAPI 文档失败: ${response.statusCode}', - uri: Uri.parse(source), - ); - } - return response.body; - } - - final filePath = source.startsWith('file://') - ? Uri.parse(source).toFilePath() - : source; - return File(filePath).readAsString(); + return LoadedOpenApiDocument( + document: merged, + sources: loadedSources, + dependencies: dependencies.toList(growable: false)..sort(), + diagnostics: diagnostics.diagnostics, + ); } } +/// Validates a normalized OpenAPI document and builds the generator IR. class OpenApiDocumentParser { + /// Creates a parser configured by [config]. OpenApiDocumentParser(this.config); static const List _httpMethods = [ @@ -82,6 +122,7 @@ class OpenApiDocumentParser { 'head', ]; + /// Configuration controlling filtering, mappings, and generated semantics. final KoiSwaggerConfig config; late final Map _document; @@ -89,15 +130,42 @@ class OpenApiDocumentParser { late final Map _parameters; late final Map _responses; late final Map _requestBodies; + late final Map _headers; + late List _servers; + late Map _parsedSecuritySchemes; + late List _globalSecurity; final Map _models = {}; final Map _componentClassNames = {}; final Map _inlineModelNames = {}; final Set _componentsInProgress = {}; final Map _classNameUsage = {}; + final Map _operationMethodNameUsage = {}; + final Set _reportedDiagnosticKeys = {}; + late KoiDiagnosticBag _diagnostics; + late String _diagnosticSource; + String? _sourceVersion; + /// Parses [loaded] into deterministic models, operations, and diagnostics. ParsedApiDocument parse(LoadedOpenApiDocument loaded) { + _models.clear(); + _componentClassNames.clear(); + _inlineModelNames.clear(); + _componentsInProgress.clear(); + _classNameUsage.clear(); + _operationMethodNameUsage.clear(); _document = loaded.document; + _diagnostics = KoiDiagnosticBag(); + for (final diagnostic in loaded.diagnostics) { + _diagnostics.add(diagnostic); + } + _diagnosticSource = loaded.sources.join(', '); + _reportedDiagnosticKeys.clear(); + _sourceVersion = config.output.extractVersionFromUrl + ? _extractVersionFromSources(loaded.sources) + : null; + _validateDocument(); + _diagnostics.throwIfErrors(message: 'OpenAPI document validation failed.'); final components = (_document['components'] as Map?)?.cast() ?? @@ -114,6 +182,17 @@ class OpenApiDocumentParser { _requestBodies = (components['requestBodies'] as Map?)?.cast() ?? const {}; + _headers = + (components['headers'] as Map?)?.cast() ?? + const {}; + _servers = _parseServers(_document['servers'], location: r'$.servers'); + _parsedSecuritySchemes = _parseSecuritySchemes( + components['securitySchemes'], + ); + _globalSecurity = _parseSecurityRequirements( + _document['security'], + location: r'$.security', + ); final operations = []; final pathItems = @@ -122,28 +201,46 @@ class OpenApiDocumentParser { final sortedPaths = pathItems.entries.toList() ..sort((left, right) => left.key.compareTo(right.key)); - for (final pathEntry in sortedPaths) { - final pathItem = _asMap(pathEntry.value); - final sharedParameters = _resolveParameters(pathItem['parameters']); - - for (final method in _httpMethods) { - final rawOperation = pathItem[method]; - if (rawOperation is! Map) { + if (config.generation.api.enabled) { + for (final pathEntry in sortedPaths) { + if (!_isAllowedPath(pathEntry.key)) { continue; } - - final operation = _parseOperation( - path: pathEntry.key, - method: method, - rawOperation: rawOperation.cast(), - pathLevelParameters: sharedParameters, + final pathItem = _asMap(pathEntry.value); + final sharedParameters = _resolveParameters(pathItem['parameters']); + final pathServers = _parseServers( + pathItem['servers'], + location: '\$.paths.${pathEntry.key}.servers', ); - if (operation != null) { - operations.add(operation); + + for (final method in _httpMethods) { + final rawOperation = pathItem[method]; + if (rawOperation is! Map) { + continue; + } + + final operation = _parseOperation( + path: pathEntry.key, + method: method, + rawOperation: rawOperation.cast(), + pathLevelParameters: sharedParameters, + pathLevelServers: pathServers, + ); + if (operation != null) { + operations.add(operation); + } } } } + if (config.generation.models.enabled) { + if (config.output.includedModels != null) { + _includeConfiguredModels(); + } else if (!config.generation.api.enabled) { + _includeAllModels(); + } + } + operations.sort((left, right) { final versionCompare = left.version.compareTo(right.version); if (versionCompare != 0) { @@ -167,11 +264,17 @@ class OpenApiDocumentParser { _models.entries.toList() ..sort((left, right) => left.key.compareTo(right.key)), ); + _diagnostics.throwIfErrors(message: 'OpenAPI document parsing failed.'); return ParsedApiDocument( models: sortedModels, operations: operations, sourceDocuments: loaded.sources, + servers: _servers, + securitySchemes: _parsedSecuritySchemes, + globalSecurity: _globalSecurity, + dependencyDocuments: loaded.dependencies, + diagnostics: _diagnostics.diagnostics, ); } @@ -180,14 +283,45 @@ class OpenApiDocumentParser { required String method, required Map rawOperation, required List> pathLevelParameters, + required List pathLevelServers, }) { + final operationId = rawOperation['operationId'] as String?; + if (!_isAllowedOperation(path, method, operationId)) { + return null; + } final selectedTag = _selectTag(rawOperation['tags']); if (selectedTag == null) { return null; } - final operationId = rawOperation['operationId'] as String?; - final methodName = _buildMethodName(operationId, method, path); + final version = _sourceVersion ?? _extractVersion(path); + final mappingKeys = [ + if (operationId != null && operationId.trim().isNotEmpty) operationId, + '${method.toUpperCase()} $path', + ]; + final baseMethodName = _mappedOperationMethodName( + mappingKeys, + fallback: _buildMethodName(operationId, method, path), + ); + final methodScope = config.output.splitByTags + ? '$version:$selectedTag' + : version; + final methodUsageKey = '$methodScope:$baseMethodName'; + final methodIndex = (_operationMethodNameUsage[methodUsageKey] ?? 0) + 1; + _operationMethodNameUsage[methodUsageKey] = methodIndex; + final methodName = methodIndex == 1 + ? baseMethodName + : '$baseMethodName$methodIndex'; + if (methodIndex > 1) { + _diagnostics.warning( + 'openapi.operation_name_collision', + 'Operation mapping produced the duplicate method name ' + '`$baseMethodName`; `${method.toUpperCase()} $path` was renamed ' + 'to `$methodName`.', + source: _diagnosticSource, + location: '#/paths/$path/$method', + ); + } final operationContext = '${toPascalCase(selectedTag)}${toPascalCase(methodName)}'; @@ -196,35 +330,69 @@ class OpenApiDocumentParser { _resolveParameters(rawOperation['parameters']), ); - final parameters = mergedParameters - .map( - (parameter) => - _parseParameter(parameter, operationContext: operationContext), - ) - .toList(growable: false); + final usedArgumentNames = { + 'body', + 'cancelToken', + 'cookieHeader', + 'reservedQuery', + }; + final parameters = []; + for (final rawParameter in mergedParameters) { + var parameter = _parseParameter( + rawParameter, + operationContext: operationContext, + mappingKeys: mappingKeys, + ); + final uniqueName = _uniqueOperationArgumentName( + parameter.name, + usedArgumentNames, + operationContext: operationContext, + wireName: parameter.wireName, + ); + if (uniqueName != parameter.name) { + parameter = parameter.withName(uniqueName); + } + parameters.add(parameter); + } final requestBody = _parseRequestBody( rawOperation['requestBody'], operationContext: operationContext, + usedArgumentNames: usedArgumentNames, ); - final returnType = _parseSuccessReturnType( + final parsedResponses = _parseResponses( rawOperation['responses'], operationContext: operationContext, ); - _parseErrorResponseSchemas( - rawOperation['responses'], - operationContext: operationContext, + final operationServers = _parseServers( + rawOperation['servers'], + location: '#/paths/$path/$method/servers', ); + final selectedServers = operationServers.isNotEmpty + ? operationServers + : pathLevelServers.isNotEmpty + ? pathLevelServers + : _servers; + final security = rawOperation.containsKey('security') + ? _parseSecurityRequirements( + rawOperation['security'], + location: '#/paths/$path/$method/security', + ) + : _globalSecurity; return ApiOperation( path: path, method: method.toUpperCase(), tag: selectedTag, - version: _extractVersion(path), + version: version, methodName: methodName, - returnType: returnType, + returnType: parsedResponses.returnType, parameters: parameters, + responses: parsedResponses.responses, + primaryResponseStatusCode: parsedResponses.primaryStatusCode, + serverUrl: selectedServers.firstOrNull?.url, + security: security, requestBody: requestBody, summary: rawOperation['summary'] as String?, description: rawOperation['description'] as String?, @@ -251,17 +419,100 @@ class OpenApiDocumentParser { bool _isAllowedTag(String tag) { final includedTags = config.output.includedTags; - if (includedTags != null && !includedTags.contains(tag)) { + if (includedTags != null && !matchesAnyPattern(tag, includedTags)) { return false; } final excludedTags = config.output.excludedTags; - if (excludedTags != null && excludedTags.contains(tag)) { + if (matchesAnyPattern(tag, excludedTags)) { return false; } return true; } + bool _isAllowedPath(String path) { + final included = config.output.includedPaths; + if (included != null && !matchesAnyPattern(path, included)) { + return false; + } + return !matchesAnyPattern(path, config.output.excludedPaths); + } + + bool _isAllowedOperation(String path, String method, String? operationId) { + final operationKey = '${method.toUpperCase()} $path'; + bool matches(List? patterns) { + return matchesAnyPattern(operationKey, patterns) || + (operationId != null && matchesAnyPattern(operationId, patterns)); + } + + final included = config.output.includedOperations; + if (included != null && !matches(included)) { + return false; + } + return !matches(config.output.excludedOperations); + } + + void _includeConfiguredModels() { + final included = config.output.includedModels; + if (included == null) { + return; + } + final matchedPatterns = {}; + final sortedNames = _schemas.keys.toList(growable: false)..sort(); + for (final name in sortedNames) { + final mappedName = config.generation.mappings.models[name]; + final matchingPatterns = included.where( + (pattern) => + matchesPattern(name, pattern) || + (mappedName != null && matchesPattern(mappedName, pattern)), + ); + if (matchingPatterns.isEmpty) { + continue; + } + matchedPatterns.addAll(matchingPatterns); + if (_isExcludedModel(name, mappedName: mappedName)) { + continue; + } + _resolveRefType( + '#/components/schemas/$name', + usage: UsageKind.shared, + suggestedName: name, + ); + } + for (final pattern in included) { + if (!matchedPatterns.contains(pattern)) { + _diagnostics.warning( + 'filter.model_pattern_unmatched', + 'The included_models filter `$pattern` did not match any component ' + 'schema.', + source: _diagnosticSource, + location: r'$.output.included_models', + ); + } + } + } + + void _includeAllModels() { + final sortedNames = _schemas.keys.toList(growable: false)..sort(); + for (final name in sortedNames) { + final mappedName = config.generation.mappings.models[name]; + if (_isExcludedModel(name, mappedName: mappedName)) { + continue; + } + _resolveRefType( + '#/components/schemas/$name', + usage: UsageKind.shared, + suggestedName: name, + ); + } + } + + bool _isExcludedModel(String refName, {String? mappedName}) { + final patterns = config.output.excludedModels; + return matchesAnyPattern(refName, patterns) || + (mappedName != null && matchesAnyPattern(mappedName, patterns)); + } + String _buildMethodName(String? operationId, String method, String path) { if (operationId != null && operationId.trim().isNotEmpty) { final rawWords = splitWords(operationId); @@ -298,11 +549,153 @@ class OpenApiDocumentParser { return toCamelCase(words.join(' ')); } + String _mappedOperationMethodName( + List mappingKeys, { + required String fallback, + }) { + String? requested; + for (final key in mappingKeys) { + requested = config.generation.mappings.operations[key]; + if (requested != null) { + break; + } + } + if (requested == null) { + return fallback; + } + return _mappedIdentifier( + requested, + fallback: fallback, + mappingKind: 'operation', + mappingSource: mappingKeys.first, + diagnosticLocation: r'$.generation.mappings.operations', + ); + } + + String? _mappedNestedName( + Map> mappings, + List contexts, + String wireName, + ) { + for (final context in contexts) { + final mapped = mappings[context]?[wireName]; + if (mapped != null) { + return mapped; + } + } + return mappings['*']?[wireName]; + } + + String _mappedIdentifier( + String requested, { + required String fallback, + required String mappingKind, + required String mappingSource, + required String diagnosticLocation, + }) { + final sanitized = sanitizeIdentifier(requested.trim(), fallback: fallback); + if (sanitized != requested.trim()) { + _diagnostics.warning( + 'config.${mappingKind}_mapping_sanitized', + '$mappingKind mapping `$mappingSource -> $requested` is not a valid ' + 'Dart identifier and was changed to `$sanitized`.', + source: config.configPath, + location: diagnosticLocation, + ); + } + return sanitized; + } + + String _mappedBodyFieldName(String operationContext, String wireName) { + final mapped = _mappedNestedName( + config.generation.mappings.properties, + [operationContext, '${operationContext}RequestBody'], + wireName, + ); + return _mappedIdentifier( + mapped ?? toCamelCase(wireName), + fallback: 'value', + mappingKind: 'property', + mappingSource: '$operationContext.$wireName', + diagnosticLocation: '#/paths/$operationContext/requestBody', + ); + } + + String _uniqueOperationArgumentName( + String requested, + Set usedNames, { + required String operationContext, + required String wireName, + }) { + var candidate = requested; + var suffix = 1; + while (!usedNames.add(candidate)) { + suffix += 1; + candidate = '$requested$suffix'; + } + if (candidate != requested) { + _diagnostics.warning( + 'openapi.operation_argument_name_collision', + 'Parameter `$wireName` in operation `$operationContext` maps to the ' + 'reserved name `$requested` and was changed to `$candidate`.', + source: _diagnosticSource, + location: '#/paths/$operationContext', + ); + } + return candidate; + } + String _extractVersion(String path) { final match = RegExp(r'/api/(v\d+)(?:/|$)').firstMatch(path); return match?.group(1) ?? 'v1'; } + String? _extractVersionFromSources(List sources) { + if (sources.length != 1) { + _diagnostics.warning( + 'config.source_version_requires_single_source', + 'extract_version_from_url cannot reliably determine an operation ' + 'source after merging multiple documents; falling back to the ' + 'API path.', + source: _diagnosticSource, + location: r'$.output.extract_version_from_url', + ); + return null; + } + + final source = sources.single; + final uri = isHttpUrl(source) || source.startsWith('file://') + ? Uri.parse(source) + : Uri.file(source); + for (final value in uri.queryParameters.values) { + final match = RegExp( + r'^v(\d+)$', + caseSensitive: false, + ).firstMatch(value.trim()); + if (match != null) { + return 'v${match.group(1)}'; + } + } + for (final segment in uri.pathSegments.reversed) { + final match = RegExp( + r'(?:^|[^a-z0-9])v(\d+)(?:[^0-9]|$)', + caseSensitive: false, + ).firstMatch(segment); + if (match != null) { + return 'v${match.group(1)}'; + } + } + + _diagnostics.warning( + 'config.source_version_not_found', + 'Unable to extract a version from `$source`; falling back to the API ' + 'path.', + source: source, + location: r'$.output.extract_version_from_url', + ); + return null; + } + List> _resolveParameters(dynamic rawParameters) { if (rawParameters is! List) { return const >[]; @@ -339,6 +732,7 @@ class OpenApiDocumentParser { final refName = _refName(ref); final resolved = _asMap(_parameters[refName]); if (resolved.isEmpty) { + _reportUnresolvedRef(ref, componentKind: 'parameter'); return parameter; } return deepMergeMaps( @@ -350,12 +744,37 @@ class OpenApiDocumentParser { ApiParameter _parseParameter( Map rawParameter, { required String operationContext, + required List mappingKeys, }) { final wireName = rawParameter['name']?.toString() ?? 'value'; final location = rawParameter['in']?.toString() ?? 'query'; + final parameterLocation = + '#/paths/$operationContext/parameters/$location/$wireName'; + if (!const { + 'path', + 'query', + 'header', + 'cookie', + }.contains(location)) { + _diagnostics.error( + 'openapi.unsupported_parameter_location', + 'Parameter `$wireName` uses unsupported in value `$location`.', + source: _diagnosticSource, + location: parameterLocation, + ); + } final required = location == 'path' ? true : rawParameter['required'] as bool? ?? false; + if (location == 'path' && rawParameter['required'] != true) { + _diagnostics.warning( + 'openapi.path_parameter_forced_required', + 'Path parameter `$wireName` must be required according to OpenAPI and ' + 'was treated as required.', + source: _diagnosticSource, + location: parameterLocation, + ); + } final schema = _extractSchema(rawParameter); final defaultValue = schema['default']; final resolvedType = _resolveType( @@ -363,23 +782,200 @@ class OpenApiDocumentParser { usage: UsageKind.request, suggestedName: '$operationContext${toPascalCase(wireName)}Parameter', ); + final style = _parameterStyle( + rawParameter['style'], + location: location, + wireName: wireName, + diagnosticLocation: parameterLocation, + ); + final explode = + rawParameter['explode'] as bool? ?? style == ApiParameterStyle.form; + _validateParameterSerialization( + schema: schema, + location: location, + wireName: wireName, + style: style, + explode: explode, + diagnosticLocation: parameterLocation, + ); + final allowReserved = rawParameter['allowReserved'] as bool? ?? false; + if (allowReserved && location != 'query') { + _diagnostics.warning( + 'openapi.allow_reserved_ignored', + 'Parameter `$wireName` uses allowReserved outside query parameters; ' + 'the declaration will be ignored.', + source: _diagnosticSource, + location: parameterLocation, + ); + } + final parameterContent = _selectParameterContent( + rawParameter['content'] as Map?, + wireName: wireName, + diagnosticLocation: parameterLocation, + ); + + final mappedName = _mappedNestedName( + config.generation.mappings.parameters, + mappingKeys, + wireName, + ); + final parameterName = _mappedIdentifier( + mappedName ?? toCamelCase(wireName), + fallback: 'value', + mappingKind: 'parameter', + mappingSource: wireName, + diagnosticLocation: parameterLocation, + ); return ApiParameter( - name: sanitizeIdentifier(toCamelCase(wireName), fallback: 'value'), + name: parameterName, wireName: wireName, location: location, type: !required && defaultValue == null ? resolvedType.asNullable() : resolvedType, required: required, + style: style, + explode: explode, + allowReserved: allowReserved && location == 'query', + contentType: parameterContent?.key, description: rawParameter['description'] as String?, defaultValue: defaultValue, ); } + ApiParameterStyle _parameterStyle( + dynamic rawStyle, { + required String location, + required String wireName, + required String diagnosticLocation, + }) { + final defaultStyle = switch (location) { + 'query' || 'cookie' => ApiParameterStyle.form, + _ => ApiParameterStyle.simple, + }; + if (rawStyle == null) { + return defaultStyle; + } + + final style = switch (rawStyle.toString()) { + 'matrix' => ApiParameterStyle.matrix, + 'label' => ApiParameterStyle.label, + 'form' => ApiParameterStyle.form, + 'simple' => ApiParameterStyle.simple, + 'spaceDelimited' => ApiParameterStyle.spaceDelimited, + 'pipeDelimited' => ApiParameterStyle.pipeDelimited, + 'deepObject' => ApiParameterStyle.deepObject, + _ => null, + }; + if (style == null) { + _diagnostics.error( + 'openapi.unsupported_parameter_style', + 'Parameter `$wireName` uses unknown style `$rawStyle`.', + source: _diagnosticSource, + location: diagnosticLocation, + ); + return defaultStyle; + } + return style; + } + + void _validateParameterSerialization({ + required Map schema, + required String location, + required String wireName, + required ApiParameterStyle style, + required bool explode, + required String diagnosticLocation, + }) { + final allowedStyles = switch (location) { + 'path' => const { + ApiParameterStyle.matrix, + ApiParameterStyle.label, + ApiParameterStyle.simple, + }, + 'query' => const { + ApiParameterStyle.form, + ApiParameterStyle.spaceDelimited, + ApiParameterStyle.pipeDelimited, + ApiParameterStyle.deepObject, + }, + 'header' => const {ApiParameterStyle.simple}, + 'cookie' => const {ApiParameterStyle.form}, + _ => const {}, + }; + if (!allowedStyles.contains(style)) { + _diagnostics.error( + 'openapi.invalid_parameter_style', + 'Parameter `$wireName` with in=$location does not allow ' + 'style=${style.name}.', + source: _diagnosticSource, + location: diagnosticLocation, + ); + } + + final materialized = _materializeSchema(schema); + final schemaType = _extractTypeValue(materialized); + if (style == ApiParameterStyle.deepObject && schemaType != 'object') { + _diagnostics.error( + 'openapi.deep_object_requires_object', + 'Parameter `$wireName` uses deepObject, but its schema is not an ' + 'object.', + source: _diagnosticSource, + location: diagnosticLocation, + ); + } + if ((style == ApiParameterStyle.spaceDelimited || + style == ApiParameterStyle.pipeDelimited) && + schemaType != 'array') { + _diagnostics.error( + 'openapi.delimited_parameter_requires_array', + 'Parameter `$wireName` uses ${style.name}, but its schema is not an ' + 'array.', + source: _diagnosticSource, + location: diagnosticLocation, + ); + } + if (style == ApiParameterStyle.deepObject && !explode) { + _diagnostics.warning( + 'openapi.deep_object_explode_false', + 'deepObject parameter `$wireName` has explode=false, which has no ' + 'standardized wire representation; treating it as explode=true.', + source: _diagnosticSource, + location: diagnosticLocation, + ); + } + } + + MapEntry>? _selectParameterContent( + Map? rawContent, { + required String wireName, + required String diagnosticLocation, + }) { + if (rawContent == null) { + return null; + } + final content = rawContent.cast(); + if (content.length != 1) { + _diagnostics.error( + 'openapi.parameter_content_count', + 'The content of parameter `$wireName` must contain exactly one media ' + 'type.', + source: _diagnosticSource, + location: '$diagnosticLocation/content', + ); + } + if (content.isEmpty) { + return null; + } + final first = content.entries.first; + return MapEntry(first.key, _asMap(first.value)); + } + ApiRequestBody? _parseRequestBody( dynamic rawRequestBody, { required String operationContext, + required Set usedArgumentNames, }) { final requestBody = _resolveRequestBodyRef(_asMap(rawRequestBody)); if (requestBody.isEmpty) { @@ -396,22 +992,65 @@ class OpenApiDocumentParser { content: content, requestBody: requestBody, operationContext: operationContext, + usedArgumentNames: usedArgumentNames, + ); + } + if (_isFormUrlEncodedContentType(content.key)) { + return _parseFormRequestBody( + content: content, + requestBody: requestBody, + operationContext: operationContext, + usedArgumentNames: usedArgumentNames, ); } final schema = _asMap(content.value['schema']); if (schema.isEmpty) { + _diagnostics.error( + 'openapi.request_body_schema_missing', + 'Request body content `${content.key}` does not define a schema.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/requestBody/content/${content.key}', + ); + return null; + } + final required = requestBody['required'] as bool? ?? false; + if (_isBinaryContentType(content.key) || _isBinarySchema(schema)) { + final binaryType = const DartType.named('Uint8List'); + return ApiRequestBody( + kind: ApiRequestBodyKind.binary, + type: required ? binaryType : binaryType.asNullable(), + required: required, + parameterName: 'body', + contentType: content.key, + ); + } + + final normalizedMediaType = _normalizedContentType(content.key); + if (!_isJsonContentType(normalizedMediaType) && + !normalizedMediaType.startsWith('text/')) { + _diagnostics.error( + 'openapi.unsupported_request_content_type', + 'Request body media type `${content.key}` has no reliable Dart ' + 'serializer. Supported media types are JSON/+json, text/*, ' + 'multipart, form-urlencoded, and binary.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/requestBody/content/${content.key}', + ); return null; } + final bodyType = _resolveType( + schema, + usage: UsageKind.request, + suggestedName: '${operationContext}RequestBody', + ); return ApiRequestBody( kind: ApiRequestBodyKind.json, - type: _resolveType( - schema, - usage: UsageKind.request, - suggestedName: '${operationContext}RequestBody', - ), - required: requestBody['required'] as bool? ?? false, + type: required ? bodyType : bodyType.asNullable(), + required: required, parameterName: 'body', contentType: content.key, ); @@ -421,6 +1060,7 @@ class OpenApiDocumentParser { required MapEntry> content, required Map requestBody, required String operationContext, + required Set usedArgumentNames, }) { final rawSchema = _asMap(content.value['schema']); if (rawSchema.isEmpty) { @@ -429,9 +1069,12 @@ class OpenApiDocumentParser { final schema = _mergeComposedSchema(rawSchema) ?? _materializeSchema(rawSchema); + final bodyRequired = requestBody['required'] as bool? ?? false; final multipartFields = _parseMultipartFields( schema, operationContext: operationContext, + usedArgumentNames: usedArgumentNames, + bodyRequired: bodyRequired, ); if (multipartFields.isEmpty) { return null; @@ -439,16 +1082,125 @@ class OpenApiDocumentParser { return ApiRequestBody( kind: ApiRequestBodyKind.multipart, - required: requestBody['required'] as bool? ?? false, + required: bodyRequired, parameterName: 'parts', contentType: content.key, multipartFields: multipartFields, ); } + ApiRequestBody? _parseFormRequestBody({ + required MapEntry> content, + required Map requestBody, + required String operationContext, + required Set usedArgumentNames, + }) { + final rawSchema = _asMap(content.value['schema']); + if (rawSchema.isEmpty) { + _diagnostics.error( + 'openapi.form_schema_missing', + 'The application/x-www-form-urlencoded request body does not define ' + 'a schema.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/requestBody/content/${content.key}', + ); + return null; + } + + final schema = + _mergeComposedSchema(rawSchema) ?? _materializeSchema(rawSchema); + final bodyRequired = requestBody['required'] as bool? ?? false; + final formFields = _parseFormFields( + schema, + operationContext: operationContext, + encoding: _asMap(content.value['encoding']), + usedArgumentNames: usedArgumentNames, + bodyRequired: bodyRequired, + ); + if (formFields.isEmpty) { + _diagnostics.error( + 'openapi.form_object_required', + 'application/x-www-form-urlencoded currently requires an object body ' + 'schema with properties.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/requestBody/content/${content.key}', + ); + return null; + } + + return ApiRequestBody( + kind: ApiRequestBodyKind.formUrlEncoded, + required: bodyRequired, + parameterName: 'fields', + contentType: content.key, + formFields: formFields, + ); + } + + List _parseFormFields( + Map schema, { + required String operationContext, + required Map encoding, + required Set usedArgumentNames, + required bool bodyRequired, + }) { + final properties = + (schema['properties'] as Map?)?.cast() ?? + const {}; + final requiredFields = _stringSet(schema['required']); + final fields = []; + + for (final entry in properties.entries) { + final wireName = entry.key; + final propertySchema = _asMap(entry.value); + final fieldEncoding = _asMap(encoding[wireName]); + final style = fieldEncoding['style']?.toString() ?? 'form'; + final explode = fieldEncoding['explode'] as bool? ?? true; + if (style != 'form' || !explode) { + _diagnostics.warning( + 'openapi.form_field_encoding_requires_adapter', + 'Form field `$wireName` uses style=$style, explode=$explode. The ' + 'generator will preserve its field type and let the API ' + 'transport adapter serialize it.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/requestBody/content/application~1x-www-form-urlencoded/encoding/$wireName', + ); + } + final type = _resolveType( + propertySchema, + usage: UsageKind.request, + suggestedName: '$operationContext${toPascalCase(wireName)}Field', + ); + final isRequired = bodyRequired && requiredFields.contains(wireName); + final fieldName = _uniqueOperationArgumentName( + _mappedBodyFieldName(operationContext, wireName), + usedArgumentNames, + operationContext: operationContext, + wireName: wireName, + ); + fields.add( + ApiMultipartField( + name: fieldName, + wireName: wireName, + type: isRequired ? type : type.asNullable(), + required: isRequired, + description: _descriptionFor(propertySchema), + contentType: fieldEncoding['contentType']?.toString(), + ), + ); + } + + return fields; + } + List _parseMultipartFields( Map schema, { required String operationContext, + required Set usedArgumentNames, + required bool bodyRequired, }) { final properties = (schema['properties'] as Map?)?.cast() ?? @@ -464,12 +1216,18 @@ class OpenApiDocumentParser { usage: UsageKind.request, suggestedName: '$operationContext${toPascalCase(wireName)}Part', ); - final isRequired = requiredFields.contains(wireName); + final isRequired = bodyRequired && requiredFields.contains(wireName); final nullableType = isRequired ? type : type.asNullable(); + final fieldName = _uniqueOperationArgumentName( + _mappedBodyFieldName(operationContext, wireName), + usedArgumentNames, + operationContext: operationContext, + wireName: wireName, + ); fields.add( ApiMultipartField( - name: sanitizeIdentifier(toCamelCase(wireName), fallback: 'value'), + name: fieldName, wireName: wireName, type: nullableType, required: isRequired, @@ -583,7 +1341,20 @@ class OpenApiDocumentParser { } bool _isMultipartContentType(String contentType) { - return contentType.toLowerCase() == 'multipart/form-data'; + return _normalizedContentType(contentType) == 'multipart/form-data'; + } + + bool _isFormUrlEncodedContentType(String contentType) { + return _normalizedContentType(contentType) == + 'application/x-www-form-urlencoded'; + } + + bool _isBinaryContentType(String contentType) { + return _normalizedContentType(contentType) == 'application/octet-stream'; + } + + String _normalizedContentType(String contentType) { + return contentType.split(';').first.trim().toLowerCase(); } Map _resolveRequestBodyRef( @@ -596,6 +1367,7 @@ class OpenApiDocumentParser { final refName = _refName(ref); final resolved = _asMap(_requestBodies[refName]); if (resolved.isEmpty) { + _reportUnresolvedRef(ref, componentKind: 'requestBody'); return requestBody; } return deepMergeMaps( @@ -604,93 +1376,301 @@ class OpenApiDocumentParser { ); } - DartType _parseSuccessReturnType( - dynamic rawResponses, { + ({ + List responses, + DartType returnType, + String? primaryStatusCode, + }) + _parseResponses(dynamic rawResponses, {required String operationContext}) { + final rawResponseMap = + (rawResponses as Map?)?.cast() ?? + const {}; + if (rawResponseMap.isEmpty) { + _diagnostics.error( + 'openapi.responses_missing', + 'Operation `$operationContext` does not define responses.', + source: _diagnosticSource, + location: '#/paths/$operationContext/responses', + ); + return ( + responses: const [], + returnType: _wrapReturnType(const DartType.voidType()), + primaryStatusCode: null, + ); + } + + final entries = rawResponseMap.entries.toList() + ..sort((left, right) => _compareResponseCodes(left.key, right.key)); + final responses = []; + for (final entry in entries) { + final statusCode = entry.key; + final isSuccess = _isSuccessCode(statusCode); + final response = _resolveResponseRef(_asMap(entry.value)); + final content = _selectContent(response['content'] as Map?); + final parsedBody = _parseResponseBody( + content, + isSuccess: isSuccess, + operationContext: operationContext, + statusCode: statusCode, + ); + responses.add( + ApiResponse( + statusCode: statusCode, + isSuccess: isSuccess, + isDefault: statusCode == 'default', + bodyKind: parsedBody.kind, + bodyType: parsedBody.type, + contentType: content?.key, + description: response['description']?.toString(), + headers: _parseResponseHeaders( + response['headers'], + operationContext: operationContext, + statusCode: statusCode, + ), + ), + ); + } + + final successResponses = responses + .where((response) => response.isSuccess) + .toList(growable: false); + if (successResponses.isEmpty) { + _diagnostics.warning( + 'openapi.success_response_missing', + 'Operation `$operationContext` has no explicit 2xx response. ' + '`default` is treated as an error response, so the method will ' + 'return void.', + source: _diagnosticSource, + location: '#/paths/$operationContext/responses', + ); + return ( + responses: responses, + returnType: _wrapReturnType(const DartType.voidType()), + primaryStatusCode: null, + ); + } + + final bodyResponses = successResponses + .where((response) => response.hasBody) + .toList(growable: false); + if (bodyResponses.isEmpty) { + final primary = _preferredSuccessResponse(successResponses); + return ( + responses: responses, + returnType: _wrapReturnType(const DartType.voidType()), + primaryStatusCode: primary.statusCode, + ); + } + + final responseFamilies = bodyResponses.map(_responseBodyFamily).toSet(); + if (responseFamilies.length > 1 && + (responseFamilies.contains('binary') || + responseFamilies.contains('stream'))) { + _diagnostics.error( + 'openapi.mixed_success_media_types', + 'Successful responses for operation `$operationContext` mix ' + 'binary/stream and other media types, which a single Retrofit ' + 'method cannot decode safely.', + source: _diagnosticSource, + location: '#/paths/$operationContext/responses', + ); + } + + final primary = _preferredSuccessResponse(bodyResponses); + final renderedTypes = bodyResponses + .map((response) => response.bodyType.render()) + .toSet(); + var resultType = primary.bodyType; + if (renderedTypes.length > 1) { + _diagnostics.warning( + 'openapi.multiple_success_response_types', + 'Successful responses for operation `$operationContext` have ' + 'different body types: ${renderedTypes.toList()..sort()}. The ' + 'return type falls back to Object?, while complete ' + 'status/content/header metadata remains available in the IR.', + source: _diagnosticSource, + location: '#/paths/$operationContext/responses', + ); + resultType = const DartType.objectType(nullable: true); + } + + return ( + responses: responses, + returnType: _wrapReturnType( + resultType, + wrapExternal: + primary.bodyKind == ApiResponseBodyKind.json || + primary.bodyKind == ApiResponseBodyKind.other, + ), + primaryStatusCode: primary.statusCode, + ); + } + + ({ApiResponseBodyKind kind, DartType type}) _parseResponseBody( + MapEntry>? content, { + required bool isSuccess, required String operationContext, + required String statusCode, }) { - final responseEntries = _successResponseEntries(rawResponses); - if (responseEntries.isEmpty) { - return _wrapReturnType(const DartType.voidType()); - } - - final selected = _resolveResponseRef(_asMap(responseEntries.first.value)); - final content = _selectContent(selected['content'] as Map?); if (content == null) { - return _wrapReturnType(const DartType.voidType()); + return (kind: ApiResponseBodyKind.none, type: const DartType.voidType()); } - + final mediaType = _normalizedContentType(content.key); final schema = _asMap(content.value['schema']); + if (_isBinaryContentType(mediaType) || + (schema.isNotEmpty && _isBinarySchema(schema))) { + return switch (config.generation.api.binaryResponseMode) { + BinaryResponseMode.bytes => ( + kind: ApiResponseBodyKind.bytes, + type: const DartType.named('Uint8List'), + ), + BinaryResponseMode.stream => ( + kind: ApiResponseBodyKind.stream, + type: const DartType.named('ResponseBody'), + ), + }; + } + if (mediaType.startsWith('text/')) { + return ( + kind: ApiResponseBodyKind.text, + type: const DartType.named('String'), + ); + } + if (!_isJsonContentType(mediaType) && mediaType != '*/*') { + _diagnostics.error( + 'openapi.unsupported_response_content_type', + 'Response `$statusCode` media type `${content.key}` has no reliable ' + 'Dart decoder. Supported media types are JSON/+json, text/*, and ' + 'binary.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/responses/$statusCode/content/${content.key}', + ); + return ( + kind: ApiResponseBodyKind.other, + type: const DartType.objectType(nullable: true), + ); + } if (schema.isEmpty) { - return _wrapReturnType(const DartType.voidType()); + _diagnostics.warning( + 'openapi.response_schema_missing', + 'Response `$statusCode` content `${content.key}` does not define a ' + 'schema; the return type falls back to Object?.', + source: _diagnosticSource, + location: + '#/paths/$operationContext/responses/$statusCode/content/${content.key}', + ); + return ( + kind: ApiResponseBodyKind.other, + type: const DartType.objectType(nullable: true), + ); } - if (config.generation.api.responseWrapper.detectStandardEnvelope && + final usage = isSuccess ? UsageKind.result : UsageKind.shared; + final suggestedName = isSuccess + ? '$operationContext${statusCode}Response' + : '$operationContext${statusCode}Error'; + if (isSuccess && + config.generation.api.responseWrapper.detectStandardEnvelope && config.generation.api.responseWrapper.mode == ResponseWrapperMode.externalGeneric) { final dataSchema = _extractEnvelopeDataSchema(schema); if (dataSchema != null) { - final innerType = _resolveType( - dataSchema, - usage: UsageKind.result, - suggestedName: '${operationContext}Data', + return ( + kind: ApiResponseBodyKind.json, + type: _resolveType( + dataSchema, + usage: usage, + suggestedName: '$operationContext${statusCode}Data', + ), ); - return _wrapReturnType(innerType); } } - final responseType = _resolveType( - schema, - usage: UsageKind.result, - suggestedName: '${operationContext}Response', + return ( + kind: _isJsonContentType(mediaType) + ? ApiResponseBodyKind.json + : ApiResponseBodyKind.other, + type: _resolveType(schema, usage: usage, suggestedName: suggestedName), ); - return _wrapReturnType(responseType); } - void _parseErrorResponseSchemas( - dynamic rawResponses, { + List _parseResponseHeaders( + dynamic rawHeaders, { required String operationContext, + required String statusCode, }) { - final responses = - (rawResponses as Map?)?.cast() ?? + final headers = + (rawHeaders as Map?)?.cast() ?? const {}; - final entries = responses.entries.toList() + final result = []; + final entries = headers.entries.toList() ..sort((left, right) => left.key.compareTo(right.key)); - for (final entry in entries) { - if (_isSuccessCode(entry.key)) { - continue; - } - final response = _resolveResponseRef(_asMap(entry.value)); - final content = _selectContent(response['content'] as Map?); - if (content == null) { - continue; - } - final schema = _asMap(content.value['schema']); - if (schema.isEmpty) { - continue; - } - _resolveType( + final header = _resolveHeaderRef(_asMap(entry.value)); + final schema = _extractSchema(header); + final required = header['required'] as bool? ?? false; + final type = _resolveType( schema, usage: UsageKind.shared, - suggestedName: '$operationContext${entry.key}Error', + suggestedName: + '$operationContext$statusCode${toPascalCase(entry.key)}Header', + ); + result.add( + ApiResponseHeader( + name: entry.key, + type: required ? type : type.asNullable(), + required: required, + description: header['description']?.toString(), + ), ); } + return result; } - List> _successResponseEntries( - dynamic rawResponses, - ) { - final responses = - (rawResponses as Map?)?.cast() ?? - const {}; - final entries = - responses.entries.where((entry) => _isSuccessCode(entry.key)).toList() - ..sort((left, right) => left.key.compareTo(right.key)); - return entries; + ApiResponse _preferredSuccessResponse(List responses) { + const preferredCodes = ['200', '201', '202', '203', '206', '204']; + for (final code in preferredCodes) { + for (final response in responses) { + if (response.statusCode == code) { + return response; + } + } + } + return responses.first; + } + + String _responseBodyFamily(ApiResponse response) { + return switch (response.bodyKind) { + ApiResponseBodyKind.bytes => 'binary', + ApiResponseBodyKind.stream => 'stream', + ApiResponseBodyKind.text => 'text', + ApiResponseBodyKind.json => 'json', + ApiResponseBodyKind.other => 'other', + ApiResponseBodyKind.none => 'none', + }; + } + + int _compareResponseCodes(String left, String right) { + int priority(String code) { + if (RegExp(r'^\d{3}$').hasMatch(code)) { + return int.parse(code); + } + if (code.toUpperCase() == '2XX') { + return 299; + } + if (code == 'default') { + return 1000; + } + return 900; + } + + final priorityCompare = priority(left).compareTo(priority(right)); + return priorityCompare != 0 ? priorityCompare : left.compareTo(right); } bool _isSuccessCode(String code) { - return RegExp(r'^2\d\d$').hasMatch(code) || code == 'default'; + return RegExp(r'^2\d\d$').hasMatch(code) || code.toUpperCase() == '2XX'; } Map _resolveResponseRef(Map response) { @@ -701,6 +1681,7 @@ class OpenApiDocumentParser { final refName = _refName(ref); final resolved = _asMap(_responses[refName]); if (resolved.isEmpty) { + _reportUnresolvedRef(ref, componentKind: 'response'); return response; } return deepMergeMaps( @@ -709,6 +1690,23 @@ class OpenApiDocumentParser { ); } + Map _resolveHeaderRef(Map header) { + final ref = header[r'$ref'] as String?; + if (ref == null) { + return header; + } + final refName = _refName(ref); + final resolved = _asMap(_headers[refName]); + if (resolved.isEmpty) { + _reportUnresolvedRef(ref, componentKind: 'header'); + return header; + } + return deepMergeMaps( + resolved, + Map.from(header)..remove(r'$ref'), + ); + } + MapEntry>? _selectContent(Map? rawContent) { final content = rawContent?.cast() ?? const {}; @@ -726,8 +1724,19 @@ class OpenApiDocumentParser { } } - final first = content.entries.first; - return MapEntry(first.key, _asMap(first.value)); + final jsonTypes = + content.keys + .where((mediaType) => _isJsonContentType(mediaType)) + .toList(growable: false) + ..sort(); + if (jsonTypes.isNotEmpty) { + final mediaType = jsonTypes.first; + return MapEntry(mediaType, _asMap(content[mediaType])); + } + + final firstKey = content.keys.toList(growable: false)..sort(); + final mediaType = firstKey.first; + return MapEntry(mediaType, _asMap(content[mediaType])); } MapEntry>? _selectRequestContent( @@ -751,8 +1760,24 @@ class OpenApiDocumentParser { } } - final first = content.entries.first; - return MapEntry(first.key, _asMap(first.value)); + final jsonTypes = + content.keys + .where((mediaType) => _isJsonContentType(mediaType)) + .toList(growable: false) + ..sort(); + if (jsonTypes.isNotEmpty) { + final mediaType = jsonTypes.first; + return MapEntry(mediaType, _asMap(content[mediaType])); + } + + final firstKey = content.keys.toList(growable: false)..sort(); + final mediaType = firstKey.first; + return MapEntry(mediaType, _asMap(content[mediaType])); + } + + bool _isJsonContentType(String contentType) { + final normalized = _normalizedContentType(contentType); + return normalized == 'application/json' || normalized.endsWith('+json'); } Map _extractSchema(Map container) { @@ -767,15 +1792,21 @@ class OpenApiDocumentParser { return _asMap(content.value['schema']); } - DartType _wrapReturnType(DartType innerType) { - if (config.generation.api.responseWrapper.mode != - ResponseWrapperMode.externalGeneric) { - return innerType; + DartType _wrapReturnType(DartType innerType, {bool wrapExternal = true}) { + var result = innerType; + if (wrapExternal && + config.generation.api.responseWrapper.mode == + ResponseWrapperMode.externalGeneric) { + final wrapperClass = + config.generation.api.responseWrapper.externalClassName ?? + 'BaseResult'; + result = DartType.named(wrapperClass, arguments: [result]); } - - final wrapperClass = - config.generation.api.responseWrapper.externalClassName ?? 'BaseResult'; - return DartType.named(wrapperClass, arguments: [innerType]); + if (config.generation.api.responseMetadataMode == + ResponseMetadataMode.httpResponse) { + result = DartType.named('HttpResponse', arguments: [result]); + } + return result; } Map? _extractEnvelopeDataSchema( @@ -836,6 +1867,15 @@ class OpenApiDocumentParser { suggestedName: suggestedName, ); } + final genericUnionDescriptor = _genericUnionDescriptorFor(schema); + if (genericUnionDescriptor != null) { + return _createGenericInlineUnionType( + schema, + descriptor: genericUnionDescriptor, + usage: usage, + suggestedName: suggestedName, + ); + } final mergedSchema = _mergeComposedSchema(schema); if (mergedSchema != null) { @@ -854,6 +1894,12 @@ class OpenApiDocumentParser { ); } + final configuredType = _configuredTypeMapping(schema); + if (configuredType != null) { + final mappedType = _dartTypeFromMapping(configuredType.dartType); + return _schemaAllowsNull(schema) ? mappedType.asNullable() : mappedType; + } + final schemaType = _extractTypeValue(schema); switch (schemaType) { case 'array': @@ -915,22 +1961,60 @@ class OpenApiDocumentParser { final refName = _refName(ref); final schema = _asMap(_schemas[refName]); if (schema.isEmpty) { + _reportUnresolvedRef( + ref, + componentKind: 'schema', + context: suggestedName, + ); return const DartType.dynamicType(); } + final mappedRefName = config.generation.mappings.models[refName] ?? refName; + if (_isExcludedModel(refName, mappedName: mappedRefName)) { + _diagnostics.error( + 'filter.required_model_excluded', + 'A selected operation or model depends on schema `$refName`, which is ' + 'excluded by excluded_models. Remove the exclusion or also filter ' + 'out the dependent declaration.', + source: _diagnosticSource, + location: '#/components/schemas/$refName', + ); + return const DartType.dynamicType(); + } + + final schemaMapping = config.generation.mappings.schemas[refName]; + if (schemaMapping != null) { + final mappedType = _dartTypeFromMapping(schemaMapping.dartType); + return _schemaAllowsNull(schema) ? mappedType.asNullable() : mappedType; + } + if (!_requiresNamedModel(schema)) { return _resolveType(schema, usage: usage, suggestedName: suggestedName); } + final kind = _modelKindFor(schema); + final directional = + kind == ModelKind.object && _hasDirectionalProperties(schema); + final componentKey = directional ? '$refName:${usage.name}' : refName; + final variantName = directional + ? switch (usage) { + UsageKind.request => '${mappedRefName}Request', + UsageKind.result => '${mappedRefName}Response', + UsageKind.shared => mappedRefName, + } + : mappedRefName; final className = _componentClassNames.putIfAbsent( - refName, - () => _uniqueClassName(_modelClassName(refName)), + componentKey, + () => _uniqueClassName(_modelClassName(variantName)), ); - final kind = _modelKindFor(schema); final unionDescriptor = kind == ModelKind.unionType ? _discriminatorUnionDescriptorFor(schema) : null; + final genericUnionDescriptor = + kind == ModelKind.unionType && unionDescriptor == null + ? _genericUnionDescriptorFor(schema) + : null; final model = _models.putIfAbsent( className, () => ModelSpec( @@ -938,38 +2022,60 @@ class OpenApiDocumentParser { originalName: refName, kind: kind, discriminatorKey: unionDescriptor?.discriminatorKey, + unionMode: genericUnionDescriptor?.mode ?? UnionMode.discriminated, description: _descriptionFor(schema), ), ); model.usages.add(usage); - if (_componentsInProgress.contains(refName)) { - return DartType.named(className); + if (_componentsInProgress.contains(componentKey)) { + final recursiveType = DartType.named(className); + return _schemaAllowsNull(schema) + ? recursiveType.asNullable() + : recursiveType; } - _componentsInProgress.add(refName); + _componentsInProgress.add(componentKey); try { if (kind == ModelKind.enumType) { if (model.enumValues.isEmpty) { - model.enumValues.addAll(_enumValuesFor(schema)); + model.enumValues.addAll(_enumValuesFor(schema, enumName: refName)); } } else if (kind == ModelKind.unionType) { - final descriptor = unionDescriptor; - if (descriptor != null && model.unionCases.isEmpty) { + if (unionDescriptor != null && model.unionCases.isEmpty) { model.unionCases.addAll( - _unionCasesFor(descriptor, usage: usage, suggestedName: refName), + _unionCasesFor( + unionDescriptor, + usage: usage, + suggestedName: refName, + ), + ); + } else if (genericUnionDescriptor != null && model.unionCases.isEmpty) { + model.unionCases.addAll( + _genericUnionCasesFor( + genericUnionDescriptor, + usage: usage, + suggestedName: refName, + ), ); } } else if (model.fields.isEmpty) { model.fields.addAll( _fieldsForObject(schema, usage: usage, contextName: refName), ); + _populateAdditionalProperties( + model, + schema, + usage: usage, + contextName: refName, + ); } } finally { - _componentsInProgress.remove(refName); + _componentsInProgress.remove(componentKey); } - return DartType.named(className); + final resolvedType = DartType.named(className); + return _schemaAllowsNull(schema) ? resolvedType.asNullable() : resolvedType; } bool _requiresNamedModel(Map schema) { @@ -980,6 +2086,9 @@ class OpenApiDocumentParser { if (_discriminatorUnionDescriptorFor(schema) != null) { return true; } + if (_genericUnionDescriptorFor(schema) != null) { + return true; + } final mergedSchema = _mergeComposedSchema(schema); final target = mergedSchema ?? schema; @@ -996,6 +2105,9 @@ class OpenApiDocumentParser { if (_discriminatorUnionDescriptorFor(schema) != null) { return ModelKind.unionType; } + if (_genericUnionDescriptorFor(schema) != null) { + return ModelKind.unionType; + } return ModelKind.object; } @@ -1036,6 +2148,85 @@ class OpenApiDocumentParser { return DartType.named(className); } + DartType _createGenericInlineUnionType( + Map schema, { + required _GenericUnionDescriptor descriptor, + required UsageKind usage, + required String suggestedName, + }) { + final branchSignature = descriptor.branches + .map( + (branch) => + '${branch.originalName}:${branch.matchKind.name}:' + '${branch.requiredProperties}:${branch.allowedValues}', + ) + .join(','); + final signature = + 'union:${descriptor.mode.name}:$suggestedName:$branchSignature'; + final className = _inlineModelNames.putIfAbsent( + signature, + () => _uniqueClassName(_modelClassName(suggestedName)), + ); + + final model = _models.putIfAbsent( + className, + () => ModelSpec( + name: className, + originalName: suggestedName, + kind: ModelKind.unionType, + unionMode: descriptor.mode, + description: _descriptionFor(schema), + ), + ); + model.usages.add(usage); + if (model.unionCases.isEmpty) { + model.unionCases.addAll( + _genericUnionCasesFor( + descriptor, + usage: usage, + suggestedName: suggestedName, + ), + ); + } + return DartType.named(className); + } + + List _genericUnionCasesFor( + _GenericUnionDescriptor descriptor, { + required UsageKind usage, + required String suggestedName, + }) { + final usedCaseNames = {}; + final result = []; + for (var index = 0; index < descriptor.branches.length; index += 1) { + final branch = descriptor.branches[index]; + final baseName = toPascalCase(branch.originalName); + final nameIndex = (usedCaseNames[baseName] ?? 0) + 1; + usedCaseNames[baseName] = nameIndex; + final caseName = nameIndex == 1 ? baseName : '$baseName$nameIndex'; + final branchType = branch.matchKind == UnionMatchKind.nullValue + ? const DartType.named('Null') + : _resolveType( + branch.schema, + usage: usage, + suggestedName: + '$suggestedName${toPascalCase(branch.originalName)}Variant', + ); + result.add( + UnionCase( + caseName: caseName, + wireValue: '', + type: branchType, + originalName: branch.originalName, + matchKind: branch.matchKind, + requiredProperties: branch.requiredProperties, + allowedValues: branch.allowedValues, + ), + ); + } + return result; + } + List _unionCasesFor( _DiscriminatedUnionDescriptor descriptor, { required UsageKind usage, @@ -1070,6 +2261,74 @@ class OpenApiDocumentParser { return cases; } + _GenericUnionDescriptor? _genericUnionDescriptorFor( + Map schema, + ) { + final materialized = _materializeSchema(schema); + if (_discriminatorUnionDescriptorFor(materialized) != null) { + return null; + } + final rawBranches = materialized['oneOf'] ?? materialized['anyOf']; + if (rawBranches is! List || rawBranches.length < 2) { + return null; + } + final mode = materialized['oneOf'] is List + ? UnionMode.oneOf + : UnionMode.anyOf; + if (rawBranches.length == 2) { + final nullBranches = rawBranches.where( + (branch) => _extractTypeValue(_asMap(branch)) == 'null', + ); + if (nullBranches.length == 1) { + return null; + } + } + + final branches = <_GenericUnionBranch>[]; + for (var index = 0; index < rawBranches.length; index += 1) { + final rawBranch = _asMap(rawBranches[index]); + if (rawBranch.isEmpty) { + return null; + } + final resolvedBranch = _materializeSchema(rawBranch); + final rawType = _extractTypeValue(resolvedBranch); + final matchKind = switch (rawType) { + 'string' => UnionMatchKind.string, + 'integer' => UnionMatchKind.integer, + 'number' => UnionMatchKind.number, + 'boolean' => UnionMatchKind.boolean, + 'object' => UnionMatchKind.object, + 'array' => UnionMatchKind.array, + 'null' => UnionMatchKind.nullValue, + _ => UnionMatchKind.any, + }; + final ref = rawBranch[r'$ref'] as String?; + final title = resolvedBranch['title']?.toString().trim(); + final originalName = + (ref == null ? null : _refName(ref)) ?? + (title == null || title.isEmpty ? null : title) ?? + '${matchKind.name}${index + 1}'; + final requiredProperties = _stringSet( + resolvedBranch['required'], + ).toList(growable: false)..sort(); + final allowedValues = [ + for (final value + in (resolvedBranch['enum'] as List?) ?? const []) + if (value != null) value as Object, + ]; + branches.add( + _GenericUnionBranch( + schema: rawBranch, + originalName: originalName, + matchKind: matchKind, + requiredProperties: requiredProperties, + allowedValues: allowedValues, + ), + ); + } + return _GenericUnionDescriptor(mode: mode, branches: branches); + } + _DiscriminatedUnionDescriptor? _discriminatorUnionDescriptorFor( Map schema, ) { @@ -1184,6 +2443,12 @@ class OpenApiDocumentParser { model.fields.addAll( _fieldsForObject(schema, usage: usage, contextName: suggestedName), ); + _populateAdditionalProperties( + model, + schema, + usage: usage, + contextName: suggestedName, + ); } return DartType.named(className); @@ -1211,7 +2476,7 @@ class OpenApiDocumentParser { ); model.usages.add(usage); if (model.enumValues.isEmpty) { - model.enumValues.addAll(_enumValuesFor(schema)); + model.enumValues.addAll(_enumValuesFor(schema, enumName: suggestedName)); } return DartType.named(className); } @@ -1221,61 +2486,406 @@ class OpenApiDocumentParser { required UsageKind usage, required String contextName, }) { - final materialized = _mergeComposedSchema(schema) ?? schema; + final materialized = + _mergeComposedSchema(schema) ?? _materializeSchema(schema); final properties = (materialized['properties'] as Map?)?.cast() ?? const {}; final requiredFields = _stringSet(materialized['required']); + final fields = []; + final usedNames = {}; - return properties.entries.map((entry) { + for (final entry in properties.entries) { final wireName = entry.key; final propertySchema = _asMap(entry.value); + final readOnly = propertySchema['readOnly'] as bool? ?? false; + final writeOnly = propertySchema['writeOnly'] as bool? ?? false; + if (readOnly && writeOnly) { + _diagnostics.error( + 'openapi.property_read_write_only', + 'Property `$contextName.$wireName` cannot be both readOnly and ' + 'writeOnly.', + source: _diagnosticSource, + location: '#/components/schemas/$contextName/properties/$wireName', + ); + } + if ((usage == UsageKind.request && readOnly) || + (usage == UsageKind.result && writeOnly)) { + continue; + } + final fieldType = _resolveType( propertySchema, usage: usage, suggestedName: '$contextName${toPascalCase(wireName)}', ); final isRequired = requiredFields.contains(wireName); - - return ModelField( - name: sanitizeIdentifier(toCamelCase(wireName), fallback: 'value'), - wireName: wireName, - type: isRequired ? fieldType : fieldType.asNullable(), - required: isRequired, - description: _descriptionFor(propertySchema), - defaultValue: propertySchema['default'], + final requestedFieldName = _mappedNestedName( + config.generation.mappings.properties, + [contextName], + wireName, ); + final baseName = _mappedIdentifier( + requestedFieldName ?? toCamelCase(wireName), + fallback: 'value', + mappingKind: 'property', + mappingSource: '$contextName.$wireName', + diagnosticLocation: + '#/components/schemas/$contextName/properties/$wireName', + ); + final nameIndex = (usedNames[baseName] ?? 0) + 1; + usedNames[baseName] = nameIndex; + final fieldName = nameIndex == 1 ? baseName : '$baseName$nameIndex'; + if (nameIndex > 1) { + _diagnostics.warning( + 'openapi.property_name_collision', + 'Property `$wireName` in model `$contextName` maps to the same Dart ' + 'name `$baseName` as another property and was changed to ' + '`$fieldName`.', + source: _diagnosticSource, + location: '#/components/schemas/$contextName/properties/$wireName', + ); + } + + fields.add( + ModelField( + name: fieldName, + wireName: wireName, + type: isRequired ? fieldType : fieldType.asNullable(), + required: isRequired, + schemaNullable: _schemaAllowsNull(propertySchema), + readOnly: readOnly, + writeOnly: writeOnly, + constraints: _schemaConstraints(propertySchema), + description: _descriptionFor(propertySchema), + defaultValue: propertySchema['default'], + ), + ); + } + return fields; + } + + Map _schemaConstraints(Map schema) { + const keys = [ + 'format', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'pattern', + 'minItems', + 'maxItems', + 'uniqueItems', + 'minProperties', + 'maxProperties', + ]; + return { + for (final key in keys) + if (schema.containsKey(key)) key: schema[key], + }; + } + + void _populateAdditionalProperties( + ModelSpec model, + Map schema, { + required UsageKind usage, + required String contextName, + }) { + final materialized = + _mergeComposedSchema(schema) ?? _materializeSchema(schema); + if (!materialized.containsKey('additionalProperties')) { + return; + } + final rawAdditionalProperties = materialized['additionalProperties']; + if (rawAdditionalProperties == false) { + return; + } + if (rawAdditionalProperties == true) { + model.additionalPropertiesType = const DartType.dynamicType(); + return; + } + if (rawAdditionalProperties is Map) { + model.additionalPropertiesType = _resolveType( + rawAdditionalProperties.cast(), + usage: usage, + suggestedName: '${contextName}AdditionalProperty', + ); + return; + } + _diagnostics.error( + 'openapi.invalid_additional_properties', + 'additionalProperties for model `$contextName` must be a boolean or a ' + 'schema.', + source: _diagnosticSource, + location: '#/components/schemas/$contextName/additionalProperties', + ); + } + + bool _hasDirectionalProperties(Map schema) { + final materialized = + _mergeComposedSchema(schema) ?? _materializeSchema(schema); + final properties = + (materialized['properties'] as Map?)?.cast() ?? + const {}; + return properties.values.any((rawProperty) { + final property = _asMap(rawProperty); + return property['readOnly'] == true || property['writeOnly'] == true; }); } - List _enumValuesFor(Map schema) { + bool _schemaAllowsNull( + Map schema, [ + Set? visitedRefs, + ]) { + if (schema['nullable'] == true || _extractTypeValue(schema) == 'null') { + return true; + } + final rawType = schema['type']; + if (rawType is List && rawType.any((type) => type.toString() == 'null')) { + return true; + } + for (final key in const ['oneOf', 'anyOf']) { + final branches = schema[key]; + if (branches is List && + branches.any( + (branch) => _extractTypeValue(_asMap(branch)) == 'null', + )) { + return true; + } + } + final ref = schema[r'$ref'] as String?; + if (ref != null) { + final refName = _refName(ref); + final visited = visitedRefs ?? {}; + if (visited.add(refName)) { + final target = _asMap(_schemas[refName]); + if (target.isNotEmpty && _schemaAllowsNull(target, visited)) { + return true; + } + } + } + return false; + } + + List _enumValuesFor( + Map schema, { + required String enumName, + }) { final rawValues = (schema['enum'] as List?) ?? const []; final usedNames = {}; final values = []; - for (final rawValue in rawValues) { - final baseName = _enumMemberBaseName(rawValue); + for (var index = 0; index < rawValues.length; index += 1) { + final rawValue = rawValues[index]; + final configured = _configuredEnumMapping(enumName, rawValue); + final extensionMapping = _enumValueExtensionMapping( + schema, + rawValue, + index, + ); + final requestedName = + configured?.name ?? + _enumExtensionName(schema, index) ?? + extensionMapping?.name; + final description = + configured?.description ?? + _enumExtensionDescription(schema, index) ?? + extensionMapping?.description; + final baseName = requestedName == null + ? _enumMemberBaseName(rawValue) + : _mappedEnumMemberName( + requestedName, + enumName: enumName, + rawValue: rawValue, + ); final nextIndex = (usedNames[baseName] ?? 0) + 1; usedNames[baseName] = nextIndex; final name = nextIndex == 1 ? baseName : '$baseName${nextIndex.toString()}'; - values.add(EnumValue(name: name, rawValue: rawValue ?? 'null')); + if (nextIndex > 1) { + _diagnostics.warning( + 'openapi.duplicate_enum_member_name', + 'Multiple values in enum `$enumName` map to `$baseName`; value ' + '`$rawValue` will use member name `$name`.', + source: _diagnosticSource, + location: '#/components/schemas/$enumName', + ); + } + values.add( + EnumValue( + name: name, + rawValue: rawValue ?? 'null', + description: description, + ), + ); + } + + if (config.generation.models.enumUnknownFallback) { + const baseName = 'unknown'; + var name = baseName; + var suffix = 1; + while (usedNames.containsKey(name)) { + suffix += 1; + name = '$baseName$suffix'; + } + var fallbackRawValue = '__unknown__'; + var rawSuffix = 1; + while (rawValues.contains(fallbackRawValue)) { + rawSuffix += 1; + fallbackRawValue = '__unknown__$rawSuffix'; + } + values.add( + EnumValue( + name: name, + rawValue: fallbackRawValue, + description: + 'The server returned an enum value unknown to this client.', + isFallback: true, + ), + ); } return values; } + EnumValueMapping? _configuredEnumMapping(String enumName, Object? rawValue) { + final mappings = config.generation.models.enumMappings[enumName]; + if (mappings == null || rawValue == null) { + return null; + } + final exact = mappings[rawValue]; + if (exact != null) { + return exact; + } + + final textMatches = mappings.entries + .where((entry) => entry.key.toString() == rawValue.toString()) + .toList(growable: false); + if (textMatches.length == 1) { + return textMatches.single.value; + } + if (textMatches.length > 1) { + _diagnostics.warning( + 'config.ambiguous_enum_mapping', + 'Value `$rawValue` in enum `$enumName` matches configuration keys of ' + 'multiple types; the mapping will be ignored.', + source: config.configPath, + location: '\$.generation.models.enum_mappings.$enumName', + ); + } + return null; + } + + String? _enumExtensionName(Map schema, int index) { + for (final key in const [ + 'x-enumNames', + 'x-enum-names', + 'x-enum-varnames', + ]) { + final rawNames = schema[key]; + if (rawNames is List && index < rawNames.length) { + final name = rawNames[index]?.toString().trim(); + if (name != null && name.isNotEmpty) { + return name; + } + } + } + return null; + } + + String? _enumExtensionDescription(Map schema, int index) { + for (final key in const [ + 'x-enumDescriptions', + 'x-enum-descriptions', + ]) { + final rawDescriptions = schema[key]; + if (rawDescriptions is List && index < rawDescriptions.length) { + final description = rawDescriptions[index]?.toString().trim(); + if (description != null && description.isNotEmpty) { + return description; + } + } + } + return null; + } + + EnumValueMapping? _enumValueExtensionMapping( + Map schema, + Object? rawValue, + int index, + ) { + final extension = schema['x-enum-values']; + if (extension is List && index < extension.length) { + final rawMapping = extension[index]; + if (rawMapping is String) { + return EnumValueMapping(name: rawMapping); + } + if (rawMapping is Map) { + final declaredValue = rawMapping['value']; + if (declaredValue != null && + rawValue != null && + declaredValue.toString() != rawValue.toString()) { + return null; + } + final name = rawMapping['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + return EnumValueMapping( + name: name, + description: rawMapping['description']?.toString(), + ); + } + } + } + if (extension is Map && rawValue != null) { + final rawMapping = extension[rawValue] ?? extension[rawValue.toString()]; + if (rawMapping is String) { + return EnumValueMapping(name: rawMapping); + } + if (rawMapping is Map) { + final name = rawMapping['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + return EnumValueMapping( + name: name, + description: rawMapping['description']?.toString(), + ); + } + } + } + return null; + } + + String _mappedEnumMemberName( + String requestedName, { + required String enumName, + required Object? rawValue, + }) { + final trimmed = requestedName.trim(); + final sanitized = sanitizeIdentifier(trimmed, fallback: 'value'); + if (sanitized != trimmed) { + _diagnostics.warning( + 'openapi.enum_member_name_sanitized', + 'Mapped name `$requestedName` in enum `$enumName` is not a valid Dart ' + 'identifier and was changed to `$sanitized`.', + source: _diagnosticSource, + location: '#/components/schemas/$enumName/enum/$rawValue', + ); + } + return sanitized; + } + String _enumMemberBaseName(Object? rawValue) { final value = rawValue?.toString().trim() ?? 'value'; if (value.isEmpty) { return 'empty'; } if (RegExp(r'^-?\d+$').hasMatch(value)) { - final normalized = value.replaceFirst('-', 'Minus'); - return sanitizeIdentifier( - 'value${toPascalCase(normalized)}', - fallback: 'value', - ); + final normalized = value.startsWith('-') + ? 'Minus${value.substring(1)}' + : value; + return sanitizeIdentifier('value$normalized', fallback: 'value'); } if (value == 'true' || value == 'false') { return value; @@ -1285,8 +2895,9 @@ class OpenApiDocumentParser { String _modelClassName(String rawName) { final prefix = config.generation.models.classPrefix; + final mappedName = config.generation.mappings.models[rawName] ?? rawName; return sanitizeIdentifier( - '$prefix${toPascalCase(rawName)}', + '$prefix${toPascalCase(mappedName)}', fallback: '${prefix}GeneratedModel', ); } @@ -1313,6 +2924,10 @@ class OpenApiDocumentParser { } Map? _extractNullableBranch(Map schema) { + if (schema['nullable'] == true) { + return Map.from(schema)..remove('nullable'); + } + final rawType = schema['type']; if (rawType is List) { final nonNullTypes = rawType @@ -1363,7 +2978,9 @@ class OpenApiDocumentParser { final mergedRequired = {}; for (final item in allOf) { - final branch = _materializeSchema(_asMap(item)); + final materializedBranch = _materializeSchema(_asMap(item)); + final branch = + _mergeComposedSchema(materializedBranch) ?? materializedBranch; final properties = (branch['properties'] as Map?)?.cast() ?? const {}; @@ -1373,6 +2990,9 @@ class OpenApiDocumentParser { if (!merged.containsKey('description') && branch['description'] != null) { merged['description'] = branch['description']; } + if (branch.containsKey('additionalProperties')) { + merged['additionalProperties'] = branch['additionalProperties']; + } } final ownProperties = @@ -1380,6 +3000,12 @@ class OpenApiDocumentParser { const {}; mergedProperties.addAll(ownProperties); mergedRequired.addAll(_stringSet(schema['required'])); + if (schema.containsKey('additionalProperties')) { + merged['additionalProperties'] = schema['additionalProperties']; + } + if (schema['nullable'] == true) { + merged['nullable'] = true; + } if (mergedRequired.isNotEmpty) { merged['required'] = mergedRequired.toList(growable: false); @@ -1396,6 +3022,7 @@ class OpenApiDocumentParser { final refName = _refName(ref); final resolved = _asMap(_schemas[refName]); if (resolved.isEmpty) { + _reportUnresolvedRef(ref, componentKind: 'schema'); return schema; } @@ -1409,6 +3036,92 @@ class OpenApiDocumentParser { return schema['enum'] is List && (schema['enum'] as List).isNotEmpty; } + DartTypeMapping? _configuredTypeMapping(Map schema) { + final schemaType = _extractTypeValue(schema); + final format = schema['format']?.toString(); + final mappings = config.generation.mappings.types; + if (format != null && format.isNotEmpty) { + final exact = mappings['$schemaType:$format']; + if (exact != null) { + return exact; + } + } + return mappings[schemaType]; + } + + DartType _dartTypeFromMapping(String rawType) { + var value = rawType.trim(); + final nullable = value.endsWith('?'); + if (nullable) { + value = value.substring(0, value.length - 1).trim(); + } + if (value == 'dynamic') { + return DartType.dynamicType(nullable: nullable); + } + if (value == 'Object') { + return DartType.objectType(nullable: nullable); + } + if (value == 'void') { + return const DartType.voidType(); + } + + final genericStart = value.indexOf('<'); + String name; + List arguments; + if (genericStart > 0 && value.endsWith('>')) { + name = value.substring(0, genericStart).trim(); + final rawArguments = value.substring(genericStart + 1, value.length - 1); + arguments = _splitGenericTypeArguments( + rawArguments, + ).map(_dartTypeFromMapping).toList(growable: false); + } else { + name = value; + arguments = const []; + } + final sanitizedName = sanitizeIdentifier(name, fallback: 'Object'); + if (sanitizedName != name) { + _diagnostics.error( + 'config.invalid_dart_type_mapping', + 'Mapped type `$rawType` contains unsupported Dart type name `$name`.', + source: config.configPath, + location: r'$.generation.mappings', + ); + return DartType.objectType(nullable: nullable); + } + if (name == 'List' && arguments.length == 1) { + return DartType.list(arguments.single, nullable: nullable); + } + if (name == 'Map' && + arguments.length == 2 && + arguments.first.kind == TypeKind.named && + arguments.first.name == 'String') { + return DartType.map(arguments.last, nullable: nullable); + } + return DartType.named(name, nullable: nullable, arguments: arguments); + } + + List _splitGenericTypeArguments(String value) { + final result = []; + var depth = 0; + var start = 0; + for (var index = 0; index < value.length; index += 1) { + final character = value[index]; + if (character == '<') { + depth += 1; + } else if (character == '>') { + depth -= 1; + } else if (character == ',' && depth == 0) { + result.add(value.substring(start, index).trim()); + start = index + 1; + } + } + final last = value.substring(start).trim(); + if (last.isNotEmpty) { + result.add(last); + } + return result; + } + String _extractTypeValue(Map schema) { final rawType = schema['type']; if (rawType is String) { @@ -1443,6 +3156,460 @@ class OpenApiDocumentParser { return schema['description'] as String? ?? schema['title'] as String?; } + List _parseServers( + dynamic rawServers, { + required String location, + }) { + if (rawServers == null) { + return const []; + } + if (rawServers is! List) { + _diagnostics.error( + 'openapi.invalid_servers', + 'servers must be an array.', + source: _diagnosticSource, + location: location, + ); + return const []; + } + final result = []; + for (var index = 0; index < rawServers.length; index += 1) { + final server = _asMap(rawServers[index]); + final rawUrl = server['url']?.toString().trim(); + if (rawUrl == null || rawUrl.isEmpty) { + _diagnostics.error( + 'openapi.server_url_missing', + 'A server entry does not define a non-empty URL.', + source: _diagnosticSource, + location: '$location[$index].url', + ); + continue; + } + final variables = + (server['variables'] as Map?)?.cast() ?? + const {}; + final resolvedVariables = {}; + var resolvedUrl = rawUrl; + for (final entry in variables.entries) { + final variable = _asMap(entry.value); + if (!variable.containsKey('default')) { + _diagnostics.error( + 'openapi.server_variable_default_missing', + 'Server variable `${entry.key}` does not define the required ' + 'default value.', + source: _diagnosticSource, + location: '$location[$index].variables.${entry.key}', + ); + continue; + } + final value = variable['default'].toString(); + final allowedValues = variable['enum']; + if (allowedValues is List && + allowedValues.isNotEmpty && + !allowedValues.map((item) => item.toString()).contains(value)) { + _diagnostics.error( + 'openapi.server_variable_default_invalid', + 'Default value `$value` for server variable `${entry.key}` is not ' + 'present in its enum.', + source: _diagnosticSource, + location: '$location[$index].variables.${entry.key}.default', + ); + } + resolvedVariables[entry.key] = value; + resolvedUrl = resolvedUrl.replaceAll('{${entry.key}}', value); + } + if (RegExp(r'\{[^}]+\}').hasMatch(resolvedUrl)) { + _diagnostics.error( + 'openapi.server_variable_unresolved', + 'Server URL `$rawUrl` still contains a variable that is undeclared ' + 'or has no default value.', + source: _diagnosticSource, + location: '$location[$index].url', + ); + } + result.add( + ApiServer( + url: resolvedUrl, + description: server['description']?.toString(), + variables: Map.unmodifiable(resolvedVariables), + ), + ); + } + return List.unmodifiable(result); + } + + Map _parseSecuritySchemes(dynamic rawSchemes) { + if (rawSchemes == null) { + return const {}; + } + if (rawSchemes is! Map) { + _diagnostics.error( + 'openapi.invalid_security_schemes', + 'components.securitySchemes must be an object.', + source: _diagnosticSource, + location: r'$.components.securitySchemes', + ); + return const {}; + } + final result = {}; + for (final entry in rawSchemes.entries) { + final name = entry.key.toString(); + final scheme = _asMap(entry.value); + final rawType = scheme['type']?.toString(); + final type = switch (rawType) { + 'http' => ApiSecuritySchemeType.http, + 'apiKey' => ApiSecuritySchemeType.apiKey, + 'oauth2' => ApiSecuritySchemeType.oauth2, + 'openIdConnect' => ApiSecuritySchemeType.openIdConnect, + 'mutualTLS' => ApiSecuritySchemeType.mutualTls, + _ => ApiSecuritySchemeType.unknown, + }; + if (type == ApiSecuritySchemeType.unknown) { + _diagnostics.warning( + 'openapi.unsupported_security_scheme', + 'Security scheme `$name` with type `${rawType ?? ''}` has no ' + 'dedicated integration and will be preserved as metadata.', + source: _diagnosticSource, + location: '\$.components.securitySchemes.$name', + ); + } + if (type == ApiSecuritySchemeType.apiKey && + (scheme['name'] == null || + !const { + 'query', + 'header', + 'cookie', + }.contains(scheme['in']?.toString()))) { + _diagnostics.error( + 'openapi.invalid_api_key_security_scheme', + 'apiKey scheme `$name` must declare a name and use query, header, ' + 'or cookie for in.', + source: _diagnosticSource, + location: '\$.components.securitySchemes.$name', + ); + } + final httpScheme = scheme['scheme']?.toString().toLowerCase(); + if (type == ApiSecuritySchemeType.http && + httpScheme != 'bearer' && + httpScheme != 'basic') { + _diagnostics.warning( + 'openapi.http_auth_requires_custom_interceptor', + 'HTTP authentication scheme `$name` uses `$httpScheme`. Implement ' + 'it with a Dio interceptor; its metadata was preserved.', + source: _diagnosticSource, + location: '\$.components.securitySchemes.$name.scheme', + ); + } + final oauthScopes = {}; + final flows = _asMap(scheme['flows']); + for (final flow in flows.values) { + oauthScopes.addAll(_asMap(_asMap(flow)['scopes']).keys); + } + result[name] = ApiSecurityScheme( + name: name, + type: type, + scheme: httpScheme, + bearerFormat: scheme['bearerFormat']?.toString(), + parameterName: scheme['name']?.toString(), + location: scheme['in']?.toString(), + description: scheme['description']?.toString(), + openIdConnectUrl: scheme['openIdConnectUrl']?.toString(), + oauthScopes: oauthScopes.toList(growable: false)..sort(), + ); + } + return Map.unmodifiable(result); + } + + List _parseSecurityRequirements( + dynamic rawRequirements, { + required String location, + }) { + if (rawRequirements == null) { + return const []; + } + if (rawRequirements is! List) { + _diagnostics.error( + 'openapi.invalid_security_requirements', + 'security must be an array of Security Requirement objects.', + source: _diagnosticSource, + location: location, + ); + return const []; + } + final result = []; + for (var index = 0; index < rawRequirements.length; index += 1) { + final rawRequirement = rawRequirements[index]; + if (rawRequirement is! Map) { + _diagnostics.error( + 'openapi.invalid_security_requirement', + 'A Security Requirement entry must be an object.', + source: _diagnosticSource, + location: '$location[$index]', + ); + continue; + } + final schemes = >{}; + for (final entry in rawRequirement.entries) { + final schemeName = entry.key.toString(); + if (!_parsedSecuritySchemes.containsKey(schemeName)) { + _diagnostics.error( + 'openapi.unknown_security_scheme', + 'Security Requirement references unknown scheme `$schemeName`.', + source: _diagnosticSource, + location: '$location[$index].$schemeName', + ); + } + final rawScopes = entry.value; + if (rawScopes is! List) { + _diagnostics.error( + 'openapi.invalid_security_scopes', + 'Scopes for scheme `$schemeName` must be an array.', + source: _diagnosticSource, + location: '$location[$index].$schemeName', + ); + schemes[schemeName] = const []; + continue; + } + schemes[schemeName] = rawScopes + .map((scope) => scope.toString()) + .toList(growable: false); + } + result.add( + ApiSecurityRequirement( + schemes: Map>.unmodifiable(schemes), + ), + ); + } + return List.unmodifiable(result); + } + + void _validateDocument() { + final rawVersion = _document['openapi']; + final version = rawVersion?.toString().trim(); + if (version == null || version.isEmpty) { + final swaggerVersion = _document['swagger']?.toString(); + if (swaggerVersion != null) { + _diagnostics.error( + 'openapi.unsupported_version', + 'Detected Swagger $swaggerVersion; this generator supports OpenAPI ' + '3.0 and 3.1.', + source: _diagnosticSource, + location: r'$.swagger', + ); + } else { + _diagnostics.error( + 'openapi.missing_version', + 'The required `openapi` version field is missing.', + source: _diagnosticSource, + location: r'$.openapi', + ); + } + } else if (!RegExp(r'^3\.(0|1)\.\d+(?:[-+].*)?$').hasMatch(version)) { + _diagnostics.error( + 'openapi.unsupported_version', + 'OpenAPI $version is unsupported; supported versions are 3.0.x and ' + '3.1.x.', + source: _diagnosticSource, + location: r'$.openapi', + ); + } + + final info = _document['info']; + if (info is! Map) { + _diagnostics.error( + 'openapi.invalid_info', + 'The required `info` object is missing.', + source: _diagnosticSource, + location: r'$.info', + ); + } + + final paths = _document['paths']; + if (paths != null && paths is! Map) { + _diagnostics.error( + 'openapi.invalid_paths', + '`paths` must be an object.', + source: _diagnosticSource, + location: r'$.paths', + ); + } else if (config.generation.api.enabled && + (paths is! Map || paths.isEmpty)) { + _diagnostics.error( + 'openapi.missing_paths', + 'API generation is enabled, but the document has no `paths` to ' + 'generate.', + source: _diagnosticSource, + location: r'$.paths', + ); + } + + final components = _document['components']; + if (components != null && components is! Map) { + _diagnostics.error( + 'openapi.invalid_components', + '`components` must be an object.', + source: _diagnosticSource, + location: r'$.components', + ); + } + + if (_document['webhooks'] is Map && + (_document['webhooks'] as Map).isNotEmpty) { + _diagnostics.warning( + 'openapi.webhooks_not_supported', + 'This version does not generate webhooks.', + source: _diagnosticSource, + location: r'$.webhooks', + ); + } + + _validateReferences(_document, r'$'); + _validateUnsupportedOperationFeatures(); + _validateUnsupportedLinks(_document, r'$'); + } + + void _validateUnsupportedLinks(dynamic value, String location) { + if (value is Map) { + if (value['links'] is Map && (value['links'] as Map).isNotEmpty) { + _diagnostics.warning( + 'openapi.links_not_supported', + 'This version preserves response structures but does not generate ' + 'call relationships for OpenAPI Link Objects.', + source: _diagnosticSource, + location: '$location.links', + ); + } + for (final entry in value.entries) { + _validateUnsupportedLinks( + entry.value, + '$location.${entry.key.toString()}', + ); + } + return; + } + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _validateUnsupportedLinks(value[index], '$location[$index]'); + } + } + } + + void _validateReferences(dynamic value, String location) { + if (value is Map) { + final ref = value[r'$ref']; + if (ref is String) { + if (ref.startsWith('#')) { + if (_resolveLocalJsonPointer(ref) == null) { + _diagnostics.error( + 'openapi.unresolved_ref', + 'Unable to resolve local reference `$ref`.', + source: _diagnosticSource, + location: '$location.\$ref', + ); + } + } else { + _diagnostics.error( + 'openapi.external_ref_not_bundled', + 'External reference `$ref` was not normalized during loading. ' + 'Check the target, network access, and resolver diagnostics.', + source: _diagnosticSource, + location: '$location.\$ref', + ); + } + } + + for (final entry in value.entries) { + _validateReferences(entry.value, '$location.${entry.key.toString()}'); + } + return; + } + if (value is List) { + for (var index = 0; index < value.length; index += 1) { + _validateReferences(value[index], '$location[$index]'); + } + } + } + + Object? _resolveLocalJsonPointer(String ref) { + if (ref == '#') { + return _document; + } + if (!ref.startsWith('#/')) { + return null; + } + + Object? current = _document; + for (final rawSegment in ref.substring(2).split('/')) { + final segment = Uri.decodeComponent( + rawSegment.replaceAll('~1', '/').replaceAll('~0', '~'), + ); + 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; + } + + void _validateUnsupportedOperationFeatures() { + final paths = _document['paths']; + if (paths is! Map) { + return; + } + for (final pathEntry in paths.entries) { + final pathItem = pathEntry.value; + if (pathItem is! Map) { + continue; + } + for (final method in _httpMethods) { + final operation = pathItem[method]; + if (operation is! Map) { + continue; + } + final location = '\$.paths.${pathEntry.key}.$method'; + if (operation['callbacks'] is Map && + (operation['callbacks'] as Map).isNotEmpty) { + _diagnostics.warning( + 'openapi.callbacks_not_supported', + 'This version does not generate callbacks.', + source: _diagnosticSource, + location: '$location.callbacks', + ); + } + } + } + } + + void _reportUnresolvedRef( + String ref, { + required String componentKind, + String? context, + }) { + final key = '$componentKind:$ref'; + if (!_reportedDiagnosticKeys.add(key)) { + return; + } + _diagnostics.error( + 'openapi.unresolved_$componentKind', + 'Unable to resolve $componentKind reference `$ref`' + '${context == null ? '' : ' (context: $context)'}.', + source: _diagnosticSource, + location: ref, + ); + } + Map _asMap(dynamic value) { if (value is Map) { return value; diff --git a/lib/src/openapi/reference_resolver.dart b/lib/src/openapi/reference_resolver.dart new file mode 100644 index 0000000..7e60094 --- /dev/null +++ b/lib/src/openapi/reference_resolver.dart @@ -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 document; + + /// Canonical external documents loaded while resolving references. + final List 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> _documentCache = + >{}; + final Set _loadedDependencies = {}; + final Map _bundledRefs = {}; + final Map> _bundledComponents = + >{}; + final Set _activeInlineRefs = {}; + + late String _rootSource; + late Map _rootDocument; + + /// Loads [source], resolves its references, and returns dependency metadata. + Future 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() + : {}; + _mergeBundledComponents(resolvedDocument); + + return ResolvedOpenApiSource( + source: _rootSource, + document: resolvedDocument, + dependencies: + _loadedDependencies + .where((dependency) => dependency != _rootSource) + .toList(growable: false) + ..sort(), + ); + } + + Future _resolveNode( + dynamic value, { + required String baseSource, + required String location, + }) async { + if (value is List) { + final result = []; + 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(); + final ref = map[r'$ref']; + if (ref is String) { + final resolvedRef = await _resolveReference( + ref, + baseSource: baseSource, + location: '$location.\$ref', + ); + final siblings = {}; + 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(), 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 = {}; + 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 _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 {}; + } + + 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 {}; + } + return {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 {}; + } + 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 {}; + } + return _resolveNode( + _deepCopy(targetValue), + baseSource: target.source, + location: '${target.source}#${target.fragment}', + ); + } finally { + _activeInlineRefs.remove(absoluteRef); + } + } + + Future> _bundleExternalComponent( + _ReferenceTarget target, { + required _ComponentPointer component, + required String location, + }) async { + final absoluteRef = '${target.source}#${target.fragment}'; + final existingRef = _bundledRefs[absoluteRef]; + if (existingRef != null) { + return {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, + () => {}, + ); + group[localName] = {}; + + 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 {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(); + } + return {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 = { + 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 document) { + if (_bundledComponents.isEmpty) { + return; + } + final components = document.putIfAbsent( + 'components', + () => {}, + ); + 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(); + for (final entry in _bundledComponents.entries) { + final rawGroup = componentMap.putIfAbsent( + entry.key, + () => {}, + ); + 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().addAll(entry.value); + } + } + + Future> _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 {}; + } + 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 {}; + } + } + + _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 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 _deepCopyMap(Map value) { + return _deepCopy(value) as Map; + } + + 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; +} diff --git a/lib/src/preset/preset.dart b/lib/src/preset/preset.dart index e613ee4..b4ee4e7 100644 --- a/lib/src/preset/preset.dart +++ b/lib/src/preset/preset.dart @@ -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 { + /// Creates a generator preset. const GeneratorPreset({ required this.name, required this.multipartBinaryStyle, required this.description, }); + /// The stable configuration name of this preset. final String name; + + /// The Dart representation used for multipart binary fields. final BinaryTransportStyle multipartBinaryStyle; + + /// A human-readable English description of this preset. final String description; } +/// Resolves the built-in generator presets by name. class GeneratorPresetRegistry { + GeneratorPresetRegistry._(); + + /// The Retrofit, Dio, and Freezed preset used by Koi Flutter projects. static const GeneratorPreset koiFlutter = GeneratorPreset( name: 'koi_flutter', multipartBinaryStyle: BinaryTransportStyle.multipartFile, description: - 'Koi Flutter preset,面向 Retrofit + Dio + Freezed 场景,' - 'multipart 二进制字段默认生成为 MultipartFile。', + 'Koi Flutter preset for Retrofit, Dio, and Freezed projects. ' + '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) { final name = rawName?.trim(); if (name == null || name.isEmpty || name == koiFlutter.name) { return koiFlutter; } - throw ArgumentError.value(name, 'generation.preset.name', '未知的 preset。'); + throw ArgumentError.value( + name, + 'generation.preset.name', + 'Unknown preset.', + ); } } diff --git a/lib/src/util/utils.dart b/lib/src/util/utils.dart index 9bc2e06..309d9c1 100644 --- a/lib/src/util/utils.dart +++ b/lib/src/util/utils.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:yaml/yaml.dart'; +/// Dart reserved words and built-in identifiers unsafe for generated names. const Set dartKeywords = { 'abstract', 'as', @@ -88,6 +89,7 @@ const List _knownSnakeCaseAcronyms = [ 'uuid', ]; +/// Splits mixed-case, snake-case, kebab-case, and punctuated input into words. List splitWords(String input) { final normalized = input .replaceAllMapped( @@ -111,6 +113,7 @@ List splitWords(String input) { .toList(growable: false); } +/// Converts [input] to a valid UpperCamelCase Dart identifier. String toPascalCase(String input) { final words = splitWords(input); if (words.isEmpty) { @@ -127,6 +130,7 @@ String toPascalCase(String input) { return sanitizeIdentifier(value, fallback: 'GeneratedValue'); } +/// Converts [input] to a valid lowerCamelCase Dart identifier. String toCamelCase(String input) { final pascal = toPascalCase(input); if (pascal.isEmpty) { @@ -136,6 +140,7 @@ String toCamelCase(String input) { return sanitizeIdentifier(value, fallback: 'generatedValue'); } +/// Converts [input] to a lowercase snake_case file or directory name. String toSnakeCase(String input) { final words = _mergeSnakeCaseAcronyms(splitWords(input)); if (words.isEmpty) { @@ -187,6 +192,7 @@ class _SnakeCaseAcronymMatch { final int endIndex; } +/// Removes invalid Dart identifier characters and applies [fallback] as needed. String sanitizeIdentifier(String raw, {required String fallback}) { var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), ''); if (value.isEmpty) { @@ -201,6 +207,7 @@ String sanitizeIdentifier(String raw, {required String fallback}) { return value; } +/// Escapes [value] for inclusion in a single-quoted generated Dart literal. String escapeDartString(String value) { return value .replaceAll('\\', r'\\') @@ -208,6 +215,7 @@ String escapeDartString(String value) { .replaceAll('\n', r'\n'); } +/// Converts a YAML or ordinary map into a string-keyed Dart map. Map yamlToMap(dynamic value) { if (value is YamlMap) { return value.map( @@ -224,6 +232,7 @@ Map yamlToMap(dynamic value) { return {}; } +/// Recursively converts YAML collection nodes into ordinary Dart values. dynamic yamlValueToDynamic(dynamic value) { if (value is YamlMap || value is Map) { return yamlToMap(value); @@ -236,6 +245,7 @@ dynamic yamlValueToDynamic(dynamic value) { return value; } +/// Recursively overlays [override] values on a copy of [base]. Map deepMergeMaps( Map base, Map override, @@ -253,10 +263,49 @@ Map deepMergeMaps( return result; } +/// Whether [value] is an absolute HTTP or HTTPS URL. bool isHttpUrl(String value) { 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? patterns) { + return patterns?.any((pattern) => matchesPattern(value, pattern)) ?? false; +} + +/// Decodes a JSON or YAML document whose root must be an object. Map decodeStructuredDocument(String content) { final trimmed = content.trimLeft(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { @@ -264,13 +313,13 @@ Map decodeStructuredDocument(String content) { if (decoded is Map) { return decoded; } - throw const FormatException('OpenAPI 根节点必须是对象。'); + throw const FormatException('The OpenAPI document root must be an object.'); } final yaml = loadYaml(content); final mapped = yamlToMap(yaml); if (mapped.isEmpty) { - throw const FormatException('无法解析 OpenAPI 文档内容。'); + throw const FormatException('Unable to parse the OpenAPI document.'); } return mapped; } diff --git a/pubspec.yaml b/pubspec.yaml index e18c236..5c31dd0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,13 @@ name: koi_swagger_parser -description: OpenAPI 3.1 parser and Koi-style Dart API generator for Retrofit + Freezed projects. -version: 0.1.0 +description: Generates Retrofit and Freezed Dart/Flutter clients from OpenAPI 3.0 and 3.1 documents. +version: 0.2.0 homepage: https://github.com/mrkoi-is/koi_swagger_parser repository: https://github.com/mrkoi-is/koi_swagger_parser issue_tracker: https://github.com/mrkoi-is/koi_swagger_parser/issues +documentation: https://pub.dev/documentation/koi_swagger_parser/latest/ + +executables: + koi_swagger_parser: topics: - openapi @@ -17,6 +21,7 @@ environment: dependencies: args: ^2.7.0 + crypto: ^3.0.7 http: ^1.5.0 path: ^1.9.1 yaml: ^3.1.3 diff --git a/test/cli_test.dart b/test/cli_test.dart new file mode 100644 index 0000000..9c653f0 --- /dev/null +++ b/test/cli_test.dart @@ -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(['--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(['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(['--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([ + '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({ + 'openapi': '3.1.0', + 'info': {'title': 'CLI', 'version': '1'}, + 'paths': { + '/health': { + 'get': { + 'operationId': 'health', + 'responses': { + '204': {'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([ + '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 _runCli(List arguments) { + return Process.run(Platform.resolvedExecutable, [ + 'run', + 'koi_swagger_parser', + ...arguments, + ], workingDirectory: Directory.current.path); +} diff --git a/test/config_loader_test.dart b/test/config_loader_test.dart index 54e8ac8..0d41bff 100644 --- a/test/config_loader_test.dart +++ b/test/config_loader_test.dart @@ -72,5 +72,127 @@ formatting: expect(config.generation.models.classPrefix, 'Koi'); 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, ['/api/**']); + expect(config.output.excludedOperations, [r'regex:^DELETE\s']); + expect(config.output.includedModels, ['*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().having( + (error) => error.diagnostics.map((item) => item.code), + 'diagnostic codes', + containsAll([ + '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().having( + (error) => error.diagnostics.map((item) => item.code), + 'diagnostic codes', + contains('config.unsupported_api_renderer'), + ), + ), + ); + }); }); } diff --git a/test/enum_mappings_and_diagnostics_test.dart b/test/enum_mappings_and_diagnostics_test.dart new file mode 100644 index 0000000..8ac502c --- /dev/null +++ b/test/enum_mappings_and_diagnostics_test.dart @@ -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([ + '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().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().having( + (error) => error.diagnostics.map((diagnostic) => diagnostic.code), + 'diagnostic codes', + contains('openapi.unresolved_ref'), + ), + ), + ); + }); + }); +} diff --git a/test/fixture_corpus_test.dart b/test/fixture_corpus_test.dart new file mode 100644 index 0000000..56e1e76 --- /dev/null +++ b/test/fixture_corpus_test.dart @@ -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 = [ + '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, + ); + }); + } +} diff --git a/test/fixtures/e2e_openapi.json b/test/fixtures/e2e_openapi.json new file mode 100644 index 0000000..a64fde7 --- /dev/null +++ b/test/fixtures/e2e_openapi.json @@ -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" + } + } + } + ] + } + } + } +} diff --git a/test/fixtures/openapi_30_compat.yaml b/test/fixtures/openapi_30_compat.yaml new file mode 100644 index 0000000..3437ea3 --- /dev/null +++ b/test/fixtures/openapi_30_compat.yaml @@ -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 diff --git a/test/generation_integration_test.dart b/test/generation_integration_test.dart index debbf1b..369a0bb 100644 --- a/test/generation_integration_test.dart +++ b/test/generation_integration_test.dart @@ -97,7 +97,7 @@ formatting: final materialResponse = File( p.join(outputDir, 'api_models', 'result', 'material_response.dart'), ).readAsStringSync(); - expect(materialResponse, contains("@JsonKey(name: 'store_id')")); + expect(materialResponse, contains("@JsonKey(name: 'store_id',")); expect(materialResponse, contains('required int storeId')); final sharedError = File( diff --git a/test/generation_lifecycle_and_filters_test.dart b/test/generation_lifecycle_and_filters_test.dart new file mode 100644 index 0000000..5c6a88b --- /dev/null +++ b/test/generation_lifecycle_and_filters_test.dart @@ -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().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({ + 'openapi': '3.1.0', + 'info': {'title': 'models', 'version': '1'}, + 'paths': {}, + 'components': { + 'schemas': {'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 dispose() => directory.delete(recursive: true); + + Future generate({bool dryRun = false}) { + return const KoiSwaggerParser().generate( + configPath: configPath, + workingDirectory: directory.path, + dryRun: dryRun, + ); + } + + Future writeDocument({ + required bool includeAdmin, + bool includeSecretOperation = false, + }) async { + final paths = { + '/api/v1/pets/{id}': { + 'get': _operation( + operationId: 'getPet', + tag: 'pets', + schemaName: 'Pet', + ), + }, + if (includeSecretOperation) + '/api/v1/pets/{id}/secret': { + 'get': _operation( + operationId: 'getPetSecret', + tag: 'pets', + schemaName: 'Pet', + ), + }, + if (includeAdmin) + '/api/v1/admin': { + 'get': _operation( + operationId: 'getAdmin', + tag: 'admin', + schemaName: 'AdminRecord', + ), + }, + }; + final document = { + 'openapi': '3.1.0', + 'info': {'title': 'Lifecycle', 'version': '1.0.0'}, + 'paths': paths, + 'components': { + 'schemas': { + 'Pet': _objectSchema('name'), + 'AdminRecord': _objectSchema('role'), + 'Standalone': _objectSchema('value'), + }, + }, + }; + await File(documentPath).writeAsString(jsonEncode(document)); + } +} + +Map _operation({ + required String operationId, + required String tag, + required String schemaName, +}) { + return { + 'operationId': operationId, + 'tags': [tag], + 'responses': { + '200': { + 'description': 'OK', + 'content': { + 'application/json': { + 'schema': { + r'$ref': '#/components/schemas/$schemaName', + }, + }, + }, + }, + }, + }; +} + +Map _objectSchema(String property) { + return { + 'type': 'object', + 'required': [property], + 'properties': { + property: {'type': 'string'}, + }, + }; +} diff --git a/test/golden_generation_test.dart b/test/golden_generation_test.dart new file mode 100644 index 0000000..090cab5 --- /dev/null +++ b/test/golden_generation_test.dart @@ -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 = {}; + 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 = { + 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(); + + expect( + sortedActual, + expected, + reason: + 'If this output change is intentional, review generated Dart first ' + 'and then update the golden hashes.', + ); + }); +} diff --git a/test/goldens/e2e_generated_files.sha256.json b/test/goldens/e2e_generated_files.sha256.json new file mode 100644 index 0000000..95080ab --- /dev/null +++ b/test/goldens/e2e_generated_files.sha256.json @@ -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" +} diff --git a/test/http_semantics_test.dart b/test/http_semantics_test.dart new file mode 100644 index 0000000..124877c --- /dev/null +++ b/test/http_semantics_test.dart @@ -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 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, [ + '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: []), + 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: { + 'openapi': '3.1.0', + 'info': {'title': 'optional body', 'version': '1'}, + 'paths': { + '/api/v1/items': { + 'post': { + 'tags': ['items'], + 'requestBody': { + 'required': false, + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + }, + }, + }, + }, + }, + 'responses': { + '204': {'description': 'ok'}, + }, + }, + }, + }, + }, + sources: const ['memory://optional-body'], + ), + ); + + expect(parsed.operations.single.requestBody!.type!.nullable, isTrue); + }); + }); +} diff --git a/test/multipart_and_union_test.dart b/test/multipart_and_union_test.dart index 13f0111..d7350c3 100644 --- a/test/multipart_and_union_test.dart +++ b/test/multipart_and_union_test.dart @@ -149,7 +149,7 @@ formatting: p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'), ).readAsStringSync(); expect(metaFile, contains('class KoiUploadMeta')); - expect(metaFile, contains("@JsonKey(name: 'album_id')")); + expect(metaFile, contains("@JsonKey(name: 'album_id',")); }); }); diff --git a/test/naming_type_mappings_test.dart b/test/naming_type_mappings_test.dart new file mode 100644 index 0000000..9b37895 --- /dev/null +++ b/test/naming_type_mappings_test.dart @@ -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 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().having( + (error) => error.diagnostics.map((item) => item.code), + 'codes', + contains('config.mapping_target_collision'), + ), + ), + ); + }); + }); +} diff --git a/test/reference_resolver_test.dart b/test/reference_resolver_test.dart new file mode 100644 index 0000000..c25e297 --- /dev/null +++ b/test/reference_resolver_test.dart @@ -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 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().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().having( + (error) => error.diagnostics.map((diagnostic) => diagnostic.code), + 'diagnostic codes', + contains('openapi.multisource_operation_conflict'), + ), + ), + ); + }); + }); +} + +Future _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" + } + } + } + } + } +} +'''; +} diff --git a/test/response_semantics_test.dart b/test/response_semantics_test.dart new file mode 100644 index 0000000..1cde591 --- /dev/null +++ b/test/response_semantics_test.dart @@ -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: { + 'openapi': '3.1.0', + 'info': {'title': 'responses', 'version': '1'}, + 'paths': { + '/api/v1/items': { + 'get': { + 'tags': ['items'], + 'operationId': 'list_items', + 'responses': { + '200': { + 'description': 'items', + 'headers': { + 'X-Rate-Limit': { + 'description': 'remaining requests', + 'schema': {'type': 'integer'}, + }, + }, + 'content': { + 'application/json': { + 'schema': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + }, + }, + '204': {'description': 'empty'}, + 'default': { + 'description': 'error', + 'content': { + 'application/problem+json': { + 'schema': { + 'type': 'object', + 'properties': { + 'detail': {'type': 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + sources: const ['memory://responses'], + ), + ); + + final operation = parsed.operations.single; + expect(operation.returnType.render(), 'List'); + 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({'Accept':")); + expect(api, contains("'application/octet-stream'")); + expect(api, contains('@DioResponseType(ResponseType.bytes)')); + expect(api, contains('Future> downloadFile')); + expect(api, contains('Header Content-Disposition: String?')); + }); + + test('binary 与 JSON 成功响应混用时结构化拒绝', () { + expect( + () => OpenApiDocumentParser(_testConfig()).parse( + LoadedOpenApiDocument( + document: { + 'openapi': '3.1.0', + 'info': {'title': 'mixed', 'version': '1'}, + 'paths': { + '/api/v1/export': { + 'get': { + 'tags': ['export'], + 'responses': { + '200': { + 'description': 'json', + 'content': { + 'application/json': { + 'schema': {'type': 'string'}, + }, + }, + }, + '206': { + 'description': 'bytes', + 'content': { + 'application/octet-stream': { + 'schema': { + 'type': 'string', + 'format': 'binary', + }, + }, + }, + }, + }, + }, + }, + }, + }, + sources: const ['memory://mixed'], + ), + ), + throwsA( + isA().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: { + 'openapi': '3.1.0', + 'info': {'title': 'xml', 'version': '1'}, + 'paths': { + '/legacy': { + 'get': { + 'responses': { + '200': { + 'description': 'xml', + 'content': { + 'application/xml': { + 'schema': {'type': 'object'}, + }, + }, + }, + }, + }, + }, + }, + }, + sources: const ['memory://xml'], + ), + ), + throwsA( + isA().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: { + 'openapi': '3.1.0', + 'info': {'title': 'links', 'version': '1'}, + 'paths': { + '/orders': { + 'get': { + 'responses': { + '200': { + 'description': 'order', + 'content': { + 'application/json': { + 'schema': {'type': 'string'}, + }, + }, + 'links': { + 'customer': { + 'operationId': 'getCustomer', + }, + }, + }, + }, + }, + }, + }, + }, + sources: const ['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: []), + 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, + ), + ), + ); +} diff --git a/test/schema_semantics_test.dart b/test/schema_semantics_test.dart new file mode 100644 index 0000000..bc25c48 --- /dev/null +++ b/test/schema_semantics_test.dart @@ -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 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 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')); + }); + }); +} diff --git a/test/server_security_test.dart b/test/server_security_test.dart new file mode 100644 index 0000000..6e6d609 --- /dev/null +++ b/test/server_security_test.dart @@ -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': {")); + expect(client, contains("'scheme': 'bearer'")); + expect(client, contains("'apiKeyAuth': {")); + expect(client, contains("'in': 'header'")); + expect(client, contains("'scopes': ['accounts:read']")); + expect(client, contains("'GET /accounts':")); + expect(client, contains("'POST /upload': >>[]")); + }); + + test('不存在的 security scheme 会结构化拒绝', () { + final config = _testConfig(); + expect( + () => OpenApiDocumentParser(config).parse( + LoadedOpenApiDocument( + document: { + 'openapi': '3.1.0', + 'info': { + 'title': 'invalid auth', + 'version': '1', + }, + 'security': [ + {'missing': []}, + ], + 'paths': { + '/value': { + 'get': { + 'responses': { + '204': {'description': 'ok'}, + }, + }, + }, + }, + }, + sources: const ['memory://invalid-auth'], + ), + ), + throwsA( + isA().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: []), + 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, + ), + ), + ); +} diff --git a/test/union_semantics_test.dart b/test/union_semantics_test.dart new file mode 100644 index 0000000..b6ef1e1 --- /dev/null +++ b/test/union_semantics_test.dart @@ -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 matches')); + + for (final file in [oneOfFile, anyOfFile]) { + final format = await Process.run(Platform.resolvedExecutable, [ + 'format', + '--output=none', + file.path, + ]); + expect( + format.exitCode, + 0, + reason: '${format.stdout}\n${format.stderr}', + ); + } + }); + }); +} diff --git a/tool/verify_generated_package.dart b/tool/verify_generated_package.dart new file mode 100644 index 0000000..4f993fc --- /dev/null +++ b/tool/verify_generated_package.dart @@ -0,0 +1,177 @@ +import 'dart:io'; + +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; +import 'package:path/path.dart' as p; + +Future main(List 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 [ + '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 [ + 'analyze', + ], workingDirectory: tempDirectory.path); + await _run(Platform.resolvedExecutable, const [ + '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 _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: {'retries': 3}, + ); + expect(settings.toJson(), { + 'theme': 'dark', + 'retries': 3, + }); + + final decoded = KoiFlexibleSettings.fromJson({ + 'theme': 'light', + 'retries': 5, + }); + expect(decoded.theme, 'light'); + expect(decoded.additionalProperties, {'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()); + + final criteria = KoiSearchCriteria.fromJson({ + 'name': 'Koi', + 'id': 7, + }); + expect(criteria, isA()); + expect((criteria as KoiSearchCriteriaAnyOf).matches, hasLength(2)); + }); + + test('Retrofit transport adapter has a concrete generated implementation', () { + expect(AccountsApi, isNotNull); + }); +} +'''); +} + +Future _run( + String executable, + List 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, + ); + } +}