Compare commits
3 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
69fff8915f | |
|
|
2f84b98f05 | |
|
|
88eb04a1ef |
|
|
@ -558,7 +558,7 @@ packages:
|
|||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "3.2.1"
|
||||
version: "3.3.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue