Swashbuckle.aspnetcore: [5.0.0] Respect Nullable context

Created on 16 Jan 2020  路  3Comments  路  Source: domaindrivendev/Swashbuckle.AspNetCore

When C# 8 nullable context is enabled and ASP.NET Core 3 MvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes == false (the default) the generated API spec does not match C# semantics (e.g. string model field is marked as nullable whci is not true with C# nullable context enabled) and ASP.NET model validation behavior.

<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
  <TargetFramework>netcoreapp3.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

```csharp
services
.AddControllers(options =>
{
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = false;
});

```csharp
namespace TestApp
{
    [ApiController]
    public class HomeController : ControllerBase
    {
        [HttpPost]
        [Route("welcome")]
        [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(TestModel))]
        public async Task<ActionResult<TestModel>> WelcomeJsonModel(TestModel model)
        {
            return model;
        }
    }

    public class TestModel
    {
        public string TestString { get; set; } = null!;

        [Required]
        public int TestInt {  get; set; }

        public AnotherTestModel? NullableSubmodel { get; set; }
    }

    public class AnotherTestModel
    {
        public int TestInt2 { get; set; }
    }
}

image

image

p2

Most helpful comment

I use this with new options.SupportNonNullableReferenceTypes(); option

    internal class NonNullableAsRequiredSchemaFilter : ISchemaFilter
    {
        private readonly MvcOptions mvcOptions;
        private readonly ISerializerDataContractResolver resolver;

        public NonNullableAsRequiredSchemaFilter(IOptions<MvcOptions> mvcOptions, ISerializerDataContractResolver resolver)
        {
            this.mvcOptions = mvcOptions.Value;
            this.resolver = resolver;
        }

        public void Apply(OpenApiSchema schema, SchemaFilterContext context)
        {
            if (mvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes)
            {
                return;
            }

            if (context.MemberInfo != null || context.ParameterInfo != null)
            {
                return;
            }

            var objectProperties = resolver.GetDataContractForType(context.Type).ObjectProperties;
            foreach (var property in objectProperties)
            {
                var attributes = CollectContextAttributes(property.MemberInfo.DeclaringType)
                    .Concat(property.MemberInfo.CustomAttributes)
                    .ToList();
                var nullable = TryGetNullable(property.MemberType, attributes);
                if (nullable == false)
                {
                    schema.Required.Add(property.Name);
                }
            }
        }

        private bool? TryGetNullable(Type type, IEnumerable<CustomAttributeData> attributes)
        {
            var valueNullable =
                type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
            if (valueNullable)
            {
                return true;
            }

            if (type.IsValueType)
            {
                return false;
            }

            var nullableFlag = GetNullableFlagByAttribute(attributes);
            if (nullableFlag != 0)
            {
                return nullableFlag == 2;
            }

            // Don't touch if the compiler doesn't generate NullableAttribute.
            return null;
        }

        private IEnumerable<CustomAttributeData> CollectContextAttributes(Type? declaringType)
        {
            if (declaringType == null)
            {
                return Enumerable.Empty<CustomAttributeData>();
            }

            var attributes = declaringType
                .CustomAttributes
                .Where(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute");
            return attributes.Concat(CollectContextAttributes(declaringType.DeclaringType));
        }

        /// <remarks>
        /// https://github.com/dotnet/roslyn/blob/7bc44488c661fd6bbb6c53f39512a6fe0cc5ef84/docs/features/nullable-metadata.md
        /// </remarks>
        private static int GetNullableFlagByAttribute(List<CustomAttributeData> attributes)
        {
            for (var i = attributes.Count - 1; i >= 0; i--)
            {
                var attribute = attributes[i];
                var fullName = attribute.AttributeType.FullName;
                if (fullName == null)
                {
                    continue;
                }

                if (fullName.Contains(
                    "System.Runtime.CompilerServices.NullableAttribute",
                    StringComparison.InvariantCulture))
                {
                    var arg = attribute.ConstructorArguments[0];
                    if (arg.ArgumentType == typeof(byte))
                    {
                        return (byte)arg.Value!;
                    }
                    else
                    {
                        return ((byte[])arg.Value!)[0];
                    }
                }

                if (fullName.Contains(
                    "System.Runtime.CompilerServices.NullableContextAttribute",
                    StringComparison.InvariantCulture))
                {
                    var nullableFlag = (byte)attribute.ConstructorArguments[0].Value!;

                    return nullableFlag;
                }
            }

            return 0;
        }
    }

edit: Now it is works with objects. Thank you @gaboe to point it out.

All 3 comments

Not sure it is about required. It just does not detect that these types are not nullable. I tried to do something like
public class RequiredSchemaFilter: ISchemaFilter { public void Apply(OpenApiSchema schema, SchemaFilterContext context) { schema.Required ??= new HashSet<string>(); schema.Required.AddRange(schema.Properties.Where(x => !x.Value.Nullable).Select(x => x.Key));
But it detects not nullable types as nullable

I use this with new options.SupportNonNullableReferenceTypes(); option

    internal class NonNullableAsRequiredSchemaFilter : ISchemaFilter
    {
        private readonly MvcOptions mvcOptions;
        private readonly ISerializerDataContractResolver resolver;

        public NonNullableAsRequiredSchemaFilter(IOptions<MvcOptions> mvcOptions, ISerializerDataContractResolver resolver)
        {
            this.mvcOptions = mvcOptions.Value;
            this.resolver = resolver;
        }

        public void Apply(OpenApiSchema schema, SchemaFilterContext context)
        {
            if (mvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes)
            {
                return;
            }

            if (context.MemberInfo != null || context.ParameterInfo != null)
            {
                return;
            }

            var objectProperties = resolver.GetDataContractForType(context.Type).ObjectProperties;
            foreach (var property in objectProperties)
            {
                var attributes = CollectContextAttributes(property.MemberInfo.DeclaringType)
                    .Concat(property.MemberInfo.CustomAttributes)
                    .ToList();
                var nullable = TryGetNullable(property.MemberType, attributes);
                if (nullable == false)
                {
                    schema.Required.Add(property.Name);
                }
            }
        }

        private bool? TryGetNullable(Type type, IEnumerable<CustomAttributeData> attributes)
        {
            var valueNullable =
                type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>);
            if (valueNullable)
            {
                return true;
            }

            if (type.IsValueType)
            {
                return false;
            }

            var nullableFlag = GetNullableFlagByAttribute(attributes);
            if (nullableFlag != 0)
            {
                return nullableFlag == 2;
            }

            // Don't touch if the compiler doesn't generate NullableAttribute.
            return null;
        }

        private IEnumerable<CustomAttributeData> CollectContextAttributes(Type? declaringType)
        {
            if (declaringType == null)
            {
                return Enumerable.Empty<CustomAttributeData>();
            }

            var attributes = declaringType
                .CustomAttributes
                .Where(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableContextAttribute");
            return attributes.Concat(CollectContextAttributes(declaringType.DeclaringType));
        }

        /// <remarks>
        /// https://github.com/dotnet/roslyn/blob/7bc44488c661fd6bbb6c53f39512a6fe0cc5ef84/docs/features/nullable-metadata.md
        /// </remarks>
        private static int GetNullableFlagByAttribute(List<CustomAttributeData> attributes)
        {
            for (var i = attributes.Count - 1; i >= 0; i--)
            {
                var attribute = attributes[i];
                var fullName = attribute.AttributeType.FullName;
                if (fullName == null)
                {
                    continue;
                }

                if (fullName.Contains(
                    "System.Runtime.CompilerServices.NullableAttribute",
                    StringComparison.InvariantCulture))
                {
                    var arg = attribute.ConstructorArguments[0];
                    if (arg.ArgumentType == typeof(byte))
                    {
                        return (byte)arg.Value!;
                    }
                    else
                    {
                        return ((byte[])arg.Value!)[0];
                    }
                }

                if (fullName.Contains(
                    "System.Runtime.CompilerServices.NullableContextAttribute",
                    StringComparison.InvariantCulture))
                {
                    var nullableFlag = (byte)attribute.ConstructorArguments[0].Value!;

                    return nullableFlag;
                }
            }

            return 0;
        }
    }

edit: Now it is works with objects. Thank you @gaboe to point it out.

@foriequal0 Thank you for your answer, your code works well for primitive types, but for objects not so well.

For example, code:

public class DocumentDto
{
    public int DocumentID { get; set; }

    public string? ExternalID { get; set; }

    public string Name { get; set; } = string.Empty;

    public NestedClass? NestedProperty { get; set; }

    public string? Extension { get; set; }
}

public class NestedClass
{
    public int PrimaryID { get; set; }
}

will generate this:

image

_nestedProperty_ is required here, but it should not.

@foriequal0 Have you encountered the same problem?

Was this page helpful?
0 / 5 - 0 ratings