commit ea93f09154bc2e369debae646440b9de532052eb Author: mr.koi Date: Tue Jun 2 22:00:13 2026 +0800 Establish the Koi Swagger parser release Prepare the OpenAPI parser and Dart generator as a publishable Koi package with CLI entrypoint, docs, examples, tests, and pub.dev release metadata. Constraint: Initial GitHub import follows koi_blueprint_flutter identity mr.koi . Rejected: Keep local runtime state in the initial repository | .dart_tool, .omx, and pubspec.lock are ignored for a reusable package source tree. Confidence: high Scope-risk: moderate Directive: Keep generated-output behavior covered by parser and generator tests before changing rendering rules. Tested: dart analyze; dart test; PUB_HOSTED_URL=https://pub.dev dart pub publish --dry-run Not-tested: End-to-end downstream build_runner compilation of generated Retrofit/Freezed output. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..177989d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.omx/ +build/ +coverage/ +.DS_Store +pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c051ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.1.0 + +- 初始化 `koi_swagger_parser`。 +- 支持 OpenAPI 3.1/FastAPI 常见结构解析、Koi 风格 Retrofit API 生成、Freezed 模型生成。 +- 支持兼容读取旧版 `generator_config.yaml`。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2083ca2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mrkoi-is + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6557d43 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# koi_swagger_parser + +`koi_swagger_parser` 是一个面向 Koi 生态的新一代 OpenAPI 解析与 Dart 代码生成器,目标不是继续修补旧式 Swagger 生成链,而是为未来的 `koi_blueprint_flutter` 提供一套更稳、更清晰、更可扩展的基础设施。 + +当前版本聚焦 Koi/FastAPI 场景,优先解决这几类问题: + +- OpenAPI 3.1 下 `anyOf[T, null]`、`type: [T, null]` 的 nullable 解析。 +- FastAPI `code/message/data` 成功 envelope 的识别与外部泛型包装。 +- `multipart/form-data` 请求体生成 `@MultiPart()` + `@Part()`,并按 preset 选择二进制类型。 +- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper。 +- 以 Koi 目录结构输出 `api/`、`api_models/request|result|shared|enums/`。 +- 兼容旧版 `generator_config.yaml`,平滑替换 `swagger_generator_flutter`。 + +## 设计目标 + +- 解析层和 Koi 业务约束解耦:parser core 不硬编码 `BaseResult`。 +- 先产出中间表示(IR),再做 Koi 风格渲染,方便未来扩展到 `koi_blueprint_flutter`。 +- 默认以 Retrofit + Freezed 为输出基线,但 response wrapper 可配置。 +- 对 FastAPI/SQLAlchemy 风格服务端有良好适配,同时不把服务端补丁写死在生成器里。 + +## 配置文件 + +推荐使用 `koi_swagger_parser.yaml`: + +```yaml +input: + swagger_urls: + - url: ./openapi.json + enabled: true + +output: + base_dir: ./lib/src + api_dir: ./lib/src/api + models_dir: ./lib/src/api_models + included_tags: + - auth + - stores + - materials + split_by_tags: true + extract_version_from_url: false + +generation: + preset: + name: koi_flutter + api: + enabled: true + use_retrofit: true + cancel_token_support: true + client: + class_name: DbaApiClient + file_name: dba_api_client + response_wrapper: + mode: external_generic + success_data_field: data + detect_standard_envelope: true + external_generic: + import: package:dba_network/dba_network.dart + class_name: BaseResult + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: true +``` + +也兼容旧版 `generator_config.yaml`: + +- `generation.api.base_result_import` 会自动映射为 `response_wrapper.mode = external_generic`。 +- `output.included_tags`、`generation.models.class_prefix` 等旧字段会继续生效。 + +## 命令行 + +```bash +dart run koi_swagger_parser generate --all +``` + +常用参数: + +- `--config`:指定配置文件路径。 +- `--cwd`:指定工作目录。 +- `--dry-run`:只解析与渲染,不落盘。 +- `--verbose`:打印更多诊断信息。 + +## 输出结构 + +默认会生成: + +```text +lib/src/ + api/ + dba_api_client.dart + index.dart + v1/ + index.dart + auth_api.dart + stores_api.dart + api_models/ + api_models.dart + request/ + result/ + shared/ + enums/ +``` + +## 当前能力 + +当前版本已经覆盖 Koi 当前最需要的能力: + +- OpenAPI 3.1 文档加载(本地 JSON/YAML、远程 URL、多 source merge)。 +- `object` / `array` / `enum` / `additionalProperties` / `allOf` 基础支持。 +- nullable query/path/body 字段解析。 +- FastAPI envelope 检测与 `BaseResult` 外部包装。 +- `multipart/form-data` 生成 `MultipartFile` / `File` / `Uint8List` / `String` 等 preset 驱动参数。 +- `oneOf` / `anyOf` + discriminator 生成 sealed union wrapper,并可被其他模型继续引用。 +- Retrofit API 文件、Freezed 模型文件、分组 index 文件生成。 + +下一阶段可以继续往前加: + +- 更丰富的 multipart metadata(如 encoding / runtime fileName / contentType preset)。 +- 无 discriminator 的更强联合类型兜底策略。 +- 更多 preset(例如 Chopper / blueprint preset / 网络层集成 preset)。 +- 与 `koi_blueprint_flutter` 的一键联动模板。 + +## 开发与验证 + +```bash +dart pub get +dart format . +dart test +``` + +架构设计说明见 `doc/ARCHITECTURE.md`。 diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..bd9b06e --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:lints/recommended.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + +linter: + rules: + public_member_api_docs: false + lines_longer_than_80_chars: false + prefer_single_quotes: true diff --git a/bin/koi_swagger_parser.dart b/bin/koi_swagger_parser.dart new file mode 100644 index 0000000..f7d8c69 --- /dev/null +++ b/bin/koi_swagger_parser.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; + +Future main(List arguments) async { + final parser = ArgParser() + ..addFlag('help', abbr: 'h', negatable: false, help: '显示帮助信息。'); + + final generateParser = ArgParser() + ..addFlag('all', abbr: 'a', negatable: false, help: '兼容旧命令格式,当前等价于生成全部资源。') + ..addOption('config', abbr: 'c', help: '配置文件路径。') + ..addOption('cwd', help: '工作目录。') + ..addFlag('dry-run', negatable: false, help: '只解析与渲染,不落盘。') + ..addFlag('verbose', abbr: 'v', negatable: false, help: '打印更多诊断信息。'); + parser.addCommand('generate', generateParser); + + late final ArgResults result; + try { + result = parser.parse(arguments); + } on FormatException catch (error) { + stderr.writeln(error.message); + stderr.writeln(parser.usage); + exitCode = 64; + return; + } + + if ((result['help'] as bool?) ?? false) { + stdout.writeln(_usage(parser)); + return; + } + + final command = result.command; + if (command == null || command.name == 'generate') { + final commandArgs = command ?? result; + await _runGenerate(commandArgs, parser); + return; + } + + stdout.writeln(_usage(parser)); +} + +Future _runGenerate(ArgResults result, ArgParser parser) async { + final generator = const KoiSwaggerParser(); + try { + final summary = await generator.generate( + configPath: result['config'] as String?, + workingDirectory: result['cwd'] as String?, + dryRun: result['dry-run'] as bool? ?? false, + verbose: result['verbose'] as bool? ?? false, + ); + + final mode = summary.dryRun ? 'dry-run' : 'written'; + stdout.writeln( + '[koi_swagger_parser] $mode ${summary.writtenFiles.length} file(s), ' + '${summary.operationsCount} operation(s), ' + '${summary.modelsCount} model(s).', + ); + } on Object catch (error, stackTrace) { + stderr.writeln('[koi_swagger_parser] generation failed: $error'); + if ((result['verbose'] as bool?) ?? false) { + stderr.writeln(stackTrace); + } else { + stderr.writeln('使用 --verbose 可以查看详细堆栈。'); + } + stderr.writeln(parser.usage); + exitCode = 1; + } +} + +String _usage(ArgParser parser) { + return [ + 'koi_swagger_parser', + '', + '用法:', + ' dart run koi_swagger_parser generate --all', + '', + parser.usage, + ].join('\n'); +} diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md new file mode 100644 index 0000000..13e6e90 --- /dev/null +++ b/doc/ARCHITECTURE.md @@ -0,0 +1,114 @@ +# Architecture + +`koi_swagger_parser` 采用“加载 -> 解析 -> IR -> 渲染 -> 落盘”的分层设计,避免把 OpenAPI 细节、Koi 业务约定、具体 Dart 输出模板揉在一起。 + +## 1. 分层 + +### config + +负责读取与归一化配置: + +- 新配置:`koi_swagger_parser.yaml` +- 兼容配置:`generator_config.yaml` +- 路径归一化、默认值、旧字段兼容映射 +- preset 解析(当前内置 `koi_flutter`) + +### openapi + +负责: + +- 本地/远程文档加载 +- 多 source 深合并 +- schema/ref 解析 +- OpenAPI 3.1 nullable / anyOf / oneOf / additionalProperties / allOf 归一化 +- multipart request body 与 discriminator union 识别 + +### ir + +产出与 OpenAPI 原文解耦的中间表示: + +- API operation +- request body / parameter +- model / enum +- Dart type graph + +IR 是后续接入 `koi_blueprint_flutter` preset 的关键。 + +### generator + +只关心如何把 IR 渲染成 Dart 文件: + +- Retrofit API +- Freezed model +- enums +- index/client 聚合文件 + +这一层不直接处理 OpenAPI 细节。 + +### preset + +用于把 parser core 的 IR 和具体客户端技术栈解耦。当前先提供一个很薄的 preset 层: + +- `koi_flutter`:默认面向 Retrofit + Dio + Freezed +- 当前主要控制 multipart 二进制字段该生成为 `MultipartFile`、`File`、`Uint8List` 还是 `String` +- 后续可以继续扩展 blueprint preset、网络层 preset、命名/包装策略 preset + +## 2. Response Wrapper 设计 + +parser core 不直接写死 `BaseResult`。 + +当前支持两种模式: + +- `passthrough` + - 返回真实 OpenAPI response schema + - 适合你希望保留服务端 envelope 模型的场景 +- `external_generic` + - 识别标准成功 envelope(默认 `code/message/data`) + - 将 `data` 提取为 inner type + - API 返回 `ExternalWrapper`,例如 `BaseResult` + +这样做的意义是: + +- parser core 保持通用 +- Koi 网络层包裹策略可配置 +- 未来可以挂更多 wrapper preset,而不是继续堆服务端补丁 + +## 3. 模型分组 + +模型按使用语义归档: + +- `request/`:只在请求体根节点使用 +- `result/`:只在成功响应根节点使用 +- `shared/`:被请求与响应共同依赖,或用于错误响应 +- `enums/`:枚举单独分组 + +这比“按 swagger component 平铺”更符合 Koi 项目结构,也更利于未来蓝图模板整理。 + +## 4. 命名原则 + +- 类名统一 PascalCase。 +- 字段统一 camelCase,并保留 `@JsonKey(name: ...)` 对应 wire name。 +- 文件名统一 snake_case。 +- API 默认按 `version + tag` 分文件。 + +命名转换统一收口到 util 层,避免每个阶段各写一套字符串处理逻辑。 + +## 5. 为什么新建项目而不是继续修补旧生成器 + +旧生成器的问题不是单点 bug,而是结构性耦合: + +- OpenAPI 解析和业务输出绑定太深。 +- nullable / anyOf / wrapper 规则分散在多个模板与解析阶段。 +- 很难为未来 `koi_blueprint_flutter` 抽出复用 IR。 + +所以这里直接重新收口: + +- parser core 面向 OpenAPI 语义 +- Koi 输出作为 renderer/preset +- 兼容旧配置,但不延续旧架构负担 + +## 6. 近期路线 + +- 第一阶段:打通 Koi/FastAPI 场景(已完成) +- 第二阶段:补齐 multipart、discriminator union、preset 基础层(当前版本) +- 第三阶段:扩更强 union fallback、更多 preset,并服务 `koi_blueprint_flutter` diff --git a/example/koi_swagger_parser_example.dart b/example/koi_swagger_parser_example.dart new file mode 100644 index 0000000..b16efc0 --- /dev/null +++ b/example/koi_swagger_parser_example.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +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(''' +input: + swagger_urls: + - url: ${p.join(packageRoot, 'test', 'fixtures', 'dba_openapi.json')} + enabled: true + +output: + base_dir: ${p.join(tempDir.path, 'lib', 'src')} + included_tags: + - stores + - materials + +generation: + api: + response_wrapper: + mode: passthrough + +formatting: + run_dart_format: false +'''); + + final result = await const KoiSwaggerParser().generate( + configPath: configFile.path, + dryRun: true, + ); + + print( + 'loaded ${result.sourceDocuments.length} source(s), ' + 'parsed ${result.operationsCount} operation(s), ' + 'rendered ${result.modelsCount} model(s).', + ); +} diff --git a/lib/koi_swagger_parser.dart b/lib/koi_swagger_parser.dart new file mode 100644 index 0000000..4371634 --- /dev/null +++ b/lib/koi_swagger_parser.dart @@ -0,0 +1,6 @@ +library; + +export 'src/config/config.dart'; +export 'src/ir/ir.dart'; +export 'src/koi_swagger_parser.dart'; +export 'src/preset/preset.dart'; diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart new file mode 100644 index 0000000..aed81e6 --- /dev/null +++ b/lib/src/config/config.dart @@ -0,0 +1,374 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +import '../preset/preset.dart'; +import '../util/utils.dart'; + +enum ResponseWrapperMode { passthrough, externalGeneric } + +class KoiSwaggerConfig { + KoiSwaggerConfig({ + required this.configPath, + required this.workingDirectory, + required this.input, + required this.output, + required this.generation, + }); + + final String? configPath; + final String workingDirectory; + final InputConfig input; + final OutputConfig output; + final GenerationConfig generation; + + static Future load({ + String? configPath, + String? workingDirectory, + }) { + return KoiSwaggerConfigLoader.load( + configPath: configPath, + workingDirectory: workingDirectory, + ); + } +} + +class InputConfig { + const InputConfig({required this.swaggerUrls}); + + final List swaggerUrls; +} + +class OutputConfig { + const OutputConfig({ + required this.baseDir, + required this.apiDir, + required this.modelsDir, + required this.splitByTags, + required this.extractVersionFromUrl, + this.includedTags, + this.excludedTags, + }); + + final String baseDir; + final String apiDir; + final String modelsDir; + final List? includedTags; + final List? excludedTags; + final bool splitByTags; + final bool extractVersionFromUrl; +} + +class GenerationConfig { + const GenerationConfig({ + required this.preset, + required this.api, + required this.models, + required this.formatting, + }); + + final GeneratorPreset preset; + final ApiGenerationConfig api; + final ModelGenerationConfig models; + final FormattingConfig formatting; +} + +class ApiGenerationConfig { + const ApiGenerationConfig({ + required this.enabled, + required this.useRetrofit, + required this.cancelTokenSupport, + required this.client, + required this.responseWrapper, + }); + + final bool enabled; + final bool useRetrofit; + final bool cancelTokenSupport; + final ClientConfig client; + final ResponseWrapperConfig responseWrapper; +} + +class ClientConfig { + const ClientConfig({required this.className, required this.fileName}); + + final String className; + final String fileName; +} + +class ResponseWrapperConfig { + const ResponseWrapperConfig({ + required this.mode, + required this.successDataField, + required this.detectStandardEnvelope, + this.externalImport, + this.externalClassName, + }); + + final ResponseWrapperMode mode; + final String successDataField; + final bool detectStandardEnvelope; + final String? externalImport; + final String? externalClassName; +} + +class ModelGenerationConfig { + const ModelGenerationConfig({ + required this.enabled, + required this.classPrefix, + required this.defaultNonNullableListToEmpty, + required this.jsonSerializable, + }); + + final bool enabled; + final String classPrefix; + final bool defaultNonNullableListToEmpty; + final JsonSerializableConfig jsonSerializable; +} + +class JsonSerializableConfig { + const JsonSerializableConfig({ + required this.checked, + required this.explicitToJson, + required this.includeIfNull, + }); + + final bool checked; + final bool explicitToJson; + final bool includeIfNull; +} + +class FormattingConfig { + const FormattingConfig({ + required this.runDartFormat, + required this.runDartFix, + }); + + final bool runDartFormat; + final bool runDartFix; +} + +class KoiSwaggerConfigLoader { + static const List _defaultNames = [ + 'koi_swagger_parser.yaml', + 'koi_swagger_parser.yml', + 'generator_config.yaml', + 'generator_config.yml', + ]; + + static Future load({ + String? configPath, + String? workingDirectory, + }) async { + final cwd = p.normalize(workingDirectory ?? Directory.current.path); + final resolvedConfig = configPath == null + ? _findConfigFile(cwd) + : _resolvePath(configPath, cwd); + final configDir = resolvedConfig == null ? cwd : p.dirname(resolvedConfig); + final rawConfig = resolvedConfig == null + ? {} + : await _readConfigFile(resolvedConfig); + + 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 baseDir = _resolvePath( + output?['base_dir'] as String? ?? './lib/src', + configDir, + ); + final apiDir = _resolvePath( + output?['api_dir'] as String? ?? p.join(baseDir, 'api'), + configDir, + ); + final modelsDir = _resolvePath( + output?['models_dir'] as String? ?? p.join(baseDir, 'api_models'), + configDir, + ); + + final legacyBaseResultImport = api?['base_result_import'] as String?; + final wrapperMode = _parseWrapperMode( + responseWrapper?['mode'] as String?, + legacyBaseResultImport: legacyBaseResultImport, + ); + + return KoiSwaggerConfig( + configPath: resolvedConfig, + workingDirectory: cwd, + input: InputConfig( + swaggerUrls: _parseSwaggerUrls( + rawConfig['input'] as Map?, + configDir, + ), + ), + output: OutputConfig( + baseDir: baseDir, + apiDir: apiDir, + modelsDir: modelsDir, + includedTags: _stringList(output?['included_tags']), + excludedTags: _stringList(output?['excluded_tags']), + splitByTags: output?['split_by_tags'] as bool? ?? true, + extractVersionFromUrl: + output?['extract_version_from_url'] as bool? ?? false, + ), + generation: GenerationConfig( + preset: GeneratorPresetRegistry.resolve(preset?['name'] as String?), + api: ApiGenerationConfig( + enabled: api?['enabled'] as bool? ?? true, + useRetrofit: api?['use_retrofit'] as bool? ?? true, + cancelTokenSupport: api?['cancel_token_support'] as bool? ?? false, + client: ClientConfig( + className: sanitizeIdentifier( + client?['class_name'] as String? ?? 'ApiClient', + fallback: 'ApiClient', + ), + fileName: toSnakeCase( + client?['file_name'] as String? ?? 'api_client', + ), + ), + responseWrapper: ResponseWrapperConfig( + mode: wrapperMode, + successDataField: + responseWrapper?['success_data_field'] as String? ?? 'data', + detectStandardEnvelope: + responseWrapper?['detect_standard_envelope'] as bool? ?? true, + externalImport: + externalGeneric?['import'] as String? ?? legacyBaseResultImport, + externalClassName: + externalGeneric?['class_name'] as String? ?? + (legacyBaseResultImport == null ? null : 'BaseResult'), + ), + ), + models: ModelGenerationConfig( + enabled: models?['enabled'] as bool? ?? true, + classPrefix: sanitizeIdentifier( + models?['class_prefix'] as String? ?? 'Koi', + fallback: 'Koi', + ), + defaultNonNullableListToEmpty: + models?['default_non_nullable_list_to_empty'] as bool? ?? true, + jsonSerializable: JsonSerializableConfig( + checked: jsonSerializable?['checked'] as bool? ?? true, + explicitToJson: + jsonSerializable?['explicit_to_json'] as bool? ?? true, + includeIfNull: + jsonSerializable?['include_if_null'] as bool? ?? false, + ), + ), + formatting: FormattingConfig( + runDartFormat: formatting?['run_dart_format'] as bool? ?? true, + runDartFix: formatting?['run_dart_fix'] as bool? ?? false, + ), + ), + ); + } + + static Future> _readConfigFile(String path) async { + final file = File(path); + final exists = file.existsSync(); + if (exists == false) { + throw FileSystemException('配置文件不存在。', path); + } + final yaml = loadYaml(await file.readAsString()); + return yamlToMap(yaml); + } + + static String? _findConfigFile(String cwd) { + for (final name in _defaultNames) { + final candidate = p.join(cwd, name); + if (File(candidate).existsSync()) { + return candidate; + } + } + return null; + } + + static String _resolvePath(String rawPath, String baseDir) { + final trimmed = rawPath.trim(); + if (trimmed.startsWith('file://')) { + return p.normalize(Uri.parse(trimmed).toFilePath()); + } + if (p.isAbsolute(trimmed)) { + return p.normalize(trimmed); + } + return p.normalize(p.join(baseDir, trimmed)); + } + + static List _parseSwaggerUrls( + Map? input, + String configDir, + ) { + final sources = input?['swagger_urls']; + if (sources is List) { + final result = []; + for (final item in sources) { + if (item is String && item.trim().isNotEmpty) { + result.add(_normalizeSource(item, configDir)); + continue; + } + if (item is Map) { + final enabled = item['enabled'] as bool? ?? true; + final url = item['url'] as String?; + final hasUrl = url?.trim().isNotEmpty ?? false; + if (enabled && hasUrl) { + result.add(_normalizeSource(url ?? '', configDir)); + } + } + } + return result; + } + return []; + } + + static String _normalizeSource(String raw, String configDir) { + final trimmed = raw.trim(); + if (trimmed.startsWith('file://') || isHttpUrl(trimmed)) { + return trimmed; + } + return _resolvePath(trimmed, configDir); + } + + static List? _stringList(dynamic value) { + if (value is List) { + final result = value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + return result.isEmpty ? null : result; + } + return null; + } + + static ResponseWrapperMode _parseWrapperMode( + String? rawMode, { + String? legacyBaseResultImport, + }) { + if (rawMode == null || rawMode.trim().isEmpty) { + return legacyBaseResultImport == null + ? ResponseWrapperMode.passthrough + : ResponseWrapperMode.externalGeneric; + } + switch (rawMode.trim()) { + case 'passthrough': + return ResponseWrapperMode.passthrough; + case 'external_generic': + return ResponseWrapperMode.externalGeneric; + default: + throw ArgumentError.value( + rawMode, + 'response_wrapper.mode', + '未知的 wrapper 模式。', + ); + } + } +} diff --git a/lib/src/generator/generator.dart b/lib/src/generator/generator.dart new file mode 100644 index 0000000..c471d4a --- /dev/null +++ b/lib/src/generator/generator.dart @@ -0,0 +1,789 @@ +import 'package:path/path.dart' as p; + +import '../config/config.dart'; +import '../ir/ir.dart'; +import '../util/utils.dart'; + +class DartGenerator { + DartGenerator(this.config); + + final KoiSwaggerConfig config; + late Map _modelIndex; + + GeneratedBundle generate(ParsedApiDocument document) { + _modelIndex = document.models; + final files = []; + + final modelFiles = {}; + for (final model in document.models.values) { + modelFiles[model.name] = p.join( + _modelCategoryDirectory(model.category), + '${_modelFileStem(model)}.dart', + ); + } + + if (config.generation.models.enabled) { + for (final model in document.models.values) { + final relativePath = modelFiles[model.name]!; + final absolutePath = p.join(config.output.modelsDir, relativePath); + files.add( + GeneratedFile( + path: absolutePath, + content: _renderModelFile( + model, + relativePath: relativePath, + modelFiles: modelFiles, + ), + ), + ); + } + + files.addAll(_buildModelIndexFiles(document.models.values, modelFiles)); + } + + if (config.generation.api.enabled) { + files.addAll(_buildApiFiles(document.operations)); + } + + return GeneratedBundle( + files: files, + operationsCount: document.operations.length, + modelsCount: document.models.length, + ); + } + + List _buildModelIndexFiles( + Iterable models, + Map modelFiles, + ) { + final files = []; + final groups = >{ + ModelCategory.request: [], + ModelCategory.result: [], + ModelCategory.shared: [], + ModelCategory.enums: [], + }; + + for (final model in models) { + groups[model.category]!.add(model); + } + + for (final entry in groups.entries) { + final exports = + entry.value + .map( + (model) => "export '${p.basename(modelFiles[model.name]!)}';", + ) + .toList(growable: false) + ..sort(); + + final directory = _modelCategoryDirectory(entry.key); + files.add( + GeneratedFile( + path: p.join(config.output.modelsDir, directory, 'index.dart'), + content: _simpleLibraryFile(exports), + ), + ); + } + + files.add( + GeneratedFile( + path: p.join(config.output.modelsDir, 'api_models.dart'), + content: _simpleLibraryFile(const [ + "export 'request/index.dart';", + "export 'result/index.dart';", + "export 'shared/index.dart';", + "export 'enums/index.dart';", + ]), + ), + ); + + files.add( + GeneratedFile( + path: p.join(config.output.modelsDir, 'index.dart'), + content: _simpleLibraryFile(const [ + "export 'api_models.dart';", + ]), + ), + ); + + return files; + } + + List _buildApiFiles(List operations) { + if (!config.generation.api.useRetrofit) { + throw UnsupportedError('当前版本只支持 Retrofit API 生成。'); + } + + final grouped = >>{}; + for (final operation in operations) { + final versionGroup = grouped.putIfAbsent( + operation.version, + () => >{}, + ); + final groupKey = config.output.splitByTags ? operation.tag : 'api'; + versionGroup.putIfAbsent(groupKey, () => []).add(operation); + } + + final files = []; + final versionImports = []; + final clientFields = []; + + final sortedVersions = grouped.keys.toList()..sort(); + for (final version in sortedVersions) { + final groups = grouped[version]!; + final apiExports = []; + final sortedGroupKeys = groups.keys.toList()..sort(); + + for (final groupKey in sortedGroupKeys) { + final apiFileName = _apiFileName(groupKey, version); + final apiClassName = _apiClassName(groupKey, version); + final absolutePath = p.join(config.output.apiDir, version, apiFileName); + + final sortedOperations = groups[groupKey]! + ..sort((left, right) { + final pathCompare = left.path.compareTo(right.path); + if (pathCompare != 0) { + return pathCompare; + } + return left.method.compareTo(right.method); + }); + + files.add( + GeneratedFile( + path: absolutePath, + content: _renderApiFile( + operations: sortedOperations, + version: version, + className: apiClassName, + fileName: apiFileName, + ), + ), + ); + apiExports.add("export '$apiFileName';"); + + final propertyName = _clientPropertyName(groupKey, version); + clientFields.add( + ' late final $version.$apiClassName $propertyName = ' + '$version.$apiClassName(_dio, baseUrl: baseUrl);', + ); + } + + files.add( + GeneratedFile( + path: p.join(config.output.apiDir, version, 'index.dart'), + content: _simpleLibraryFile(apiExports..sort()), + ), + ); + versionImports.add("import '$version/index.dart' as $version;"); + } + + final clientFileName = + '${toSnakeCase(config.generation.api.client.fileName)}.dart'; + files.add( + GeneratedFile( + path: p.join(config.output.apiDir, clientFileName), + content: _renderClientFile( + versionImports: versionImports, + clientFields: clientFields, + ), + ), + ); + + final rootExports = [ + "export '$clientFileName';", + for (final version in sortedVersions) "export '$version/index.dart';", + ]; + files.add( + GeneratedFile( + path: p.join(config.output.apiDir, 'index.dart'), + content: _simpleLibraryFile(rootExports), + ), + ); + + return files; + } + + String _renderModelFile( + ModelSpec model, { + required String relativePath, + required Map modelFiles, + }) { + switch (model.kind) { + case ModelKind.enumType: + return _renderEnumFile(model); + case ModelKind.unionType: + return _renderUnionFile( + model, + relativePath: relativePath, + modelFiles: modelFiles, + ); + case ModelKind.object: + return _renderObjectModelFile( + model, + relativePath: relativePath, + modelFiles: modelFiles, + ); + } + } + + String _renderObjectModelFile( + ModelSpec model, { + required String relativePath, + required Map modelFiles, + }) { + final currentDir = p.dirname(relativePath); + final candidateNames = modelFiles.keys.toSet(); + final imports = {}; + for (final field in model.fields) { + final referencedNames = field.type.collectReferencedNames(candidateNames) + ..remove(model.name); + for (final referencedName in referencedNames) { + final targetPath = modelFiles[referencedName]!; + final importPath = p + .relative(targetPath, from: currentDir) + .replaceAll('\\', '/'); + imports.add("import '$importPath';"); + } + } + + final jsonSerializable = config.generation.models.jsonSerializable; + final fieldLines = []; + for (final field in model.fields) { + final description = field.description; + if (description != null && description.trim().isNotEmpty) { + fieldLines.addAll(_docComment(description, prefix: ' ')); + } + + final annotations = []; + if (field.name != field.wireName) { + annotations.add( + "@JsonKey(name: '${escapeDartString(field.wireName)}')", + ); + } + + final defaultValue = _defaultExpressionFor(field); + if (defaultValue != null) { + annotations.add('@Default($defaultValue)'); + } + + for (final annotation in annotations) { + fieldLines.add(' $annotation'); + } + + final parameterPrefix = defaultValue == null && field.required + ? 'required ' + : ''; + fieldLines.add( + ' $parameterPrefix${field.type.render()} ${field.name},', + ); + } + + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln('// ignore_for_file: type=lint, invalid_annotation_target') + ..writeln() + ..writeln("import 'package:freezed_annotation/freezed_annotation.dart';"); + + if (imports.isNotEmpty) { + buffer.writeln(); + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + } + + final fileStem = p.basenameWithoutExtension(relativePath); + buffer + ..writeln() + ..writeln("part '$fileStem.freezed.dart';") + ..writeln("part '$fileStem.g.dart';") + ..writeln(); + + if (model.description != null && model.description!.trim().isNotEmpty) { + for (final line in _docComment(model.description!)) { + buffer.writeln(line); + } + } + + buffer + ..writeln('@freezed') + ..writeln('abstract class ${model.name} with _\$${model.name} {') + ..writeln( + ' @JsonSerializable(' + 'checked: ${jsonSerializable.checked}, ' + 'explicitToJson: ${jsonSerializable.explicitToJson}, ' + 'includeIfNull: ${jsonSerializable.includeIfNull})', + ) + ..writeln(' const factory ${model.name}({'); + + for (final line in fieldLines) { + buffer.writeln(line); + } + + buffer + ..writeln(' }) = _${model.name};') + ..writeln() + ..writeln( + ' factory ${model.name}.fromJson(Map json) => ' + '_\$${model.name}FromJson(json);', + ) + ..writeln('}'); + + return buffer.toString(); + } + + String _renderUnionFile( + 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';"); + } + } + + final discriminatorKey = model.discriminatorKey ?? 'type'; + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln(); + + if (imports.isNotEmpty) { + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + buffer.writeln(); + } + + if (model.description != null && model.description!.trim().isNotEmpty) { + for (final line in _docComment(model.description!)) { + buffer.writeln(line); + } + } + + buffer + ..writeln('sealed class ${model.name} {') + ..writeln(' const ${model.name}();') + ..writeln() + ..writeln(' factory ${model.name}.fromJson(Map json) {') + ..writeln( + " final discriminator = json['${escapeDartString(discriminatorKey)}'];", + ) + ..writeln(' switch (discriminator) {'); + + for (final unionCase in model.unionCases) { + buffer + ..writeln(' case ${_dartLiteral(unionCase.wireValue)}:') + ..writeln( + ' return ${model.name}${unionCase.caseName}(' + '${unionCase.type.render()}.fromJson(json));', + ); + } + + buffer + ..writeln(' default:') + ..writeln( + " throw ArgumentError.value(discriminator, '$discriminatorKey', 'Unsupported discriminator for ${model.name}.');", + ) + ..writeln(' }') + ..writeln(' }') + ..writeln() + ..writeln(' Map toJson();') + ..writeln('}') + ..writeln(); + + for (final unionCase in model.unionCases) { + buffer + ..writeln( + 'final class ${model.name}${unionCase.caseName} extends ${model.name} {', + ) + ..writeln(' const ${model.name}${unionCase.caseName}(this.value);') + ..writeln() + ..writeln(' final ${unionCase.type.render()} value;') + ..writeln() + ..writeln(' @override') + ..writeln(' Map toJson() => value.toJson();') + ..writeln('}') + ..writeln(); + } + + return '${buffer.toString().trimRight()}\n'; + } + + String _renderEnumFile(ModelSpec model) { + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln() + ..writeln("import 'package:json_annotation/json_annotation.dart';") + ..writeln(); + + if (model.description != null && model.description!.trim().isNotEmpty) { + for (final line in _docComment(model.description!)) { + buffer.writeln(line); + } + } + + buffer.writeln('enum ${model.name} {'); + for (final value in model.enumValues) { + buffer.writeln(' @JsonValue(${_dartLiteral(value.rawValue)})'); + buffer.writeln(' ${value.name},'); + } + buffer.writeln('}'); + return buffer.toString(); + } + + String _renderApiFile({ + required List operations, + required String version, + required String className, + required String fileName, + }) { + final currentDir = p.join(config.output.apiDir, version); + final modelsIndexPath = p + .relative( + p.join(config.output.modelsDir, 'api_models.dart'), + from: currentDir, + ) + .replaceAll('\\', '/'); + + final imports = [ + if (operations.any( + (operation) => _operationUsesNamedType(operation, 'File'), + )) + "import 'dart:io';", + if (operations.any( + (operation) => _operationUsesNamedType(operation, 'Uint8List'), + )) + "import 'dart:typed_data';", + "import 'package:dio/dio.dart';", + "import 'package:retrofit/retrofit.dart';", + "import '$modelsIndexPath';", + ]; + + final wrapperImport = config.generation.api.responseWrapper.externalImport; + if (config.generation.api.responseWrapper.mode == + ResponseWrapperMode.externalGeneric && + wrapperImport != null && + wrapperImport.isNotEmpty) { + imports.add("import '$wrapperImport';"); + } + + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln(); + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + + final partStem = p.basenameWithoutExtension(fileName); + buffer + ..writeln() + ..writeln("part '$partStem.g.dart';") + ..writeln() + ..writeln('@RestApi(parser: Parser.JsonSerializable)') + ..writeln('abstract class $className {') + ..writeln( + ' factory $className(Dio dio, {String? baseUrl}) = _$className;', + ) + ..writeln(); + + for (final operation in operations) { + if (operation.summary != null && operation.summary!.trim().isNotEmpty) { + for (final line in _docComment(operation.summary!)) { + buffer.writeln(' $line'); + } + } + if (operation.description != null && + operation.description!.trim().isNotEmpty && + operation.description != operation.summary) { + for (final line in _docComment(operation.description!)) { + buffer.writeln(' $line'); + } + } + if (operation.deprecated) { + buffer.writeln(" @Deprecated('Deprecated in OpenAPI spec.')"); + } + if (operation.requestBody?.kind == ApiRequestBodyKind.multipart) { + buffer.writeln(' @MultiPart()'); + } + buffer + ..writeln(" @${operation.method}('${operation.path}')") + ..writeln( + ' Future<${operation.returnType.render()}> ' + '${operation.methodName}(${_renderApiParameters(operation)});', + ) + ..writeln(); + } + + buffer.writeln('}'); + return buffer.toString(); + } + + String _renderApiParameters(ApiOperation operation) { + final parameters = []; + for (final parameter in operation.parameters) { + final annotation = switch (parameter.location) { + 'path' => "@Path('${escapeDartString(parameter.wireName)}')", + 'header' => "@Header('${escapeDartString(parameter.wireName)}')", + _ => "@Query('${escapeDartString(parameter.wireName)}')", + }; + final defaultValue = _defaultExpressionForType( + parameter.defaultValue, + parameter.type, + ); + final requiredPrefix = parameter.required ? 'required ' : ''; + final defaultSuffix = defaultValue == null ? '' : ' = $defaultValue'; + parameters.add( + '$annotation $requiredPrefix${parameter.type.render()} ${parameter.name}$defaultSuffix', + ); + } + + final requestBody = operation.requestBody; + if (requestBody != null) { + if (requestBody.kind == ApiRequestBodyKind.multipart) { + for (final field in requestBody.multipartFields) { + parameters.add(_renderMultipartParameter(field)); + } + } else { + final requiredPrefix = requestBody.required ? 'required ' : ''; + final bodyType = requestBody.type?.render() ?? 'Object'; + parameters.add( + '@Body() $requiredPrefix$bodyType ${requestBody.parameterName}', + ); + } + } + + if (config.generation.api.cancelTokenSupport) { + parameters.add('@CancelRequest() CancelToken? cancelToken'); + } + + if (parameters.isEmpty) { + return ''; + } + + final buffer = StringBuffer()..writeln('{'); + for (final parameter in parameters) { + buffer.writeln(' $parameter,'); + } + buffer.write(' }'); + return buffer.toString(); + } + + String _renderMultipartParameter(ApiMultipartField field) { + final contentType = field.contentType; + final annotation = contentType == null + ? "@Part(name: '${escapeDartString(field.wireName)}')" + : "@Part(name: '${escapeDartString(field.wireName)}', contentType: '${escapeDartString(contentType)}')"; + final requiredPrefix = field.required ? 'required ' : ''; + return '$annotation $requiredPrefix${field.type.render()} ${field.name}'; + } + + bool _operationUsesNamedType(ApiOperation operation, String typeName) { + for (final parameter in operation.parameters) { + if (_typeUsesNamed(parameter.type, typeName)) { + return true; + } + } + + final requestBody = operation.requestBody; + if (requestBody == null) { + return false; + } + + final bodyType = requestBody.type; + if (bodyType != null && _typeUsesNamed(bodyType, typeName)) { + return true; + } + + for (final field in requestBody.multipartFields) { + if (_typeUsesNamed(field.type, typeName)) { + return true; + } + } + + return false; + } + + bool _typeUsesNamed(DartType type, String typeName) { + if (type.kind == TypeKind.named && type.name == typeName) { + return true; + } + for (final argument in type.arguments) { + if (_typeUsesNamed(argument, typeName)) { + return true; + } + } + return false; + } + + String _renderClientFile({ + required List versionImports, + required List clientFields, + }) { + final clientClassName = config.generation.api.client.className; + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln() + ..writeln("import 'package:dio/dio.dart';") + ..writeln(); + + final sortedImports = versionImports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + + buffer + ..writeln() + ..writeln('class $clientClassName {') + ..writeln(' $clientClassName(this._dio, {this.baseUrl});') + ..writeln() + ..writeln(' final Dio _dio;') + ..writeln(' final String? baseUrl;') + ..writeln(); + + final sortedFields = clientFields.toList()..sort(); + for (final field in sortedFields) { + buffer.writeln(field); + } + + buffer.writeln('}'); + return buffer.toString(); + } + + String? _defaultExpressionFor(ModelField field) { + final explicitDefault = _defaultExpressionForType( + field.defaultValue, + field.type, + ); + if (explicitDefault != null) { + return explicitDefault; + } + + if (config.generation.models.defaultNonNullableListToEmpty && + field.required && + field.type.kind == TypeKind.list) { + return '<${field.type.arguments.first.render()}>[]'; + } + return null; + } + + String? _defaultExpressionForType(Object? value, DartType type) { + if (value == null) { + return null; + } + + if (type.kind == TypeKind.named) { + final model = _modelIndex[type.name]; + if (model != null && model.kind == ModelKind.enumType) { + for (final enumValue in model.enumValues) { + if (enumValue.rawValue == value) { + return '${type.name}.${enumValue.name}'; + } + } + } + } + + return _dartLiteral(value); + } + + String _dartLiteral(Object? value) { + if (value == null) { + return 'null'; + } + if (value is String) { + return "'${escapeDartString(value)}'"; + } + if (value is bool || value is num) { + return value.toString(); + } + if (value is List) { + final items = value.map(_dartLiteral).join(', '); + return '[$items]'; + } + if (value is Map) { + final entries = value.entries + .map( + (entry) => + '${_dartLiteral(entry.key)}: ${_dartLiteral(entry.value)}', + ) + .join(', '); + return '{$entries}'; + } + return "'${escapeDartString(value.toString())}'"; + } + + String _simpleLibraryFile(List exports) { + final buffer = StringBuffer() + ..writeln('// GENERATED BY koi_swagger_parser. DO NOT EDIT BY HAND.') + ..writeln() + ..writeln('library;') + ..writeln(); + for (final export in exports) { + buffer.writeln(export); + } + return buffer.toString(); + } + + List _docComment(String text, {String prefix = ''}) { + return text + .trim() + .split('\n') + .map((line) => '$prefix/// ${line.trim()}') + .toList(growable: false); + } + + String _modelCategoryDirectory(ModelCategory category) { + return switch (category) { + ModelCategory.request => 'request', + ModelCategory.result => 'result', + ModelCategory.shared => 'shared', + ModelCategory.enums => 'enums', + }; + } + + String _modelFileStem(ModelSpec model) { + final prefix = config.generation.models.classPrefix; + final trimmed = model.name.startsWith(prefix) + ? model.name.substring(prefix.length) + : model.name; + final candidate = trimmed.isEmpty ? model.name : trimmed; + return toSnakeCase(candidate); + } + + String _apiFileName(String groupKey, String version) { + if (config.output.splitByTags) { + return '${toSnakeCase(groupKey)}_api.dart'; + } + return '${toSnakeCase(version)}_api.dart'; + } + + String _apiClassName(String groupKey, String version) { + if (config.output.splitByTags) { + return '${toPascalCase(groupKey)}Api'; + } + final base = config.generation.api.client.className; + return version == 'v1' ? '${base}Api' : '$base${toPascalCase(version)}Api'; + } + + String _clientPropertyName(String groupKey, String version) { + final base = config.output.splitByTags + ? toCamelCase(groupKey) + : toCamelCase(version); + return version == 'v1' || !config.output.splitByTags + ? base + : '$base${toPascalCase(version)}'; + } +} diff --git a/lib/src/ir/ir.dart b/lib/src/ir/ir.dart new file mode 100644 index 0000000..828b847 --- /dev/null +++ b/lib/src/ir/ir.dart @@ -0,0 +1,312 @@ +enum ModelKind { object, enumType, unionType } + +enum ModelCategory { request, result, shared, enums } + +enum UsageKind { request, result, shared } + +enum TypeKind { named, list, map, dynamicType, objectType, voidType } + +enum ApiRequestBodyKind { json, multipart } + +class DartType { + const DartType.named( + this.name, { + this.nullable = false, + this.arguments = const [], + }) : kind = TypeKind.named; + + DartType.list(DartType itemType, {this.nullable = false}) + : kind = TypeKind.list, + name = 'List', + arguments = [itemType]; + + DartType.map(DartType valueType, {this.nullable = false}) + : kind = TypeKind.map, + name = 'Map', + arguments = [const DartType.named('String'), valueType]; + + const DartType.dynamicType({this.nullable = false}) + : kind = TypeKind.dynamicType, + name = 'dynamic', + arguments = const []; + + const DartType.objectType({this.nullable = false}) + : kind = TypeKind.objectType, + name = 'Object', + arguments = const []; + + const DartType.voidType() + : kind = TypeKind.voidType, + name = 'void', + arguments = const [], + nullable = false; + + final TypeKind kind; + final String name; + final List arguments; + final bool nullable; + + DartType asNullable() { + if (nullable || kind == TypeKind.voidType) { + return this; + } + switch (kind) { + case TypeKind.named: + return DartType.named(name, nullable: true, arguments: arguments); + case TypeKind.list: + return DartType.list(arguments.first, nullable: true); + case TypeKind.map: + return DartType.map(arguments.last, nullable: true); + case TypeKind.dynamicType: + return const DartType.dynamicType(nullable: true); + case TypeKind.objectType: + return const DartType.objectType(nullable: true); + case TypeKind.voidType: + return this; + } + } + + bool get isCollection => kind == TypeKind.list || kind == TypeKind.map; + + String render() { + final suffix = nullable ? '?' : ''; + return switch (kind) { + TypeKind.named => + arguments.isEmpty + ? '$name$suffix' + : '$name<${arguments.map((item) => item.render()).join(', ')}>$suffix', + TypeKind.list => 'List<${arguments.first.render()}>$suffix', + TypeKind.map => 'Map$suffix', + TypeKind.dynamicType => 'dynamic', + TypeKind.objectType => nullable ? 'Object?' : 'Object', + TypeKind.voidType => 'void', + }; + } + + Set collectReferencedNames(Set candidates) { + final result = {}; + if (kind == TypeKind.named && candidates.contains(name)) { + result.add(name); + } + for (final argument in arguments) { + result.addAll(argument.collectReferencedNames(candidates)); + } + return result; + } +} + +class ModelField { + const ModelField({ + required this.name, + required this.wireName, + required this.type, + required this.required, + this.description, + this.defaultValue, + }); + + final String name; + final String wireName; + final DartType type; + final bool required; + final String? description; + final Object? defaultValue; +} + +class EnumValue { + const EnumValue({required this.name, required this.rawValue}); + + final String name; + final Object rawValue; +} + +class UnionCase { + const UnionCase({ + required this.caseName, + required this.wireValue, + required this.type, + required this.originalName, + }); + + final String caseName; + final String wireValue; + final DartType type; + final String originalName; +} + +class ModelSpec { + ModelSpec({ + required this.name, + required this.originalName, + required this.kind, + List? fields, + List? enumValues, + List? unionCases, + this.discriminatorKey, + this.description, + }) : fields = fields ?? [], + enumValues = enumValues ?? [], + unionCases = unionCases ?? [], + usages = {}; + + final String name; + final String originalName; + final ModelKind kind; + final List fields; + final List enumValues; + final List unionCases; + final String? discriminatorKey; + final String? description; + final Set usages; + + ModelCategory get category { + if (kind == ModelKind.enumType) { + return ModelCategory.enums; + } + if (usages.contains(UsageKind.shared) || + (usages.contains(UsageKind.request) && + usages.contains(UsageKind.result))) { + return ModelCategory.shared; + } + if (usages.contains(UsageKind.result)) { + return ModelCategory.result; + } + if (usages.contains(UsageKind.request)) { + return ModelCategory.request; + } + return ModelCategory.shared; + } +} + +class ApiParameter { + const ApiParameter({ + required this.name, + required this.wireName, + required this.location, + required this.type, + required this.required, + this.description, + this.defaultValue, + }); + + final String name; + final String wireName; + final String location; + final DartType type; + final bool required; + final String? description; + final Object? defaultValue; +} + +class ApiMultipartField { + const ApiMultipartField({ + required this.name, + required this.wireName, + required this.type, + required this.required, + this.description, + this.contentType, + }); + + final String name; + final String wireName; + final DartType type; + final bool required; + final String? description; + final String? contentType; +} + +class ApiRequestBody { + const ApiRequestBody({ + required this.kind, + required this.required, + required this.parameterName, + this.type, + this.contentType, + this.multipartFields = const [], + }); + + final ApiRequestBodyKind kind; + final DartType? type; + final bool required; + final String parameterName; + final String? contentType; + final List multipartFields; +} + +class ApiOperation { + const ApiOperation({ + required this.path, + required this.method, + required this.tag, + required this.version, + required this.methodName, + required this.returnType, + required this.parameters, + this.requestBody, + this.summary, + this.description, + this.deprecated = false, + }); + + final String path; + final String method; + final String tag; + final String version; + final String methodName; + final DartType returnType; + final List parameters; + final ApiRequestBody? requestBody; + final String? summary; + final String? description; + final bool deprecated; +} + +class ParsedApiDocument { + const ParsedApiDocument({ + required this.models, + required this.operations, + required this.sourceDocuments, + }); + + final Map models; + final List operations; + final List sourceDocuments; +} + +class GeneratedFile { + const GeneratedFile({required this.path, required this.content}); + + final String path; + final String content; +} + +class GeneratedBundle { + const GeneratedBundle({ + required this.files, + required this.operationsCount, + required this.modelsCount, + }); + + final List files; + final int operationsCount; + final int modelsCount; +} + +class GenerationResult { + const GenerationResult({ + required this.configPath, + required this.sourceDocuments, + required this.writtenFiles, + required this.operationsCount, + required this.modelsCount, + required this.dryRun, + }); + + final String? configPath; + final List sourceDocuments; + final List writtenFiles; + final int operationsCount; + final int modelsCount; + final bool dryRun; +} diff --git a/lib/src/koi_swagger_parser.dart b/lib/src/koi_swagger_parser.dart new file mode 100644 index 0000000..98f5d2a --- /dev/null +++ b/lib/src/koi_swagger_parser.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'config/config.dart'; +import 'generator/generator.dart'; +import 'ir/ir.dart'; +import 'openapi/openapi.dart'; + +class KoiSwaggerParser { + const KoiSwaggerParser(); + + Future generate({ + String? configPath, + String? workingDirectory, + bool dryRun = false, + bool verbose = false, + }) async { + final config = await KoiSwaggerConfig.load( + configPath: configPath, + workingDirectory: workingDirectory, + ); + if (config.input.swaggerUrls.isEmpty) { + throw StateError('没有可用的 OpenAPI 文档源,请检查配置文件的 input.swagger_urls。'); + } + + final loader = OpenApiDocumentLoader(); + final loaded = await loader.load(config.input.swaggerUrls); + if (verbose) { + stdout.writeln( + '[koi_swagger_parser] loaded ${loaded.sources.length} source(s).', + ); + } + + final parser = OpenApiDocumentParser(config); + final document = parser.parse(loaded); + if (verbose) { + stdout.writeln( + '[koi_swagger_parser] parsed ' + '${document.operations.length} operation(s), ' + '${document.models.length} model(s).', + ); + } + + final generator = DartGenerator(config); + final bundle = generator.generate(document); + final filePaths = bundle.files + .map((file) => file.path) + .toList(growable: false); + + if (!dryRun) { + for (final file in bundle.files) { + final ioFile = File(file.path); + ioFile.parent.createSync(recursive: true); + ioFile.writeAsStringSync(file.content); + } + + if (config.generation.formatting.runDartFormat) { + await _runFormatter([ + config.output.apiDir, + config.output.modelsDir, + ]); + } + } + + return GenerationResult( + configPath: config.configPath, + sourceDocuments: loaded.sources, + writtenFiles: filePaths, + operationsCount: bundle.operationsCount, + modelsCount: bundle.modelsCount, + dryRun: dryRun, + ); + } + + Future _runFormatter(List targets) async { + final uniqueTargets = targets.toSet().toList(growable: false); + final result = await Process.run(Platform.resolvedExecutable, [ + 'format', + ...uniqueTargets, + ]); + if (result.exitCode != 0) { + throw ProcessException( + Platform.resolvedExecutable, + ['format', ...uniqueTargets], + result.stderr.toString(), + result.exitCode, + ); + } + } +} diff --git a/lib/src/openapi/openapi.dart b/lib/src/openapi/openapi.dart new file mode 100644 index 0000000..55dbc71 --- /dev/null +++ b/lib/src/openapi/openapi.dart @@ -0,0 +1,1460 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import '../config/config.dart'; +import '../preset/preset.dart'; +import '../ir/ir.dart'; +import '../util/utils.dart'; + +class LoadedOpenApiDocument { + const LoadedOpenApiDocument({required this.document, required this.sources}); + + final Map document; + final List sources; +} + +class _DiscriminatedUnionDescriptor { + const _DiscriminatedUnionDescriptor({ + required this.discriminatorKey, + required this.branches, + }); + + final String discriminatorKey; + final List<_DiscriminatedUnionBranch> branches; +} + +class _DiscriminatedUnionBranch { + const _DiscriminatedUnionBranch({ + required this.refName, + required this.wireValue, + }); + + final String refName; + final String wireValue; +} + +class OpenApiDocumentLoader { + const OpenApiDocumentLoader(); + + Future load(List sources) async { + var merged = {}; + final loadedSources = []; + + for (final source in sources) { + final content = await _readSource(source); + final document = decodeStructuredDocument(content); + merged = merged.isEmpty ? document : deepMergeMaps(merged, document); + loadedSources.add(source); + } + + 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(); + } +} + +class OpenApiDocumentParser { + OpenApiDocumentParser(this.config); + + static const List _httpMethods = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + ]; + + final KoiSwaggerConfig config; + + late final Map _document; + late final Map _schemas; + late final Map _parameters; + late final Map _responses; + late final Map _requestBodies; + + final Map _models = {}; + final Map _componentClassNames = {}; + final Map _inlineModelNames = {}; + final Set _componentsInProgress = {}; + final Map _classNameUsage = {}; + + ParsedApiDocument parse(LoadedOpenApiDocument loaded) { + _document = loaded.document; + + final components = + (_document['components'] as Map?)?.cast() ?? + const {}; + _schemas = + (components['schemas'] as Map?)?.cast() ?? + const {}; + _parameters = + (components['parameters'] as Map?)?.cast() ?? + const {}; + _responses = + (components['responses'] as Map?)?.cast() ?? + const {}; + _requestBodies = + (components['requestBodies'] as Map?)?.cast() ?? + const {}; + + final operations = []; + final pathItems = + (_document['paths'] as Map?)?.cast() ?? + const {}; + 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) { + continue; + } + + final operation = _parseOperation( + path: pathEntry.key, + method: method, + rawOperation: rawOperation.cast(), + pathLevelParameters: sharedParameters, + ); + if (operation != null) { + operations.add(operation); + } + } + } + + operations.sort((left, right) { + final versionCompare = left.version.compareTo(right.version); + if (versionCompare != 0) { + return versionCompare; + } + + final tagCompare = left.tag.compareTo(right.tag); + if (tagCompare != 0) { + return tagCompare; + } + + final pathCompare = left.path.compareTo(right.path); + if (pathCompare != 0) { + return pathCompare; + } + + return left.method.compareTo(right.method); + }); + + final sortedModels = Map.fromEntries( + _models.entries.toList() + ..sort((left, right) => left.key.compareTo(right.key)), + ); + + return ParsedApiDocument( + models: sortedModels, + operations: operations, + sourceDocuments: loaded.sources, + ); + } + + ApiOperation? _parseOperation({ + required String path, + required String method, + required Map rawOperation, + required List> pathLevelParameters, + }) { + final selectedTag = _selectTag(rawOperation['tags']); + if (selectedTag == null) { + return null; + } + + final operationId = rawOperation['operationId'] as String?; + final methodName = _buildMethodName(operationId, method, path); + final operationContext = + '${toPascalCase(selectedTag)}${toPascalCase(methodName)}'; + + final mergedParameters = _mergeParameters( + pathLevelParameters, + _resolveParameters(rawOperation['parameters']), + ); + + final parameters = mergedParameters + .map( + (parameter) => + _parseParameter(parameter, operationContext: operationContext), + ) + .toList(growable: false); + + final requestBody = _parseRequestBody( + rawOperation['requestBody'], + operationContext: operationContext, + ); + + final returnType = _parseSuccessReturnType( + rawOperation['responses'], + operationContext: operationContext, + ); + _parseErrorResponseSchemas( + rawOperation['responses'], + operationContext: operationContext, + ); + + return ApiOperation( + path: path, + method: method.toUpperCase(), + tag: selectedTag, + version: _extractVersion(path), + methodName: methodName, + returnType: returnType, + parameters: parameters, + requestBody: requestBody, + summary: rawOperation['summary'] as String?, + description: rawOperation['description'] as String?, + deprecated: rawOperation['deprecated'] as bool? ?? false, + ); + } + + String? _selectTag(dynamic rawTags) { + final tags = rawTags is List + ? rawTags.map((item) => item.toString()).toList(growable: false) + : const []; + + if (tags.isEmpty) { + return _isAllowedTag('default') ? 'default' : null; + } + + for (final tag in tags) { + if (_isAllowedTag(tag)) { + return tag; + } + } + return null; + } + + bool _isAllowedTag(String tag) { + final includedTags = config.output.includedTags; + if (includedTags != null && !includedTags.contains(tag)) { + return false; + } + + final excludedTags = config.output.excludedTags; + if (excludedTags != null && excludedTags.contains(tag)) { + return false; + } + return true; + } + + String _buildMethodName(String? operationId, String method, String path) { + if (operationId != null && operationId.trim().isNotEmpty) { + final rawWords = splitWords(operationId); + final trimmedWords = []; + for (var index = 0; index < rawWords.length; index++) { + final word = rawWords[index]; + if (word.toLowerCase() == 'api' && + index + 1 < rawWords.length && + RegExp(r'^v\d+$').hasMatch(rawWords[index + 1].toLowerCase())) { + break; + } + trimmedWords.add(word); + } + + if (trimmedWords.isNotEmpty) { + return toCamelCase(trimmedWords.join(' ')); + } + } + + final words = [method.toLowerCase()]; + for (final segment in path.split('/')) { + if (segment.isEmpty || + segment == 'api' || + RegExp(r'^v\d+$').hasMatch(segment)) { + continue; + } + if (segment.startsWith('{') && segment.endsWith('}')) { + words.add('by'); + words.add(segment.substring(1, segment.length - 1)); + } else { + words.add(segment); + } + } + return toCamelCase(words.join(' ')); + } + + String _extractVersion(String path) { + final match = RegExp(r'/api/(v\d+)(?:/|$)').firstMatch(path); + return match?.group(1) ?? 'v1'; + } + + List> _resolveParameters(dynamic rawParameters) { + if (rawParameters is! List) { + return const >[]; + } + return rawParameters + .map((parameter) => _resolveParameterRef(_asMap(parameter))) + .where((parameter) => parameter.isNotEmpty) + .toList(growable: false); + } + + List> _mergeParameters( + List> pathLevel, + List> operationLevel, + ) { + final merged = >{}; + for (final parameter in pathLevel) { + merged[_parameterKey(parameter)] = parameter; + } + for (final parameter in operationLevel) { + merged[_parameterKey(parameter)] = parameter; + } + return merged.values.toList(growable: false); + } + + String _parameterKey(Map parameter) { + return '${parameter['in']}:${parameter['name']}'; + } + + Map _resolveParameterRef(Map parameter) { + final ref = parameter[r'$ref'] as String?; + if (ref == null) { + return parameter; + } + final refName = _refName(ref); + final resolved = _asMap(_parameters[refName]); + if (resolved.isEmpty) { + return parameter; + } + return deepMergeMaps( + resolved, + Map.from(parameter)..remove(r'$ref'), + ); + } + + ApiParameter _parseParameter( + Map rawParameter, { + required String operationContext, + }) { + final wireName = rawParameter['name']?.toString() ?? 'value'; + final location = rawParameter['in']?.toString() ?? 'query'; + final required = location == 'path' + ? true + : rawParameter['required'] as bool? ?? false; + final schema = _extractSchema(rawParameter); + final defaultValue = schema['default']; + final resolvedType = _resolveType( + schema, + usage: UsageKind.request, + suggestedName: '$operationContext${toPascalCase(wireName)}Parameter', + ); + + return ApiParameter( + name: sanitizeIdentifier(toCamelCase(wireName), fallback: 'value'), + wireName: wireName, + location: location, + type: !required && defaultValue == null + ? resolvedType.asNullable() + : resolvedType, + required: required, + description: rawParameter['description'] as String?, + defaultValue: defaultValue, + ); + } + + ApiRequestBody? _parseRequestBody( + dynamic rawRequestBody, { + required String operationContext, + }) { + final requestBody = _resolveRequestBodyRef(_asMap(rawRequestBody)); + if (requestBody.isEmpty) { + return null; + } + + final content = _selectRequestContent(requestBody['content'] as Map?); + if (content == null) { + return null; + } + + if (_isMultipartContentType(content.key)) { + return _parseMultipartRequestBody( + content: content, + requestBody: requestBody, + operationContext: operationContext, + ); + } + + final schema = _asMap(content.value['schema']); + if (schema.isEmpty) { + return null; + } + + return ApiRequestBody( + kind: ApiRequestBodyKind.json, + type: _resolveType( + schema, + usage: UsageKind.request, + suggestedName: '${operationContext}RequestBody', + ), + required: requestBody['required'] as bool? ?? false, + parameterName: 'body', + contentType: content.key, + ); + } + + ApiRequestBody? _parseMultipartRequestBody({ + required MapEntry> content, + required Map requestBody, + required String operationContext, + }) { + final rawSchema = _asMap(content.value['schema']); + if (rawSchema.isEmpty) { + return null; + } + + final schema = + _mergeComposedSchema(rawSchema) ?? _materializeSchema(rawSchema); + final multipartFields = _parseMultipartFields( + schema, + operationContext: operationContext, + ); + if (multipartFields.isEmpty) { + return null; + } + + return ApiRequestBody( + kind: ApiRequestBodyKind.multipart, + required: requestBody['required'] as bool? ?? false, + parameterName: 'parts', + contentType: content.key, + multipartFields: multipartFields, + ); + } + + List _parseMultipartFields( + Map schema, { + required String operationContext, + }) { + 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 type = _resolveMultipartFieldType( + propertySchema, + usage: UsageKind.request, + suggestedName: '$operationContext${toPascalCase(wireName)}Part', + ); + final isRequired = requiredFields.contains(wireName); + final nullableType = isRequired ? type : type.asNullable(); + + fields.add( + ApiMultipartField( + name: sanitizeIdentifier(toCamelCase(wireName), fallback: 'value'), + wireName: wireName, + type: nullableType, + required: isRequired, + description: _descriptionFor(propertySchema), + contentType: _multipartContentTypeForField(nullableType), + ), + ); + } + + return fields; + } + + DartType _resolveMultipartFieldType( + Map schema, { + required UsageKind usage, + required String suggestedName, + }) { + if (schema.isEmpty) { + return const DartType.dynamicType(); + } + + final nullableBranch = _extractNullableBranch(schema); + if (nullableBranch != null) { + return _resolveMultipartFieldType( + nullableBranch, + usage: usage, + suggestedName: suggestedName, + ).asNullable(); + } + + final materialized = + _mergeComposedSchema(_materializeSchema(schema)) ?? + _materializeSchema(schema); + if (_isBinarySchema(materialized)) { + return _multipartBinaryType(); + } + + if (_extractTypeValue(materialized) == 'array') { + final itemSchema = _materializeSchema(_asMap(materialized['items'])); + if (_isBinarySchema(itemSchema)) { + return DartType.list(_multipartBinaryType()); + } + } + + final ref = schema[r'$ref'] as String?; + if (ref != null) { + return _resolveRefType(ref, usage: usage, suggestedName: suggestedName); + } + + return _resolveType(schema, usage: usage, suggestedName: suggestedName); + } + + DartType _multipartBinaryType() { + switch (config.generation.preset.multipartBinaryStyle) { + case BinaryTransportStyle.multipartFile: + return const DartType.named('MultipartFile'); + case BinaryTransportStyle.file: + return const DartType.named('File'); + case BinaryTransportStyle.bytes: + return const DartType.named('Uint8List'); + case BinaryTransportStyle.string: + return const DartType.named('String'); + } + } + + String? _multipartContentTypeForField(DartType type) { + return _isStructuredMultipartType(type) ? 'application/json' : null; + } + + bool _isStructuredMultipartType(DartType type) { + switch (type.kind) { + case TypeKind.map: + return true; + case TypeKind.list: + final itemType = type.arguments.first; + if (_isPrimitiveMultipartNamedType(itemType)) { + return false; + } + return true; + case TypeKind.objectType: + return true; + case TypeKind.named: + final model = _models[type.name]; + return model != null && model.kind != ModelKind.enumType; + case TypeKind.dynamicType: + case TypeKind.voidType: + return false; + } + } + + bool _isPrimitiveMultipartNamedType(DartType type) { + if (type.kind != TypeKind.named) { + return false; + } + const primitiveNames = { + 'String', + 'int', + 'num', + 'double', + 'bool', + 'DateTime', + 'MultipartFile', + 'File', + 'Uint8List', + }; + final model = _models[type.name]; + if (model != null && model.kind == ModelKind.enumType) { + return true; + } + return primitiveNames.contains(type.name); + } + + bool _isMultipartContentType(String contentType) { + return contentType.toLowerCase() == 'multipart/form-data'; + } + + Map _resolveRequestBodyRef( + Map requestBody, + ) { + final ref = requestBody[r'$ref'] as String?; + if (ref == null) { + return requestBody; + } + final refName = _refName(ref); + final resolved = _asMap(_requestBodies[refName]); + if (resolved.isEmpty) { + return requestBody; + } + return deepMergeMaps( + resolved, + Map.from(requestBody)..remove(r'$ref'), + ); + } + + DartType _parseSuccessReturnType( + dynamic rawResponses, { + required String operationContext, + }) { + 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()); + } + + final schema = _asMap(content.value['schema']); + if (schema.isEmpty) { + return _wrapReturnType(const DartType.voidType()); + } + + if (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 _wrapReturnType(innerType); + } + } + + final responseType = _resolveType( + schema, + usage: UsageKind.result, + suggestedName: '${operationContext}Response', + ); + return _wrapReturnType(responseType); + } + + void _parseErrorResponseSchemas( + dynamic rawResponses, { + required String operationContext, + }) { + final responses = + (rawResponses as Map?)?.cast() ?? + const {}; + final entries = responses.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( + schema, + usage: UsageKind.shared, + suggestedName: '$operationContext${entry.key}Error', + ); + } + } + + 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; + } + + bool _isSuccessCode(String code) { + return RegExp(r'^2\d\d$').hasMatch(code) || code == 'default'; + } + + Map _resolveResponseRef(Map response) { + final ref = response[r'$ref'] as String?; + if (ref == null) { + return response; + } + final refName = _refName(ref); + final resolved = _asMap(_responses[refName]); + if (resolved.isEmpty) { + return response; + } + return deepMergeMaps( + resolved, + Map.from(response)..remove(r'$ref'), + ); + } + + MapEntry>? _selectContent(Map? rawContent) { + final content = + rawContent?.cast() ?? const {}; + if (content.isEmpty) { + return null; + } + + const preferredTypes = [ + 'application/json', + 'application/problem+json', + ]; + for (final preferredType in preferredTypes) { + if (content.containsKey(preferredType)) { + return MapEntry(preferredType, _asMap(content[preferredType])); + } + } + + final first = content.entries.first; + return MapEntry(first.key, _asMap(first.value)); + } + + MapEntry>? _selectRequestContent( + Map? rawContent, + ) { + final content = + rawContent?.cast() ?? const {}; + if (content.isEmpty) { + return null; + } + + const preferredTypes = [ + 'multipart/form-data', + 'application/json', + 'application/x-www-form-urlencoded', + 'application/octet-stream', + ]; + for (final preferredType in preferredTypes) { + if (content.containsKey(preferredType)) { + return MapEntry(preferredType, _asMap(content[preferredType])); + } + } + + final first = content.entries.first; + return MapEntry(first.key, _asMap(first.value)); + } + + Map _extractSchema(Map container) { + final direct = _asMap(container['schema']); + if (direct.isNotEmpty) { + return direct; + } + final content = _selectContent(container['content'] as Map?); + if (content == null) { + return const {}; + } + return _asMap(content.value['schema']); + } + + DartType _wrapReturnType(DartType innerType) { + if (config.generation.api.responseWrapper.mode != + ResponseWrapperMode.externalGeneric) { + return innerType; + } + + final wrapperClass = + config.generation.api.responseWrapper.externalClassName ?? 'BaseResult'; + return DartType.named(wrapperClass, arguments: [innerType]); + } + + Map? _extractEnvelopeDataSchema( + Map schema, + ) { + final materialized = _materializeSchema(schema); + if (!_isStandardEnvelope(materialized)) { + return null; + } + + final properties = + (materialized['properties'] as Map?)?.cast() ?? + const {}; + final dataField = config.generation.api.responseWrapper.successDataField; + return _asMap(properties[dataField]); + } + + bool _isStandardEnvelope(Map schema) { + final properties = + (schema['properties'] as Map?)?.cast() ?? + const {}; + final dataField = config.generation.api.responseWrapper.successDataField; + if (!properties.containsKey(dataField)) { + return false; + } + return properties.containsKey('code') && properties.containsKey('message'); + } + + DartType _resolveType( + Map schema, { + required UsageKind usage, + required String suggestedName, + }) { + if (schema.isEmpty) { + return const DartType.dynamicType(); + } + + final nullableBranch = _extractNullableBranch(schema); + if (nullableBranch != null) { + return _resolveType( + nullableBranch, + usage: usage, + suggestedName: suggestedName, + ).asNullable(); + } + + final ref = schema[r'$ref'] as String?; + if (ref != null) { + return _resolveRefType(ref, usage: usage, suggestedName: suggestedName); + } + + final unionDescriptor = _discriminatorUnionDescriptorFor(schema); + if (unionDescriptor != null) { + return _createInlineUnionType( + schema, + descriptor: unionDescriptor, + usage: usage, + suggestedName: suggestedName, + ); + } + + final mergedSchema = _mergeComposedSchema(schema); + if (mergedSchema != null) { + return _resolveType( + mergedSchema, + usage: usage, + suggestedName: suggestedName, + ); + } + + if (_isEnumSchema(schema)) { + return _createInlineEnumType( + schema, + usage: usage, + suggestedName: suggestedName, + ); + } + + final schemaType = _extractTypeValue(schema); + switch (schemaType) { + case 'array': + final itemType = _resolveType( + _asMap(schema['items']), + usage: usage, + suggestedName: '${suggestedName}Item', + ); + return DartType.list(itemType); + case 'object': + final properties = + (schema['properties'] as Map?)?.cast() ?? + const {}; + final additionalProperties = schema['additionalProperties']; + if (properties.isNotEmpty) { + return _createInlineObjectType( + schema, + usage: usage, + suggestedName: suggestedName, + ); + } + if (additionalProperties is Map) { + final valueType = _resolveType( + additionalProperties.cast(), + usage: usage, + suggestedName: '${suggestedName}Value', + ); + return DartType.map(valueType); + } + return DartType.map(const DartType.dynamicType()); + case 'integer': + return const DartType.named('int'); + case 'number': + final format = schema['format'] as String?; + return switch (format) { + 'float' || 'double' => const DartType.named('double'), + _ => const DartType.named('num'), + }; + case 'boolean': + return const DartType.named('bool'); + case 'string': + final format = schema['format'] as String?; + return switch (format) { + 'date' || 'date-time' => const DartType.named('DateTime'), + _ => const DartType.named('String'), + }; + case 'null': + return const DartType.voidType(); + default: + return const DartType.objectType(); + } + } + + DartType _resolveRefType( + String ref, { + required UsageKind usage, + required String suggestedName, + }) { + final refName = _refName(ref); + final schema = _asMap(_schemas[refName]); + if (schema.isEmpty) { + return const DartType.dynamicType(); + } + + if (!_requiresNamedModel(schema)) { + return _resolveType(schema, usage: usage, suggestedName: suggestedName); + } + + final className = _componentClassNames.putIfAbsent( + refName, + () => _uniqueClassName(_modelClassName(refName)), + ); + + final kind = _modelKindFor(schema); + final unionDescriptor = kind == ModelKind.unionType + ? _discriminatorUnionDescriptorFor(schema) + : null; + final model = _models.putIfAbsent( + className, + () => ModelSpec( + name: className, + originalName: refName, + kind: kind, + discriminatorKey: unionDescriptor?.discriminatorKey, + description: _descriptionFor(schema), + ), + ); + model.usages.add(usage); + + if (_componentsInProgress.contains(refName)) { + return DartType.named(className); + } + + _componentsInProgress.add(refName); + try { + if (kind == ModelKind.enumType) { + if (model.enumValues.isEmpty) { + model.enumValues.addAll(_enumValuesFor(schema)); + } + } else if (kind == ModelKind.unionType) { + final descriptor = unionDescriptor; + if (descriptor != null && model.unionCases.isEmpty) { + model.unionCases.addAll( + _unionCasesFor(descriptor, usage: usage, suggestedName: refName), + ); + } + } else if (model.fields.isEmpty) { + model.fields.addAll( + _fieldsForObject(schema, usage: usage, contextName: refName), + ); + } + } finally { + _componentsInProgress.remove(refName); + } + + return DartType.named(className); + } + + bool _requiresNamedModel(Map schema) { + if (_isEnumSchema(schema)) { + return true; + } + + if (_discriminatorUnionDescriptorFor(schema) != null) { + return true; + } + + final mergedSchema = _mergeComposedSchema(schema); + final target = mergedSchema ?? schema; + final properties = + (target['properties'] as Map?)?.cast() ?? + const {}; + return _extractTypeValue(target) == 'object' && properties.isNotEmpty; + } + + ModelKind _modelKindFor(Map schema) { + if (_isEnumSchema(schema)) { + return ModelKind.enumType; + } + if (_discriminatorUnionDescriptorFor(schema) != null) { + return ModelKind.unionType; + } + return ModelKind.object; + } + + DartType _createInlineUnionType( + Map schema, { + required _DiscriminatedUnionDescriptor descriptor, + required UsageKind usage, + required String suggestedName, + }) { + final branchSignature = descriptor.branches + .map((branch) => '${branch.refName}:${branch.wireValue}') + .join(','); + final signature = + 'union:$suggestedName:${descriptor.discriminatorKey}:$branchSignature'; + final className = _inlineModelNames.putIfAbsent( + signature, + () => _uniqueClassName(_modelClassName(suggestedName)), + ); + + final model = _models.putIfAbsent( + className, + () => ModelSpec( + name: className, + originalName: suggestedName, + kind: ModelKind.unionType, + discriminatorKey: descriptor.discriminatorKey, + description: _descriptionFor(schema), + ), + ); + model.usages.add(usage); + + if (model.unionCases.isEmpty) { + model.unionCases.addAll( + _unionCasesFor(descriptor, usage: usage, suggestedName: suggestedName), + ); + } + + return DartType.named(className); + } + + List _unionCasesFor( + _DiscriminatedUnionDescriptor descriptor, { + required UsageKind usage, + required String suggestedName, + }) { + final usedCaseNames = {}; + final cases = []; + + for (final branch in descriptor.branches) { + final baseCaseName = toPascalCase(branch.wireValue); + final nextIndex = (usedCaseNames[baseCaseName] ?? 0) + 1; + usedCaseNames[baseCaseName] = nextIndex; + final caseName = nextIndex == 1 + ? baseCaseName + : '$baseCaseName${nextIndex.toString()}'; + + cases.add( + UnionCase( + caseName: caseName, + wireValue: branch.wireValue, + type: _resolveRefType( + '#/components/schemas/${branch.refName}', + usage: usage, + suggestedName: + '$suggestedName${toPascalCase(branch.refName)}Variant', + ), + originalName: branch.refName, + ), + ); + } + + return cases; + } + + _DiscriminatedUnionDescriptor? _discriminatorUnionDescriptorFor( + Map schema, + ) { + final materialized = _materializeSchema(schema); + final discriminator = _asMap(materialized['discriminator']); + final discriminatorKey = discriminator['propertyName'] as String?; + final normalizedKey = discriminatorKey?.trim(); + if (normalizedKey == null || normalizedKey.isEmpty) { + return null; + } + + final rawBranches = materialized['oneOf'] ?? materialized['anyOf']; + if (rawBranches is! List || rawBranches.length < 2) { + return null; + } + + final mapping = _asMap(discriminator['mapping']); + final inverseMapping = {}; + for (final entry in mapping.entries) { + inverseMapping[_refName(entry.value.toString())] = entry.key; + } + + final branches = <_DiscriminatedUnionBranch>[]; + for (final rawBranch in rawBranches) { + final branch = _asMap(rawBranch); + final ref = branch[r'$ref'] as String?; + if (ref == null) { + return null; + } + final refName = _refName(ref); + if (_isSupportedUnionBranch(refName) == false) { + return null; + } + + final wireValue = + inverseMapping[refName] ?? + _inferDiscriminatorValue(refName, normalizedKey) ?? + refName; + branches.add( + _DiscriminatedUnionBranch(refName: refName, wireValue: wireValue), + ); + } + + return _DiscriminatedUnionDescriptor( + discriminatorKey: normalizedKey, + branches: branches, + ); + } + + bool _isSupportedUnionBranch(String refName) { + final schema = _asMap(_schemas[refName]); + if (schema.isEmpty) { + return false; + } + + final materialized = + _mergeComposedSchema(schema) ?? _materializeSchema(schema); + final properties = + (materialized['properties'] as Map?)?.cast() ?? + const {}; + return _extractTypeValue(materialized) == 'object' && properties.isNotEmpty; + } + + String? _inferDiscriminatorValue(String refName, String discriminatorKey) { + final schema = _asMap(_schemas[refName]); + if (schema.isEmpty) { + return null; + } + + final materialized = + _mergeComposedSchema(schema) ?? _materializeSchema(schema); + final properties = + (materialized['properties'] as Map?)?.cast() ?? + const {}; + final discriminatorProperty = _asMap(properties[discriminatorKey]); + final constValue = discriminatorProperty['const']; + if (constValue != null) { + return constValue.toString(); + } + + final enumValues = discriminatorProperty['enum']; + if (enumValues is List && enumValues.length == 1) { + return enumValues.first?.toString(); + } + + return null; + } + + DartType _createInlineObjectType( + Map schema, { + required UsageKind usage, + required String suggestedName, + }) { + final signature = 'object:$suggestedName'; + final className = _inlineModelNames.putIfAbsent( + signature, + () => _uniqueClassName(_modelClassName(suggestedName)), + ); + + final model = _models.putIfAbsent( + className, + () => ModelSpec( + name: className, + originalName: suggestedName, + kind: ModelKind.object, + description: _descriptionFor(schema), + ), + ); + model.usages.add(usage); + + if (model.fields.isEmpty) { + model.fields.addAll( + _fieldsForObject(schema, usage: usage, contextName: suggestedName), + ); + } + + return DartType.named(className); + } + + DartType _createInlineEnumType( + Map schema, { + required UsageKind usage, + required String suggestedName, + }) { + final signature = 'enum:$suggestedName:${schema['enum']}'; + final className = _inlineModelNames.putIfAbsent( + signature, + () => _uniqueClassName(_modelClassName(suggestedName)), + ); + + final model = _models.putIfAbsent( + className, + () => ModelSpec( + name: className, + originalName: suggestedName, + kind: ModelKind.enumType, + description: _descriptionFor(schema), + ), + ); + model.usages.add(usage); + if (model.enumValues.isEmpty) { + model.enumValues.addAll(_enumValuesFor(schema)); + } + return DartType.named(className); + } + + Iterable _fieldsForObject( + Map schema, { + required UsageKind usage, + required String contextName, + }) { + final materialized = _mergeComposedSchema(schema) ?? schema; + final properties = + (materialized['properties'] as Map?)?.cast() ?? + const {}; + final requiredFields = _stringSet(materialized['required']); + + return properties.entries.map((entry) { + final wireName = entry.key; + final propertySchema = _asMap(entry.value); + 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'], + ); + }); + } + + List _enumValuesFor(Map schema) { + final rawValues = (schema['enum'] as List?) ?? const []; + final usedNames = {}; + final values = []; + + for (final rawValue in rawValues) { + final baseName = _enumMemberBaseName(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')); + } + return values; + } + + 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', + ); + } + if (value == 'true' || value == 'false') { + return value; + } + return sanitizeIdentifier(toCamelCase(value), fallback: 'value'); + } + + String _modelClassName(String rawName) { + final prefix = config.generation.models.classPrefix; + return sanitizeIdentifier( + '$prefix${toPascalCase(rawName)}', + fallback: '${prefix}GeneratedModel', + ); + } + + String _uniqueClassName(String baseName) { + final nextIndex = (_classNameUsage[baseName] ?? 0) + 1; + _classNameUsage[baseName] = nextIndex; + return nextIndex == 1 ? baseName : '$baseName$nextIndex'; + } + + bool _isBinarySchema(Map schema) { + final materialized = _materializeSchema(schema); + if (_extractTypeValue(materialized) != 'string') { + return false; + } + + final format = materialized['format'] as String?; + if (format == 'binary' || format == 'base64') { + return true; + } + + final contentEncoding = materialized['contentEncoding'] as String?; + return contentEncoding == 'binary' || contentEncoding == 'base64'; + } + + Map? _extractNullableBranch(Map schema) { + final rawType = schema['type']; + if (rawType is List) { + final nonNullTypes = rawType + .where((item) => item.toString() != 'null') + .toList(); + if (nonNullTypes.length == 1 && rawType.length == 2) { + return Map.from(schema)..['type'] = nonNullTypes.first; + } + } + + for (final key in const ['anyOf', 'oneOf']) { + final items = schema[key]; + if (items is! List || items.length != 2) { + continue; + } + + Map? nonNullBranch; + var hasNull = false; + for (final item in items) { + final branch = _asMap(item); + if (_extractTypeValue(branch) == 'null') { + hasNull = true; + } else { + nonNullBranch = branch; + } + } + + if (hasNull && nonNullBranch != null) { + return nonNullBranch; + } + } + + return null; + } + + Map? _mergeComposedSchema(Map schema) { + final allOf = schema['allOf']; + if (allOf is! List || allOf.isEmpty) { + return null; + } + + final merged = { + 'type': 'object', + 'properties': {}, + 'required': [], + }; + final mergedProperties = (merged['properties'] as Map); + final mergedRequired = {}; + + for (final item in allOf) { + final branch = _materializeSchema(_asMap(item)); + final properties = + (branch['properties'] as Map?)?.cast() ?? + const {}; + mergedProperties.addAll(properties); + mergedRequired.addAll(_stringSet(branch['required'])); + + if (!merged.containsKey('description') && branch['description'] != null) { + merged['description'] = branch['description']; + } + } + + final ownProperties = + (schema['properties'] as Map?)?.cast() ?? + const {}; + mergedProperties.addAll(ownProperties); + mergedRequired.addAll(_stringSet(schema['required'])); + + if (mergedRequired.isNotEmpty) { + merged['required'] = mergedRequired.toList(growable: false); + } + return merged; + } + + Map _materializeSchema(Map schema) { + final ref = schema[r'$ref'] as String?; + if (ref == null) { + return schema; + } + + final refName = _refName(ref); + final resolved = _asMap(_schemas[refName]); + if (resolved.isEmpty) { + return schema; + } + + return deepMergeMaps( + resolved, + Map.from(schema)..remove(r'$ref'), + ); + } + + bool _isEnumSchema(Map schema) { + return schema['enum'] is List && (schema['enum'] as List).isNotEmpty; + } + + String _extractTypeValue(Map schema) { + final rawType = schema['type']; + if (rawType is String) { + return rawType; + } + if (rawType is List) { + for (final item in rawType) { + final type = item.toString(); + if (type != 'null') { + return type; + } + } + } + if (schema.containsKey('properties') || + schema.containsKey('additionalProperties')) { + return 'object'; + } + if (schema.containsKey('items')) { + return 'array'; + } + return 'dynamic'; + } + + Set _stringSet(dynamic rawValues) { + if (rawValues is! List) { + return const {}; + } + return rawValues.map((item) => item.toString()).toSet(); + } + + String? _descriptionFor(Map schema) { + return schema['description'] as String? ?? schema['title'] as String?; + } + + Map _asMap(dynamic value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; + } + + String _refName(String ref) { + final segments = ref.split('/'); + return segments.isEmpty ? ref : segments.last; + } +} diff --git a/lib/src/preset/preset.dart b/lib/src/preset/preset.dart new file mode 100644 index 0000000..e613ee4 --- /dev/null +++ b/lib/src/preset/preset.dart @@ -0,0 +1,32 @@ +enum BinaryTransportStyle { multipartFile, file, bytes, string } + +class GeneratorPreset { + const GeneratorPreset({ + required this.name, + required this.multipartBinaryStyle, + required this.description, + }); + + final String name; + final BinaryTransportStyle multipartBinaryStyle; + final String description; +} + +class GeneratorPresetRegistry { + static const GeneratorPreset koiFlutter = GeneratorPreset( + name: 'koi_flutter', + multipartBinaryStyle: BinaryTransportStyle.multipartFile, + description: + 'Koi Flutter preset,面向 Retrofit + Dio + Freezed 场景,' + 'multipart 二进制字段默认生成为 MultipartFile。', + ); + + static GeneratorPreset resolve(String? rawName) { + final name = rawName?.trim(); + if (name == null || name.isEmpty || name == koiFlutter.name) { + return koiFlutter; + } + + throw ArgumentError.value(name, 'generation.preset.name', '未知的 preset。'); + } +} diff --git a/lib/src/util/utils.dart b/lib/src/util/utils.dart new file mode 100644 index 0000000..9bc2e06 --- /dev/null +++ b/lib/src/util/utils.dart @@ -0,0 +1,276 @@ +import 'dart:convert'; + +import 'package:yaml/yaml.dart'; + +const Set dartKeywords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +final RegExp _leadingAcronymBoundary = RegExp(r'([A-Z]+)([A-Z][a-z])'); +final RegExp _camelBoundary = RegExp(r'([a-z0-9])([A-Z])'); +final RegExp _nonWordChars = RegExp(r'[^A-Za-z0-9]+'); +final RegExp _multiSpace = RegExp(r'\s+'); +const List _knownSnakeCaseAcronyms = [ + 'api', + 'http', + 'https', + 'iot', + 'jwt', + 'mqtt', + 'uri', + 'url', + 'uuid', +]; + +List splitWords(String input) { + final normalized = input + .replaceAllMapped( + _leadingAcronymBoundary, + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAllMapped( + _camelBoundary, + (match) => '${match.group(1)} ${match.group(2)}', + ) + .replaceAll(_nonWordChars, ' ') + .trim(); + + if (normalized.isEmpty) { + return const []; + } + + return normalized + .split(_multiSpace) + .where((part) => part.isNotEmpty) + .toList(growable: false); +} + +String toPascalCase(String input) { + final words = splitWords(input); + if (words.isEmpty) { + return 'GeneratedValue'; + } + + final value = words + .map( + (word) => + word.substring(0, 1).toUpperCase() + + word.substring(1).toLowerCase(), + ) + .join(); + return sanitizeIdentifier(value, fallback: 'GeneratedValue'); +} + +String toCamelCase(String input) { + final pascal = toPascalCase(input); + if (pascal.isEmpty) { + return 'generatedValue'; + } + final value = pascal.substring(0, 1).toLowerCase() + pascal.substring(1); + return sanitizeIdentifier(value, fallback: 'generatedValue'); +} + +String toSnakeCase(String input) { + final words = _mergeSnakeCaseAcronyms(splitWords(input)); + if (words.isEmpty) { + return 'generated_value'; + } + return words.map((word) => word.toLowerCase()).join('_'); +} + +List _mergeSnakeCaseAcronyms(List words) { + if (words.length < 2) { + return words; + } + + final merged = []; + var index = 0; + while (index < words.length) { + final acronym = _matchSnakeCaseAcronym(words, index); + if (acronym != null) { + merged.add(acronym.acronym); + index = acronym.endIndex; + continue; + } + merged.add(words[index]); + index += 1; + } + return merged; +} + +_SnakeCaseAcronymMatch? _matchSnakeCaseAcronym(List words, int start) { + for (final acronym in _knownSnakeCaseAcronyms) { + var combined = ''; + for (var end = start; end < words.length && end < start + 4; end += 1) { + combined += words[end].toLowerCase(); + if (combined == acronym) { + return _SnakeCaseAcronymMatch(acronym: acronym, endIndex: end + 1); + } + if (acronym.startsWith(combined) == false) { + break; + } + } + } + return null; +} + +class _SnakeCaseAcronymMatch { + const _SnakeCaseAcronymMatch({required this.acronym, required this.endIndex}); + + final String acronym; + final int endIndex; +} + +String sanitizeIdentifier(String raw, {required String fallback}) { + var value = raw.replaceAll(RegExp(r'[^A-Za-z0-9_]'), ''); + if (value.isEmpty) { + value = fallback; + } + if (RegExp(r'^\d').hasMatch(value)) { + value = 'v$value'; + } + if (dartKeywords.contains(value)) { + value = '${value}Value'; + } + return value; +} + +String escapeDartString(String value) { + return value + .replaceAll('\\', r'\\') + .replaceAll("'", r"\'") + .replaceAll('\n', r'\n'); +} + +Map yamlToMap(dynamic value) { + if (value is YamlMap) { + return value.map( + (key, dynamic innerValue) => + MapEntry(key.toString(), yamlValueToDynamic(innerValue)), + ); + } + if (value is Map) { + return value.map( + (key, dynamic innerValue) => + MapEntry(key.toString(), yamlValueToDynamic(innerValue)), + ); + } + return {}; +} + +dynamic yamlValueToDynamic(dynamic value) { + if (value is YamlMap || value is Map) { + return yamlToMap(value); + } + if (value is YamlList || value is List) { + return [ + for (final item in value as Iterable) yamlValueToDynamic(item), + ]; + } + return value; +} + +Map deepMergeMaps( + Map base, + Map override, +) { + final result = Map.from(base); + for (final entry in override.entries) { + final existing = result[entry.key]; + final incoming = entry.value; + if (existing is Map && incoming is Map) { + result[entry.key] = deepMergeMaps(existing, incoming); + } else { + result[entry.key] = incoming; + } + } + return result; +} + +bool isHttpUrl(String value) { + return value.startsWith('http://') || value.startsWith('https://'); +} + +Map decodeStructuredDocument(String content) { + final trimmed = content.trimLeft(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + final decoded = jsonDecode(content); + if (decoded is Map) { + return decoded; + } + throw const FormatException('OpenAPI 根节点必须是对象。'); + } + + final yaml = loadYaml(content); + final mapped = yamlToMap(yaml); + if (mapped.isEmpty) { + throw const FormatException('无法解析 OpenAPI 文档内容。'); + } + return mapped; +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..e18c236 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,26 @@ +name: koi_swagger_parser +description: OpenAPI 3.1 parser and Koi-style Dart API generator for Retrofit + Freezed projects. +version: 0.1.0 +homepage: https://github.com/mrkoi-is/koi_swagger_parser +repository: https://github.com/mrkoi-is/koi_swagger_parser +issue_tracker: https://github.com/mrkoi-is/koi_swagger_parser/issues + +topics: + - openapi + - swagger + - retrofit + - codegen + - dart + +environment: + sdk: ^3.11.0 + +dependencies: + args: ^2.7.0 + http: ^1.5.0 + path: ^1.9.1 + yaml: ^3.1.3 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.26.3 diff --git a/test/config_loader_test.dart b/test/config_loader_test.dart new file mode 100644 index 0000000..54e8ac8 --- /dev/null +++ b/test/config_loader_test.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('KoiSwaggerConfigLoader', () { + test('兼容旧版 generator_config.yaml 字段', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_config_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final configFile = File(p.join(tempDir.path, 'generator_config.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: ./openapi.json + enabled: true + +output: + base_dir: ./lib/src + api_dir: ./lib/src/api + models_dir: ./lib/src/api_models + included_tags: + - stores + +generation: + api: + enabled: true + use_retrofit: true + cancel_token_support: true + base_result_import: package:dba_network/dba_network.dart + client: + class_name: DbaApiClient + file_name: dba_api_client + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + final config = await KoiSwaggerConfig.load( + workingDirectory: tempDir.path, + ); + + expect(config.input.swaggerUrls, [ + p.join(tempDir.path, 'openapi.json'), + ]); + expect(config.output.includedTags, ['stores']); + expect( + config.generation.api.responseWrapper.mode, + ResponseWrapperMode.externalGeneric, + ); + expect( + config.generation.api.responseWrapper.externalImport, + 'package:dba_network/dba_network.dart', + ); + expect( + config.generation.api.responseWrapper.externalClassName, + 'BaseResult', + ); + expect(config.generation.api.cancelTokenSupport, isTrue); + expect(config.generation.preset.name, 'koi_flutter'); + expect(config.generation.models.classPrefix, 'Koi'); + expect(config.generation.formatting.runDartFormat, isFalse); + }); + }); +} diff --git a/test/defaults_and_parameters_test.dart b/test/defaults_and_parameters_test.dart new file mode 100644 index 0000000..74b9a34 --- /dev/null +++ b/test/defaults_and_parameters_test.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('default values and optional parameters', () { + test('枚举默认值与 query 默认值生成合法 Dart 代码', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_defaults_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final specFile = File(p.join(tempDir.path, 'openapi.json')); + await specFile.writeAsString(r''' +{ + "openapi": "3.1.0", + "info": { + "title": "Defaults Sample", + "version": "1.0.0" + }, + "paths": { + "/api/v1/samples": { + "get": { + "tags": ["samples"], + "operationId": "list_samples_api_v1_samples_get", + "parameters": [ + { + "name": "page_no", + "in": "query", + "required": false, + "schema": {"type": "integer", "default": 1} + }, + { + "name": "tag", + "in": "query", + "required": false, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/SampleResponse"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "SampleResponse": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "integer"}, + "conflict_policy": { + "type": "string", + "enum": ["exclusive", "mix"], + "default": "exclusive" + } + } + } + } + } +} +'''); + + final outputDir = p.join(tempDir.path, 'lib', 'src'); + final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: ${specFile.path} + enabled: true + +output: + base_dir: $outputDir + api_dir: ${p.join(outputDir, 'api')} + models_dir: ${p.join(outputDir, 'api_models')} + included_tags: + - samples + split_by_tags: true + +generation: + preset: + name: koi_flutter + api: + enabled: true + use_retrofit: true + cancel_token_support: false + client: + class_name: SampleApiClient + file_name: sample_api_client + response_wrapper: + mode: passthrough + detect_standard_envelope: false + success_data_field: data + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + await const KoiSwaggerParser().generate(configPath: configFile.path); + + final apiFile = File( + p.join(outputDir, 'api', 'v1', 'samples_api.dart'), + ).readAsStringSync(); + expect(apiFile, contains("@Query('page_no') int pageNo = 1")); + expect(apiFile, contains("@Query('tag') String? tag")); + + final resultFile = File( + p.join(outputDir, 'api_models', 'result', 'sample_response.dart'), + ).readAsStringSync(); + expect( + resultFile, + contains('@Default(KoiSampleResponseConflictPolicy.exclusive)'), + ); + }); + }); +} diff --git a/test/fixtures/dba_openapi.json b/test/fixtures/dba_openapi.json new file mode 100644 index 0000000..85803c7 --- /dev/null +++ b/test/fixtures/dba_openapi.json @@ -0,0 +1,6803 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Digital Business Area Server", + "version": "0.1.0" + }, + "paths": { + "/api/v1/auth/register": { + "post": { + "tags": [ + "auth" + ], + "summary": "Register User", + "operationId": "register_user_api_v1_auth_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RegisterResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Login User", + "operationId": "login_user_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_LoginResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stores": { + "get": { + "tags": [ + "stores" + ], + "summary": "List Stores", + "operationId": "list_stores_api_v1_stores_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_StoreResponse__" + } + } + } + } + } + }, + "post": { + "tags": [ + "stores" + ], + "summary": "Create Store", + "operationId": "create_store_api_v1_stores_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreWrite" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_StoreResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stores/{store_id}": { + "get": { + "tags": [ + "stores" + ], + "summary": "Get Store", + "operationId": "get_store_api_v1_stores__store_id__get", + "parameters": [ + { + "name": "store_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Store Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_StoreResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "stores" + ], + "summary": "Update Store", + "operationId": "update_store_api_v1_stores__store_id__put", + "parameters": [ + { + "name": "store_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Store Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoreWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_StoreResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "stores" + ], + "summary": "Delete Store", + "operationId": "delete_store_api_v1_stores__store_id__delete", + "parameters": [ + { + "name": "store_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Store Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices": { + "get": { + "tags": [ + "devices" + ], + "summary": "List Devices", + "operationId": "list_devices_api_v1_devices_get", + "parameters": [ + { + "name": "store_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Store Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_list_DeviceResponse__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "devices" + ], + "summary": "Create Device", + "operationId": "create_device_api_v1_devices_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/{device_id}": { + "get": { + "tags": [ + "devices" + ], + "summary": "Get Device", + "operationId": "get_device_api_v1_devices__device_id__get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "devices" + ], + "summary": "Update Device", + "operationId": "update_device_api_v1_devices__device_id__put", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "devices" + ], + "summary": "Delete Device", + "operationId": "delete_device_api_v1_devices__device_id__delete", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/register": { + "post": { + "tags": [ + "devices" + ], + "summary": "Register Device", + "operationId": "register_device_api_v1_devices_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRegistrationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceRegistrationResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/{device_id}/heartbeat": { + "post": { + "tags": [ + "devices" + ], + "summary": "Heartbeat", + "operationId": "heartbeat_api_v1_devices__device_id__heartbeat_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceHeartbeatResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/{device_id}/status": { + "get": { + "tags": [ + "devices" + ], + "summary": "Get Device Status", + "operationId": "get_device_status_api_v1_devices__device_id__status_get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceStatusResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/check-offline": { + "post": { + "tags": [ + "devices" + ], + "summary": "Check Offline", + "operationId": "check_offline_api_v1_devices_check_offline_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_OfflineCheckResponse_" + } + } + } + } + } + } + }, + "/api/v1/devices/{device_id}/config": { + "get": { + "tags": [ + "devices" + ], + "summary": "Get Device Config", + "operationId": "get_device_config_api_v1_devices__device_id__config_get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceConfigResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "devices" + ], + "summary": "Update Device Config", + "operationId": "update_device_config_api_v1_devices__device_id__config_put", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceConfigUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceConfigResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "devices" + ], + "summary": "Reset Device Config", + "operationId": "reset_device_config_api_v1_devices__device_id__config_delete", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceConfigResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/devices/{device_id}/command": { + "post": { + "tags": [ + "devices" + ], + "summary": "Send Device Command", + "operationId": "send_device_command_api_v1_devices__device_id__command_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceCommandRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceCommandResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/device-groups": { + "get": { + "tags": [ + "device_groups" + ], + "summary": "List Groups", + "operationId": "list_groups_api_v1_device_groups_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceGroupListResponse_" + } + } + } + } + } + }, + "post": { + "tags": [ + "device_groups" + ], + "summary": "Create Group", + "operationId": "create_group_api_v1_device_groups_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceGroupWrite" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceGroupSummary_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/device-groups/{group_id}": { + "get": { + "tags": [ + "device_groups" + ], + "summary": "Get Group", + "operationId": "get_group_api_v1_device_groups__group_id__get", + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Group Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceGroupDetailResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "device_groups" + ], + "summary": "Update Group", + "operationId": "update_group_api_v1_device_groups__group_id__put", + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Group Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceGroupWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceGroupSummary_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "device_groups" + ], + "summary": "Delete Group", + "operationId": "delete_group_api_v1_device_groups__group_id__delete", + "parameters": [ + { + "name": "group_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Group Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/device-groups/batch-action": { + "post": { + "tags": [ + "device_groups" + ], + "summary": "Batch Action", + "operationId": "batch_action_api_v1_device_groups_batch_action_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchDeviceActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_BatchDeviceActionResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/materials": { + "get": { + "tags": [ + "materials" + ], + "summary": "List Materials", + "operationId": "list_materials_api_v1_materials_get", + "parameters": [ + { + "name": "store_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Store Id" + } + }, + { + "name": "tag", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tag" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MaterialListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "materials" + ], + "summary": "Create Material", + "operationId": "create_material_api_v1_materials_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterialCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MaterialResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/materials/tags": { + "get": { + "tags": [ + "materials" + ], + "summary": "List Material Tags", + "operationId": "list_material_tags_api_v1_materials_tags_get", + "parameters": [ + { + "name": "store_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Store Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MaterialTagListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/materials/upload-token": { + "get": { + "tags": [ + "materials" + ], + "summary": "Get Upload Token", + "operationId": "get_upload_token_api_v1_materials_upload_token_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_QiniuUploadTokenResponse_" + } + } + } + } + } + } + }, + "/api/v1/materials/{material_id}": { + "get": { + "tags": [ + "materials" + ], + "summary": "Get Material", + "operationId": "get_material_api_v1_materials__material_id__get", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Material Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MaterialResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "materials" + ], + "summary": "Update Material", + "operationId": "update_material_api_v1_materials__material_id__put", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Material Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterialUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MaterialResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "materials" + ], + "summary": "Delete Material", + "operationId": "delete_material_api_v1_materials__material_id__delete", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Material Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlists": { + "get": { + "tags": [ + "playlists" + ], + "summary": "List Playlists", + "operationId": "list_playlists_api_v1_playlists_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylistListResponse_" + } + } + } + } + } + }, + "post": { + "tags": [ + "playlists" + ], + "summary": "Create Playlist", + "operationId": "create_playlist_api_v1_playlists_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistWrite" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylistResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlists/{playlist_id}": { + "get": { + "tags": [ + "playlists" + ], + "summary": "Get Playlist", + "operationId": "get_playlist_api_v1_playlists__playlist_id__get", + "parameters": [ + { + "name": "playlist_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Playlist Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylistResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "playlists" + ], + "summary": "Update Playlist", + "operationId": "update_playlist_api_v1_playlists__playlist_id__put", + "parameters": [ + { + "name": "playlist_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Playlist Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylistWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylistResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "playlists" + ], + "summary": "Delete Playlist", + "operationId": "delete_playlist_api_v1_playlists__playlist_id__delete", + "parameters": [ + { + "name": "playlist_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Playlist Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plans/{device_id}": { + "get": { + "tags": [ + "plans" + ], + "summary": "Get Plan", + "operationId": "get_plan_api_v1_plans__device_id__get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AdPlanResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "plans" + ], + "summary": "Put Plan", + "operationId": "put_plan_api_v1_plans__device_id__put", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdPlanWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AdPlanResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plans/{device_id}/publish": { + "post": { + "tags": [ + "plans" + ], + "summary": "Publish Plan", + "operationId": "publish_plan_api_v1_plans__device_id__publish_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanPublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanPublishResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plans/{device_id}/upload_and_publish": { + "post": { + "tags": [ + "plans" + ], + "summary": "Upload And Publish", + "operationId": "upload_and_publish_api_v1_plans__device_id__upload_and_publish_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanUploadPublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanUploadPublishResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plans/{device_id}/emergency": { + "post": { + "tags": [ + "plans" + ], + "summary": "Send Emergency", + "operationId": "send_emergency_api_v1_plans__device_id__emergency_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmergencyPlayRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_EmergencyEventResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plans/{device_id}/emergency/cancel": { + "post": { + "tags": [ + "plans" + ], + "summary": "Cancel Emergency", + "operationId": "cancel_emergency_api_v1_plans__device_id__emergency_cancel_post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/EmergencyCancelRequest" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_EmergencyEventResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plan-templates": { + "get": { + "tags": [ + "plan_templates" + ], + "summary": "List Templates", + "operationId": "list_templates_api_v1_plan_templates_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateListResponse_" + } + } + } + } + } + }, + "post": { + "tags": [ + "plan_templates" + ], + "summary": "Create Template", + "operationId": "create_template_api_v1_plan_templates_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanTemplateWrite" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateMutationResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plan-templates/{template_id}": { + "get": { + "tags": [ + "plan_templates" + ], + "summary": "Get Template", + "operationId": "get_template_api_v1_plan_templates__template_id__get", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Template Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "plan_templates" + ], + "summary": "Update Template", + "operationId": "update_template_api_v1_plan_templates__template_id__put", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Template Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanTemplateWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateMutationResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "plan_templates" + ], + "summary": "Delete Template", + "operationId": "delete_template_api_v1_plan_templates__template_id__delete", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Template Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plan-templates/{template_id}/apply": { + "post": { + "tags": [ + "plan_templates" + ], + "summary": "Apply Template", + "operationId": "apply_template_api_v1_plan_templates__template_id__apply_post", + "parameters": [ + { + "name": "template_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Template Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanTemplateApplyRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateApplyResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/plan-templates/from-plan/{device_id}": { + "post": { + "tags": [ + "plan_templates" + ], + "summary": "Create Template From Plan", + "operationId": "create_template_from_plan_api_v1_plan_templates_from_plan__device_id__post", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlanTemplateFromPlanWrite" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlanTemplateMutationResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playback_plans": { + "get": { + "tags": [ + "playback_plans" + ], + "summary": "List Plans", + "operationId": "list_plans_api_v1_playback_plans_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaybackPlanListResponse_" + } + } + } + } + } + }, + "post": { + "tags": [ + "playback_plans" + ], + "summary": "Create Plan", + "operationId": "create_plan_api_v1_playback_plans_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackPlanWrite" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaybackPlanResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playback_plans/{plan_id}": { + "get": { + "tags": [ + "playback_plans" + ], + "summary": "Get Plan", + "operationId": "get_plan_api_v1_playback_plans__plan_id__get", + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Plan Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaybackPlanResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "playback_plans" + ], + "summary": "Update Plan", + "operationId": "update_plan_api_v1_playback_plans__plan_id__put", + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Plan Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaybackPlanWrite" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaybackPlanResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "playback_plans" + ], + "summary": "Delete Plan", + "operationId": "delete_plan_api_v1_playback_plans__plan_id__delete", + "parameters": [ + { + "name": "plan_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Plan Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlogs": { + "get": { + "tags": [ + "playlogs" + ], + "summary": "List Playlogs", + "operationId": "list_playlogs_api_v1_playlogs_get", + "parameters": [ + { + "name": "device_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Device Id" + } + }, + { + "name": "material_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Material Id" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 100, + "title": "Limit" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 0, + "title": "Offset" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylogListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "playlogs" + ], + "summary": "Create Playlog", + "operationId": "create_playlog_api_v1_playlogs_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlaylogCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylogResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlogs/batch": { + "post": { + "tags": [ + "playlogs" + ], + "summary": "Create Playlogs Batch", + "operationId": "create_playlogs_batch_api_v1_playlogs_batch_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPlaylogCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__str__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlogs/stats/{device_id}": { + "get": { + "tags": [ + "playlogs" + ], + "summary": "Playlog Stats", + "operationId": "playlog_stats_api_v1_playlogs_stats__device_id__get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaylogStatsResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/playlogs/recent/{device_id}": { + "get": { + "tags": [ + "playlogs" + ], + "summary": "Recent Playlogs", + "operationId": "recent_playlogs_api_v1_playlogs_recent__device_id__get", + "parameters": [ + { + "name": "device_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Device Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RecentPlaylogResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/analytics/dashboard": { + "get": { + "tags": [ + "analytics" + ], + "summary": "Dashboard", + "operationId": "dashboard_api_v1_analytics_dashboard_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AnalyticsDashboardResponse_" + } + } + } + } + } + } + }, + "/api/v1/analytics/plays-by-day": { + "get": { + "tags": [ + "analytics" + ], + "summary": "Plays By Day", + "operationId": "plays_by_day_api_v1_analytics_plays_by_day_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_PlaysByDayResponse_" + } + } + } + } + } + } + }, + "/api/v1/analytics/top-materials": { + "get": { + "tags": [ + "analytics" + ], + "summary": "Top Materials", + "operationId": "top_materials_api_v1_analytics_top_materials_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_TopMaterialsResponse_" + } + } + } + } + } + } + }, + "/api/v1/analytics/device-activity": { + "get": { + "tags": [ + "analytics" + ], + "summary": "Device Activity", + "operationId": "device_activity_api_v1_analytics_device_activity_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_DeviceActivityResponse_" + } + } + } + } + } + } + }, + "/api/v1/analytics/hourly-distribution": { + "get": { + "tags": [ + "analytics" + ], + "summary": "Hourly Distribution", + "operationId": "hourly_distribution_api_v1_analytics_hourly_distribution_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_HourlyDistributionResponse_" + } + } + } + } + } + } + }, + "/api/v1/iot/devices": { + "get": { + "tags": [ + "iot_devices" + ], + "summary": "List Iot Devices", + "operationId": "list_iot_devices_api_v1_iot_devices_get", + "parameters": [ + { + "name": "page_no", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1, + "title": "Page No" + } + }, + { + "name": "page_size", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 10, + "title": "Page Size" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_IoTDeviceListResponse_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/iot/devices/{device_name}": { + "get": { + "tags": [ + "iot_devices" + ], + "summary": "Get Iot Device", + "operationId": "get_iot_device_api_v1_iot_devices__device_name__get", + "parameters": [ + { + "name": "device_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__Any__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "iot_devices" + ], + "summary": "Delete Iot Device", + "operationId": "delete_iot_device_api_v1_iot_devices__device_name__delete", + "parameters": [ + { + "name": "device_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Device Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_dict_str__bool__" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/iot/devices/sync": { + "post": { + "tags": [ + "iot_devices" + ], + "summary": "Sync Iot Devices", + "operationId": "sync_iot_devices_api_v1_iot_devices_sync_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_IoTDeviceSyncResponse_" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AdPlanResponse": { + "properties": { + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "ver_ts": { + "type": "integer", + "title": "Ver Ts" + }, + "content": { + "additionalProperties": true, + "type": "object", + "title": "Content" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "device_id", + "ver_ts", + "content", + "created_at", + "updated_at" + ], + "title": "AdPlanResponse" + }, + "AdPlanWrite": { + "properties": { + "device_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Device Id" + }, + "content": { + "additionalProperties": true, + "type": "object", + "title": "Content" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "AdPlanWrite" + }, + "AnalyticsDashboardResponse": { + "properties": { + "devices": { + "$ref": "#/components/schemas/DeviceDashboardStats" + }, + "today": { + "$ref": "#/components/schemas/TodayDashboardStats" + }, + "week": { + "$ref": "#/components/schemas/WeekDashboardStats" + }, + "materials": { + "$ref": "#/components/schemas/MaterialDashboardStats" + }, + "generated_at": { + "type": "integer", + "title": "Generated At" + } + }, + "type": "object", + "required": [ + "devices", + "today", + "week", + "materials", + "generated_at" + ], + "title": "AnalyticsDashboardResponse" + }, + "ApiResponse_AdPlanResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/AdPlanResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[AdPlanResponse]" + }, + "ApiResponse_AnalyticsDashboardResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/AnalyticsDashboardResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[AnalyticsDashboardResponse]" + }, + "ApiResponse_BatchDeviceActionResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/BatchDeviceActionResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[BatchDeviceActionResponse]" + }, + "ApiResponse_DeviceActivityResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceActivityResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceActivityResponse]" + }, + "ApiResponse_DeviceCommandResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceCommandResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceCommandResponse]" + }, + "ApiResponse_DeviceConfigResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceConfigResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceConfigResponse]" + }, + "ApiResponse_DeviceGroupDetailResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceGroupDetailResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceGroupDetailResponse]" + }, + "ApiResponse_DeviceGroupListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceGroupListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceGroupListResponse]" + }, + "ApiResponse_DeviceGroupSummary_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceGroupSummary" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceGroupSummary]" + }, + "ApiResponse_DeviceHeartbeatResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceHeartbeatResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceHeartbeatResponse]" + }, + "ApiResponse_DeviceRegistrationResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceRegistrationResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceRegistrationResponse]" + }, + "ApiResponse_DeviceResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceResponse]" + }, + "ApiResponse_DeviceStatusResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/DeviceStatusResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[DeviceStatusResponse]" + }, + "ApiResponse_EmergencyEventResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/EmergencyEventResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[EmergencyEventResponse]" + }, + "ApiResponse_HourlyDistributionResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/HourlyDistributionResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[HourlyDistributionResponse]" + }, + "ApiResponse_IoTDeviceListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/IoTDeviceListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[IoTDeviceListResponse]" + }, + "ApiResponse_IoTDeviceSyncResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/IoTDeviceSyncResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[IoTDeviceSyncResponse]" + }, + "ApiResponse_LoginResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/LoginResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[LoginResponse]" + }, + "ApiResponse_MaterialListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/MaterialListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[MaterialListResponse]" + }, + "ApiResponse_MaterialResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/MaterialResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[MaterialResponse]" + }, + "ApiResponse_MaterialTagListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/MaterialTagListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[MaterialTagListResponse]" + }, + "ApiResponse_OfflineCheckResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/OfflineCheckResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[OfflineCheckResponse]" + }, + "ApiResponse_PlanPublishResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanPublishResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanPublishResponse]" + }, + "ApiResponse_PlanTemplateApplyResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanTemplateApplyResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanTemplateApplyResponse]" + }, + "ApiResponse_PlanTemplateListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanTemplateListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanTemplateListResponse]" + }, + "ApiResponse_PlanTemplateMutationResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanTemplateMutationResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanTemplateMutationResponse]" + }, + "ApiResponse_PlanTemplateResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanTemplateResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanTemplateResponse]" + }, + "ApiResponse_PlanUploadPublishResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlanUploadPublishResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlanUploadPublishResponse]" + }, + "ApiResponse_PlaybackPlanListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaybackPlanListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaybackPlanListResponse]" + }, + "ApiResponse_PlaybackPlanResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaybackPlanResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaybackPlanResponse]" + }, + "ApiResponse_PlaylistListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaylistListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaylistListResponse]" + }, + "ApiResponse_PlaylistResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaylistResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaylistResponse]" + }, + "ApiResponse_PlaylogListResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaylogListResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaylogListResponse]" + }, + "ApiResponse_PlaylogResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaylogResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaylogResponse]" + }, + "ApiResponse_PlaylogStatsResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaylogStatsResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaylogStatsResponse]" + }, + "ApiResponse_PlaysByDayResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/PlaysByDayResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[PlaysByDayResponse]" + }, + "ApiResponse_QiniuUploadTokenResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/QiniuUploadTokenResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[QiniuUploadTokenResponse]" + }, + "ApiResponse_RecentPlaylogResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/RecentPlaylogResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[RecentPlaylogResponse]" + }, + "ApiResponse_RegisterResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/RegisterResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[RegisterResponse]" + }, + "ApiResponse_StoreResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/StoreResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[StoreResponse]" + }, + "ApiResponse_TopMaterialsResponse_": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "$ref": "#/components/schemas/TopMaterialsResponse" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[TopMaterialsResponse]" + }, + "ApiResponse_dict_str__Any__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[dict[str, Any]]" + }, + "ApiResponse_dict_str__bool__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[dict[str, bool]]" + }, + "ApiResponse_dict_str__str__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[dict[str, str]]" + }, + "ApiResponse_list_DeviceResponse__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "items": { + "$ref": "#/components/schemas/DeviceResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[list[DeviceResponse]]" + }, + "ApiResponse_list_StoreResponse__": { + "properties": { + "code": { + "type": "integer", + "title": "Code", + "default": 200 + }, + "message": { + "type": "string", + "title": "Message", + "default": "ok" + }, + "data": { + "items": { + "$ref": "#/components/schemas/StoreResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ApiResponse[list[StoreResponse]]" + }, + "BatchDeviceActionRequest": { + "properties": { + "device_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "title": "Device Ids" + }, + "action": { + "type": "string", + "title": "Action" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + }, + "type": "object", + "required": [ + "device_ids", + "action" + ], + "title": "BatchDeviceActionRequest" + }, + "BatchDeviceActionResponse": { + "properties": { + "action": { + "type": "string", + "title": "Action" + }, + "affected_count": { + "type": "integer", + "title": "Affected Count" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "action", + "affected_count", + "message" + ], + "title": "BatchDeviceActionResponse" + }, + "BatchPlaylogCreate": { + "properties": { + "logs": { + "items": { + "$ref": "#/components/schemas/PlaylogCreate" + }, + "type": "array", + "title": "Logs" + } + }, + "type": "object", + "title": "BatchPlaylogCreate" + }, + "DeviceActivityItem": { + "properties": { + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "device_name": { + "type": "string", + "title": "Device Name" + }, + "status": { + "type": "string", + "title": "Status" + }, + "play_count": { + "type": "integer", + "title": "Play Count" + }, + "total_duration": { + "type": "integer", + "title": "Total Duration" + } + }, + "type": "object", + "required": [ + "device_id", + "device_name", + "status", + "play_count", + "total_duration" + ], + "title": "DeviceActivityItem" + }, + "DeviceActivityResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DeviceActivityItem" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "DeviceActivityResponse" + }, + "DeviceCommandRequest": { + "properties": { + "command": { + "type": "string", + "title": "Command" + }, + "payload": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Payload" + } + }, + "type": "object", + "required": [ + "command" + ], + "title": "DeviceCommandRequest" + }, + "DeviceCommandResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "command": { + "type": "string", + "title": "Command" + } + }, + "type": "object", + "required": [ + "status", + "device_id", + "command" + ], + "title": "DeviceCommandResponse" + }, + "DeviceConfigResponse": { + "properties": { + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + }, + "config_version": { + "type": "integer", + "title": "Config Version" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message" + } + }, + "type": "object", + "required": [ + "device_id", + "config", + "config_version" + ], + "title": "DeviceConfigResponse" + }, + "DeviceConfigUpdate": { + "properties": { + "config": { + "additionalProperties": true, + "type": "object", + "title": "Config" + } + }, + "type": "object", + "required": [ + "config" + ], + "title": "DeviceConfigUpdate" + }, + "DeviceCreate": { + "properties": { + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "mqtt_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mqtt Topic" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "type": "object", + "required": [ + "store_id", + "name" + ], + "title": "DeviceCreate" + }, + "DeviceDashboardStats": { + "properties": { + "total": { + "type": "integer", + "title": "Total" + }, + "online": { + "type": "integer", + "title": "Online" + }, + "offline": { + "type": "integer", + "title": "Offline" + } + }, + "type": "object", + "required": [ + "total", + "online", + "offline" + ], + "title": "DeviceDashboardStats" + }, + "DeviceGroupDetailResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "devices": { + "items": { + "$ref": "#/components/schemas/DeviceGroupDevice" + }, + "type": "array", + "title": "Devices" + }, + "device_count": { + "type": "integer", + "title": "Device Count" + } + }, + "type": "object", + "required": [ + "id", + "name", + "store_id", + "device_count" + ], + "title": "DeviceGroupDetailResponse" + }, + "DeviceGroupDevice": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "id", + "name", + "status" + ], + "title": "DeviceGroupDevice" + }, + "DeviceGroupListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DeviceGroupSummary" + }, + "type": "array", + "title": "Data" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "DeviceGroupListResponse" + }, + "DeviceGroupSummary": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "device_count": { + "type": "integer", + "title": "Device Count" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "store_id", + "device_count" + ], + "title": "DeviceGroupSummary" + }, + "DeviceGroupWrite": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + } + }, + "type": "object", + "required": [ + "name", + "store_id" + ], + "title": "DeviceGroupWrite" + }, + "DeviceHeartbeatResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + } + }, + "type": "object", + "required": [ + "status", + "timestamp", + "device_id" + ], + "title": "DeviceHeartbeatResponse" + }, + "DeviceRegistrationRequest": { + "properties": { + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "mqtt_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mqtt Topic" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "type": "object", + "required": [ + "store_id", + "name" + ], + "title": "DeviceRegistrationRequest" + }, + "DeviceRegistrationResponse": { + "properties": { + "device": { + "$ref": "#/components/schemas/DeviceResponse" + }, + "device_key": { + "type": "string", + "title": "Device Key" + }, + "device_sn": { + "type": "string", + "title": "Device Sn" + }, + "config_uploaded": { + "type": "boolean", + "title": "Config Uploaded" + }, + "iot_core_enabled": { + "type": "boolean", + "title": "Iot Core Enabled" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "device", + "device_key", + "device_sn", + "config_uploaded", + "iot_core_enabled", + "message" + ], + "title": "DeviceRegistrationResponse" + }, + "DeviceResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "sn": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sn" + }, + "status": { + "type": "string", + "title": "Status" + }, + "mqtt_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mqtt Topic" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags", + "default": [] + }, + "last_seen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Seen" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "store_id", + "name", + "status", + "created_at", + "updated_at" + ], + "title": "DeviceResponse" + }, + "DeviceStatusResponse": { + "properties": { + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "status": { + "type": "string", + "title": "Status" + }, + "last_seen": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Seen" + }, + "last_seen_ago": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Last Seen Ago" + } + }, + "type": "object", + "required": [ + "device_id", + "status" + ], + "title": "DeviceStatusResponse" + }, + "DeviceUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "store_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Store Id" + }, + "mqtt_topic": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mqtt Topic" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + "type": "object", + "title": "DeviceUpdate" + }, + "EmergencyCancelRequest": { + "properties": { + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reason" + } + }, + "type": "object", + "title": "EmergencyCancelRequest" + }, + "EmergencyEventResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "version": { + "type": "integer", + "title": "Version" + } + }, + "type": "object", + "required": [ + "message", + "device_id", + "version" + ], + "title": "EmergencyEventResponse" + }, + "EmergencyPlayItem": { + "properties": { + "material_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Material Id" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Url" + }, + "type": { + "anyOf": [ + { + "type": "string", + "enum": [ + "image", + "video" + ] + }, + { + "type": "null" + } + ], + "title": "Type" + }, + "duration": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Duration" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "type": "object", + "title": "EmergencyPlayItem" + }, + "EmergencyPlayRequest": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/EmergencyPlayItem" + }, + "type": "array", + "title": "Items" + }, + "duration": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Duration" + }, + "policy": { + "type": "string", + "enum": [ + "override", + "insert" + ], + "title": "Policy", + "default": "override" + }, + "version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "type": "object", + "title": "EmergencyPlayRequest" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HourlyDistributionItem": { + "properties": { + "hour": { + "type": "integer", + "title": "Hour" + }, + "plays": { + "type": "integer", + "title": "Plays" + } + }, + "type": "object", + "required": [ + "hour", + "plays" + ], + "title": "HourlyDistributionItem" + }, + "HourlyDistributionResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/HourlyDistributionItem" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "HourlyDistributionResponse" + }, + "IoTDeviceListResponse": { + "properties": { + "devices": { + "items": { + "$ref": "#/components/schemas/IoTDeviceResponse" + }, + "type": "array", + "title": "Devices" + }, + "totalCount": { + "type": "integer", + "title": "Totalcount" + }, + "pageNo": { + "type": "integer", + "title": "Pageno" + }, + "pageSize": { + "type": "integer", + "title": "Pagesize" + } + }, + "type": "object", + "required": [ + "totalCount", + "pageNo", + "pageSize" + ], + "title": "IoTDeviceListResponse" + }, + "IoTDeviceResponse": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "authType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authtype" + }, + "desc": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Desc" + }, + "createTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Createtime" + }, + "updateTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updatetime" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + }, + "lastActiveTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Lastactivetime" + } + }, + "type": "object", + "title": "IoTDeviceResponse" + }, + "IoTDeviceSyncResponse": { + "properties": { + "synced": { + "type": "integer", + "title": "Synced" + }, + "local_only": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Local Only" + }, + "iot_only": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Iot Only" + }, + "total_local": { + "type": "integer", + "title": "Total Local" + }, + "total_iot": { + "type": "integer", + "title": "Total Iot" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "synced", + "total_local", + "total_iot", + "message" + ], + "title": "IoTDeviceSyncResponse" + }, + "LoginRequest": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "username", + "password" + ], + "title": "LoginRequest" + }, + "LoginResponse": { + "properties": { + "access_token": { + "type": "string", + "title": "Access Token" + }, + "user": { + "$ref": "#/components/schemas/UserInfo" + } + }, + "type": "object", + "required": [ + "access_token", + "user" + ], + "title": "LoginResponse" + }, + "MaterialCreate": { + "properties": { + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + }, + "size": { + "type": "integer", + "title": "Size", + "default": 0 + }, + "duration": { + "type": "integer", + "title": "Duration", + "default": 0 + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags" + } + }, + "type": "object", + "required": [ + "store_id", + "name", + "type", + "url", + "key" + ], + "title": "MaterialCreate" + }, + "MaterialDashboardStats": { + "properties": { + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "MaterialDashboardStats" + }, + "MaterialListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/MaterialResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "MaterialListResponse" + }, + "MaterialResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "type": { + "type": "string", + "title": "Type" + }, + "url": { + "type": "string", + "title": "Url" + }, + "key": { + "type": "string", + "title": "Key" + }, + "size": { + "type": "integer", + "title": "Size" + }, + "duration": { + "type": "integer", + "title": "Duration" + }, + "tags": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Tags" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "store_id", + "name", + "type", + "url", + "key", + "size", + "duration", + "created_at", + "updated_at" + ], + "title": "MaterialResponse" + }, + "MaterialTagListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/MaterialTagResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "MaterialTagListResponse" + }, + "MaterialTagResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "id", + "store_id", + "name" + ], + "title": "MaterialTagResponse" + }, + "MaterialUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + } + }, + "type": "object", + "title": "MaterialUpdate" + }, + "OfflineCheckResponse": { + "properties": { + "checked_at": { + "type": "integer", + "title": "Checked At" + }, + "marked_offline": { + "type": "integer", + "title": "Marked Offline" + } + }, + "type": "object", + "required": [ + "checked_at", + "marked_offline" + ], + "title": "OfflineCheckResponse" + }, + "PlanPublishRequest": { + "properties": { + "plan_url": { + "type": "string", + "title": "Plan Url" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "checksum": { + "type": "string", + "title": "Checksum" + }, + "size": { + "type": "integer", + "title": "Size" + } + }, + "type": "object", + "required": [ + "plan_url", + "version", + "checksum", + "size" + ], + "title": "PlanPublishRequest" + }, + "PlanPublishResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + } + }, + "type": "object", + "required": [ + "message", + "version", + "device_id" + ], + "title": "PlanPublishResponse" + }, + "PlanTargetResponse": { + "properties": { + "target_type": { + "type": "string", + "enum": [ + "device", + "group", + "tag", + "store" + ], + "title": "Target Type" + }, + "target_value": { + "type": "string", + "title": "Target Value" + }, + "id": { + "type": "integer", + "title": "Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "target_type", + "target_value", + "id", + "created_at", + "updated_at" + ], + "title": "PlanTargetResponse" + }, + "PlanTargetWrite": { + "properties": { + "target_type": { + "type": "string", + "enum": [ + "device", + "group", + "tag", + "store" + ], + "title": "Target Type" + }, + "target_value": { + "type": "string", + "title": "Target Value" + } + }, + "type": "object", + "required": [ + "target_type", + "target_value" + ], + "title": "PlanTargetWrite" + }, + "PlanTemplateApplyRequest": { + "properties": { + "device_ids": { + "items": { + "type": "integer" + }, + "type": "array", + "minItems": 1, + "title": "Device Ids" + } + }, + "type": "object", + "required": [ + "device_ids" + ], + "title": "PlanTemplateApplyRequest" + }, + "PlanTemplateApplyResponse": { + "properties": { + "template_id": { + "type": "integer", + "title": "Template Id" + }, + "applied_count": { + "type": "integer", + "title": "Applied Count" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "template_id", + "applied_count", + "version", + "message" + ], + "title": "PlanTemplateApplyResponse" + }, + "PlanTemplateFromPlanWrite": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "content": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Content" + } + }, + "type": "object", + "required": [ + "name", + "store_id" + ], + "title": "PlanTemplateFromPlanWrite" + }, + "PlanTemplateListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PlanTemplateResponse" + }, + "type": "array", + "title": "Data" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "total" + ], + "title": "PlanTemplateListResponse" + }, + "PlanTemplateMutationResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "id", + "name", + "message" + ], + "title": "PlanTemplateMutationResponse" + }, + "PlanTemplateResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "content": { + "additionalProperties": true, + "type": "object", + "title": "Content" + }, + "usage_count": { + "type": "integer", + "title": "Usage Count" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "description", + "store_id", + "content", + "usage_count", + "created_at", + "updated_at" + ], + "title": "PlanTemplateResponse" + }, + "PlanTemplateWrite": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "content": { + "additionalProperties": true, + "type": "object", + "title": "Content" + } + }, + "type": "object", + "required": [ + "name", + "store_id", + "content" + ], + "title": "PlanTemplateWrite" + }, + "PlanUploadPublishRequest": { + "properties": { + "content": { + "additionalProperties": true, + "type": "object", + "title": "Content" + }, + "version": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + "type": "object", + "required": [ + "content" + ], + "title": "PlanUploadPublishRequest" + }, + "PlanUploadPublishResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + }, + "version": { + "type": "integer", + "title": "Version" + }, + "plan_url": { + "type": "string", + "title": "Plan Url" + }, + "checksum": { + "type": "string", + "title": "Checksum" + } + }, + "type": "object", + "required": [ + "message", + "version", + "plan_url", + "checksum" + ], + "title": "PlanUploadPublishResponse" + }, + "PlaybackPlanListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PlaybackPlanResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "PlaybackPlanListResponse" + }, + "PlaybackPlanResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "device_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Device Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "default_playlist_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Default Playlist Id" + }, + "schedule": { + "items": { + "$ref": "#/components/schemas/PlaybackScheduleSlot" + }, + "type": "array", + "title": "Schedule" + }, + "priority": { + "type": "integer", + "title": "Priority", + "default": 0 + }, + "conflict_policy": { + "type": "string", + "enum": [ + "exclusive", + "mix", + "weighted" + ], + "title": "Conflict Policy", + "default": "exclusive" + }, + "scene": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/PlanTargetResponse" + }, + "type": "array", + "title": "Targets" + } + }, + "type": "object", + "required": [ + "id", + "store_id", + "name", + "created_at", + "updated_at" + ], + "title": "PlaybackPlanResponse" + }, + "PlaybackPlanWrite": { + "properties": { + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "device_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Device Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "default_playlist_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Default Playlist Id" + }, + "schedule": { + "items": { + "$ref": "#/components/schemas/PlaybackScheduleSlot" + }, + "type": "array", + "title": "Schedule" + }, + "priority": { + "type": "integer", + "title": "Priority", + "default": 0 + }, + "conflict_policy": { + "type": "string", + "enum": [ + "exclusive", + "mix", + "weighted" + ], + "title": "Conflict Policy", + "default": "exclusive" + }, + "scene": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Scene" + }, + "targets": { + "items": { + "$ref": "#/components/schemas/PlanTargetWrite" + }, + "type": "array", + "title": "Targets" + } + }, + "type": "object", + "required": [ + "store_id", + "name" + ], + "title": "PlaybackPlanWrite" + }, + "PlaybackScheduleSlot": { + "properties": { + "day": { + "type": "integer", + "maximum": 7.0, + "minimum": 1.0, + "title": "Day" + }, + "start": { + "type": "string", + "title": "Start" + }, + "end": { + "type": "string", + "title": "End" + }, + "playlist_id": { + "type": "integer", + "title": "Playlist Id" + } + }, + "type": "object", + "required": [ + "day", + "start", + "end", + "playlist_id" + ], + "title": "PlaybackScheduleSlot" + }, + "PlaylistItem": { + "properties": { + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "duration": { + "type": "integer", + "title": "Duration" + } + }, + "type": "object", + "required": [ + "material_id", + "duration" + ], + "title": "PlaylistItem" + }, + "PlaylistListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PlaylistResponse" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "PlaylistListResponse" + }, + "PlaylistResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "items": { + "items": { + "$ref": "#/components/schemas/PlaylistItem" + }, + "type": "array", + "title": "Items" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "store_id", + "name", + "created_at", + "updated_at" + ], + "title": "PlaylistResponse" + }, + "PlaylistWrite": { + "properties": { + "store_id": { + "type": "integer", + "title": "Store Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "items": { + "items": { + "$ref": "#/components/schemas/PlaylistItem" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "store_id", + "name" + ], + "title": "PlaylistWrite" + }, + "PlaylogCreate": { + "properties": { + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "start_time": { + "type": "integer", + "title": "Start Time" + }, + "end_time": { + "type": "integer", + "title": "End Time" + }, + "duration": { + "type": "integer", + "title": "Duration" + } + }, + "type": "object", + "required": [ + "device_id", + "material_id", + "start_time", + "end_time", + "duration" + ], + "title": "PlaylogCreate" + }, + "PlaylogItem": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "material_name": { + "type": "string", + "title": "Material Name" + }, + "material_type": { + "type": "string", + "title": "Material Type" + }, + "start_time": { + "type": "integer", + "title": "Start Time" + }, + "end_time": { + "type": "integer", + "title": "End Time" + }, + "duration": { + "type": "integer", + "title": "Duration" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "device_id", + "material_id", + "material_name", + "material_type", + "start_time", + "end_time", + "duration" + ], + "title": "PlaylogItem" + }, + "PlaylogListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PlaylogItem" + }, + "type": "array", + "title": "Data" + }, + "total": { + "type": "integer", + "title": "Total" + }, + "limit": { + "type": "integer", + "title": "Limit" + }, + "offset": { + "type": "integer", + "title": "Offset" + } + }, + "type": "object", + "required": [ + "total", + "limit", + "offset" + ], + "title": "PlaylogListResponse" + }, + "PlaylogResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "device_id": { + "type": "integer", + "title": "Device Id" + }, + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "material_name": { + "type": "string", + "title": "Material Name" + }, + "material_type": { + "type": "string", + "title": "Material Type" + }, + "start_time": { + "type": "integer", + "title": "Start Time" + }, + "end_time": { + "type": "integer", + "title": "End Time" + }, + "duration": { + "type": "integer", + "title": "Duration" + }, + "created_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "device_id", + "material_id", + "material_name", + "material_type", + "start_time", + "end_time", + "duration" + ], + "title": "PlaylogResponse" + }, + "PlaylogStatsDateRange": { + "properties": { + "start": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Start" + }, + "end": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + "type": "object", + "title": "PlaylogStatsDateRange" + }, + "PlaylogStatsResponse": { + "properties": { + "total_plays": { + "type": "integer", + "title": "Total Plays" + }, + "total_duration": { + "type": "integer", + "title": "Total Duration" + }, + "unique_materials": { + "type": "integer", + "title": "Unique Materials" + }, + "date_range": { + "$ref": "#/components/schemas/PlaylogStatsDateRange" + } + }, + "type": "object", + "required": [ + "total_plays", + "total_duration", + "unique_materials", + "date_range" + ], + "title": "PlaylogStatsResponse" + }, + "PlaysByDayItem": { + "properties": { + "date": { + "type": "string", + "title": "Date" + }, + "timestamp": { + "type": "integer", + "title": "Timestamp" + }, + "plays": { + "type": "integer", + "title": "Plays" + }, + "duration": { + "type": "integer", + "title": "Duration" + } + }, + "type": "object", + "required": [ + "date", + "timestamp", + "plays", + "duration" + ], + "title": "PlaysByDayItem" + }, + "PlaysByDayResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/PlaysByDayItem" + }, + "type": "array", + "title": "Data" + }, + "total_days": { + "type": "integer", + "title": "Total Days" + } + }, + "type": "object", + "required": [ + "total_days" + ], + "title": "PlaysByDayResponse" + }, + "QiniuUploadTokenResponse": { + "properties": { + "token": { + "type": "string", + "title": "Token" + }, + "expires_at": { + "type": "integer", + "title": "Expires At" + } + }, + "type": "object", + "required": [ + "token", + "expires_at" + ], + "title": "QiniuUploadTokenResponse" + }, + "RecentPlaylogItem": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "material_name": { + "type": "string", + "title": "Material Name" + }, + "material_type": { + "type": "string", + "title": "Material Type" + }, + "start_time": { + "type": "integer", + "title": "Start Time" + }, + "end_time": { + "type": "integer", + "title": "End Time" + }, + "duration": { + "type": "integer", + "title": "Duration" + } + }, + "type": "object", + "required": [ + "id", + "material_id", + "material_name", + "material_type", + "start_time", + "end_time", + "duration" + ], + "title": "RecentPlaylogItem" + }, + "RecentPlaylogResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/RecentPlaylogItem" + }, + "type": "array", + "title": "Data" + }, + "count": { + "type": "integer", + "title": "Count" + } + }, + "type": "object", + "required": [ + "count" + ], + "title": "RecentPlaylogResponse" + }, + "RegisterRequest": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "username", + "password" + ], + "title": "RegisterRequest" + }, + "RegisterResponse": { + "properties": { + "message": { + "type": "string", + "title": "Message" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "RegisterResponse" + }, + "StoreResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "id", + "name", + "created_at", + "updated_at" + ], + "title": "StoreResponse" + }, + "StoreWrite": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Address" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "StoreWrite" + }, + "TodayDashboardStats": { + "properties": { + "plays": { + "type": "integer", + "title": "Plays" + }, + "duration_seconds": { + "type": "integer", + "title": "Duration Seconds" + }, + "duration_formatted": { + "type": "string", + "title": "Duration Formatted" + } + }, + "type": "object", + "required": [ + "plays", + "duration_seconds", + "duration_formatted" + ], + "title": "TodayDashboardStats" + }, + "TopMaterialItem": { + "properties": { + "material_id": { + "type": "integer", + "title": "Material Id" + }, + "material_name": { + "type": "string", + "title": "Material Name" + }, + "material_type": { + "type": "string", + "title": "Material Type" + }, + "play_count": { + "type": "integer", + "title": "Play Count" + }, + "total_duration": { + "type": "integer", + "title": "Total Duration" + } + }, + "type": "object", + "required": [ + "material_id", + "material_name", + "material_type", + "play_count", + "total_duration" + ], + "title": "TopMaterialItem" + }, + "TopMaterialsResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/TopMaterialItem" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "title": "TopMaterialsResponse" + }, + "UserInfo": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "username": { + "type": "string", + "title": "Username" + }, + "role": { + "type": "string", + "title": "Role" + } + }, + "type": "object", + "required": [ + "id", + "username", + "role" + ], + "title": "UserInfo" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "WeekDashboardStats": { + "properties": { + "plays": { + "type": "integer", + "title": "Plays" + } + }, + "type": "object", + "required": [ + "plays" + ], + "title": "WeekDashboardStats" + } + } + } +} \ No newline at end of file diff --git a/test/generation_integration_test.dart b/test/generation_integration_test.dart new file mode 100644 index 0000000..debbf1b --- /dev/null +++ b/test/generation_integration_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('DBA fixture generation', () { + test('正确生成 Koi 风格 API 与模型文件', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_dba_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final outputDir = p.join(tempDir.path, 'lib', 'src'); + final fixturePath = p.normalize( + p.join(Directory.current.path, 'test', 'fixtures', 'dba_openapi.json'), + ); + + final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: $fixturePath + enabled: true + +output: + base_dir: $outputDir + api_dir: ${p.join(outputDir, 'api')} + models_dir: ${p.join(outputDir, 'api_models')} + included_tags: + - stores + - materials + split_by_tags: true + +generation: + api: + enabled: true + use_retrofit: true + cancel_token_support: true + client: + class_name: DbaApiClient + file_name: dba_api_client + response_wrapper: + mode: external_generic + detect_standard_envelope: true + success_data_field: data + external_generic: + import: package:dba_network/dba_network.dart + class_name: BaseResult + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + final result = await const KoiSwaggerParser().generate( + configPath: configFile.path, + ); + + expect(result.operationsCount, greaterThan(0)); + expect(result.modelsCount, greaterThan(0)); + + final materialsApi = File( + p.join(outputDir, 'api', 'v1', 'materials_api.dart'), + ).readAsStringSync(); + expect( + materialsApi, + contains('Future> listMaterials'), + ); + expect(materialsApi, contains("@Query('store_id') int? storeId")); + expect(materialsApi, contains("@Query('tag') String? tag")); + + final storesApi = File( + p.join(outputDir, 'api', 'v1', 'stores_api.dart'), + ).readAsStringSync(); + expect( + storesApi, + contains('Future>> listStores'), + ); + expect( + storesApi, + contains('Future>> deleteStore'), + ); + + final storeWrite = File( + p.join(outputDir, 'api_models', 'request', 'store_write.dart'), + ).readAsStringSync(); + expect(storeWrite, contains('String? address')); + + final materialResponse = File( + p.join(outputDir, 'api_models', 'result', 'material_response.dart'), + ).readAsStringSync(); + expect(materialResponse, contains("@JsonKey(name: 'store_id')")); + expect(materialResponse, contains('required int storeId')); + + final sharedError = File( + p.join(outputDir, 'api_models', 'shared', 'http_validation_error.dart'), + ); + expect(sharedError.existsSync(), isTrue); + }); + }); +} diff --git a/test/multipart_and_union_test.dart b/test/multipart_and_union_test.dart new file mode 100644 index 0000000..13f0111 --- /dev/null +++ b/test/multipart_and_union_test.dart @@ -0,0 +1,298 @@ +import 'dart:io'; + +import 'package:koi_swagger_parser/koi_swagger_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + group('multipart generation', () { + test('按 preset 生成 Retrofit multipart 参数', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_multipart_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final specFile = File(p.join(tempDir.path, 'openapi.json')); + await specFile.writeAsString(r''' +{ + "openapi": "3.1.0", + "info": { + "title": "Multipart Sample", + "version": "1.0.0" + }, + "paths": { + "/api/v1/uploads": { + "post": { + "tags": ["uploads"], + "operationId": "create_upload_api_v1_uploads_post", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": ["file", "meta"], + "properties": { + "file": {"type": "string", "format": "binary"}, + "files": { + "type": "array", + "items": {"type": "string", "format": "binary"} + }, + "meta": {"$ref": "#/components/schemas/UploadMeta"}, + "note": {"type": "string"} + } + } + } + } + }, + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/UploadResponse"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "UploadMeta": { + "type": "object", + "required": ["album_id"], + "properties": { + "album_id": {"type": "integer"}, + "tags": { + "type": "array", + "items": {"type": "string"} + } + } + }, + "UploadResponse": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "integer"} + } + } + } + } +} +'''); + + final outputDir = p.join(tempDir.path, 'lib', 'src'); + final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: ${specFile.path} + enabled: true + +output: + base_dir: $outputDir + api_dir: ${p.join(outputDir, 'api')} + models_dir: ${p.join(outputDir, 'api_models')} + split_by_tags: true + +generation: + preset: + name: koi_flutter + api: + enabled: true + use_retrofit: true + cancel_token_support: false + client: + class_name: SampleApiClient + file_name: sample_api_client + response_wrapper: + mode: passthrough + detect_standard_envelope: false + success_data_field: data + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + await const KoiSwaggerParser().generate(configPath: configFile.path); + + final apiFile = File( + p.join(outputDir, 'api', 'v1', 'uploads_api.dart'), + ).readAsStringSync(); + expect(apiFile, contains('@MultiPart()')); + expect( + apiFile, + contains("@Part(name: 'file') required MultipartFile file"), + ); + expect( + apiFile, + contains("@Part(name: 'files') List? files"), + ); + expect( + apiFile, + contains( + "@Part(name: 'meta', contentType: 'application/json') required KoiUploadMeta meta", + ), + ); + expect(apiFile, contains("@Part(name: 'note') String? note")); + + final metaFile = File( + p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'), + ).readAsStringSync(); + expect(metaFile, contains('class KoiUploadMeta')); + expect(metaFile, contains("@JsonKey(name: 'album_id')")); + }); + }); + + group('discriminator union generation', () { + test('生成 sealed wrapper 并在结果模型中引用', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_union_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final specFile = File(p.join(tempDir.path, 'openapi.json')); + await specFile.writeAsString(r''' +{ + "openapi": "3.1.0", + "info": { + "title": "Union Sample", + "version": "1.0.0" + }, + "paths": { + "/api/v1/pets/{pet_id}": { + "get": { + "tags": ["pets"], + "operationId": "get_pet_api_v1_pets__pet_id__get", + "parameters": [ + { + "name": "pet_id", + "in": "path", + "required": true, + "schema": {"type": "integer"} + } + ], + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PetResponse"} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "PetResponse": { + "type": "object", + "required": ["pet"], + "properties": { + "pet": {"$ref": "#/components/schemas/Pet"} + } + }, + "Pet": { + "oneOf": [ + {"$ref": "#/components/schemas/Cat"}, + {"$ref": "#/components/schemas/Dog"} + ], + "discriminator": { + "propertyName": "pet_type", + "mapping": { + "cat": "#/components/schemas/Cat", + "dog": "#/components/schemas/Dog" + } + } + }, + "Cat": { + "type": "object", + "required": ["pet_type", "lives"], + "properties": { + "pet_type": {"type": "string", "enum": ["cat"]}, + "lives": {"type": "integer"} + } + }, + "Dog": { + "type": "object", + "required": ["pet_type", "breed"], + "properties": { + "pet_type": {"type": "string", "enum": ["dog"]}, + "breed": {"type": "string"} + } + } + } + } +} +'''); + + final outputDir = p.join(tempDir.path, 'lib', 'src'); + final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: ${specFile.path} + enabled: true + +output: + base_dir: $outputDir + api_dir: ${p.join(outputDir, 'api')} + models_dir: ${p.join(outputDir, 'api_models')} + split_by_tags: true + +generation: + api: + enabled: true + use_retrofit: true + cancel_token_support: false + client: + class_name: SampleApiClient + file_name: sample_api_client + response_wrapper: + mode: passthrough + detect_standard_envelope: false + success_data_field: data + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + await const KoiSwaggerParser().generate(configPath: configFile.path); + + final unionFile = File( + p.join(outputDir, 'api_models', 'result', 'pet.dart'), + ).readAsStringSync(); + expect(unionFile, contains('sealed class KoiPet')); + expect( + unionFile, + contains('factory KoiPet.fromJson(Map json)'), + ); + expect(unionFile, contains("case 'cat':")); + expect(unionFile, contains('return KoiPetCat(KoiCat.fromJson(json));')); + expect(unionFile, contains('final class KoiPetDog extends KoiPet')); + + final responseFile = File( + p.join(outputDir, 'api_models', 'result', 'pet_response.dart'), + ).readAsStringSync(); + expect(responseFile, contains('required KoiPet pet')); + }); + }); +} diff --git a/test/passthrough_and_enum_test.dart b/test/passthrough_and_enum_test.dart new file mode 100644 index 0000000..b7fa875 --- /dev/null +++ b/test/passthrough_and_enum_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('passthrough wrapper', () { + test('保留 envelope 模型并生成 enum', () async { + final tempDir = await Directory.systemTemp.createTemp( + 'koi_swagger_parser_passthrough_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final specFile = File(p.join(tempDir.path, 'openapi.json')); + await specFile.writeAsString(''' +{ + "openapi": "3.1.0", + "info": { + "title": "Sample", + "version": "1.0.0" + }, + "paths": { + "/api/v1/orders": { + "get": { + "tags": ["orders"], + "operationId": "list_orders_api_v1_orders_get", + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "\$ref": "#/components/schemas/ApiResponse_OrderStatusResponse_" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ApiResponse_OrderStatusResponse_": { + "type": "object", + "required": ["data"], + "properties": { + "code": {"type": "integer"}, + "message": {"type": "string"}, + "data": {"\$ref": "#/components/schemas/OrderStatusResponse"} + } + }, + "OrderStatusResponse": { + "type": "object", + "required": ["status"], + "properties": { + "status": {"\$ref": "#/components/schemas/OrderStatus"} + } + }, + "OrderStatus": { + "type": "string", + "enum": ["pending", "done"] + } + } + } +} +'''); + + final outputDir = p.join(tempDir.path, 'lib', 'src'); + final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml')); + await configFile.writeAsString(''' +input: + swagger_urls: + - url: ${specFile.path} + enabled: true + +output: + base_dir: $outputDir + api_dir: ${p.join(outputDir, 'api')} + models_dir: ${p.join(outputDir, 'api_models')} + split_by_tags: true + +generation: + api: + enabled: true + use_retrofit: true + cancel_token_support: false + client: + class_name: SampleApiClient + file_name: sample_api_client + response_wrapper: + mode: passthrough + detect_standard_envelope: true + success_data_field: data + models: + enabled: true + class_prefix: Koi + default_non_nullable_list_to_empty: true + json_serializable: + checked: true + explicit_to_json: true + include_if_null: false +formatting: + run_dart_format: false +'''); + + await const KoiSwaggerParser().generate(configPath: configFile.path); + + final apiFile = File( + p.join(outputDir, 'api', 'v1', 'orders_api.dart'), + ).readAsStringSync(); + expect( + apiFile, + contains('Future listOrders'), + ); + expect(apiFile, isNot(contains('BaseResult'))); + + final envelopeModel = File( + p.join( + outputDir, + 'api_models', + 'result', + 'api_response_order_status_response.dart', + ), + ).readAsStringSync(); + expect( + envelopeModel, + contains('class KoiApiResponseOrderStatusResponse'), + ); + + final enumFile = File( + p.join(outputDir, 'api_models', 'enums', 'order_status.dart'), + ).readAsStringSync(); + expect(enumFile, contains('enum KoiOrderStatus')); + expect(enumFile, contains("@JsonValue('pending')")); + }); + }); +} diff --git a/test/utils_test.dart b/test/utils_test.dart new file mode 100644 index 0000000..bfa4536 --- /dev/null +++ b/test/utils_test.dart @@ -0,0 +1,15 @@ +import 'package:koi_swagger_parser/src/util/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('toSnakeCase', () { + test('保持常见缩写合并后的文件名可读性', () { + expect(toSnakeCase('IoTDeviceResponse'), 'iot_device_response'); + expect( + toSnakeCase('ApiResponse_IoTDeviceListResponse_'), + 'api_response_iot_device_list_response', + ); + expect(toSnakeCase('HTTPValidationError'), 'http_validation_error'); + }); + }); +}