Skip to content

Commit 557d074

Browse files
authored
Fix schema for nullable enums (#3377)
Fix schema for nullable enums.
1 parent caadbd3 commit 557d074

20 files changed

Lines changed: 302 additions & 39 deletions

File tree

src/Swashbuckle.AspNetCore.Newtonsoft/SchemaGenerator/NewtonsoftDataContractResolver.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ public DataContract GetDataContractForType(Type type)
2222
jsonConverter: JsonConverterFunc);
2323
}
2424

25-
var jsonContract = _contractResolver.ResolveContract(effectiveType);
25+
var jsonContract = _contractResolver.ResolveContract(type);
2626

27-
if (jsonContract is JsonPrimitiveContract && !jsonContract.UnderlyingType.IsEnum)
27+
var effectiveUnderlyingType = Nullable.GetUnderlyingType(jsonContract.UnderlyingType) ?? jsonContract.UnderlyingType;
28+
29+
if (jsonContract is JsonPrimitiveContract && !effectiveUnderlyingType.IsEnum)
2830
{
29-
if (!PrimitiveTypesAndFormats.TryGetValue(jsonContract.UnderlyingType, out var primitiveTypeAndFormat))
31+
if (!PrimitiveTypesAndFormats.TryGetValue(effectiveUnderlyingType, out var primitiveTypeAndFormat))
3032
{
3133
primitiveTypeAndFormat = Tuple.Create(DataType.String, (string)null);
3234
}
@@ -38,9 +40,9 @@ public DataContract GetDataContractForType(Type type)
3840
jsonConverter: JsonConverterFunc);
3941
}
4042

