Establish the Koi Swagger parser release

Prepare the OpenAPI parser and Dart generator as a publishable Koi package with CLI entrypoint, docs, examples, tests, and pub.dev release metadata.

Constraint: Initial GitHub import follows koi_blueprint_flutter identity mr.koi <i@mrkoi.is>.

Rejected: Keep local runtime state in the initial repository | .dart_tool, .omx, and pubspec.lock are ignored for a reusable package source tree.

Confidence: high

Scope-risk: moderate

Directive: Keep generated-output behavior covered by parser and generator tests before changing rendering rules.

Tested: dart analyze; dart test; PUB_HOSTED_URL=https://pub.dev dart pub publish --dry-run

Not-tested: End-to-end downstream build_runner compilation of generated Retrofit/Freezed output.
This commit is contained in:
mr.koi
2026-06-02 22:00:13 +08:00
commit ea93f09154
24 changed files with 11355 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('KoiSwaggerConfigLoader', () {
test('兼容旧版 generator_config.yaml 字段', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_config_',
);
addTearDown(() => tempDir.delete(recursive: true));
final configFile = File(p.join(tempDir.path, 'generator_config.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ./openapi.json
enabled: true
output:
base_dir: ./lib/src
api_dir: ./lib/src/api
models_dir: ./lib/src/api_models
included_tags:
- stores
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: true
base_result_import: package:dba_network/dba_network.dart
client:
class_name: DbaApiClient
file_name: dba_api_client
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
final config = await KoiSwaggerConfig.load(
workingDirectory: tempDir.path,
);
expect(config.input.swaggerUrls, <String>[
p.join(tempDir.path, 'openapi.json'),
]);
expect(config.output.includedTags, <String>['stores']);
expect(
config.generation.api.responseWrapper.mode,
ResponseWrapperMode.externalGeneric,
);
expect(
config.generation.api.responseWrapper.externalImport,
'package:dba_network/dba_network.dart',
);
expect(
config.generation.api.responseWrapper.externalClassName,
'BaseResult',
);
expect(config.generation.api.cancelTokenSupport, isTrue);
expect(config.generation.preset.name, 'koi_flutter');
expect(config.generation.models.classPrefix, 'Koi');
expect(config.generation.formatting.runDartFormat, isFalse);
});
});
}
+133
View File
@@ -0,0 +1,133 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('default values and optional parameters', () {
test('枚举默认值与 query 默认值生成合法 Dart 代码', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_defaults_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Defaults Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/samples": {
"get": {
"tags": ["samples"],
"operationId": "list_samples_api_v1_samples_get",
"parameters": [
{
"name": "page_no",
"in": "query",
"required": false,
"schema": {"type": "integer", "default": 1}
},
{
"name": "tag",
"in": "query",
"required": false,
"schema": {"type": "string"}
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/SampleResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"SampleResponse": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"conflict_policy": {
"type": "string",
"enum": ["exclusive", "mix"],
"default": "exclusive"
}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
included_tags:
- samples
split_by_tags: true
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'samples_api.dart'),
).readAsStringSync();
expect(apiFile, contains("@Query('page_no') int pageNo = 1"));
expect(apiFile, contains("@Query('tag') String? tag"));
final resultFile = File(
p.join(outputDir, 'api_models', 'result', 'sample_response.dart'),
).readAsStringSync();
expect(
resultFile,
contains('@Default(KoiSampleResponseConflictPolicy.exclusive)'),
);
});
});
}
+6803
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('DBA fixture generation', () {
test('正确生成 Koi 风格 API 与模型文件', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_dba_',
);
addTearDown(() => tempDir.delete(recursive: true));
final outputDir = p.join(tempDir.path, 'lib', 'src');
final fixturePath = p.normalize(
p.join(Directory.current.path, 'test', 'fixtures', 'dba_openapi.json'),
);
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: $fixturePath
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
included_tags:
- stores
- materials
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: true
client:
class_name: DbaApiClient
file_name: dba_api_client
response_wrapper:
mode: external_generic
detect_standard_envelope: true
success_data_field: data
external_generic:
import: package:dba_network/dba_network.dart
class_name: BaseResult
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
final result = await const KoiSwaggerParser().generate(
configPath: configFile.path,
);
expect(result.operationsCount, greaterThan(0));
expect(result.modelsCount, greaterThan(0));
final materialsApi = File(
p.join(outputDir, 'api', 'v1', 'materials_api.dart'),
).readAsStringSync();
expect(
materialsApi,
contains('Future<BaseResult<KoiMaterialListResponse>> listMaterials'),
);
expect(materialsApi, contains("@Query('store_id') int? storeId"));
expect(materialsApi, contains("@Query('tag') String? tag"));
final storesApi = File(
p.join(outputDir, 'api', 'v1', 'stores_api.dart'),
).readAsStringSync();
expect(
storesApi,
contains('Future<BaseResult<List<KoiStoreResponse>>> listStores'),
);
expect(
storesApi,
contains('Future<BaseResult<Map<String, bool>>> deleteStore'),
);
final storeWrite = File(
p.join(outputDir, 'api_models', 'request', 'store_write.dart'),
).readAsStringSync();
expect(storeWrite, contains('String? address'));
final materialResponse = File(
p.join(outputDir, 'api_models', 'result', 'material_response.dart'),
).readAsStringSync();
expect(materialResponse, contains("@JsonKey(name: 'store_id')"));
expect(materialResponse, contains('required int storeId'));
final sharedError = File(
p.join(outputDir, 'api_models', 'shared', 'http_validation_error.dart'),
);
expect(sharedError.existsSync(), isTrue);
});
});
}
+298
View File
@@ -0,0 +1,298 @@
import 'dart:io';
import 'package:koi_swagger_parser/koi_swagger_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group('multipart generation', () {
test('按 preset 生成 Retrofit multipart 参数', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_multipart_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Multipart Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/uploads": {
"post": {
"tags": ["uploads"],
"operationId": "create_upload_api_v1_uploads_post",
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": ["file", "meta"],
"properties": {
"file": {"type": "string", "format": "binary"},
"files": {
"type": "array",
"items": {"type": "string", "format": "binary"}
},
"meta": {"$ref": "#/components/schemas/UploadMeta"},
"note": {"type": "string"}
}
}
}
}
},
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/UploadResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"UploadMeta": {
"type": "object",
"required": ["album_id"],
"properties": {
"album_id": {"type": "integer"},
"tags": {
"type": "array",
"items": {"type": "string"}
}
}
},
"UploadResponse": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
preset:
name: koi_flutter
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'uploads_api.dart'),
).readAsStringSync();
expect(apiFile, contains('@MultiPart()'));
expect(
apiFile,
contains("@Part(name: 'file') required MultipartFile file"),
);
expect(
apiFile,
contains("@Part(name: 'files') List<MultipartFile>? files"),
);
expect(
apiFile,
contains(
"@Part(name: 'meta', contentType: 'application/json') required KoiUploadMeta meta",
),
);
expect(apiFile, contains("@Part(name: 'note') String? note"));
final metaFile = File(
p.join(outputDir, 'api_models', 'request', 'upload_meta.dart'),
).readAsStringSync();
expect(metaFile, contains('class KoiUploadMeta'));
expect(metaFile, contains("@JsonKey(name: 'album_id')"));
});
});
group('discriminator union generation', () {
test('生成 sealed wrapper 并在结果模型中引用', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_union_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString(r'''
{
"openapi": "3.1.0",
"info": {
"title": "Union Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/pets/{pet_id}": {
"get": {
"tags": ["pets"],
"operationId": "get_pet_api_v1_pets__pet_id__get",
"parameters": [
{
"name": "pet_id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}
],
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PetResponse"}
}
}
}
}
}
}
},
"components": {
"schemas": {
"PetResponse": {
"type": "object",
"required": ["pet"],
"properties": {
"pet": {"$ref": "#/components/schemas/Pet"}
}
},
"Pet": {
"oneOf": [
{"$ref": "#/components/schemas/Cat"},
{"$ref": "#/components/schemas/Dog"}
],
"discriminator": {
"propertyName": "pet_type",
"mapping": {
"cat": "#/components/schemas/Cat",
"dog": "#/components/schemas/Dog"
}
}
},
"Cat": {
"type": "object",
"required": ["pet_type", "lives"],
"properties": {
"pet_type": {"type": "string", "enum": ["cat"]},
"lives": {"type": "integer"}
}
},
"Dog": {
"type": "object",
"required": ["pet_type", "breed"],
"properties": {
"pet_type": {"type": "string", "enum": ["dog"]},
"breed": {"type": "string"}
}
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: false
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final unionFile = File(
p.join(outputDir, 'api_models', 'result', 'pet.dart'),
).readAsStringSync();
expect(unionFile, contains('sealed class KoiPet'));
expect(
unionFile,
contains('factory KoiPet.fromJson(Map<String, dynamic> json)'),
);
expect(unionFile, contains("case 'cat':"));
expect(unionFile, contains('return KoiPetCat(KoiCat.fromJson(json));'));
expect(unionFile, contains('final class KoiPetDog extends KoiPet'));
final responseFile = File(
p.join(outputDir, 'api_models', 'result', 'pet_response.dart'),
).readAsStringSync();
expect(responseFile, contains('required KoiPet pet'));
});
});
}
+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('passthrough wrapper', () {
test('保留 envelope 模型并生成 enum', () async {
final tempDir = await Directory.systemTemp.createTemp(
'koi_swagger_parser_passthrough_',
);
addTearDown(() => tempDir.delete(recursive: true));
final specFile = File(p.join(tempDir.path, 'openapi.json'));
await specFile.writeAsString('''
{
"openapi": "3.1.0",
"info": {
"title": "Sample",
"version": "1.0.0"
},
"paths": {
"/api/v1/orders": {
"get": {
"tags": ["orders"],
"operationId": "list_orders_api_v1_orders_get",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"\$ref": "#/components/schemas/ApiResponse_OrderStatusResponse_"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ApiResponse_OrderStatusResponse_": {
"type": "object",
"required": ["data"],
"properties": {
"code": {"type": "integer"},
"message": {"type": "string"},
"data": {"\$ref": "#/components/schemas/OrderStatusResponse"}
}
},
"OrderStatusResponse": {
"type": "object",
"required": ["status"],
"properties": {
"status": {"\$ref": "#/components/schemas/OrderStatus"}
}
},
"OrderStatus": {
"type": "string",
"enum": ["pending", "done"]
}
}
}
}
''');
final outputDir = p.join(tempDir.path, 'lib', 'src');
final configFile = File(p.join(tempDir.path, 'koi_swagger_parser.yaml'));
await configFile.writeAsString('''
input:
swagger_urls:
- url: ${specFile.path}
enabled: true
output:
base_dir: $outputDir
api_dir: ${p.join(outputDir, 'api')}
models_dir: ${p.join(outputDir, 'api_models')}
split_by_tags: true
generation:
api:
enabled: true
use_retrofit: true
cancel_token_support: false
client:
class_name: SampleApiClient
file_name: sample_api_client
response_wrapper:
mode: passthrough
detect_standard_envelope: true
success_data_field: data
models:
enabled: true
class_prefix: Koi
default_non_nullable_list_to_empty: true
json_serializable:
checked: true
explicit_to_json: true
include_if_null: false
formatting:
run_dart_format: false
''');
await const KoiSwaggerParser().generate(configPath: configFile.path);
final apiFile = File(
p.join(outputDir, 'api', 'v1', 'orders_api.dart'),
).readAsStringSync();
expect(
apiFile,
contains('Future<KoiApiResponseOrderStatusResponse> listOrders'),
);
expect(apiFile, isNot(contains('BaseResult')));
final envelopeModel = File(
p.join(
outputDir,
'api_models',
'result',
'api_response_order_status_response.dart',
),
).readAsStringSync();
expect(
envelopeModel,
contains('class KoiApiResponseOrderStatusResponse'),
);
final enumFile = File(
p.join(outputDir, 'api_models', 'enums', 'order_status.dart'),
).readAsStringSync();
expect(enumFile, contains('enum KoiOrderStatus'));
expect(enumFile, contains("@JsonValue('pending')"));
});
});
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:koi_swagger_parser/src/util/utils.dart';
import 'package:test/test.dart';
void main() {
group('toSnakeCase', () {
test('保持常见缩写合并后的文件名可读性', () {
expect(toSnakeCase('IoTDeviceResponse'), 'iot_device_response');
expect(
toSnakeCase('ApiResponse_IoTDeviceListResponse_'),
'api_response_iot_device_list_response',
);
expect(toSnakeCase('HTTPValidationError'), 'http_validation_error');
});
});
}