diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/MemberInfoExtensions.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/MemberInfoExtensions.cs index e3f104bfa5..34f9a51e28 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/MemberInfoExtensions.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/MemberInfoExtensions.cs @@ -12,6 +12,7 @@ public static class MemberInfoExtensions private const string NullableFlagsFieldName = "NullableFlags"; private const string NullableContextAttributeFullTypeName = "System.Runtime.CompilerServices.NullableContextAttribute"; private const string FlagFieldName = "Flag"; + private const int NotAnnotated = 1; // See https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/docs/features/nullable-metadata.md?plain=1#L40 public static IEnumerable GetInlineAndMetadataAttributes(this MemberInfo memberInfo) { @@ -50,7 +51,7 @@ public static bool IsNonNullableReferenceType(this MemberInfo memberInfo) if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field && field.GetValue(nullableAttribute) is byte[] flags && - flags.Length >= 1 && flags[0] == 1) + flags.Length >= 1 && flags[0] == NotAnnotated) { return true; } @@ -67,17 +68,39 @@ public static bool IsDictionaryValueNonNullable(this MemberInfo memberInfo) if (memberType.IsValueType) return false; var nullableAttribute = memberInfo.GetNullableAttribute(); + var genericArguments = memberType.GetGenericArguments(); + + if (genericArguments.Length != 2) + { + return false; + } + + var valueArgument = genericArguments[1]; + var valueArgumentIsNullable = valueArgument.IsGenericType && valueArgument.GetGenericTypeDefinition() == typeof(Nullable<>); if (nullableAttribute == null) { - return memberInfo.GetNullableFallbackValue(); + return !valueArgumentIsNullable && memberInfo.GetNullableFallbackValue(); } - if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field && - field.GetValue(nullableAttribute) is byte[] flags && - flags.Length == 3 && flags[2] == 1) + if (nullableAttribute.GetType().GetField(NullableFlagsFieldName) is FieldInfo field) { - return true; + if (field.GetValue(nullableAttribute) is byte[] flags) + { + // See https://github.com/dotnet/roslyn/blob/af7b0ebe2b0ed5c335a928626c25620566372dd1/docs/features/nullable-metadata.md + // Observations in the debugger show that the arity of the flags array is 3 only if all 3 items are reference types, i.e. + // Dictionary would have arity 3 (one for the Dictionary, one for the string key, one for the object value), + // however Dictionary would have arity 2 (one for the Dictionary, one for the string key), the value is skipped + // due it being a value type. + if (flags.Length == 2) // Value in the dictionary is a value type. + { + return !valueArgumentIsNullable; + } + else if (flags.Length == 3) // Value in the dictionary is a reference type. + { + return flags[2] == NotAnnotated; + } + } } return false; @@ -108,7 +131,7 @@ private static bool GetNullableFallbackValue(this MemberInfo memberInfo) if (nullableContext != null) { if (nullableContext.GetType().GetField(FlagFieldName) is FieldInfo field && - field.GetValue(nullableContext) is byte flag && flag == 1) + field.GetValue(nullableContext) is byte flag && flag == NotAnnotated) { return true; } diff --git a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs index 89df6d7cc1..187ec8757e 100644 --- a/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs +++ b/src/Swashbuckle.AspNetCore.SwaggerGen/SchemaGenerator/SchemaGenerator.cs @@ -99,10 +99,26 @@ private OpenApiSchema GenerateSchemaForMember( } // NullableAttribute behaves differently for Dictionaries - if (schema.AdditionalPropertiesAllowed && modelType.IsGenericType && - modelType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + if (schema.AdditionalPropertiesAllowed && modelType.IsGenericType) { - schema.AdditionalProperties.Nullable = !memberInfo.IsDictionaryValueNonNullable(); + var genericTypes = modelType + .GetInterfaces() +#if NETSTANDARD2_0 + .Concat(new[] { modelType }) +#else + .Append(modelType) +#endif + .Where(t => t.IsGenericType) + .ToArray(); + + var isDictionaryType = + genericTypes.Any(t => t.GetGenericTypeDefinition() == typeof(IDictionary<,>)) || + genericTypes.Any(t => t.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)); + + if (isDictionaryType) + { + schema.AdditionalProperties.Nullable = !memberInfo.IsDictionaryValueNonNullable(); + } } schema.ApplyValidationAttributes(customAttributes); diff --git a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs index 3b547b38e8..1aaaa43d31 100644 --- a/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs +++ b/test/Swashbuckle.AspNetCore.SwaggerGen.Test/SchemaGenerator/JsonSerializerSchemaGeneratorTests.cs @@ -696,11 +696,131 @@ public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes( } [Theory] - [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithNonNullableContent), true, false)] - [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithNonNullableContent), false, false)] - [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithNullableContent), false, true)] - [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithNullableContent), true, true)] - public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations( + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_Dictionary( + Type declaringType, + string propertyName, + bool expectedNullableProperty, + bool expectedNullableContent) + { + var subject = Subject( + configureGenerator: c => c.SupportNonNullableReferenceTypes = true + ); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository); + + var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName]; + var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties; + Assert.Equal(expectedNullableProperty, propertySchema.Nullable); + Assert.Equal(expectedNullableContent, contentSchema.Nullable); + } + + [Theory] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IDictionary( + Type declaringType, + string propertyName, + bool expectedNullableProperty, + bool expectedNullableContent) + { + var subject = Subject( + configureGenerator: c => c.SupportNonNullableReferenceTypes = true + ); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository); + + var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName]; + var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties; + Assert.Equal(expectedNullableProperty, propertySchema.Nullable); + Assert.Equal(expectedNullableContent, contentSchema.Nullable); + } + + [Theory] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IReadOnlyDictionary( + Type declaringType, + string propertyName, + bool expectedNullableProperty, + bool expectedNullableContent) + { + var subject = Subject( + configureGenerator: c => c.SupportNonNullableReferenceTypes = true + ); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository); + + var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName]; + var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties; + Assert.Equal(expectedNullableProperty, propertySchema.Nullable); + Assert.Equal(expectedNullableContent, contentSchema.Nullable); + } + + [Theory] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithValueTypeInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithValueTypeInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableDictionaryWithValueTypeInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableDictionaryWithValueTypeInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_DictionaryWithValueType( + Type declaringType, + string propertyName, + bool expectedNullableProperty, + bool expectedNullableContent) + { + var subject = Subject( + configureGenerator: c => c.SupportNonNullableReferenceTypes = true + ); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository); + + var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName]; + var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties; + Assert.Equal(expectedNullableProperty, propertySchema.Nullable); + Assert.Equal(expectedNullableContent, contentSchema.Nullable); + } + + [Theory] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryWithValueTypeInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryWithValueTypeInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIDictionaryWithValueTypeInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIDictionaryWithValueTypeInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IDictionaryWithValueType( + Type declaringType, + string propertyName, + bool expectedNullableProperty, + bool expectedNullableContent) + { + var subject = Subject( + configureGenerator: c => c.SupportNonNullableReferenceTypes = true + ); + var schemaRepository = new SchemaRepository(); + + var referenceSchema = subject.GenerateSchema(declaringType, schemaRepository); + + var propertySchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName]; + var contentSchema = schemaRepository.Schemas[referenceSchema.Reference.Id].Properties[propertyName].AdditionalProperties; + Assert.Equal(expectedNullableProperty, propertySchema.Nullable); + Assert.Equal(expectedNullableContent, contentSchema.Nullable); + } + + [Theory] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryWithValueTypeInNonNullableContent), true, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryWithValueTypeInNonNullableContent), false, false)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NonNullableIReadOnlyDictionaryWithValueTypeInNullableContent), false, true)] + [InlineData(typeof(TypeWithNullableContext), nameof(TypeWithNullableContext.NullableIReadOnlyDictionaryWithValueTypeInNullableContent), true, true)] + public void GenerateSchema_SupportsOption_SupportNonNullableReferenceTypes_NullableAttribute_Compiler_Optimizations_Situations_IReadOnlyDictionaryWithValueType( Type declaringType, string propertyName, bool expectedNullableProperty, diff --git a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithNullableContext.cs b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithNullableContext.cs index 422be7b367..481b950dc5 100644 --- a/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithNullableContext.cs +++ b/test/Swashbuckle.AspNetCore.TestSupport/Fixtures/TypeWithNullableContext.cs @@ -15,10 +15,35 @@ public class TypeWithNullableContext public List? NullableList { get; set; } public List NonNullableList { get; set; } = default!; - public Dictionary? NullableDictionaryWithNonNullableContent { get; set; } - public Dictionary NonNullableDictionaryWithNonNullableContent { get; set; } = default!; - public Dictionary NonNullableDictionaryWithNullableContent { get; set; } = default!; - public Dictionary? NullableDictionaryWithNullableContent { get; set; } + public Dictionary? NullableDictionaryInNonNullableContent { get; set; } + public Dictionary NonNullableDictionaryInNonNullableContent { get; set; } = default!; + public Dictionary NonNullableDictionaryInNullableContent { get; set; } = default!; + public Dictionary? NullableDictionaryInNullableContent { get; set; } + + public IDictionary? NullableIDictionaryInNonNullableContent { get; set; } + public IDictionary NonNullableIDictionaryInNonNullableContent { get; set; } = default!; + public IDictionary NonNullableIDictionaryInNullableContent { get; set; } = default!; + public IDictionary? NullableIDictionaryInNullableContent { get; set; } + + public IReadOnlyDictionary? NullableIReadOnlyDictionaryInNonNullableContent { get; set; } + public IReadOnlyDictionary NonNullableIReadOnlyDictionaryInNonNullableContent { get; set; } = default!; + public IReadOnlyDictionary NonNullableIReadOnlyDictionaryInNullableContent { get; set; } = default!; + public IReadOnlyDictionary? NullableIReadOnlyDictionaryInNullableContent { get; set; } + + public Dictionary? NullableDictionaryWithValueTypeInNonNullableContent { get; set; } + public Dictionary NonNullableDictionaryWithValueTypeInNonNullableContent { get; set; } = default!; + public Dictionary NonNullableDictionaryWithValueTypeInNullableContent { get; set; } = default!; + public Dictionary? NullableDictionaryWithValueTypeInNullableContent { get; set; } + + public IDictionary? NullableIDictionaryWithValueTypeInNonNullableContent { get; set; } + public IDictionary NonNullableIDictionaryWithValueTypeInNonNullableContent { get; set; } = default!; + public IDictionary NonNullableIDictionaryWithValueTypeInNullableContent { get; set; } = default!; + public IDictionary? NullableIDictionaryWithValueTypeInNullableContent { get; set; } + + public IReadOnlyDictionary? NullableIReadOnlyDictionaryWithValueTypeInNonNullableContent { get; set; } + public IReadOnlyDictionary NonNullableIReadOnlyDictionaryWithValueTypeInNonNullableContent { get; set; } = default!; + public IReadOnlyDictionary NonNullableIReadOnlyDictionaryWithValueTypeInNullableContent { get; set; } = default!; + public IReadOnlyDictionary? NullableIReadOnlyDictionaryWithValueTypeInNullableContent { get; set; } public class SubTypeWithOneNullableContent { @@ -33,4 +58,4 @@ public class SubTypeWithOneNonNullableContent } #nullable restore -} \ No newline at end of file +}