41-
if (jsonContract is JsonPrimitiveContract && jsonContract.UnderlyingType.IsEnum)
43+
if (jsonContract is JsonPrimitiveContract && effectiveUnderlyingType.IsEnum)
4244
{
43-
var enumValues = jsonContract.UnderlyingType.GetEnumValues();
45+
var enumValues = effectiveUnderlyingType.GetEnumValues();
4446

4547
// Test to determine if the serializer will treat as string
4648
var serializeAsString = (enumValues.Length > 0) &&
@@ -52,7 +54,7 @@ public DataContract GetDataContractForType(Type type)
5254

5355
var primitiveTypeAndFormat = serializeAsString
5456
? PrimitiveTypesAndFormats[typeof(string)]
55-
: PrimitiveTypesAndFormats[jsonContract.UnderlyingType.GetEnumUnderlyingType()];
57+
: PrimitiveTypesAndFormats[effectiveUnderlyingType.GetEnumUnderlyingType()];
5658

5759
return DataContract.ForPrimitive(
5860
underlyingType: jsonContract.UnderlyingType,

src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/JsonSerializerDataContractResolver.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public DataContract GetDataContractForType(Type type)
4949
primitiveTypeAndFormat = PrimitiveTypesAndFormats[exampleType];
5050

5151
return DataContract.ForPrimitive(
52-
underlyingType: effectiveType,
52+
underlyingType: type,
5353
dataType: primitiveTypeAndFormat.Item1,
5454
dataFormat: primitiveTypeAndFormat.Item2,
5555
jsonConverter: (value) => JsonConverterFunc(value, type));

src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ private OpenApiSchema GenerateSchemaForMember(
5454
MemberInfo memberInfo,
5555
DataProperty dataProperty = null)
5656
{
57+
if (dataProperty != null)
58+
{
59+
var customAttributes = memberInfo.GetInlineAndMetadataAttributes();
60+
61+
var requiredAttribute = customAttributes.OfType<RequiredAttribute>().FirstOrDefault();
62+
63+
if (!IsNullable(customAttributes, requiredAttribute, dataProperty, memberInfo))
64+
{
65+
modelType = Nullable.GetUnderlyingType(modelType) ?? modelType;
66+
}
67+
}
68+
5769
var dataContract = GetDataContractFor(modelType);
5870

5971
var schema = _generatorOptions.UseOneOfForPolymorphism && IsBaseTypeWithKnownTypesDefined(dataContract, out var knownTypesDataContracts)
@@ -75,11 +87,7 @@ private OpenApiSchema GenerateSchemaForMember(
7587
{
7688
var requiredAttribute = customAttributes.OfType<RequiredAttribute>().FirstOrDefault();
7789

78-
var nullable = _generatorOptions.SupportNonNullableReferenceTypes
79-
? dataProperty.IsNullable && requiredAttribute == null && !memberInfo.IsNonNullableReferenceType()
80-
: dataProperty.IsNullable && requiredAttribute == null;
81-
82-
schema.Nullable = nullable;
90+
schema.Nullable = IsNullable(customAttributes, requiredAttribute, dataProperty, memberInfo);
8391

8492
schema.ReadOnly = dataProperty.IsReadOnly;
8593
schema.WriteOnly = dataProperty.IsWriteOnly;
@@ -129,6 +137,13 @@ private OpenApiSchema GenerateSchemaForMember(
129137
return schema;
130138
}
131139

140+
private bool IsNullable(IEnumerable<object> customAttributes, RequiredAttribute requiredAttribute, DataProperty dataProperty, MemberInfo memberInfo)
141+
{
142+
return _generatorOptions.SupportNonNullableReferenceTypes
143+
? dataProperty.IsNullable && requiredAttribute == null && !memberInfo.IsNonNullableReferenceType()
144+
: dataProperty.IsNullable && requiredAttribute == null;
145+
}
146+
132147
private OpenApiSchema GenerateSchemaForParameter(
133148
Type modelType,
134149
SchemaRepository schemaRepository,
@@ -264,7 +279,7 @@ private OpenApiSchema GenerateConcreteSchema(DataContract dataContract, SchemaRe
264279
case DataType.String:
265280
{
266281
schemaFactory = () => CreatePrimitiveSchema(dataContract);
267-
returnAsReference = dataContract.UnderlyingType.IsEnum && !_generatorOptions.UseInlineDefinitionsForEnums;
282+
returnAsReference = (Nullable.GetUnderlyingType(dataContract.UnderlyingType) ?? dataContract.UnderlyingType).IsEnum && !_generatorOptions.UseInlineDefinitionsForEnums;
268283
break;
269284
}
270285

@@ -330,10 +345,19 @@ private static OpenApiSchema CreatePrimitiveSchema(DataContract dataContract)
330345
}
331346
#pragma warning restore CS0618 // Type or member is obsolete
332347

333-
if (dataContract.UnderlyingType.IsEnum)
348+
var underlyingType = Nullable.GetUnderlyingType(dataContract.UnderlyingType) ?? dataContract.UnderlyingType;
349+
350+
if (underlyingType.IsEnum)
334351
{
335-
schema.Enum = [.. dataContract.UnderlyingType.GetEnumValues()
336-
.Cast<object>()
352+
var enumValues = underlyingType.GetEnumValues().Cast<object>();
353+
354+
if (dataContract.UnderlyingType != underlyingType)
355+
{
356+
schema.Nullable = true;
357+
enumValues = enumValues.Append(null);
358+
}
359+
360+
schema.Enum = [.. enumValues
337361
.Select(value => dataContract.JsonConverter(value))
338362
.Distinct()
339363
.Select(JsonModelFactory.CreateFromJson)];
@@ -440,9 +464,11 @@ private OpenApiSchema CreateObjectSchema(DataContract dataContract, SchemaReposi
440464
continue;
441465
}
442466

467+
var memberType = dataProperty.IsNullable ? dataProperty.MemberType : (Nullable.GetUnderlyingType(dataProperty.MemberType) ?? dataProperty.MemberType);
468+
443469
schema.Properties[dataProperty.Name] = (dataProperty.MemberInfo != null)
444-
? GenerateSchemaForMember(dataProperty.MemberType, schemaRepository, dataProperty.MemberInfo, dataProperty)
445-
: GenerateSchemaForType(dataProperty.MemberType, schemaRepository);
470+
? GenerateSchemaForMember(memberType, schemaRepository, dataProperty.MemberInfo, dataProperty)
471+
: GenerateSchemaForType(memberType, schemaRepository);
446472

447473
var markNonNullableTypeAsRequired =
448474
_generatorOptions.NonNullableReferenceTypesAsRequired &&

src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ apiParameter.Type is not null &&
598598

599599
var schema = (type != null)
600600
? GenerateSchema(
601-
type,
601+
isRequired ? (Nullable.GetUnderlyingType(type) ?? type) : type,
602602
schemaRepository,
603603
apiParameter.PropertyInfo(),
604604
apiParameter.ParameterInfo(),

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.SwaggerEndpoint_ReturnsValidSwaggerJson_startupType=Basic.Startup_swaggerRequestUri=v1.DotNet8_0.verified.txt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1491,7 +1491,7 @@
14911491
"$ref": "#/components/schemas/ProductStatus"
14921492
},
14931493
"status2": {
1494-
"$ref": "#/components/schemas/ProductStatus"
1494+
"$ref": "#/components/schemas/ProductStatusNullable"
14951495
}
14961496
},
14971497
"additionalProperties": false,
@@ -1511,6 +1511,17 @@
15111511
"type": "integer",
15121512
"format": "int32"
15131513
},
1514+
"ProductStatusNullable": {
1515+
"enum": [
1516+
0,
1517+
1,
1518+
2,
1519+
null
1520+
],
1521+
"type": "integer",
1522+
"format": "int32",
1523+
"nullable": true
1524+
},
15141525
"Promotion": {
15151526
"type": "object",
15161527
"properties": {

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.SwaggerEndpoint_ReturnsValidSwaggerJson_startupType=Basic.Startup_swaggerRequestUri=v1.DotNet9_0.verified.txt

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1491,7 +1491,7 @@
14911491
"$ref": "#/components/schemas/ProductStatus"
14921492
},
14931493
"status2": {
1494-
"$ref": "#/components/schemas/ProductStatus"
1494+
"$ref": "#/components/schemas/ProductStatusNullable"
14951495
}
14961496
},
14971497
"additionalProperties": false,
@@ -1511,6 +1511,17 @@
15111511
"type": "integer",
15121512
"format": "int32"
15131513
},
1514+
"ProductStatusNullable": {
1515+
"enum": [
1516+
0,
1517+
1,
1518+
2,
1519+
null
1520+
],
1521+
"type": "integer",
1522+
"format": "int32",
1523+
"nullable": true
1524+
},
15141525
"Promotion": {
15151526
"type": "object",
15161527
"properties": {

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,33 @@
1414
{
1515
"name": "logLevel",
1616
"in": "query",
17+
"schema": {
18+
"allOf": [
19+
{
20+
"$ref": "#/components/schemas/LogLevelNullable"
21+
}
22+
],
23+
"default": 4
24+
}
25+
}
26+
],
27+
"responses": {
28+
"200": {
29+
"description": "OK"
30+
}
31+
}
32+
}
33+
},
34+
"/api/RequiredEnum": {
35+
"get": {
36+
"tags": [
37+
"RequiredEnum"
38+
],
39+
"parameters": [
40+
{
41+
"name": "logLevel",
42+
"in": "query",
43+
"required": true,
1744
"schema": {
1845
"allOf": [
1946
{
@@ -46,6 +73,21 @@
4673
],
4774
"type": "integer",
4875
"format": "int32"
76+
},
77+
"LogLevelNullable": {
78+
"enum": [
79+
0,
80+
1,
81+
2,
82+
3,
83+
4,
84+
5,
85+
6,
86+
null
87+
],
88+
"type": "integer",
89+
"format": "int32",
90+
"nullable": true
4991
}
5092
}
5193
}

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=MvcWithNullable.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,33 @@
1414
{
1515
"name": "logLevel",
1616
"in": "query",
17+
"schema": {
18+
"allOf": [
19+
{
20+
"$ref": "#/components/schemas/LogLevelNullable"
21+
}
22+
],
23+
"default": 4
24+
}
25+
}
26+
],
27+
"responses": {
28+
"200": {
29+
"description": "OK"
30+
}
31+
}
32+
}
33+
},
34+
"/api/RequiredEnum": {
35+
"get": {
36+
"tags": [
37+
"RequiredEnum"
38+
],
39+
"parameters": [
40+
{
41+
"name": "logLevel",
42+
"in": "query",
43+
"required": true,
1744
"schema": {
1845
"allOf": [
1946
{
@@ -46,6 +73,21 @@
4673
],
4774
"type": "integer",
4875
"format": "int32"
76+
},
77+
"LogLevelNullable": {
78+
"enum": [
79+
0,
80+
1,
81+
2,
82+
3,
83+
4,
84+
5,
85+
6,
86+
null
87+
],
88+
"type": "integer",
89+
"format": "int32",
90+
"nullable": true
4991
}
5092
}
5193
}

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=WebApi.Program_swaggerRequestUri=v1.DotNet8_0.verified.txt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@
379379
"name": "paramNine",
380380
"in": "query",
381381
"schema": {
382-
"$ref": "#/components/schemas/DateTimeKind"
382+
"$ref": "#/components/schemas/DateTimeKindNullable"
383383
}
384384
},
385385
{
@@ -946,7 +946,7 @@
946946
"format": "time"
947947
},
948948
"paramNine": {
949-
"$ref": "#/components/schemas/DateTimeKind"
949+
"$ref": "#/components/schemas/DateTimeKindNullable"
950950
},
951951
"paramTen": {
952952
"$ref": "#/components/schemas/DateTimeKind"
@@ -995,6 +995,17 @@
995995
"type": "integer",
996996
"format": "int32"
997997
},
998+
"DateTimeKindNullable": {
999+
"enum": [
1000+
0,
1001+
1,
1002+
2,
1003+
null
1004+
],
1005+
"type": "integer",
1006+
"format": "int32",
1007+
"nullable": true
1008+
},
9981009
"Fruit": {
9991010
"type": "object",
10001011
"properties": {

test/Swashbuckle.AspNetCore.IntegrationTests/snapshots/VerifyTests.Swagger_IsValidJson_No_Startup_entryPointType=WebApi.Program_swaggerRequestUri=v1.DotNet9_0.verified.txt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@
379379
"name": "paramNine",
380380
"in": "query",
381381
"schema": {
382-
"$ref": "#/components/schemas/DateTimeKind"
382+
"$ref": "#/components/schemas/DateTimeKindNullable"
383383
}
384384
},
385385
{
@@ -946,7 +946,7 @@
946946
"format": "time"
947947
},
948948
"paramNine": {
949-
"$ref": "#/components/schemas/DateTimeKind"
949+
"$ref": "#/components/schemas/DateTimeKindNullable"
950950
},
951951
"paramTen": {
952952
"$ref": "#/components/schemas/DateTimeKind"
@@ -995,6 +995,17 @@
995995
"type": "integer",
996996
"format": "int32"
997997
},
998+
"DateTimeKindNullable": {
999+
"enum": [
1000+
0,
1001+
1,
1002+
2,
1003+
null
1004+
],
1005+
"type": "integer",
1006+
"format": "int32",
1007+
"nullable": true
1008+
},
9981009
"Fruit": {
9991010
"type": "object",
10001011
"properties": {

0 commit comments

Comments
 (0)