From 69fff8915fa4d4509826698c8c40815e7b19c69c Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 22 Mar 2026 13:17:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20OpenAPI=203.1=20=E5=8F=AF?= =?UTF-8?q?=E7=A9=BA=E8=81=94=E5=90=88=E7=B1=BB=E5=9E=8B=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/models/api_schema.dart | 151 +++++++++++++++++++++++++++++++- test/models_test.dart | 114 ++++++++++++++++++++++++ test/simple_generator_test.dart | 46 ++++++++++ 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/lib/core/models/api_schema.dart b/lib/core/models/api_schema.dart index 86ea8c7..3afe95e 100644 --- a/lib/core/models/api_schema.dart +++ b/lib/core/models/api_schema.dart @@ -396,7 +396,7 @@ class ApiModel { .where((entry) { final value = entry.value; if (value is Map) { - return !(value['nullable'] as bool? ?? false); + return !_isNullableSchemaDefinition(value); } return true; }) @@ -528,6 +528,27 @@ class ApiModel { type: type, ); } + + static bool _isNullableSchemaDefinition(Map schema) { + if (schema['nullable'] as bool? ?? false) { + return true; + } + + for (final unionKey in const ['anyOf', 'oneOf']) { + final unionList = schema[unionKey] as List?; + if (unionList == null) { + continue; + } + + for (final branch in unionList) { + if (branch is Map && branch['type'] == 'null') { + return true; + } + } + } + + return false; + } } /// API属性信息 @@ -566,8 +587,20 @@ class ApiProperty { ); } - final type = PropertyType.fromString(json['type'] as String? ?? 'string'); + final resolvedNullableUnion = _resolveNullableUnionProperty( + name, + json, + requiredFields, + maxDepth: maxDepth, + currentDepth: currentDepth, + ); + if (resolvedNullableUnion != null) { + return resolvedNullableUnion; + } + + var type = PropertyType.fromString(json['type'] as String?); String? reference; + final nullable = json['nullable'] as bool? ?? false; ApiModel? items; final nestedProperties = {}; var nestedRequired = []; @@ -579,6 +612,14 @@ class ApiProperty { reference = ref.split('/').last; } + if (type == PropertyType.unknown) { + if (json['properties'] != null) { + type = PropertyType.object; + } else if (json['items'] != null) { + type = PropertyType.array; + } + } + // 处理复杂 schema(组合模式等) if (json['allOf'] != null || json['oneOf'] != null || @@ -694,7 +735,7 @@ class ApiProperty { format: json['format'] as String?, description: json['description'] as String? ?? '', required: requiredFields.contains(name), - nullable: json['nullable'] as bool? ?? false, + nullable: nullable, example: json['example'], defaultValue: json['default'], reference: reference, @@ -704,6 +745,110 @@ class ApiProperty { schema: schema, ); } + + static ApiProperty? _resolveNullableUnionProperty( + String name, + Map json, + List requiredFields, { + required int maxDepth, + required int currentDepth, + }) { + for (final unionKey in const ['anyOf', 'oneOf']) { + final unionList = json[unionKey] as List?; + if (unionList == null) { + continue; + } + + final nonNullBranches = >[]; + var hasNullBranch = false; + + for (final item in unionList) { + if (item is! Map) { + continue; + } + if (item['type'] == 'null') { + hasNullBranch = true; + continue; + } + nonNullBranches.add(item); + } + + if (!hasNullBranch) { + continue; + } + + final required = requiredFields.contains(name); + final description = json['description'] as String? ?? ''; + final schema = ApiSchema.fromJson(json); + + if (nonNullBranches.length != 1) { + return ApiProperty( + name: name, + type: PropertyType.unknown, + format: json['format'] as String?, + description: description, + required: required, + nullable: true, + example: json['example'], + defaultValue: json['default'], + schema: schema, + ); + } + + final resolvedBranch = ApiProperty.fromJson( + name, + _mergeNullableUnionBranch(json, nonNullBranches.single), + requiredFields, + maxDepth: maxDepth, + currentDepth: currentDepth, + ); + + return ApiProperty( + name: name, + type: resolvedBranch.type, + format: resolvedBranch.format ?? json['format'] as String?, + description: resolvedBranch.description.isNotEmpty + ? resolvedBranch.description + : description, + required: required, + nullable: true, + example: json.containsKey('example') + ? json['example'] + : resolvedBranch.example, + defaultValue: json.containsKey('default') + ? json['default'] + : resolvedBranch.defaultValue, + reference: resolvedBranch.reference, + items: resolvedBranch.items, + nestedProperties: resolvedBranch.nestedProperties, + nestedRequired: resolvedBranch.nestedRequired, + schema: schema, + ); + } + + return null; + } + + static Map _mergeNullableUnionBranch( + Map parent, + Map branch, + ) { + final merged = Map.from(branch); + + for (final key in const ['description', 'format', 'example', 'default']) { + if (!merged.containsKey(key) && parent.containsKey(key)) { + merged[key] = parent[key]; + } + } + + if ((parent['nullable'] as bool? ?? false) || + (branch['nullable'] as bool? ?? false)) { + merged['nullable'] = true; + } + + return merged; + } + final String name; final PropertyType type; final String? format; diff --git a/test/models_test.dart b/test/models_test.dart index f5d873f..227dce5 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -1055,6 +1055,43 @@ void main() { expect(model.properties['name']!.required, true); expect(model.required, ['id', 'name']); }); + + test( + 'treats anyOf/oneOf nullable properties as optional when required is omitted', + () { + final json = { + 'description': 'Envelope model', + 'properties': { + 'data': { + 'anyOf': [ + { + 'type': 'array', + 'items': {r'$ref': '#/components/schemas/StoreResponse'}, + }, + {'type': 'null'}, + ], + }, + 'meta': { + 'oneOf': [ + {r'$ref': '#/components/schemas/MetaResponse'}, + {'type': 'null'}, + ], + }, + 'code': { + 'type': 'integer', + }, + }, + }; + + final model = ApiModel.fromJson('Envelope', json); + + expect(model.required, ['code']); + expect(model.properties['data']!.nullable, isTrue); + expect(model.properties['data']!.required, isFalse); + expect(model.properties['meta']!.nullable, isTrue); + expect(model.properties['meta']!.required, isFalse); + expect(model.properties['code']!.required, isTrue); + }); }); group('ApiProperty complex types', () { @@ -1750,4 +1787,81 @@ void main() { expect(schema.dependencies.length, 1); }); }); + + group('ApiProperty nullable union', () { + test('parses anyOf ref + null as nullable reference property', () { + final property = ApiProperty.fromJson( + 'data', + { + 'anyOf': [ + {r'$ref': '#/components/schemas/StoreResponse'}, + {'type': 'null'}, + ], + }, + const [], + ); + + expect(property.type, PropertyType.reference); + expect(property.reference, 'StoreResponse'); + expect(property.nullable, isTrue); + }); + + test('parses oneOf ref + null as nullable reference property', () { + final property = ApiProperty.fromJson( + 'data', + { + 'oneOf': [ + {r'$ref': '#/components/schemas/StoreResponse'}, + {'type': 'null'}, + ], + }, + const [], + ); + + expect(property.type, PropertyType.reference); + expect(property.reference, 'StoreResponse'); + expect(property.nullable, isTrue); + }); + + test('parses anyOf array + null without losing item type', () { + final property = ApiProperty.fromJson( + 'data', + { + 'anyOf': [ + { + 'type': 'array', + 'items': {r'$ref': '#/components/schemas/StoreResponse'}, + }, + {'type': 'null'}, + ], + }, + const [], + ); + + expect(property.type, PropertyType.array); + expect(property.items, isNotNull); + expect(property.items?.name, 'StoreResponse'); + expect(property.nullable, isTrue); + }); + + test('downgrades nullable multi-branch union to unknown instead of string', + () { + final property = ApiProperty.fromJson( + 'data', + { + 'anyOf': [ + {r'$ref': '#/components/schemas/StoreResponse'}, + {r'$ref': '#/components/schemas/DeviceResponse'}, + {'type': 'null'}, + ], + }, + const [], + ); + + expect(property.type, PropertyType.unknown); + expect(property.reference, isNull); + expect(property.nullable, isTrue); + expect(property.schema, isNotNull); + }); + }); } diff --git a/test/simple_generator_test.dart b/test/simple_generator_test.dart index e3a8689..15c96e6 100644 --- a/test/simple_generator_test.dart +++ b/test/simple_generator_test.dart @@ -343,6 +343,52 @@ void main() { contains('factory User.fromJson(Map json)'), ); }); + + test('preserves nullable anyOf array item type in generated models', () { + final document = SwaggerDocument.fromJson({ + 'openapi': '3.1.0', + 'info': { + 'title': 'Nullable union test', + 'version': '1.0.0', + }, + 'paths': {}, + 'components': { + 'schemas': { + 'StoreResponse': { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + }, + 'required': ['id'], + }, + 'StoreListEnvelope': { + 'type': 'object', + 'properties': { + 'data': { + 'anyOf': [ + { + 'type': 'array', + 'items': { + r'$ref': '#/components/schemas/StoreResponse', + }, + }, + {'type': 'null'}, + ], + }, + }, + }, + }, + }, + }); + + final generator = ModelCodeGenerator(document); + final result = generator + .generateSingleModelFile(document.models['StoreListEnvelope']!); + + expect(result, contains('List? data')); + expect(result, isNot(contains('List data'))); + expect(result, isNot(contains('String data'))); + }); }); }); }