Complete OpenAPI 3.0/3.1 generation, diagnostics, lifecycle safety, enum and naming mappings, bilingual documentation, examples, and pub.dev release validation.
327 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
);
|
|
}
|