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..f0e1ef9449 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,109 @@ public void GenerateSchema_GeneratesDictionarySchema_IfDictionaryType( Assert.NotNull(schema.AdditionalProperties); Assert.Equal(expectedAdditionalPropertiesType, schema.AdditionalProperties.Type); } + + 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, + bool additionalPropertiesContainsReference) + { + var memberInfo = type.GetProperties().First(); + 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] @@ -414,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() { @@ -455,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() { @@ -483,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)] @@ -772,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; } +}