Files
koi_swagger_parser/test/response_semantics_test.dart
mr.koi 454e6c51d7 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.
2026-07-31 16:47:39 +08:00

327 lines
11 KiB
Dart

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,
),
),
);
}