Compare commits

...

3 Commits

Author SHA1 Message Date
Max 69fff8915f 修复 OpenAPI 3.1 可空联合类型解析 2026-03-22 13:17:52 +08:00
Max 2f84b98f05 feat: update 3.3.30 2026-01-31 00:30:49 +08:00
Max 88eb04a1ef Merge tag '3.3.0' into develop
3.3.0
2026-01-31 00:29:19 +08:00
4 changed files with 309 additions and 4 deletions

View File

@ -558,7 +558,7 @@ packages:
path: ".."
relative: true
source: path
version: "3.2.1"
version: "3.3.0"
term_glyph:
dependency: transitive
description:

View File

@ -396,7 +396,7 @@ class ApiModel {
.where((entry) {
final value = entry.value;
if (value is Map<String, dynamic>) {
return !(value['nullable'] as bool? ?? false);
return !_isNullableSchemaDefinition(value);
}
return true;
})
@ -528,6 +528,27 @@ class ApiModel {
type: type,
);
}
static bool _isNullableSchemaDefinition(Map<String, dynamic> schema) {
if (schema['nullable'] as bool? ?? false) {
return true;
}
for (final unionKey in const ['anyOf', 'oneOf']) {
final unionList = schema[unionKey] as List<dynamic>?;
if (unionList == null) {
continue;
}
for (final branch in unionList) {
if (branch is Map<String, dynamic> && 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 = <String, ApiProperty>{};
var nestedRequired = <String>[];
@ -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<String, dynamic> json,
List<String> requiredFields, {
required int maxDepth,
required int currentDepth,
}) {
for (final unionKey in const ['anyOf', 'oneOf']) {
final unionList = json[unionKey] as List<dynamic>?;
if (unionList == null) {
continue;
}
final nonNullBranches = <Map<String, dynamic>>[];
var hasNullBranch = false;
for (final item in unionList) {
if (item is! Map<String, dynamic>) {
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<String, dynamic> _mergeNullableUnionBranch(
Map<String, dynamic> parent,
Map<String, dynamic> branch,
) {
final merged = Map<String, dynamic>.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;

View File

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

View File

@ -343,6 +343,52 @@ void main() {
contains('factory User.fromJson(Map<String, dynamic> json)'),
);
});
test('preserves nullable anyOf array item type in generated models', () {
final document = SwaggerDocument.fromJson(<String, dynamic>{
'openapi': '3.1.0',
'info': {
'title': 'Nullable union test',
'version': '1.0.0',
},
'paths': <String, dynamic>{},
'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<StoreResponse>? data'));
expect(result, isNot(contains('List<dynamic> data')));
expect(result, isNot(contains('String data')));
});
});
});
}