From 51f6598b4d48996160f93c1c7283e5a0a9605210 Mon Sep 17 00:00:00 2001 From: ldeluigi <44567586+ldeluigi@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:24:58 +0200 Subject: [PATCH 1/3] Handle different configurations for nullability in swagger gen (#3936) --- .../AnnotationsSchemaFilter.cs | 3 +- .../SchemaGenerator/SchemaGenerator.cs | 25 ++++++++-- .../JsonSerializerSchemaGeneratorTests.cs | 46 ++++++++++++++++++- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/Swashbuckle.AspNetCore.Annotations/AnnotationsSchemaFilter.cs b/src/Swashbuckle.AspNetCore.Annotations/AnnotationsSchemaFilter.cs index 58fcd2c90f..07795eb829 100644 --- a/src/Swashbuckle.AspNetCore.Annotations/AnnotationsSchemaFilter.cs +++ b/src/Swashbuckle.AspNetCore.Annotations/AnnotationsSchemaFilter.cs @@ -102,9 +102,10 @@ private static void ApplySchemaAttribute(IOpenApiSchema schema, SwaggerSchemaAtt if (schemaAttribute.NullableFlag is { } nullable) { // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3387 + // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3936 if (nullable) { - concrete.Type ??= JsonSchemaType.Null; + concrete.Type ??= JsonSchemaType.Boolean | JsonSchemaType.Integer | JsonSchemaType.Number | JsonSchemaType.String | JsonSchemaType.Object | JsonSchemaType.Array; concrete.Type |= JsonSchemaType.Null; } else if (concrete.Type.HasValue) diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs index 98954136b3..236a86f482 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs @@ -214,7 +214,7 @@ private IOpenApiSchema GenerateSchemaForType(Type modelType, SchemaRepository sc : GenerateConcreteSchema(dataContract, schemaRepository); ApplyFilters(schema, modelType, schemaRepository); - + if (Nullable.GetUnderlyingType(modelType) != null && schema is OpenApiSchema concrete) { SetNullable(concrete, true); @@ -632,10 +632,29 @@ private System.Text.Json.Nodes.JsonNode GenerateDefaultValue( private static void SetNullable(OpenApiSchema schema, bool nullable) { // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3387 + // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/3936 if (nullable) { - schema.Type ??= JsonSchemaType.Null; - schema.Type |= JsonSchemaType.Null; + if (schema.AllOf is { Count: > 0 } allOf) + { + schema.Type ??= JsonSchemaType.Null; + } + else if (schema.AnyOf is { Count: > 0 } anyOf) + { + anyOf.Add(new OpenApiSchema { Type = JsonSchemaType.Null }); + } + else if (schema.OneOf is { Count: > 0 } oneOf) + { + oneOf.Add(new OpenApiSchema { Type = JsonSchemaType.Null }); + } + else + { + schema.Type ??= JsonSchemaType.Boolean | JsonSchemaType.Integer | JsonSchemaType.Number | JsonSchemaType.String | JsonSchemaType.Object | JsonSchemaType.Array; + } + if (schema.Type.HasValue) + { + schema.Type |= JsonSchemaType.Null; + } } else if (schema.Type.HasValue) { diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index d866a7b40c..7cdb476748 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -32,7 +32,7 @@ public void GenerateSchema_GeneratesFileSchema_BinaryStringResultType(Type type) Assert.Equal("binary", schema.Format); } - public static TheoryData PrimitiveTypeData => new() + public static TheoryData PrimitiveTypeData => new() { { typeof(bool), JsonSchemaTypes.Boolean, null }, { typeof(byte), JsonSchemaTypes.Integer, "int32" }, @@ -67,13 +67,14 @@ public void GenerateSchema_GeneratesFileSchema_BinaryStringResultType(Type type) { typeof(Int128?), JsonSchemaTypes.Integer | JsonSchemaType.Null, "int128" }, { typeof(UInt128), JsonSchemaTypes.Integer, "int128" }, { typeof(UInt128?), JsonSchemaTypes.Integer | JsonSchemaType.Null, "int128" }, + { typeof(object), null, null }, }; [Theory] [MemberData(nameof(PrimitiveTypeData))] public void GenerateSchema_GeneratesPrimitiveSchema_IfPrimitiveOrNullablePrimitiveType( Type type, - JsonSchemaType expectedSchemaType, + JsonSchemaType? expectedSchemaType, string expectedFormat) { var schema = Subject().GenerateSchema(type, new SchemaRepository()); @@ -122,9 +123,13 @@ public void GenerateSchema_DedupesEnumValues_IfEnumTypeHasDuplicateValues() public static TheoryData CollectionTypeData => new() { { typeof(IDictionary), JsonSchemaTypes.Integer }, + { typeof(IDictionary), JsonSchemaTypes.Integer | JsonSchemaType.Null }, { typeof(IDictionary), JsonSchemaTypes.Integer }, { typeof(IReadOnlyDictionary), JsonSchemaTypes.Boolean }, + { typeof(IReadOnlyDictionary), JsonSchemaTypes.Boolean | JsonSchemaType.Null }, { typeof(IDictionary), null }, + { typeof(IDictionary), null }, + { typeof(IDictionary), null }, { typeof(ExpandoObject), null }, }; @@ -141,6 +146,43 @@ public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryType( Assert.NotNull(schema.AdditionalProperties); Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type); } + + private record TestRecordStringInteger(IDictionary Property); + private record TestRecordStringIntegerNullable(IDictionary Property); + private record TestRecordEmptyIntEnumInteger(IDictionary Property); + private record TestRecordStringBoolean(IDictionary Property); + private record TestRecordStringBooleanNullable(IDictionary Property); + private record TestRecordDictionary(IDictionary Property); + private record TestRecordStringObject(IDictionary Property); + private record TestRecordStringObjectNullable(IDictionary Property); + private record TestRecordExpandoObject(ExpandoObject Property); + + public static TheoryData GenericArgumentsTypeData => new() + { + { typeof(TestRecordStringInteger), JsonSchemaTypes.Integer }, + { typeof(TestRecordStringIntegerNullable), JsonSchemaTypes.Integer | JsonSchemaType.Null }, + { typeof(TestRecordEmptyIntEnumInteger), JsonSchemaTypes.Integer }, + { typeof(TestRecordStringBoolean), JsonSchemaTypes.Boolean }, + { typeof(TestRecordStringBooleanNullable), JsonSchemaTypes.Boolean | JsonSchemaType.Null }, + { typeof(TestRecordDictionary), null }, + { typeof(TestRecordStringObject), null }, + { typeof(TestRecordStringObjectNullable), JsonSchemaTypes.Null | JsonSchemaTypes.Boolean | JsonSchemaTypes.Integer | JsonSchemaTypes.Number | JsonSchemaTypes.String | JsonSchemaTypes.Array | JsonSchemaTypes.Object }, + { typeof(TestRecordExpandoObject), null }, + }; + + [Theory] + [MemberData(nameof(GenericArgumentsTypeData))] + public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryTypeMember( + Type type, + JsonSchemaType? expectedAdditionalPropertiesType) + { + var memberInfo = type.GetProperties().First(); + var schema = Subject(configureGenerator: g => g.SupportNonNullableReferenceTypes = true).GenerateSchema(memberInfo.PropertyType, new SchemaRepository(), memberInfo: memberInfo); + Assert.Equal(JsonSchemaTypes.Object, schema.Type); + Assert.True(schema.AdditionalPropertiesAllowed); + Assert.NotNull(schema.AdditionalProperties); + Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type); + } #nullable restore [Fact] From 3e484a642d92c374507a935cbf0c267ad5285a6f Mon Sep 17 00:00:00 2001 From: ldeluigi <44567586+ldeluigi@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:05:32 +0200 Subject: [PATCH 2/3] Rename memberdata --- .../SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index 7cdb476748..534b98a843 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -157,7 +157,7 @@ private record TestRecordStringObject(IDictionary Property); private record TestRecordStringObjectNullable(IDictionary Property); private record TestRecordExpandoObject(ExpandoObject Property); - public static TheoryData GenericArgumentsTypeData => new() + public static TheoryData DictionaryWrappersData => new() { { typeof(TestRecordStringInteger), JsonSchemaTypes.Integer }, { typeof(TestRecordStringIntegerNullable), JsonSchemaTypes.Integer | JsonSchemaType.Null }, @@ -171,7 +171,7 @@ private record TestRecordExpandoObject(ExpandoObject Property); }; [Theory] - [MemberData(nameof(GenericArgumentsTypeData))] + [MemberData(nameof(DictionaryWrappersData))] public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryTypeMember( Type type, JsonSchemaType? expectedAdditionalPropertiesType) From 6d0ed7381d60340aaea03744a68ac1736bf0ed4e Mon Sep 17 00:00:00 2001 From: ldeluigi <44567586+ldeluigi@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:51:50 +0200 Subject: [PATCH 3/3] Fix missing covera --- .../JsonSerializerSchemaGeneratorTests.cs | 148 +++++++++++------- .../JsonSerializerSchemaGeneratorTestTypes.cs | 65 ++++++++ .../Fixtures/PolymorphicIntermediateTypes.cs | 16 ++ 3 files changed, 171 insertions(+), 58 deletions(-) create mode 100644 test/Swashbuckle.AspNetCore.TestSupport/Fixtures/JsonSerializerSchemaGeneratorTestTypes.cs create mode 100644 test/Swashbuckle.AspNetCore.TestSupport/Fixtures/PolymorphicIntermediateTypes.cs diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index 534b98a843..f0e1ef9449 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -147,42 +147,108 @@ public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryType( Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type); } - private record TestRecordStringInteger(IDictionary Property); - private record TestRecordStringIntegerNullable(IDictionary Property); - private record TestRecordEmptyIntEnumInteger(IDictionary Property); - private record TestRecordStringBoolean(IDictionary Property); - private record TestRecordStringBooleanNullable(IDictionary Property); - private record TestRecordDictionary(IDictionary Property); - private record TestRecordStringObject(IDictionary Property); - private record TestRecordStringObjectNullable(IDictionary Property); - private record TestRecordExpandoObject(ExpandoObject Property); - - public static TheoryData DictionaryWrappersData => new() - { - { typeof(TestRecordStringInteger), JsonSchemaTypes.Integer }, - { typeof(TestRecordStringIntegerNullable), JsonSchemaTypes.Integer | JsonSchemaType.Null }, - { typeof(TestRecordEmptyIntEnumInteger), JsonSchemaTypes.Integer }, - { typeof(TestRecordStringBoolean), JsonSchemaTypes.Boolean }, - { typeof(TestRecordStringBooleanNullable), JsonSchemaTypes.Boolean | JsonSchemaType.Null }, - { typeof(TestRecordDictionary), null }, - { typeof(TestRecordStringObject), null }, - { typeof(TestRecordStringObjectNullable), JsonSchemaTypes.Null | JsonSchemaTypes.Boolean | JsonSchemaTypes.Integer | JsonSchemaTypes.Number | JsonSchemaTypes.String | JsonSchemaTypes.Array | JsonSchemaTypes.Object }, - { typeof(TestRecordExpandoObject), null }, + public static TheoryData DictionaryWrappersData => new() + { + { typeof(TestRecordStringInteger), JsonSchemaTypes.Integer, false }, + { typeof(TestRecordStringIntegerNullable), JsonSchemaTypes.Integer | JsonSchemaType.Null, false }, + { typeof(TestRecordEmptyIntEnumInteger), JsonSchemaTypes.Integer, false }, + { typeof(TestRecordStringBoolean), JsonSchemaTypes.Boolean, false }, + { typeof(TestRecordStringBooleanNullable), JsonSchemaTypes.Boolean | JsonSchemaType.Null, false }, + { typeof(TestRecordDictionary), null, false }, + { typeof(TestRecordStringObject), null, false }, + { typeof(TestRecordStringObjectNullable), JsonSchemaTypes.Null | JsonSchemaTypes.Boolean | JsonSchemaTypes.Integer | JsonSchemaTypes.Number | JsonSchemaTypes.String | JsonSchemaTypes.Array | JsonSchemaTypes.Object, false }, + { typeof(TestRecordExpandoObject), null, false }, + { typeof(TestRecordStringComplexType), null, true }, + { typeof(TestRecordStringComplexTypeNullable), null, true }, }; [Theory] [MemberData(nameof(DictionaryWrappersData))] public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryTypeMember( Type type, - JsonSchemaType? expectedAdditionalPropertiesType) + JsonSchemaType? expectedAdditionalPropertiesType, + bool additionalPropertiesContainsReference) { var memberInfo = type.GetProperties().First(); - var schema = Subject(configureGenerator: g => g.SupportNonNullableReferenceTypes = true).GenerateSchema(memberInfo.PropertyType, new SchemaRepository(), memberInfo: memberInfo); + var schemaRepository = new SchemaRepository(); + var schema = Subject(configureGenerator: g => g.SupportNonNullableReferenceTypes = true) + .GenerateSchema(memberInfo.PropertyType, schemaRepository, memberInfo: memberInfo); + Assert.Equal(JsonSchemaTypes.Object, schema.Type); Assert.True(schema.AdditionalPropertiesAllowed); Assert.NotNull(schema.AdditionalProperties); + + if (additionalPropertiesContainsReference) + { + var additionalPropertiesReference = Assert.IsType(schema.AdditionalProperties); + Assert.Contains(additionalPropertiesReference.Reference.Id, schemaRepository.Schemas.Keys); + return; + } + Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type); } + + [Fact] + public void GenerateSchema_SetsNullableFlag_IfMemberSchemaHasAnyOf() + { + var subject = Subject(configureGenerator: c => + { + c.SupportNonNullableReferenceTypes = true; + c.CustomTypeMappings.Add(typeof(CustomAnyOfType), () => new OpenApiSchema + { + AnyOf = + [ + new OpenApiSchema { Type = JsonSchemaTypes.String } + ] + }); + }); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Assert.IsType(subject.GenerateSchema(typeof(TypeWithNullableCustomAnyOfProperty), schemaRepository)); + Assert.NotNull(referenceSchema.Reference); + var schemaReference = referenceSchema.Reference!; + Assert.NotNull(schemaReference.Id); + var schemaId = schemaReference.Id!; + var generatedSchema = schemaRepository.Schemas[schemaId]; + var properties = Assert.IsType>(generatedSchema.Properties); + var propertySchema = properties[nameof(TypeWithNullableCustomAnyOfProperty.Property)]; + + Assert.NotNull(propertySchema.AnyOf); + Assert.Equal(2, propertySchema.AnyOf.Count); + Assert.Contains(propertySchema.AnyOf, s => s.Type == JsonSchemaType.Null); + Assert.Null(propertySchema.Type); + } + + [Fact] + public void GenerateSchema_SetsNullableFlag_IfMemberSchemaHasOneOf() + { + var subject = Subject(configureGenerator: c => + { + c.SupportNonNullableReferenceTypes = true; + c.CustomTypeMappings.Add(typeof(CustomOneOfType), () => new OpenApiSchema + { + OneOf = + [ + new OpenApiSchema { Type = JsonSchemaTypes.String } + ] + }); + }); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = Assert.IsType(subject.GenerateSchema(typeof(TypeWithNullableCustomOneOfProperty), schemaRepository)); + Assert.NotNull(referenceSchema.Reference); + var schemaReference = referenceSchema.Reference!; + Assert.NotNull(schemaReference.Id); + var schemaId = schemaReference.Id!; + var generatedSchema = schemaRepository.Schemas[schemaId]; + var properties = Assert.IsType>(generatedSchema.Properties); + var propertySchema = properties[nameof(TypeWithNullableCustomOneOfProperty.Property)]; + + Assert.NotNull(propertySchema.OneOf); + Assert.Equal(2, propertySchema.OneOf.Count); + Assert.Contains(propertySchema.OneOf, s => s.Type == JsonSchemaType.Null); + Assert.Null(propertySchema.Type); + } #nullable restore [Fact] @@ -456,18 +522,6 @@ public void GenerateSchema_SetsReadOnlyAndWriteOnlyFlags_IfPropertyIsRestricted( Assert.True(schema.Properties["WriteOnlyProperty"].WriteOnly); } - public class TypeWithRequiredProperties - { - public required string RequiredString { get; set; } - public required int RequiredInt { get; set; } - } - - public class TypeWithRequiredPropertyAndValidationAttribute - { - [MinLength(1)] - public required string RequiredProperty { get; set; } - } - [Fact] public void GenerateSchema_SetsRequired_IfPropertyHasRequiredKeyword() { @@ -497,13 +551,6 @@ public void GenerateSchema_SetsRequired_IfPropertyHasRequiredKeywordAndValidatio Assert.Equal(["RequiredProperty"], schema.Required); } -#nullable enable - public class TypeWithNullableReferenceTypes - { - public required string? RequiredNullableString { get; set; } - public required string RequiredNonNullableString { get; set; } - } - [Fact] public void GenerateSchema_SetsRequiredAndNullable_IfPropertyHasRequiredKeywordAndIsNullable() { @@ -525,7 +572,7 @@ public void GenerateSchema_SetsRequiredAndNullable_IfPropertyHasRequiredKeywordA AssertIsNullable(schema.Properties["RequiredNonNullableString"], false); Assert.Contains("RequiredNonNullableString", schema.Required.ToArray()); } -#nullable disable + [Theory] [InlineData(typeof(TypeWithParameterizedConstructor), nameof(TypeWithParameterizedConstructor.Id), false)] @@ -814,21 +861,6 @@ public void GenerateSchema_PreservesIntermediateBaseProperties_WhenUsingOneOfPol Assert.False(cSchema.Properties.ContainsKey(nameof(ModelOfA.PropertyOfA))); } - public abstract class ModelOfA - { - public string PropertyOfA { get; set; } - } - - public class ModelOfB : ModelOfA - { - public string PropertyOfB { get; set; } - } - - public class ModelOfC : ModelOfB - { - public string PropertyOfC { get; set; } - } - [Fact] public void GenerateSchema_SupportsOption_UseAllOfToExtendReferenceSchemas() { diff --git a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/JsonSerializerSchemaGeneratorTestTypes.cs b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/JsonSerializerSchemaGeneratorTestTypes.cs new file mode 100644 index 0000000000..ac195bfe6f --- /dev/null +++ b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/JsonSerializerSchemaGeneratorTestTypes.cs @@ -0,0 +1,65 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Dynamic; + +namespace Swashbuckle.AspNetCore.TestSupport; + +#nullable enable + +public record TestRecordStringInteger(IDictionary Property); + +public record TestRecordStringIntegerNullable(IDictionary Property); + +public record TestRecordEmptyIntEnumInteger(IDictionary Property); + +public record TestRecordStringBoolean(IDictionary Property); + +public record TestRecordStringBooleanNullable(IDictionary Property); + +public record TestRecordDictionary(IDictionary Property); + +public record TestRecordStringObject(IDictionary Property); + +public record TestRecordStringObjectNullable(IDictionary Property); + +public record TestRecordExpandoObject(ExpandoObject Property); + +public record TestRecordStringComplexType(IDictionary Property); + +public record TestRecordStringComplexTypeNullable(IDictionary Property); + +public class CustomAnyOfType; + +public class CustomOneOfType; + +public class TypeWithNullableCustomAnyOfProperty +{ + public CustomAnyOfType? Property { get; set; } +} + +public class TypeWithNullableCustomOneOfProperty +{ + public CustomOneOfType? Property { get; set; } +} + +public class TypeWithRequiredProperties +{ + public required string RequiredString { get; set; } + + public required int RequiredInt { get; set; } +} + +public class TypeWithRequiredPropertyAndValidationAttribute +{ + [MinLength(1)] + public required string RequiredProperty { get; set; } +} + +public class TypeWithNullableReferenceTypes +{ + public required string? RequiredNullableString { get; set; } + + public required string RequiredNonNullableString { get; set; } +} + +#nullable restore diff --git a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/PolymorphicIntermediateTypes.cs b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/PolymorphicIntermediateTypes.cs new file mode 100644 index 0000000000..4c108c6e71 --- /dev/null +++ b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/PolymorphicIntermediateTypes.cs @@ -0,0 +1,16 @@ +namespace Swashbuckle.AspNetCore.TestSupport; + +public abstract class ModelOfA +{ + public string PropertyOfA { get; set; } +} + +public class ModelOfB : ModelOfA +{ + public string PropertyOfB { get; set; } +} + +public class ModelOfC : ModelOfB +{ + public string PropertyOfC { get; set; } +}