Release 0.2.0
Complete OpenAPI 3.0/3.1 generation, diagnostics, lifecycle safety, enum and naming mappings, bilingual documentation, examples, and pub.dev release validation.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('top-level help is in English', () async {
|
||||
final result = await _runCli(<String>['--help']);
|
||||
|
||||
expect(result.exitCode, 0, reason: result.stderr.toString());
|
||||
expect(result.stdout.toString(), contains('Usage:'));
|
||||
expect(result.stdout.toString(), contains('Available commands:'));
|
||||
expect(result.stdout.toString(), contains('generate'));
|
||||
expect(result.stdout.toString(), isNot(contains('用法')));
|
||||
});
|
||||
|
||||
test('generate help documents command options in English', () async {
|
||||
final result = await _runCli(<String>['generate', '--help']);
|
||||
|
||||
expect(result.exitCode, 0, reason: result.stderr.toString());
|
||||
expect(
|
||||
result.stdout.toString(),
|
||||
contains('Generate API clients and models.'),
|
||||
);
|
||||
expect(result.stdout.toString(), contains('--config'));
|
||||
expect(result.stdout.toString(), contains('--dry-run'));
|
||||
expect(result.stdout.toString(), contains('--watch'));
|
||||
expect(result.stdout.toString(), isNot(contains('显示帮助')));
|
||||
});
|
||||
|
||||
test('invalid arguments report an English usage error', () async {
|
||||
final result = await _runCli(<String>['--unknown']);
|
||||
|
||||
expect(result.exitCode, 64);
|
||||
expect(result.stderr.toString(), startsWith('Error:'));
|
||||
expect(result.stderr.toString(), contains('Usage:'));
|
||||
});
|
||||
|
||||
test('generation failures are in English by default', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_cli_missing_',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final missingConfig = p.join(directory.path, 'missing.yaml');
|
||||
final result = await _runCli(<String>[
|
||||
'generate',
|
||||
'--config',
|
||||
missingConfig,
|
||||
]);
|
||||
|
||||
expect(result.exitCode, 1);
|
||||
expect(result.stderr.toString(), contains('Generation failed.'));
|
||||
expect(
|
||||
result.stderr.toString(),
|
||||
contains('Run with --verbose to see the underlying error.'),
|
||||
);
|
||||
expect(result.stderr.toString(), isNot(contains('生成失败')));
|
||||
});
|
||||
|
||||
test(
|
||||
'generate --dry-run reports planned writes without touching output',
|
||||
() async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_cli_',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final source = File(p.join(directory.path, 'openapi.json'));
|
||||
await source.writeAsString(
|
||||
jsonEncode(<String, Object?>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, Object?>{'title': 'CLI', 'version': '1'},
|
||||
'paths': <String, Object?>{
|
||||
'/health': <String, Object?>{
|
||||
'get': <String, Object?>{
|
||||
'operationId': 'health',
|
||||
'responses': <String, Object?>{
|
||||
'204': <String, Object?>{'description': 'healthy'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
final output = p.join(directory.path, 'lib', 'src');
|
||||
final config = File(p.join(directory.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- ${source.path}
|
||||
output:
|
||||
base_dir: $output
|
||||
api_dir: ${p.join(output, 'api')}
|
||||
models_dir: ${p.join(output, 'models')}
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await _runCli(<String>[
|
||||
'generate',
|
||||
'--config',
|
||||
config.path,
|
||||
'--dry-run',
|
||||
]);
|
||||
|
||||
expect(result.exitCode, 0, reason: result.stderr.toString());
|
||||
expect(result.stdout.toString(), contains('dry-run:'));
|
||||
expect(result.stdout.toString(), contains('would be written'));
|
||||
expect(Directory(output).existsSync(), isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ProcessResult> _runCli(List<String> arguments) {
|
||||
return Process.run(Platform.resolvedExecutable, <String>[
|
||||
'run',
|
||||
'koi_swagger_parser',
|
||||
...arguments,
|
||||
], workingDirectory: Directory.current.path);
|
||||
}
|
||||
@@ -72,5 +72,127 @@ formatting:
|
||||
expect(config.generation.models.classPrefix, 'Koi');
|
||||
expect(config.generation.formatting.runDartFormat, isFalse);
|
||||
});
|
||||
|
||||
test('解析生命周期、过滤与 build_runner 配置', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_config_lifecycle_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
await File(p.join(tempDir.path, 'koi_swagger_parser.yaml')).writeAsString(
|
||||
'''
|
||||
input:
|
||||
swagger_urls:
|
||||
- ./openapi.json
|
||||
output:
|
||||
included_paths:
|
||||
- /api/**
|
||||
excluded_operations:
|
||||
- regex:^DELETE\\s
|
||||
included_models:
|
||||
- '*Response'
|
||||
clean_stale_files: false
|
||||
skip_if_unchanged: false
|
||||
manifest_path: ./.generated/koi.json
|
||||
generation:
|
||||
api:
|
||||
enabled: true
|
||||
models:
|
||||
enabled: true
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
run_build_runner: true
|
||||
build_runner_mode: watch
|
||||
delete_conflicting_outputs: false
|
||||
''',
|
||||
);
|
||||
|
||||
final config = await KoiSwaggerConfig.load(
|
||||
workingDirectory: tempDir.path,
|
||||
);
|
||||
|
||||
expect(config.output.includedPaths, <String>['/api/**']);
|
||||
expect(config.output.excludedOperations, <String>[r'regex:^DELETE\s']);
|
||||
expect(config.output.includedModels, <String>['*Response']);
|
||||
expect(config.output.cleanStaleFiles, isFalse);
|
||||
expect(config.output.skipIfUnchanged, isFalse);
|
||||
expect(
|
||||
config.output.manifestPath,
|
||||
p.join(tempDir.path, '.generated', 'koi.json'),
|
||||
);
|
||||
expect(config.generation.formatting.runBuildRunner, isTrue);
|
||||
expect(
|
||||
config.generation.formatting.buildRunnerMode,
|
||||
BuildRunnerMode.watch,
|
||||
);
|
||||
expect(config.generation.formatting.deleteConflictingOutputs, isFalse);
|
||||
});
|
||||
|
||||
test('错误标量类型和无效 regex 产生结构化诊断', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_config_invalid_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ./openapi.json
|
||||
enabled: yes
|
||||
output:
|
||||
split_by_tags: wrong
|
||||
included_paths:
|
||||
- regex:[
|
||||
generation:
|
||||
api:
|
||||
enabled: true
|
||||
models:
|
||||
enabled: true
|
||||
''');
|
||||
|
||||
await expectLater(
|
||||
KoiSwaggerConfig.load(workingDirectory: tempDir.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'diagnostic codes',
|
||||
containsAll(<String>[
|
||||
'config.invalid_boolean',
|
||||
'config.invalid_filter_regex',
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('API enabled 时明确拒绝未实现的非 Retrofit renderer', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_config_renderer_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
await File(p.join(tempDir.path, 'koi_swagger_parser.yaml')).writeAsString(
|
||||
'''
|
||||
input:
|
||||
swagger_urls:
|
||||
- ./openapi.json
|
||||
generation:
|
||||
api:
|
||||
enabled: true
|
||||
use_retrofit: false
|
||||
models:
|
||||
enabled: true
|
||||
''',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
KoiSwaggerConfig.load(workingDirectory: tempDir.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'diagnostic codes',
|
||||
contains('config.unsupported_api_renderer'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('enum mappings', () {
|
||||
test('按配置和扩展字段优先级生成名称、描述与 unknown fallback', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_enum_mappings_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final specFile = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await specFile.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Enum mappings",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/deliveries": {
|
||||
"get": {
|
||||
"tags": ["deliveries"],
|
||||
"operationId": "list_deliveries",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeliveryResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"DeliveryResponse": {
|
||||
"type": "object",
|
||||
"required": ["state"],
|
||||
"properties": {
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/DeliveryState"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DeliveryState": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1, 2, 3, 4],
|
||||
"x-enumNames": [null, "ONE_FROM_NAMES"],
|
||||
"x-enum-varnames": [
|
||||
"ZERO_FROM_VARNAMES",
|
||||
"ONE_FROM_VARNAMES",
|
||||
"TWO_FROM_VARNAMES"
|
||||
],
|
||||
"x-enumDescriptions": [null, "来自 x-enumDescriptions"],
|
||||
"x-enum-descriptions": [
|
||||
"来自 x-enum-descriptions",
|
||||
"旧描述",
|
||||
"第二项描述"
|
||||
],
|
||||
"x-enum-values": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"value": 3,
|
||||
"name": "THREE_FROM_VALUES",
|
||||
"description": "来自 x-enum-values"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${specFile.path}
|
||||
enabled: true
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
enum_unknown_fallback: true
|
||||
future_option: true
|
||||
enum_key_mappings:
|
||||
DeliveryState:
|
||||
- value: 0
|
||||
name: OLD_ZERO
|
||||
enum_mappings:
|
||||
DeliveryState:
|
||||
- value: 0
|
||||
name: CONFIG_ZERO
|
||||
description: 来自 enum_mappings
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: configFile.path,
|
||||
);
|
||||
|
||||
final enumFile = File(
|
||||
p.join(outputDir, 'api_models', 'enums', 'delivery_state.dart'),
|
||||
).readAsStringSync();
|
||||
expect(enumFile, contains('CONFIG_ZERO,'));
|
||||
expect(enumFile, isNot(contains('OLD_ZERO,')));
|
||||
expect(enumFile, contains('ONE_FROM_NAMES,'));
|
||||
expect(enumFile, contains('TWO_FROM_VARNAMES,'));
|
||||
expect(enumFile, contains('THREE_FROM_VALUES,'));
|
||||
expect(enumFile, contains('value4,'));
|
||||
expect(enumFile, contains('unknown,'));
|
||||
expect(enumFile, contains('/// 来自 enum_mappings'));
|
||||
expect(enumFile, contains('/// 来自 x-enumDescriptions'));
|
||||
expect(enumFile, contains('/// 第二项描述'));
|
||||
expect(enumFile, contains('/// 来自 x-enum-values'));
|
||||
|
||||
final responseFile = File(
|
||||
p.join(outputDir, 'api_models', 'result', 'delivery_response.dart'),
|
||||
).readAsStringSync();
|
||||
expect(
|
||||
responseFile,
|
||||
contains('unknownEnumValue: KoiDeliveryState.unknown'),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
containsAll(<String>[
|
||||
'config.unknown_key',
|
||||
'config.enum_mapping_overridden',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('无效映射通过结构化配置错误拒绝', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_invalid_enum_mapping_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ./openapi.json
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
enum_mappings:
|
||||
Status:
|
||||
- value: 1
|
||||
''');
|
||||
|
||||
await expectLater(
|
||||
KoiSwaggerConfig.load(configPath: configFile.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
'diagnostic codes',
|
||||
contains('config.invalid_enum_mapping'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('OpenAPI diagnostics', () {
|
||||
test('extract_version_from_url 从单一 source 文件名提取版本', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_source_version_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final specFile = File(p.join(tempDir.path, 'catalog-v4.openapi.json'));
|
||||
await specFile.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Source version",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/catalog": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "no content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${specFile.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
extract_version_from_url: true
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: configFile.path,
|
||||
);
|
||||
|
||||
expect(
|
||||
File(p.join(outputDir, 'api', 'v4', 'default_api.dart')).existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
isNot(contains('config.source_version_not_found')),
|
||||
);
|
||||
});
|
||||
|
||||
test('unresolved ref 不再静默退化为 dynamic', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_unresolved_ref_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final specFile = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await specFile.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Broken refs",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/items": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MissingModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${specFile.path}
|
||||
output:
|
||||
base_dir: ${p.join(tempDir.path, 'lib', 'src')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
''');
|
||||
|
||||
await expectLater(
|
||||
const KoiSwaggerParser().generate(configPath: configFile.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
'diagnostic codes',
|
||||
contains('openapi.unresolved_ref'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
final fixtures = <String>[
|
||||
'openapi_30_compat.yaml',
|
||||
'e2e_openapi.json',
|
||||
'dba_openapi.json',
|
||||
];
|
||||
|
||||
for (final fixtureName in fixtures) {
|
||||
test('fixture corpus parses and renders $fixtureName', () async {
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_corpus_',
|
||||
);
|
||||
addTearDown(() => tempDirectory.delete(recursive: true));
|
||||
final source = p.join(
|
||||
Directory.current.path,
|
||||
'test',
|
||||
'fixtures',
|
||||
fixtureName,
|
||||
);
|
||||
final output = p.join(tempDirectory.path, 'lib', 'src');
|
||||
final config = File(
|
||||
p.join(tempDirectory.path, 'koi_swagger_parser.yaml'),
|
||||
);
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: $source
|
||||
output:
|
||||
base_dir: $output
|
||||
api_dir: ${p.join(output, 'api')}
|
||||
models_dir: ${p.join(output, 'models')}
|
||||
manifest_path: ${p.join(tempDirectory.path, '.generated', 'manifest.json')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: config.path,
|
||||
dryRun: true,
|
||||
);
|
||||
|
||||
expect(result.operationsCount, greaterThan(0));
|
||||
expect(result.modelsCount, greaterThan(0));
|
||||
expect(result.generatedFiles, isNotEmpty);
|
||||
expect(
|
||||
result.diagnostics.where(
|
||||
(diagnostic) => diagnostic.severity == DiagnosticSeverity.error,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+228
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Koi generated package verification",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/accounts/{segments}": {
|
||||
"post": {
|
||||
"tags": ["accounts"],
|
||||
"operationId": "create_account",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "segments",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"style": "label",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"in": "query",
|
||||
"style": "pipeDelimited",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"in": "cookie",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "created",
|
||||
"headers": {
|
||||
"X-Request-Id": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/unions": {
|
||||
"get": {
|
||||
"tags": ["unions"],
|
||||
"operationId": "get_union",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "union",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnionEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/download": {
|
||||
"get": {
|
||||
"tags": ["files"],
|
||||
"operationId": "download_file",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "bytes",
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Account": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"secret",
|
||||
"display_name",
|
||||
"settings"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"readOnly": true
|
||||
},
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"writeOnly": true,
|
||||
"minLength": 8
|
||||
},
|
||||
"display_name": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/AccountState"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/FlexibleSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"AccountState": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"disabled"
|
||||
],
|
||||
"x-enumNames": [
|
||||
"active",
|
||||
"disabled"
|
||||
]
|
||||
},
|
||||
"FlexibleSettings": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"theme"
|
||||
],
|
||||
"properties": {
|
||||
"theme": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"UnionEnvelope": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"value",
|
||||
"criteria"
|
||||
],
|
||||
"properties": {
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/StringOrInteger"
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/components/schemas/SearchCriteria"
|
||||
}
|
||||
}
|
||||
},
|
||||
"StringOrInteger": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "integer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SearchCriteria": {
|
||||
"anyOf": [
|
||||
{
|
||||
"title": "ByName",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "ById",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: OpenAPI 3.0 compatibility corpus
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://{environment}.example.test
|
||||
variables:
|
||||
environment:
|
||||
default: api
|
||||
enum:
|
||||
- api
|
||||
- staging
|
||||
security:
|
||||
- clientKey: []
|
||||
paths:
|
||||
/v1/animals/{id}:
|
||||
get:
|
||||
operationId: getAnimal
|
||||
tags:
|
||||
- animals
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
responses:
|
||||
"200":
|
||||
description: Animal
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Pet"
|
||||
"404":
|
||||
description: Missing
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
components:
|
||||
securitySchemes:
|
||||
clientKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Client-Key
|
||||
schemas:
|
||||
Animal:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
nickname:
|
||||
type: string
|
||||
nullable: true
|
||||
Cat:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Animal"
|
||||
- type: object
|
||||
required:
|
||||
- lives
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- cat
|
||||
lives:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 9
|
||||
Dog:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/Animal"
|
||||
- type: object
|
||||
required:
|
||||
- trained
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- dog
|
||||
trained:
|
||||
type: boolean
|
||||
Pet:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/Cat"
|
||||
- $ref: "#/components/schemas/Dog"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
cat: "#/components/schemas/Cat"
|
||||
dog: "#/components/schemas/Dog"
|
||||
Problem:
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- not_found
|
||||
- forbidden
|
||||
x-enum-varnames:
|
||||
- notFound
|
||||
- forbidden
|
||||
detail:
|
||||
type: string
|
||||
@@ -97,7 +97,7 @@ formatting:
|
||||
final materialResponse = File(
|
||||
p.join(outputDir, 'api_models', 'result', 'material_response.dart'),
|
||||
).readAsStringSync();
|
||||
expect(materialResponse, contains("@JsonKey(name: 'store_id')"));
|
||||
expect(materialResponse, contains("@JsonKey(name: 'store_id',"));
|
||||
expect(materialResponse, contains('required int storeId'));
|
||||
|
||||
final sharedError = File(
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('generation lifecycle', () {
|
||||
test(
|
||||
'writes a manifest, skips unchanged work, and detects local edits',
|
||||
() async {
|
||||
final fixture = await _LifecycleFixture.create();
|
||||
addTearDown(fixture.dispose);
|
||||
await fixture.writeDocument(includeAdmin: false);
|
||||
|
||||
final first = await fixture.generate();
|
||||
expect(first.skippedUnchanged, isFalse);
|
||||
expect(first.writtenFiles, isNotEmpty);
|
||||
expect(File(first.manifestPath!).existsSync(), isTrue);
|
||||
|
||||
final second = await fixture.generate();
|
||||
expect(second.skippedUnchanged, isTrue);
|
||||
expect(second.writtenFiles, isEmpty);
|
||||
expect(
|
||||
second.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
contains('lifecycle.unchanged_skipped'),
|
||||
);
|
||||
|
||||
final petsApi = File(
|
||||
p.join(fixture.outputDir, 'api', 'v1', 'pets_api.dart'),
|
||||
);
|
||||
await petsApi.writeAsString(
|
||||
'${await petsApi.readAsString()}\n// edited\n',
|
||||
);
|
||||
|
||||
final repaired = await fixture.generate();
|
||||
expect(repaired.skippedUnchanged, isFalse);
|
||||
expect(repaired.writtenFiles, contains(p.normalize(petsApi.path)));
|
||||
expect(await petsApi.readAsString(), isNot(contains('// edited')));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'deletes only stale files that still match their manifest hash',
|
||||
() async {
|
||||
final fixture = await _LifecycleFixture.create();
|
||||
addTearDown(fixture.dispose);
|
||||
await fixture.writeDocument(includeAdmin: true);
|
||||
await fixture.generate();
|
||||
|
||||
final adminApi = File(
|
||||
p.join(fixture.outputDir, 'api', 'v1', 'admin_api.dart'),
|
||||
);
|
||||
final adminModel = File(
|
||||
p.join(
|
||||
fixture.outputDir,
|
||||
'api_models',
|
||||
'result',
|
||||
'admin_record.dart',
|
||||
),
|
||||
);
|
||||
expect(adminApi.existsSync(), isTrue);
|
||||
expect(adminModel.existsSync(), isTrue);
|
||||
await adminApi.writeAsString(
|
||||
'${await adminApi.readAsString()}\n// keep my local edit\n',
|
||||
);
|
||||
|
||||
await fixture.writeDocument(includeAdmin: false);
|
||||
final result = await fixture.generate();
|
||||
|
||||
expect(adminApi.existsSync(), isTrue);
|
||||
expect(adminModel.existsSync(), isFalse);
|
||||
expect(result.deletedFiles, contains(p.normalize(adminModel.path)));
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
contains('lifecycle.stale_file_modified'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('dry-run reports changes without writing files', () async {
|
||||
final fixture = await _LifecycleFixture.create();
|
||||
addTearDown(fixture.dispose);
|
||||
await fixture.writeDocument(includeAdmin: false);
|
||||
|
||||
final result = await fixture.generate(dryRun: true);
|
||||
|
||||
expect(result.writtenFiles, isEmpty);
|
||||
expect(result.wouldWriteFiles, isNotEmpty);
|
||||
expect(result.generatedFiles, isNotEmpty);
|
||||
expect(File(result.manifestPath!).existsSync(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('path, operation, and model filters', () {
|
||||
test(
|
||||
'filters operations and can include an unreferenced component',
|
||||
() async {
|
||||
final fixture = await _LifecycleFixture.create(
|
||||
outputOptions: '''
|
||||
included_paths:
|
||||
- /api/v1/pets/**
|
||||
excluded_operations:
|
||||
- getPetSecret
|
||||
included_models:
|
||||
- Standalone
|
||||
''',
|
||||
);
|
||||
addTearDown(fixture.dispose);
|
||||
await fixture.writeDocument(
|
||||
includeAdmin: true,
|
||||
includeSecretOperation: true,
|
||||
);
|
||||
|
||||
final result = await fixture.generate();
|
||||
|
||||
expect(result.operationsCount, 1);
|
||||
expect(
|
||||
result.generatedFiles.any((path) => path.endsWith('standalone.dart')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
result.generatedFiles.any((path) => path.endsWith('admin_api.dart')),
|
||||
isFalse,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects excluding a model required by a selected operation',
|
||||
() async {
|
||||
final fixture = await _LifecycleFixture.create(
|
||||
outputOptions: '''
|
||||
excluded_models:
|
||||
- Pet
|
||||
''',
|
||||
);
|
||||
addTearDown(fixture.dispose);
|
||||
await fixture.writeDocument(includeAdmin: false);
|
||||
|
||||
await expectLater(
|
||||
fixture.generate(),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'diagnostic codes',
|
||||
contains('filter.required_model_excluded'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('models-only mode generates unreferenced component schemas', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_models_only_',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final document = File(p.join(directory.path, 'openapi.json'));
|
||||
await document.writeAsString(
|
||||
jsonEncode(<String, Object?>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, Object?>{'title': 'models', 'version': '1'},
|
||||
'paths': <String, Object?>{},
|
||||
'components': <String, Object?>{
|
||||
'schemas': <String, Object?>{'Standalone': _objectSchema('value')},
|
||||
},
|
||||
}),
|
||||
);
|
||||
final output = p.join(directory.path, 'lib', 'src');
|
||||
final config = File(p.join(directory.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- ${document.path}
|
||||
output:
|
||||
base_dir: $output
|
||||
api_dir: ${p.join(output, 'api')}
|
||||
models_dir: ${p.join(output, 'models')}
|
||||
generation:
|
||||
api:
|
||||
enabled: false
|
||||
models:
|
||||
enabled: true
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: config.path,
|
||||
);
|
||||
|
||||
expect(result.operationsCount, 0);
|
||||
expect(result.modelsCount, 1);
|
||||
expect(
|
||||
result.generatedFiles.any((path) => path.endsWith('standalone.dart')),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
result.generatedFiles.any(
|
||||
(path) => path.contains('${p.separator}api${p.separator}'),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _LifecycleFixture {
|
||||
_LifecycleFixture({
|
||||
required this.directory,
|
||||
required this.documentPath,
|
||||
required this.configPath,
|
||||
required this.outputDir,
|
||||
});
|
||||
|
||||
final Directory directory;
|
||||
final String documentPath;
|
||||
final String configPath;
|
||||
final String outputDir;
|
||||
|
||||
static Future<_LifecycleFixture> create({String outputOptions = ''}) async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_lifecycle_',
|
||||
);
|
||||
final documentPath = p.join(directory.path, 'openapi.json');
|
||||
final configPath = p.join(directory.path, 'koi_swagger_parser.yaml');
|
||||
final outputDir = p.join(directory.path, 'lib', 'src');
|
||||
await File(configPath).writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: $documentPath
|
||||
enabled: true
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
manifest_path: ${p.join(directory.path, '.generated', 'manifest.json')}
|
||||
clean_stale_files: true
|
||||
skip_if_unchanged: true
|
||||
split_by_tags: true
|
||||
$outputOptions
|
||||
generation:
|
||||
api:
|
||||
enabled: true
|
||||
use_retrofit: true
|
||||
models:
|
||||
enabled: true
|
||||
class_prefix: Koi
|
||||
formatting:
|
||||
run_dart_fix: false
|
||||
run_dart_format: false
|
||||
''');
|
||||
return _LifecycleFixture(
|
||||
directory: directory,
|
||||
documentPath: documentPath,
|
||||
configPath: configPath,
|
||||
outputDir: outputDir,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> dispose() => directory.delete(recursive: true);
|
||||
|
||||
Future<GenerationResult> generate({bool dryRun = false}) {
|
||||
return const KoiSwaggerParser().generate(
|
||||
configPath: configPath,
|
||||
workingDirectory: directory.path,
|
||||
dryRun: dryRun,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> writeDocument({
|
||||
required bool includeAdmin,
|
||||
bool includeSecretOperation = false,
|
||||
}) async {
|
||||
final paths = <String, Object?>{
|
||||
'/api/v1/pets/{id}': <String, Object?>{
|
||||
'get': _operation(
|
||||
operationId: 'getPet',
|
||||
tag: 'pets',
|
||||
schemaName: 'Pet',
|
||||
),
|
||||
},
|
||||
if (includeSecretOperation)
|
||||
'/api/v1/pets/{id}/secret': <String, Object?>{
|
||||
'get': _operation(
|
||||
operationId: 'getPetSecret',
|
||||
tag: 'pets',
|
||||
schemaName: 'Pet',
|
||||
),
|
||||
},
|
||||
if (includeAdmin)
|
||||
'/api/v1/admin': <String, Object?>{
|
||||
'get': _operation(
|
||||
operationId: 'getAdmin',
|
||||
tag: 'admin',
|
||||
schemaName: 'AdminRecord',
|
||||
),
|
||||
},
|
||||
};
|
||||
final document = <String, Object?>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, Object?>{'title': 'Lifecycle', 'version': '1.0.0'},
|
||||
'paths': paths,
|
||||
'components': <String, Object?>{
|
||||
'schemas': <String, Object?>{
|
||||
'Pet': _objectSchema('name'),
|
||||
'AdminRecord': _objectSchema('role'),
|
||||
'Standalone': _objectSchema('value'),
|
||||
},
|
||||
},
|
||||
};
|
||||
await File(documentPath).writeAsString(jsonEncode(document));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> _operation({
|
||||
required String operationId,
|
||||
required String tag,
|
||||
required String schemaName,
|
||||
}) {
|
||||
return <String, Object?>{
|
||||
'operationId': operationId,
|
||||
'tags': <String>[tag],
|
||||
'responses': <String, Object?>{
|
||||
'200': <String, Object?>{
|
||||
'description': 'OK',
|
||||
'content': <String, Object?>{
|
||||
'application/json': <String, Object?>{
|
||||
'schema': <String, Object?>{
|
||||
r'$ref': '#/components/schemas/$schemaName',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, Object?> _objectSchema(String property) {
|
||||
return <String, Object?>{
|
||||
'type': 'object',
|
||||
'required': <String>[property],
|
||||
'properties': <String, Object?>{
|
||||
property: <String, Object?>{'type': 'string'},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('e2e fixture generated file hashes match the reviewed golden', () async {
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_golden_',
|
||||
);
|
||||
addTearDown(() => tempDirectory.delete(recursive: true));
|
||||
final root = Directory.current.path;
|
||||
final fixture = p.join(root, 'test', 'fixtures', 'e2e_openapi.json');
|
||||
final output = p.join(tempDirectory.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDirectory.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: $fixture
|
||||
output:
|
||||
base_dir: $output
|
||||
api_dir: ${p.join(output, 'api')}
|
||||
models_dir: ${p.join(output, 'models')}
|
||||
manifest_path: ${p.join(tempDirectory.path, '.generated', 'manifest.json')}
|
||||
generation:
|
||||
api:
|
||||
response_mode: http_response
|
||||
binary_response_mode: bytes
|
||||
cancel_token_support: true
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
enum_unknown_fallback: true
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: config.path,
|
||||
);
|
||||
final actual = <String, String>{};
|
||||
for (final path in result.generatedFiles) {
|
||||
final relative = p.relative(path, from: output).replaceAll('\\', '/');
|
||||
actual[relative] = sha256
|
||||
.convert(await File(path).readAsBytes())
|
||||
.toString();
|
||||
}
|
||||
final sortedActual = <String, String>{
|
||||
for (final key in (actual.keys.toList()..sort())) key: actual[key]!,
|
||||
};
|
||||
final goldenFile = File(
|
||||
p.join(root, 'test', 'goldens', 'e2e_generated_files.sha256.json'),
|
||||
);
|
||||
final expected = (jsonDecode(await goldenFile.readAsString()) as Map)
|
||||
.cast<String, dynamic>();
|
||||
|
||||
expect(
|
||||
sortedActual,
|
||||
expected,
|
||||
reason:
|
||||
'If this output change is intentional, review generated Dart first '
|
||||
'and then update the golden hashes.',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"api/api_client.dart": "0e4bc00c8854d3f380739955f64df058308536ac70c950d94380fdfa346f742d",
|
||||
"api/index.dart": "10b58e9eaacf0e36c28b9d59332d70f1bf33811abdcc1820cdc31b7d6ccd213f",
|
||||
"api/v1/accounts_api.dart": "f1d2d742486c9a98326ede91fb2c6a4259830fdcf25cad96d18cea3bd7a2a79f",
|
||||
"api/v1/files_api.dart": "a667efe796d9f67e7aa5eaac47d7029fceeaf032d0f1f7ef7d77241afb289990",
|
||||
"api/v1/index.dart": "b7b00d8385488c99cfbd605b402092ee88495f81213f93dea3656d5c30a7ee3b",
|
||||
"api/v1/unions_api.dart": "9a9ec25401d58d186da4e26725bedc7dbce81926582261f117b9df0d82b78773",
|
||||
"models/api_models.dart": "93b32a1869cf4e04268b1988ea6f89ca074efb0aa36bc9bbd399b2e21778c644",
|
||||
"models/enums/account_state.dart": "9a013aca5a2467d83d9e15675fb3e1db3bf08b73ff67964170566f4140d426c1",
|
||||
"models/enums/index.dart": "c07bcc9aae88d3075441505822e4818b1ec54f4992fb95cdf61d3657404cf431",
|
||||
"models/index.dart": "adfef74c3b0d6b3f37785531612a6e87ccf84c40fa0ca45c29106a3c265efefb",
|
||||
"models/request/account_request.dart": "fb3bfd99b8cdd6431e5772513209d12e2f2bd267492b9fc8d54906e5f0aa9387",
|
||||
"models/request/index.dart": "b184b1c90150386d6eb2f34ac9d5b0444f86b01021687f9397cc4ca62a41cce3",
|
||||
"models/result/account_response.dart": "37efb0dde5c50d048502b09c5a2df6cb92b8a8bd78b9d54e4600bb8031565d26",
|
||||
"models/result/index.dart": "ce8f83b6bea6dc08310fc3a7a27b64ac24390febfb0fd357ee25ca4bad2da786",
|
||||
"models/result/search_criteria.dart": "44b954e24346c564abcff2acba4b5448efc3c80fbc43df1ceedb621b8ae6c25f",
|
||||
"models/result/search_criteria_by_id_variant.dart": "18ad15d0e1d4bd2993ab3412643435b679a1c20eab55d90f87652db3a36d23b2",
|
||||
"models/result/search_criteria_by_name_variant.dart": "4c4c2b03ddbedea9233ded04570f5ec71d9bab4893a0d962688a2124bb123abd",
|
||||
"models/result/string_or_integer.dart": "39b4cc3e011196b6556469792020588d27e669ed39363d587ef6e9f5fc0272bd",
|
||||
"models/result/union_envelope.dart": "0224c40b31b3ee0825ae74716bf6ca272922071897b5ed315cd56fa593fee9f9",
|
||||
"models/shared/flexible_settings.dart": "bf79e5b64d35330ffebc62cc73409f80000aa1b740dcc0ea2a22c87aac3fcb26",
|
||||
"models/shared/index.dart": "876999aa6d55bb50e861e09c8b387aec1e374cb923c640f7b7d1aaf13741a1da"
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('HTTP wire semantics', () {
|
||||
test(
|
||||
'生成 style/explode/cookie/allowReserved transport adapter 与表单',
|
||||
() async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_http_semantics_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final specFile = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await specFile.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "HTTP Semantics", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/api/v1/items/{coordinates}": {
|
||||
"post": {
|
||||
"tags": ["items"],
|
||||
"operationId": "search_items",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "coordinates",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"style": "matrix",
|
||||
"explode": false,
|
||||
"schema": {"type": "array", "items": {"type": "integer"}}
|
||||
},
|
||||
{
|
||||
"name": "filter",
|
||||
"in": "query",
|
||||
"style": "deepObject",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {"type": "string"},
|
||||
"active": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"in": "query",
|
||||
"style": "form",
|
||||
"explode": false,
|
||||
"schema": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"in": "query",
|
||||
"style": "pipeDelimited",
|
||||
"schema": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
{
|
||||
"name": "redirect",
|
||||
"in": "query",
|
||||
"allowReserved": true,
|
||||
"schema": {"type": "string"}
|
||||
},
|
||||
{
|
||||
"name": "X-Flags",
|
||||
"in": "header",
|
||||
"schema": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"in": "cookie",
|
||||
"required": true,
|
||||
"schema": {"type": "string"}
|
||||
},
|
||||
{
|
||||
"name": "features",
|
||||
"in": "cookie",
|
||||
"explode": true,
|
||||
"schema": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["name", "state"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"enum": ["draft", "published"]
|
||||
},
|
||||
"labels": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {"204": {"description": "updated"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = File(
|
||||
p.join(tempDir.path, 'koi_swagger_parser.yaml'),
|
||||
);
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${specFile.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
await const KoiSwaggerParser().generate(configPath: configFile.path);
|
||||
|
||||
final apiFile = File(p.join(outputDir, 'api', 'v1', 'items_api.dart'));
|
||||
final api = await apiFile.readAsString();
|
||||
|
||||
expect(api, contains('class ItemsApi {'));
|
||||
expect(api, contains('final _ItemsApiRetrofit _delegate;'));
|
||||
expect(api, contains('required List<int> coordinates'));
|
||||
expect(api, contains('@RestApi(parser: Parser.JsonSerializable)'));
|
||||
expect(api, contains('abstract class _ItemsApiRetrofit'));
|
||||
expect(
|
||||
api,
|
||||
contains("@POST('/api/v1/items/{coordinates}{reservedQuery}')"),
|
||||
);
|
||||
expect(
|
||||
api,
|
||||
contains("@Path('coordinates') required String coordinates"),
|
||||
);
|
||||
expect(api, contains("@Query('filter') required"));
|
||||
expect(api, contains("@Query('sort') required String? sort"));
|
||||
expect(api, contains("@Header('X-Flags') required String? xFlags"));
|
||||
expect(
|
||||
api,
|
||||
contains("@Header('Cookie') required String? cookieHeader"),
|
||||
);
|
||||
expect(
|
||||
api,
|
||||
contains("@Path('reservedQuery') required String reservedQuery"),
|
||||
);
|
||||
expect(api, contains('@FormUrlEncoded()'));
|
||||
expect(api, contains("@Field('state') required Object? state"));
|
||||
expect(api, contains('_openApiCookieHeader('));
|
||||
expect(api, contains('_openApiReservedQuery('));
|
||||
expect(api, contains("case 'matrix':"));
|
||||
expect(api, contains("'pipeDelimited' => '|'"));
|
||||
|
||||
final format = await Process.run(Platform.resolvedExecutable, <String>[
|
||||
'format',
|
||||
'--output=none',
|
||||
apiFile.path,
|
||||
]);
|
||||
expect(
|
||||
format.exitCode,
|
||||
0,
|
||||
reason: '${format.stdout}\n${format.stderr}',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('可选 JSON body 生成 nullable 类型与 nullToAbsent', () async {
|
||||
final config = KoiSwaggerConfig(
|
||||
configPath: null,
|
||||
workingDirectory: Directory.current.path,
|
||||
input: const InputConfig(swaggerUrls: <String>[]),
|
||||
output: OutputConfig(
|
||||
baseDir: Directory.current.path,
|
||||
apiDir: p.join(Directory.current.path, 'api'),
|
||||
modelsDir: p.join(Directory.current.path, 'models'),
|
||||
splitByTags: true,
|
||||
extractVersionFromUrl: false,
|
||||
),
|
||||
generation: GenerationConfig(
|
||||
preset: GeneratorPresetRegistry.koiFlutter,
|
||||
api: const ApiGenerationConfig(
|
||||
enabled: true,
|
||||
useRetrofit: true,
|
||||
cancelTokenSupport: false,
|
||||
client: ClientConfig(
|
||||
className: 'ApiClient',
|
||||
fileName: 'api_client',
|
||||
),
|
||||
responseWrapper: ResponseWrapperConfig(
|
||||
mode: ResponseWrapperMode.passthrough,
|
||||
successDataField: 'data',
|
||||
detectStandardEnvelope: false,
|
||||
),
|
||||
),
|
||||
models: const ModelGenerationConfig(
|
||||
enabled: true,
|
||||
classPrefix: 'Koi',
|
||||
defaultNonNullableListToEmpty: true,
|
||||
jsonSerializable: JsonSerializableConfig(
|
||||
checked: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
),
|
||||
),
|
||||
formatting: const FormattingConfig(
|
||||
runDartFormat: false,
|
||||
runDartFix: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
final parsed = OpenApiDocumentParser(config).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{'title': 'optional body', 'version': '1'},
|
||||
'paths': <String, dynamic>{
|
||||
'/api/v1/items': <String, dynamic>{
|
||||
'post': <String, dynamic>{
|
||||
'tags': <String>['items'],
|
||||
'requestBody': <String, dynamic>{
|
||||
'required': false,
|
||||
'content': <String, dynamic>{
|
||||
'application/json': <String, dynamic>{
|
||||
'schema': <String, dynamic>{
|
||||
'type': 'object',
|
||||
'properties': <String, dynamic>{
|
||||
'name': <String, dynamic>{'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'responses': <String, dynamic>{
|
||||
'204': <String, dynamic>{'description': 'ok'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://optional-body'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(parsed.operations.single.requestBody!.type!.nullable, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -149,7 +149,7 @@ formatting:
|
||||
p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'),
|
||||
).readAsStringSync();
|
||||
expect(metaFile, contains('class KoiUploadMeta'));
|
||||
expect(metaFile, contains("@JsonKey(name: 'album_id')"));
|
||||
expect(metaFile, contains("@JsonKey(name: 'album_id',"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('naming and type mappings', () {
|
||||
test(
|
||||
'应用 model/property/parameter/operation/schema/type/import 映射',
|
||||
() async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_all_mappings_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "mappings", "version": "1"},
|
||||
"paths": {
|
||||
"/api/v1/pets/{pet_id}": {
|
||||
"get": {
|
||||
"tags": ["pets"],
|
||||
"operationId": "get_pet",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pet_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
{
|
||||
"name": "include_details",
|
||||
"in": "query",
|
||||
"schema": {"type": "boolean"}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "pet",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Pet"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Pet": {
|
||||
"type": "object",
|
||||
"required": ["pet_id", "price", "trace_id"],
|
||||
"properties": {
|
||||
"pet_id": {"type": "integer"},
|
||||
"price": {"$ref": "#/components/schemas/Money"},
|
||||
"trace_id": {"type": "string", "format": "uuid"}
|
||||
}
|
||||
},
|
||||
"Money": {
|
||||
"type": "object",
|
||||
"required": ["amount"],
|
||||
"properties": {
|
||||
"amount": {"type": "number"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
mappings:
|
||||
models:
|
||||
Pet: Creature
|
||||
properties:
|
||||
Pet:
|
||||
pet_id: identifier
|
||||
parameters:
|
||||
get_pet:
|
||||
pet_id: identifier
|
||||
include_details: withDetails
|
||||
operations:
|
||||
get_pet: fetchCreature
|
||||
schemas:
|
||||
Money:
|
||||
type: ExternalMoney
|
||||
import: package:mapping_sample/external_types.dart
|
||||
types:
|
||||
"string:uuid":
|
||||
type: UuidValue
|
||||
import: package:mapping_sample/external_types.dart
|
||||
imports:
|
||||
UuidValue: package:mapping_sample/external_types.dart
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: config.path,
|
||||
);
|
||||
|
||||
final model = File(
|
||||
p.join(outputDir, 'models', 'result', 'creature.dart'),
|
||||
).readAsStringSync();
|
||||
expect(model, contains('class KoiCreature'));
|
||||
expect(model, contains('required int identifier'));
|
||||
expect(model, contains('required ExternalMoney price'));
|
||||
expect(model, contains('required UuidValue traceId'));
|
||||
expect(
|
||||
model,
|
||||
contains("import 'package:mapping_sample/external_types.dart';"),
|
||||
);
|
||||
expect(
|
||||
result.writtenFiles.any((path) => path.endsWith('money.dart')),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
final api = File(
|
||||
p.join(outputDir, 'api', 'v1', 'pets_api.dart'),
|
||||
).readAsStringSync();
|
||||
expect(api, contains('Future<KoiCreature> fetchCreature'));
|
||||
expect(api, contains("@Path('pet_id') required int identifier"));
|
||||
expect(api, contains("@Query('include_details') bool? withDetails"));
|
||||
},
|
||||
);
|
||||
|
||||
test('显式映射目标冲突在加载配置时拒绝', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_mapping_collision_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(
|
||||
'{"openapi":"3.1.0","info":{"title":"x","version":"1"},'
|
||||
'"paths":{"/x":{"get":{"responses":{"204":{"description":"ok"}}}}}}',
|
||||
);
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
generation:
|
||||
mappings:
|
||||
operations:
|
||||
first: duplicateName
|
||||
second: duplicateName
|
||||
''');
|
||||
|
||||
expect(
|
||||
() => KoiSwaggerConfig.load(configPath: config.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'codes',
|
||||
contains('config.mapping_target_collision'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group(r'$ref resolver', () {
|
||||
test('解析相对文件、跨组件引用、循环引用与名称冲突', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_relative_refs_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final commonFile = File(p.join(tempDir.path, 'common.yaml'));
|
||||
await commonFile.writeAsString(r'''
|
||||
components:
|
||||
parameters:
|
||||
Limit:
|
||||
name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
requestBodies:
|
||||
PayloadBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Payload'
|
||||
responses:
|
||||
ExternalResponse:
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExternalResponseModel'
|
||||
schemas:
|
||||
Payload:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
ExternalResponseModel:
|
||||
type: object
|
||||
required: [item]
|
||||
properties:
|
||||
item:
|
||||
$ref: '#/components/schemas/ExternalItem'
|
||||
ExternalItem:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
parent:
|
||||
$ref: '#/components/schemas/ExternalResponseModel'
|
||||
''');
|
||||
|
||||
final rootFile = File(p.join(tempDir.path, 'openapi.yaml'));
|
||||
await rootFile.writeAsString(r'''
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Relative refs
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/v1/external:
|
||||
post:
|
||||
tags: [external]
|
||||
parameters:
|
||||
- $ref: './common.yaml#/components/parameters/Limit'
|
||||
requestBody:
|
||||
$ref: './common.yaml#/components/requestBodies/PayloadBody'
|
||||
responses:
|
||||
'200':
|
||||
$ref: './common.yaml#/components/responses/ExternalResponse'
|
||||
components:
|
||||
schemas:
|
||||
ExternalItem:
|
||||
type: object
|
||||
properties:
|
||||
localOnly:
|
||||
type: boolean
|
||||
''');
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = await _writeConfig(
|
||||
tempDir.path,
|
||||
source: rootFile.path,
|
||||
outputDir: outputDir,
|
||||
);
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: configFile.path,
|
||||
);
|
||||
|
||||
expect(result.dependencyDocuments, contains(commonFile.path));
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
contains('openapi.external_component_renamed'),
|
||||
);
|
||||
|
||||
final apiFile = File(
|
||||
p.join(outputDir, 'api', 'v1', 'external_api.dart'),
|
||||
).readAsStringSync();
|
||||
expect(apiFile, contains("@Query('limit') int? limit"));
|
||||
expect(apiFile, contains('@Body() required KoiPayload body'));
|
||||
expect(
|
||||
apiFile,
|
||||
contains('Future<KoiExternalResponseModel> postExternal'),
|
||||
);
|
||||
|
||||
final responseFile = File(
|
||||
p.join(
|
||||
outputDir,
|
||||
'api_models',
|
||||
'result',
|
||||
'external_response_model.dart',
|
||||
),
|
||||
).readAsStringSync();
|
||||
expect(responseFile, contains('required KoiCommonExternalItem item'));
|
||||
|
||||
final itemFile = File(
|
||||
p.join(outputDir, 'api_models', 'result', 'common_external_item.dart'),
|
||||
).readAsStringSync();
|
||||
expect(itemFile, contains('KoiExternalResponseModel? parent'));
|
||||
});
|
||||
|
||||
test('解析远程根文档中的相对远程引用', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_remote_refs_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
addTearDown(() => server.close(force: true));
|
||||
server.listen((request) async {
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
if (request.uri.path == '/root.json') {
|
||||
request.response.write(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Remote refs",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/remote": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "./models.json#/components/schemas/RemoteModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
} else if (request.uri.path == '/models.json') {
|
||||
request.response.write(r'''
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"RemoteModel": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
} else {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
}
|
||||
await request.response.close();
|
||||
});
|
||||
|
||||
final rootUrl = 'http://${server.address.host}:${server.port}/root.json';
|
||||
final modelsUrl =
|
||||
'http://${server.address.host}:${server.port}/models.json';
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = await _writeConfig(
|
||||
tempDir.path,
|
||||
source: rootUrl,
|
||||
outputDir: outputDir,
|
||||
);
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: configFile.path,
|
||||
);
|
||||
|
||||
expect(result.dependencyDocuments, contains(modelsUrl));
|
||||
expect(
|
||||
File(
|
||||
p.join(outputDir, 'api_models', 'result', 'remote_model.dart'),
|
||||
).existsSync(),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('外部文档中不存在的 pointer 产生结构化错误', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_missing_external_ref_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final commonFile = File(p.join(tempDir.path, 'common.json'));
|
||||
await commonFile.writeAsString('{"components":{"schemas":{}}}');
|
||||
final rootFile = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await rootFile.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Missing external pointer",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/items": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "./common.json#/components/schemas/Missing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
|
||||
final configFile = await _writeConfig(
|
||||
tempDir.path,
|
||||
source: rootFile.path,
|
||||
outputDir: p.join(tempDir.path, 'lib', 'src'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
const KoiSwaggerParser().generate(configPath: configFile.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
'diagnostic codes',
|
||||
contains('openapi.unresolved_external_ref'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('多 source component 冲突会确定性重命名并改写引用', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_multisource_components_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final firstFile = File(p.join(tempDir.path, 'one.json'));
|
||||
await firstFile.writeAsString(
|
||||
_singleModelDocument(
|
||||
title: 'First',
|
||||
path: '/api/v1/first',
|
||||
propertyName: 'id',
|
||||
propertyType: 'integer',
|
||||
),
|
||||
);
|
||||
final secondFile = File(p.join(tempDir.path, 'two.json'));
|
||||
await secondFile.writeAsString(
|
||||
_singleModelDocument(
|
||||
title: 'Second',
|
||||
path: '/api/v1/second',
|
||||
propertyName: 'name',
|
||||
propertyType: 'string',
|
||||
),
|
||||
);
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${firstFile.path}
|
||||
- url: ${secondFile.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: configFile.path,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
contains('openapi.multisource_component_renamed'),
|
||||
);
|
||||
expect(
|
||||
File(
|
||||
p.join(outputDir, 'api_models', 'result', 'thing.dart'),
|
||||
).readAsStringSync(),
|
||||
contains('required int id'),
|
||||
);
|
||||
expect(
|
||||
File(
|
||||
p.join(outputDir, 'api_models', 'result', 'two_thing.dart'),
|
||||
).readAsStringSync(),
|
||||
contains('required String name'),
|
||||
);
|
||||
});
|
||||
|
||||
test('多 source 重复 operation 会阻止生成', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_multisource_operations_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
|
||||
final firstFile = File(p.join(tempDir.path, 'one.json'));
|
||||
final secondFile = File(p.join(tempDir.path, 'two.json'));
|
||||
final document = _singleModelDocument(
|
||||
title: 'Duplicate',
|
||||
path: '/api/v1/duplicate',
|
||||
propertyName: 'id',
|
||||
propertyType: 'integer',
|
||||
);
|
||||
await firstFile.writeAsString(document);
|
||||
await secondFile.writeAsString(document);
|
||||
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${firstFile.path}
|
||||
- url: ${secondFile.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
''');
|
||||
|
||||
await expectLater(
|
||||
const KoiSwaggerParser().generate(configPath: configFile.path),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
'diagnostic codes',
|
||||
contains('openapi.multisource_operation_conflict'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<File> _writeConfig(
|
||||
String directory, {
|
||||
required String source,
|
||||
required String outputDir,
|
||||
}) async {
|
||||
final configFile = File(p.join(directory, 'koi_swagger_parser.yaml'));
|
||||
await configFile.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: $source
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'api_models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
return configFile;
|
||||
}
|
||||
|
||||
String _singleModelDocument({
|
||||
required String title,
|
||||
required String path,
|
||||
required String propertyName,
|
||||
required String propertyType,
|
||||
}) {
|
||||
return '''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "$title",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"$path": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"\$ref": "#/components/schemas/Thing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Thing": {
|
||||
"type": "object",
|
||||
"required": ["$propertyName"],
|
||||
"properties": {
|
||||
"$propertyName": {
|
||||
"type": "$propertyType"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('response semantics', () {
|
||||
test('default 不作为成功响应,并保留多个 2xx 与响应头元数据', () {
|
||||
final parsed = OpenApiDocumentParser(_testConfig()).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{'title': 'responses', 'version': '1'},
|
||||
'paths': <String, dynamic>{
|
||||
'/api/v1/items': <String, dynamic>{
|
||||
'get': <String, dynamic>{
|
||||
'tags': <String>['items'],
|
||||
'operationId': 'list_items',
|
||||
'responses': <String, dynamic>{
|
||||
'200': <String, dynamic>{
|
||||
'description': 'items',
|
||||
'headers': <String, dynamic>{
|
||||
'X-Rate-Limit': <String, dynamic>{
|
||||
'description': 'remaining requests',
|
||||
'schema': <String, dynamic>{'type': 'integer'},
|
||||
},
|
||||
},
|
||||
'content': <String, dynamic>{
|
||||
'application/json': <String, dynamic>{
|
||||
'schema': <String, dynamic>{
|
||||
'type': 'array',
|
||||
'items': <String, dynamic>{'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'204': <String, dynamic>{'description': 'empty'},
|
||||
'default': <String, dynamic>{
|
||||
'description': 'error',
|
||||
'content': <String, dynamic>{
|
||||
'application/problem+json': <String, dynamic>{
|
||||
'schema': <String, dynamic>{
|
||||
'type': 'object',
|
||||
'properties': <String, dynamic>{
|
||||
'detail': <String, dynamic>{'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://responses'],
|
||||
),
|
||||
);
|
||||
|
||||
final operation = parsed.operations.single;
|
||||
expect(operation.returnType.render(), 'List<String>');
|
||||
expect(operation.primaryResponseStatusCode, '200');
|
||||
expect(operation.responses, hasLength(3));
|
||||
expect(
|
||||
operation.responses
|
||||
.singleWhere((response) => response.statusCode == 'default')
|
||||
.isSuccess,
|
||||
isFalse,
|
||||
);
|
||||
final success = operation.responses.singleWhere(
|
||||
(response) => response.statusCode == '200',
|
||||
);
|
||||
expect(success.headers.single.name, 'X-Rate-Limit');
|
||||
expect(success.headers.single.type.render(), 'int?');
|
||||
expect(
|
||||
parsed.models.values.any(
|
||||
(model) => model.originalName.contains('defaultError'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('binary response 可生成 bytes + HttpResponse + Accept 元数据', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_binary_response_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "download", "version": "1"},
|
||||
"paths": {
|
||||
"/api/v1/files/{id}": {
|
||||
"get": {
|
||||
"tags": ["files"],
|
||||
"operationId": "download_file",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string"}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "file",
|
||||
"headers": {
|
||||
"Content-Disposition": {
|
||||
"schema": {"type": "string"}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {"type": "string", "format": "binary"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
api:
|
||||
response_mode: http_response
|
||||
binary_response_mode: bytes
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
await const KoiSwaggerParser().generate(configPath: config.path);
|
||||
final apiFile = File(p.join(outputDir, 'api', 'v1', 'files_api.dart'));
|
||||
final api = await apiFile.readAsString();
|
||||
expect(api, contains("import 'dart:typed_data';"));
|
||||
expect(api, contains("@Headers(<String, dynamic>{'Accept':"));
|
||||
expect(api, contains("'application/octet-stream'"));
|
||||
expect(api, contains('@DioResponseType(ResponseType.bytes)'));
|
||||
expect(api, contains('Future<HttpResponse<Uint8List>> downloadFile'));
|
||||
expect(api, contains('Header Content-Disposition: String?'));
|
||||
});
|
||||
|
||||
test('binary 与 JSON 成功响应混用时结构化拒绝', () {
|
||||
expect(
|
||||
() => OpenApiDocumentParser(_testConfig()).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{'title': 'mixed', 'version': '1'},
|
||||
'paths': <String, dynamic>{
|
||||
'/api/v1/export': <String, dynamic>{
|
||||
'get': <String, dynamic>{
|
||||
'tags': <String>['export'],
|
||||
'responses': <String, dynamic>{
|
||||
'200': <String, dynamic>{
|
||||
'description': 'json',
|
||||
'content': <String, dynamic>{
|
||||
'application/json': <String, dynamic>{
|
||||
'schema': <String, dynamic>{'type': 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
'206': <String, dynamic>{
|
||||
'description': 'bytes',
|
||||
'content': <String, dynamic>{
|
||||
'application/octet-stream': <String, dynamic>{
|
||||
'schema': <String, dynamic>{
|
||||
'type': 'string',
|
||||
'format': 'binary',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://mixed'],
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'codes',
|
||||
contains('openapi.mixed_success_media_types'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('XML/Protobuf 等未实现 decoder 的 media type 会明确拒绝', () {
|
||||
expect(
|
||||
() => OpenApiDocumentParser(_testConfig()).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{'title': 'xml', 'version': '1'},
|
||||
'paths': <String, dynamic>{
|
||||
'/legacy': <String, dynamic>{
|
||||
'get': <String, dynamic>{
|
||||
'responses': <String, dynamic>{
|
||||
'200': <String, dynamic>{
|
||||
'description': 'xml',
|
||||
'content': <String, dynamic>{
|
||||
'application/xml': <String, dynamic>{
|
||||
'schema': <String, dynamic>{'type': 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://xml'],
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'codes',
|
||||
contains('openapi.unsupported_response_content_type'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('Link Object 不静默丢失,产生明确 warning', () {
|
||||
final parsed = OpenApiDocumentParser(_testConfig()).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{'title': 'links', 'version': '1'},
|
||||
'paths': <String, dynamic>{
|
||||
'/orders': <String, dynamic>{
|
||||
'get': <String, dynamic>{
|
||||
'responses': <String, dynamic>{
|
||||
'200': <String, dynamic>{
|
||||
'description': 'order',
|
||||
'content': <String, dynamic>{
|
||||
'application/json': <String, dynamic>{
|
||||
'schema': <String, dynamic>{'type': 'string'},
|
||||
},
|
||||
},
|
||||
'links': <String, dynamic>{
|
||||
'customer': <String, dynamic>{
|
||||
'operationId': 'getCustomer',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://links'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
parsed.diagnostics.map((item) => item.code),
|
||||
contains('openapi.links_not_supported'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
KoiSwaggerConfig _testConfig() {
|
||||
return KoiSwaggerConfig(
|
||||
configPath: null,
|
||||
workingDirectory: Directory.current.path,
|
||||
input: const InputConfig(swaggerUrls: <String>[]),
|
||||
output: OutputConfig(
|
||||
baseDir: Directory.current.path,
|
||||
apiDir: p.join(Directory.current.path, 'api'),
|
||||
modelsDir: p.join(Directory.current.path, 'models'),
|
||||
splitByTags: true,
|
||||
extractVersionFromUrl: false,
|
||||
),
|
||||
generation: GenerationConfig(
|
||||
preset: GeneratorPresetRegistry.koiFlutter,
|
||||
api: const ApiGenerationConfig(
|
||||
enabled: true,
|
||||
useRetrofit: true,
|
||||
cancelTokenSupport: false,
|
||||
client: ClientConfig(className: 'ApiClient', fileName: 'api_client'),
|
||||
responseWrapper: ResponseWrapperConfig(
|
||||
mode: ResponseWrapperMode.passthrough,
|
||||
successDataField: 'data',
|
||||
detectStandardEnvelope: false,
|
||||
),
|
||||
),
|
||||
models: const ModelGenerationConfig(
|
||||
enabled: true,
|
||||
classPrefix: 'Koi',
|
||||
defaultNonNullableListToEmpty: true,
|
||||
jsonSerializable: JsonSerializableConfig(
|
||||
checked: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
),
|
||||
),
|
||||
formatting: const FormattingConfig(
|
||||
runDartFormat: false,
|
||||
runDartFix: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('schema semantics', () {
|
||||
test(
|
||||
'区分 required/nullable/absent,按方向拆分 readOnly/writeOnly,并保留 additionalProperties',
|
||||
() async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_schema_semantics_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "schema semantics", "version": "1"},
|
||||
"paths": {
|
||||
"/api/v1/accounts": {
|
||||
"post": {
|
||||
"tags": ["accounts"],
|
||||
"operationId": "create_account",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Account"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/Account"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Account": {
|
||||
"type": "object",
|
||||
"required": ["id", "secret", "display_name", "settings"],
|
||||
"properties": {
|
||||
"id": {"type": "integer", "readOnly": true},
|
||||
"secret": {"type": "string", "writeOnly": true},
|
||||
"display_name": {"type": ["string", "null"]},
|
||||
"nickname": {"type": "string"},
|
||||
"settings": {"$ref": "#/components/schemas/FlexibleSettings"}
|
||||
}
|
||||
},
|
||||
"FlexibleSettings": {
|
||||
"type": "object",
|
||||
"required": ["theme"],
|
||||
"properties": {
|
||||
"theme": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
models:
|
||||
class_prefix: Koi
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
await const KoiSwaggerParser().generate(configPath: config.path);
|
||||
|
||||
final request = File(
|
||||
p.join(outputDir, 'models', 'request', 'account_request.dart'),
|
||||
).readAsStringSync();
|
||||
final response = File(
|
||||
p.join(outputDir, 'models', 'result', 'account_response.dart'),
|
||||
).readAsStringSync();
|
||||
expect(request, contains('class KoiAccountRequest'));
|
||||
expect(request, isNot(contains('required int id')));
|
||||
expect(request, contains('required String secret'));
|
||||
expect(request, contains('required String? displayName'));
|
||||
expect(request, contains('includeIfNull: true'));
|
||||
expect(request, contains('String? nickname'));
|
||||
expect(request, contains('disallowNullValue: true'));
|
||||
|
||||
expect(response, contains('class KoiAccountResponse'));
|
||||
expect(response, contains('required int id'));
|
||||
expect(response, isNot(contains('required String secret')));
|
||||
expect(response, contains('required String? displayName'));
|
||||
|
||||
final settings = File(
|
||||
p.join(outputDir, 'models', 'shared', 'flexible_settings.dart'),
|
||||
).readAsStringSync();
|
||||
expect(settings, contains('Map<String, int> additionalProperties'));
|
||||
expect(settings, contains('ReadAdditionalProperties'));
|
||||
expect(settings, contains('createToJson: false'));
|
||||
expect(settings, contains('result[entry.key]'));
|
||||
expect(settings, contains("result['theme']"));
|
||||
|
||||
final api = File(
|
||||
p.join(outputDir, 'api', 'v1', 'accounts_api.dart'),
|
||||
).readAsStringSync();
|
||||
expect(api, contains('required KoiAccountRequest body'));
|
||||
expect(api, contains('Future<KoiAccountResponse> createAccount'));
|
||||
},
|
||||
);
|
||||
|
||||
test('同一模型属性名冲突时确定性后缀并给出诊断', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_property_collision_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "collision", "version": "1"},
|
||||
"paths": {
|
||||
"/api/v1/value": {
|
||||
"get": {
|
||||
"tags": ["value"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"some-value": {"type": "string"},
|
||||
"some_value": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
final result = await const KoiSwaggerParser().generate(
|
||||
configPath: config.path,
|
||||
);
|
||||
expect(
|
||||
result.diagnostics.map((diagnostic) => diagnostic.code),
|
||||
contains('openapi.property_name_collision'),
|
||||
);
|
||||
final modelPath = result.writtenFiles.singleWhere(
|
||||
(path) => path.endsWith('response.dart'),
|
||||
);
|
||||
final model = File(modelPath).readAsStringSync();
|
||||
expect(model, contains('String? someValue'));
|
||||
expect(model, contains('String? someValue2'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:koi_swagger_parser/src/openapi/openapi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('servers and security metadata', () {
|
||||
test('解析 server variables、operation override 与鉴权元数据', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_server_security_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "servers and auth", "version": "1"},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://{environment}.example.com/{version}",
|
||||
"variables": {
|
||||
"environment": {
|
||||
"default": "api",
|
||||
"enum": ["api", "staging"]
|
||||
},
|
||||
"version": {"default": "v1"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{"bearerAuth": []},
|
||||
{"apiKeyAuth": []}
|
||||
],
|
||||
"paths": {
|
||||
"/accounts": {
|
||||
"get": {
|
||||
"tags": ["accounts"],
|
||||
"operationId": "list_accounts",
|
||||
"responses": {"204": {"description": "ok"}}
|
||||
}
|
||||
},
|
||||
"/upload": {
|
||||
"post": {
|
||||
"tags": ["accounts"],
|
||||
"operationId": "upload_account",
|
||||
"servers": [{"url": "https://upload.example.com"}],
|
||||
"security": [],
|
||||
"responses": {"204": {"description": "ok"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"type": "http",
|
||||
"scheme": "bearer",
|
||||
"bearerFormat": "JWT"
|
||||
},
|
||||
"basicAuth": {
|
||||
"type": "http",
|
||||
"scheme": "basic"
|
||||
},
|
||||
"apiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-API-Key"
|
||||
},
|
||||
"oauth": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"clientCredentials": {
|
||||
"tokenUrl": "https://auth.example.com/token",
|
||||
"scopes": {
|
||||
"accounts:read": "read accounts"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
await const KoiSwaggerParser().generate(configPath: config.path);
|
||||
|
||||
final api = File(
|
||||
p.join(outputDir, 'api', 'v1', 'accounts_api.dart'),
|
||||
).readAsStringSync();
|
||||
expect(
|
||||
api,
|
||||
contains(
|
||||
"@RestApi(baseUrl: 'https://api.example.com/v1', "
|
||||
'parser: Parser.JsonSerializable)',
|
||||
),
|
||||
);
|
||||
expect(api, contains('Security: bearerAuth OR apiKeyAuth'));
|
||||
expect(api, contains("@POST('https://upload.example.com/upload')"));
|
||||
|
||||
final client = File(
|
||||
p.join(outputDir, 'api', 'api_client.dart'),
|
||||
).readAsStringSync();
|
||||
expect(
|
||||
client,
|
||||
contains("baseUrl = baseUrl ?? 'https://api.example.com/v1'"),
|
||||
);
|
||||
expect(client, contains("'bearerAuth': <String, Object?>{"));
|
||||
expect(client, contains("'scheme': 'bearer'"));
|
||||
expect(client, contains("'apiKeyAuth': <String, Object?>{"));
|
||||
expect(client, contains("'in': 'header'"));
|
||||
expect(client, contains("'scopes': <String>['accounts:read']"));
|
||||
expect(client, contains("'GET /accounts':"));
|
||||
expect(client, contains("'POST /upload': <Map<String, List<String>>>[]"));
|
||||
});
|
||||
|
||||
test('不存在的 security scheme 会结构化拒绝', () {
|
||||
final config = _testConfig();
|
||||
expect(
|
||||
() => OpenApiDocumentParser(config).parse(
|
||||
LoadedOpenApiDocument(
|
||||
document: <String, dynamic>{
|
||||
'openapi': '3.1.0',
|
||||
'info': <String, dynamic>{
|
||||
'title': 'invalid auth',
|
||||
'version': '1',
|
||||
},
|
||||
'security': <dynamic>[
|
||||
<String, dynamic>{'missing': <String>[]},
|
||||
],
|
||||
'paths': <String, dynamic>{
|
||||
'/value': <String, dynamic>{
|
||||
'get': <String, dynamic>{
|
||||
'responses': <String, dynamic>{
|
||||
'204': <String, dynamic>{'description': 'ok'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sources: const <String>['memory://invalid-auth'],
|
||||
),
|
||||
),
|
||||
throwsA(
|
||||
isA<KoiDiagnosticException>().having(
|
||||
(error) => error.diagnostics.map((item) => item.code),
|
||||
'codes',
|
||||
contains('openapi.unknown_security_scheme'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
KoiSwaggerConfig _testConfig() {
|
||||
return KoiSwaggerConfig(
|
||||
configPath: null,
|
||||
workingDirectory: Directory.current.path,
|
||||
input: const InputConfig(swaggerUrls: <String>[]),
|
||||
output: OutputConfig(
|
||||
baseDir: Directory.current.path,
|
||||
apiDir: p.join(Directory.current.path, 'api'),
|
||||
modelsDir: p.join(Directory.current.path, 'models'),
|
||||
splitByTags: true,
|
||||
extractVersionFromUrl: false,
|
||||
),
|
||||
generation: GenerationConfig(
|
||||
preset: GeneratorPresetRegistry.koiFlutter,
|
||||
api: const ApiGenerationConfig(
|
||||
enabled: true,
|
||||
useRetrofit: true,
|
||||
cancelTokenSupport: false,
|
||||
client: ClientConfig(className: 'ApiClient', fileName: 'api_client'),
|
||||
responseWrapper: ResponseWrapperConfig(
|
||||
mode: ResponseWrapperMode.passthrough,
|
||||
successDataField: 'data',
|
||||
detectStandardEnvelope: false,
|
||||
),
|
||||
),
|
||||
models: const ModelGenerationConfig(
|
||||
enabled: true,
|
||||
classPrefix: 'Koi',
|
||||
defaultNonNullableListToEmpty: true,
|
||||
jsonSerializable: JsonSerializableConfig(
|
||||
checked: true,
|
||||
explicitToJson: true,
|
||||
includeIfNull: false,
|
||||
),
|
||||
),
|
||||
formatting: const FormattingConfig(
|
||||
runDartFormat: false,
|
||||
runDartFix: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('generic union generation', () {
|
||||
test('生成 primitive oneOf 与可多分支匹配的 anyOf wrapper', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'koi_swagger_parser_generic_union_',
|
||||
);
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final spec = File(p.join(tempDir.path, 'openapi.json'));
|
||||
await spec.writeAsString(r'''
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "generic unions", "version": "1"},
|
||||
"paths": {
|
||||
"/api/v1/value": {
|
||||
"get": {
|
||||
"tags": ["value"],
|
||||
"operationId": "get_value",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/UnionEnvelope"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"UnionEnvelope": {
|
||||
"type": "object",
|
||||
"required": ["value", "criteria"],
|
||||
"properties": {
|
||||
"value": {"$ref": "#/components/schemas/StringOrInteger"},
|
||||
"criteria": {"$ref": "#/components/schemas/SearchCriteria"}
|
||||
}
|
||||
},
|
||||
"StringOrInteger": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"}
|
||||
]
|
||||
},
|
||||
"SearchCriteria": {
|
||||
"anyOf": [
|
||||
{
|
||||
"title": "ByName",
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {"name": {"type": "string"}}
|
||||
},
|
||||
{
|
||||
"title": "ById",
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "integer"}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''');
|
||||
final outputDir = p.join(tempDir.path, 'lib', 'src');
|
||||
final config = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
|
||||
await config.writeAsString('''
|
||||
input:
|
||||
swagger_urls:
|
||||
- url: ${spec.path}
|
||||
output:
|
||||
base_dir: $outputDir
|
||||
api_dir: ${p.join(outputDir, 'api')}
|
||||
models_dir: ${p.join(outputDir, 'models')}
|
||||
generation:
|
||||
api:
|
||||
response_wrapper:
|
||||
mode: passthrough
|
||||
formatting:
|
||||
run_dart_format: false
|
||||
''');
|
||||
|
||||
await const KoiSwaggerParser().generate(configPath: config.path);
|
||||
|
||||
final oneOfFile = File(
|
||||
p.join(outputDir, 'models', 'result', 'string_or_integer.dart'),
|
||||
);
|
||||
final oneOf = oneOfFile.readAsStringSync();
|
||||
expect(
|
||||
oneOf,
|
||||
contains('factory KoiStringOrInteger.fromJson(Object? json)'),
|
||||
);
|
||||
expect(oneOf, contains('if (json is String)'));
|
||||
expect(oneOf, contains('if (json is int)'));
|
||||
expect(oneOf, contains('if (matches.length != 1)'));
|
||||
expect(
|
||||
oneOf,
|
||||
contains(
|
||||
'final class KoiStringOrIntegerString1 extends KoiStringOrInteger',
|
||||
),
|
||||
);
|
||||
|
||||
final anyOfFile = File(
|
||||
p.join(outputDir, 'models', 'result', 'search_criteria.dart'),
|
||||
);
|
||||
final anyOf = anyOfFile.readAsStringSync();
|
||||
expect(anyOf, contains("json.containsKey('name')"));
|
||||
expect(anyOf, contains("json.containsKey('id')"));
|
||||
expect(
|
||||
anyOf,
|
||||
contains(
|
||||
'final class KoiSearchCriteriaAnyOf extends KoiSearchCriteria',
|
||||
),
|
||||
);
|
||||
expect(anyOf, contains('final List<KoiSearchCriteria> matches'));
|
||||
|
||||
for (final file in <File>[oneOfFile, anyOfFile]) {
|
||||
final format = await Process.run(Platform.resolvedExecutable, <String>[
|
||||
'format',
|
||||
'--output=none',
|
||||
file.path,
|
||||
]);
|
||||
expect(
|
||||
format.exitCode,
|
||||
0,
|
||||
reason: '${format.stdout}\n${format.stderr}',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user