Fixing Nullable Reference Types in AspNetCore

Fixing Nullable Reference Types in AspNetCore

With the introduction of nullable reference types in C# 8.0, developers were promised a safer and more reliable way to handle null values.

In real world API development, nullable reference types often fall short, leaving developers with a false sense of security.

By the end of this article, you'll understand how nullable reference types can lead to unexpected null pointer exceptions and how to fix it. If you just want the github repo.

This article is talking mostly about an AspNetCore WebApi that serves requests from a client (most often a SPA frontend) and uses EntityFramework Core to store data.

The Code

One single endpoint which takes in a name and greets you in all caps.

app.MapPost("/greeting", (GreetingRequest request) =>
    {
        var loud = $"HELLO {request.Name.ToUpper()}";
        return new GreetingResponse { Greeting = loud };
    }).WithOpenApi();
 
public record GreetingRequest
{
    public string Name { get; set; }
}
 
public record GreetingResponse
{
    public string Greeting { get; set; }
}

The Three Problems

Nullable references are enabled.
My GreetingRequest.Name is a string not a string?, so I'm fine calling ToUpper()
I have no hints, no warnings, no squiggly lines.

In fact if I do this $"HELLO {request.Name?.ToUpper()}" I'm getting a little hint that the question mark operator here is unnecessary, because I'm safe.

1) Deserialization of http requests into GreetingRequest

If you post {} or { name: null } AspNetCore will just accept your request and crash as soon as it hits ToUpper().

2) The generated openAPI schema.

It's just plain wrong. It will mark GreetingRequest.name as optional and nullable.

If you try to generate clients from this schema, they will reflect that and all the properties will be optional and nullable.

3) The EF Core entity class when loaded from the DbContext

This depends on whether NullableReferenceTypes are enabled in your EF Core project or not.
If it's disabled, the Person.Name property will map to a nullable field in the database.

If you call var person = await dbContext.Persons.FirstAsync() and the name is null in the database field,
it will be null in your Person instance too.

The Solutions

1) Deserialization of http requests into GreetingRequest

The first step to fixing this is the required keyword. Let's adjust our GreetingsRequest to use it.

public record GreetingRequest
{
    public required string Name { get; set; }
}

This will prevent any clients from posting {}. You can not omit required properties.
The standard AspNetCore request validation will catch this and respond with a 400 BadRequest.

Sadly, this does not prevent direct null posts. { name: null } gets past the request validation just fine.

I have no idea why. To me this looks like a bug.
I have not found any attribute or setting in System.Text.JsonSerialization to fix this.

So I wrote my own middleware to validate that properties marked with required are not null and respond with a 400 BadRequest if so.

public class EnsureRequiredProps : IEndpointFilter
{
  public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
  {
    var request = context.Arguments.FirstOrDefault();
    if (request is null)
      return await next(context);
 
    // Find all properties on the current type with the 'required' keyword
    // Find all values on the current request that are null
    // Transform into a Dictionary for ValidationProblem
 
    var nullFailures = request.GetType().GetProperties()
      .Where(x => x.GetCustomAttribute<RequiredMemberAttribute>() is not null)
      .Where(x => x.GetValue(request) is null)
      .ToDictionary(x => x.Name, x => new[] { $"{x.Name} is required. It can't be deserialized to null." });
 
    if (nullFailures.Any())
        return TypedResults.ValidationProblem(nullFailures);
 
    return await next(context);
  }
}
 
// Add it to the endpoint like this.
app.MapPost("/greeting", (GreetingRequest request) =>
    {
        var loud = $"HELLO {request.Name?.ToUpper()}";
        return new GreetingResponse { Greeting = loud };
    })
    .AddEndpointFilter<EnsureRequiredProps>()
    .WithOpenApi();

Great! That fixes it!
Now I can actually trust the static analysis. Now I'm actually safe.

Any request type that makes it into my endpoint can now be trusted.

2) The generated OpenAPI Schema

The generated schema for our GreetingRequest looks like this.

"GreetingRequest": {
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "nullable": true
    }
  },
  "additionalProperties": false
}

I'm not an expert on the openAPI specification,
but from what I understand this schema says name is optional and nullable.

A correct, meaning name is required and not nullable, looks like this.

"GreetingRequest": {
  "required": [
    "name"
  ],
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
    }
  },
  "additionalProperties": false
},

Notice the new required array and the missing "nullable": true.

Again, I don't really understand why. This looks like another bug to me.
It feels like the openAPI schema generator for AspNetCore should respect AspNetCore features such as nullable reference types and especially the required keyword.

This can be fixed by adjusting the schema generation.

public class RequiredSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        // Find all properties on the current type with the 'required' keyword
        var requiredClrProps = context.Type.GetProperties()
            .Where(x => x.GetCustomAttribute<RequiredMemberAttribute>() is not null)
            .ToList();
 
        // Find all the matching properties in the openAPI schema (adjust for case differences)
        var requiredJsonProps = schema.Properties
            .Where(j => requiredClrProps.Any(p => p.Name == j.Key || p.Name == ToPascalCase(j.Key)))
            .ToList();
 
        // Set properties as required
        schema.Required = requiredJsonProps.Select(x => x.Key).ToHashSet();
 
        // Optionally set them non nullable too.
        foreach (var requiredJsonProp in requiredJsonProps)
            requiredJsonProp.Value.Nullable = false;
    }
 
    private string ToPascalCase(string str)
    {
        return char.ToUpper(str[0]) + str.Substring(1);
    }
 
    // Configure in Program.cs like this
    builder.Services.AddSwaggerGen(x => x.SchemaFilter<RequiredSchemaFilter>());
}

Great! Another one fixed.

As you can see, I'm just checking for the required keyword and then adjust the schema however I please.
You could just check for nullability instead and skip the required keyword altogether.

I like the required keyword, because it forces you on the API side to adhere to your own contract.
You can never forget to set it. You can't set it to null.

Now the generated schema is correct.
Generated clients will make sure to mark properties as required and not nullable.

3) Loading EF Core Entity Classes

This one is less egregious and definitely not a bug.
But I think it makes sense to mention it, while talking about lying nullable reference analysis.

If you have your ef core entities in a project with nullable references disabled,
string will be nullable on the database level by default.

public class Person {
    public int Id { get; set; }
    public string Name { get; set; }
}

These are easy fixes. Turn on nullable reference types or simply mark the property as required explicitly.

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Person>()
            .Property(x => x.Name)
            .IsRequired();
    }

Now you wont ever be able to write null into your database, so you will never be able to get null out either.
Initialize your strings for good measure too.

public class Person {
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

Conclusion

This makes nullable reference types actually real to me.
Now I can actually trust the types that are coming into my API from either the client or ef core.

The type of applications I'm working on are mostly
SPA frontend => AspNetCore Api => PostgresDB via EF Core

Without these fixes taking the static analysis serious, made no sense to me.

90%+ of the code I write is either getting data from a request or from ef core
and neither of them respected nullable reference types.

You can check out the working code in this github repo.