Files
koi_swagger_parser/test/reference_resolver_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

454 lines
12 KiB
Dart

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"
}
}
}
}
}
}
''';
}