Release 0.2.0

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