Many of the headline features introduced in ASP.NET Core in .NET 7 were about minimal APIs, such as route groups and filters. But just because minimal APIs were a big focus doesn’t mean that MVC controllers were left out in the cold! In this post I discuss some new MVC features that were introduced in .NET 7.

Most of these features apply to MVC controllers, API controllers, and to Razor Pages, but I just refer to them as MVC controllers everywhere in this post.

1. IParseable / TryParse for primitive binding

One of the big differences between minimal APIs and MVC is the model binding. MVC uses a “binding provider” approach, which is essentially unchanged all the way back to ASP.NET days. With this approach you can bind your action method types to form values, headers, the query string, and much more.

By default, MVC binds some simple types such as decimal and DateTime, or any types that have a TypeConverter from string. Anything else is treated as a complex type, which means it either binds to the body of the request, or it binds as “a complex type”. Binding as a complex type means each of the individual properties of the type are bound, instead of the type itself.

That’s all a bit abstract, so let’s look at an example. We’ll start with the standard WeatherForecastController from the default template webapi template:

[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        return Enumerable.Range(1, 6).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

Now, lets imagine that we want to control which days we show forecasts for using a query string. We’ll create a simple class to hold the values:

public class Days
{
    public int From { get; set; }
    public int To { get; set; }
}

and bind it to the request in the action method:

public class WeatherForecastController : ControllerBase
{
    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get(Days days) // ? Extra query
    {
        //                         ?From     ?To
        return Enumerable.Range(days.From, days.To).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

Now, this is where the changes to MVC come in. In .NET 6, If you want to bind to the Days properties (From and To), then you need to either write a TypeConverter for Days, or bind to the From and To properties, something like:

/WeatherForecast?from=1&to=3

In .NET 7, you have another option. You could implement the IParseable<T> interface, and create the Days object from any string you like. For example, lets imagine you want to call the API like this:

/WeatherForecast/1-3

Minimal APIs already support binding to IParseable<T> in .NET 6, and in .NET 7, you can use it with MVC controllers too.

Technically you don’t need to implement IParseable<T>, you just need to implement the TryParse() method.

The following shows a basic IParseable<T> implementation for the Days type. This also makes some simple changes, such as making the type a readonly struct which wasn’t possible with .NET 6. The implementation isn’t particularly important, and it’s ignoring lots of edge cases, the important point is you can implement it:

public readonly struct Days : IParsable<Days>
{
    public int From { get; }
    public int To { get; }

    public Days(int from, int to)
    {
        From = from;
        To = to;
    }

    public static bool TryParse([NotNullWhen(true)] string? value, IFormatProvider? provider, [MaybeNullWhen(false)] out Days result)
    {
        if(value is not null)
        {
            var separator = value.IndexOf('-');
            if(separator > 0 && separator < value.Length - 1)
            {
                var fromSpan = value.AsSpan().Slice(0, separator);
                var toSpan = value.AsSpan(separator + 1);

                if(int.TryParse(fromSpan, NumberStyles.None, provider, out var from)
                && int.TryParse(toSpan, NumberStyles.None, provider, out var to))
                {
                    result = new Days(from, to);
                    return true;
                }
            }
        }

        result = default;
        return false;
    }

    public static Days Parse(string value, IFormatProvider? provider)
    {
        if (!TryParse(value, provider, out var result))
        {
           throw new ArgumentException("Could not parse supplied value.", nameof(value));
        }

        return result;
    }
}

You can then update your API signature to the following:

[HttpGet("{days}", Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get(Days days)
{
    // ...
}

Is implementing IParseable<T> easier than writing a custom model binder? Probably. Is it easier than a custom TypeConverter. Maybe?

The really important point is that you can use the same types in minimal APIs and in controllers. That should make the transition from minimal APIs to controllers (and vice versa) easier.

Nevertheless, if you want to disable this feature, you can always remove the functionality by removing the TryParseModelBinderProvider from the MvcOptions:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;

var builder = WebApplication.CreateBuilder(args);

// Remove the TryParseModelBinderProvider to revert the functionality
builder.Services.Configure<MvcOptions>(options => {
    options.ModelBinderProviders.RemoveType<TryParseModelBinderProvider>();
});

2. MVC Controllers can infer [FromServices] automatically

Another minimal API feature that made its way to MVC is automatic inferring of services registered in DI, instead of requiring an explicit [FromServices] attribute. For example, in minimal APIs you can just inject services directly into your minimal API endpoints:

//   LinkGenerator ? is registered in DI
app.MapGet("/api", (LinkGenerator links) => {
    return Results.Ok();
});

If we think about the MVC equivalent in .NET 6, it would look something like this:

public class SomeController : Controller
{                                //? Required in .NET 6
    public void IActionResult Get([FromServices] LinkGenerator links)
    {
        return Ok();
    }
}

Notice that in .NET 6, the [FromServices] annotation is necessary, otherwise MVC tries to bind the links parameter to the request body.

Well, in .NET 7, you can ditch the [FromServices] attribute:

public class SomeController : Controller
{                               //? No attribute required in .NET 7 
    public void IActionResult Get(LinkGenerator links)
    {
        return Ok();
    }
}

This functionality builds on the IServiceProviderIsService functionality introduced in .NET 6, and should “just work”. I don’t think [FromService] is often used with controllers, but still this potentially removes a little bit of boilerplate and again keeps parity with minimal APIs.

3. Using nullable annotations to infer required

With each .NET release, the support for nullable reference types gets a bit better. For ASP.NET Core in .NET 7, support was added in a couple of places:

  • Whether the Body can be null is inferred from the nullability of the parameter
  • Whether a [FromServices] parameter is required is based on the nullability of the parameter

I’ll describe each of these briefly below.

Inferring body nullability

In .NET 6, when binding to the body of a request (whether explicitly using [FromBody] or when this is inferred), attempting to bind to a request where Content-Length == 0 will fail with A non-empty request body is required.".

You can work around this globally in .NET 6, and allow null for the parameter instead, by configuring the MvcOptions.AllowEmptyInputInBodyModelBinding property:

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<MvcOptions>(options =>
{
    options.AllowEmptyInputInBodyModelBinding = true;
});

Alternatively, if you don’t want to allow null for every action in your app (and you probably don’t) then you can enable it per-action by adding an explicit [FromBody] attribute, and setting EmptyBodyBehavior, for example:

public IActionResult Post([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] MyBody? body)
{
    // body will be null if the request Content-Length == 0
}

That works, but it’s pretty verbose and ugly…

Well, in .NET 7, this all becomes a lot simpler. Instead of adding the attribute, you can rely on nullable annotations instead. A nullable parameter implies EmptyBodyBehavior.Allow, a non-nullable type indicates EmptyBodyBehavior.Disallow:

public class ExampleController : Controller
{
    public IActionResult Post(MyBody? body) //? Nullable
    {
        // body will be null if the request Content-Length == 0
    }
    
    public IActionResult Post(MyBody body) //? Non-nullable
    {
        // Request will fail with a 400 and "A non-empty request body is required."
        // when Content-Length == 0
    }
}

More little tweaks, to again stay more in keeping with minimal APIs.

Inferring service optionality

The next feature is in a similar vein—using nullable annotations to indicate optionality. In this case, it’s related to the [FromService] annotation. In .NET 6, even if the decorated parameter is marked as nullable, attempting to call the API gives you an InvalidOperationException:

InvalidOperationException: No service for type 'SomeService' has been registered

This changes in .NET 7, so that a [FromServices] parameter marked as nullable, as shown below, will be null if it’s not available in DI:

[HttpGet(Name = "GetWeatherForecast")]               // Nullable ?
public IEnumerable<WeatherForecast> Get([FromServices] SomeService? service)
{
    // service is null if not registered in DI
}

You might be wondering what happens if you attempt to bind a nullable service that isn’t registered in DI, using the “service inference” feature I listed in point 2?

The answer is, “probably not what you want”. If the parameter type (SomeService above) isn’t registered in DI, it won’t be treated as a service, it’ll be treated like any other complex types. That means MVC will try to bind to any bindable properties of the service, for example trying to bind to a form or JSON body for POST requests. That probably isn’t what you want!

4. IResult is supported in MVC

MVC has IActionResult and ActionResult<T>; minimal APIs have IResult and IValueHttpResult<TValue>. And the two shouldn’t mix!

Unfortunately, that’s easier said than done. If you’ve been writing a lot of minimal APIs, and then you dip into MVC, you might find yourself accidentally doing something like this:

[HttpGet(Name = "GetWeatherForecast")]
public IResult Get()
{
    return Results.Ok(new { Name = "My name" });
}

Unfortunately, this isn’t technically an error: there’s no compile time warning about using IResult with MVC, and no runtime error. Instead, MVC serializes the IResult object to JSON, which feels like what you want it to do, until you see the output:

{
  "value": {
    "name" : "My name"
  },
  "statusCode" : 200,
  "contentType" : null
}

As you can see, MVC hasn’t just serialized the object we passed in Ok(), it’s serialized the whole Results object. This is reminiscent of some of the terrible APIs that return a 200 status code, but contain statusCode: 400 in the body! ?

In .NET 7, MVC added limited support for serializing IResult objects as you would expect, so you get the following output instead:

{
  "name" : "My name"
}

When you return IResult from an MVC action, you won’t get any of the MVC features like content negotiation and output formatters; you’ll always get JSON. Nevertheless, if you’re only creating a JSON API, this has the advantage that you can potentially share more helpers and components between your minimal APIs and MVC controllers by just returning IResult, instead of needing to have parallel processing for both IActionResult and IResult.

5. Customized Metadata providers give better error names in responses

The final issue relates to how errors are returned from an API when validation fails.

Ok, a caveat to this feature. Either I don’t understand it, or I couldn’t get it to work. There already appear to be issues somewhat related to it on GitHub, but I need to do some more investigation.

For example purposes, lets start with this basic model:

public class DayRange
{
    [Range(1,6)]
    public int From { get; set; }
    [Range(1, 6)]
    public int To { get; set; }
}

We then have an API controller that populates the values from the URL:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet("{from}/{to}", Name = "GetWeatherForecast")]
    public WeatherForecast[] Get([FromRoute]DayRange days)
    {
        // ...
    }
}

If you call this API with invalid values for From or To (for example by calling /WeatherForecast/0/8), then the API controller generates a ProblemDetails object, which includes a description of the errors:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-743daeb0f5ac9bbb0495f06e65467716-c2a6b5102ffce491-00",
  "errors": {
    "To": [
      "The field To must be between 1 and 6."
    ],
    "From": [
      "The field From must be between 1 and 6."
    ]
  }
}

The important thing to notice here is that the property names in the errors dictionary are written in PascalCase, the same as the C# property names. However, typically, JSON APIs use camelCase for JSON properties.

.NET 7 added a new ModelMetadataDetailsProvider, which theoretically should let you control how these are written:

using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers(options =>
{
    options.ModelMetadataDetailsProviders.Add(
        new SystemTextJsonValidationMetadataProvider(JsonNamingPolicy.CamelCase));
});
// ...

According to the docs and the original issue, as I understand them, this should mean the errors section is generated as:

{
  "errors": {
    "to": [
      "The field to must be between 1 and 6."
    ],
    "from": [
      "The field from must be between 1 and 6."
    ]
  }
}

with both the dictionary key and the name in the message being camelCase. But when I tried it, it didn’t seem to do anything.

In contrast, I could almost achieve this by setting the MVC JsonOptions to explicitly use camelCase for dictionary keys:

using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers().AddJsonOptions(options => 
{ 
    options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
});

With this configuration, the dictionary keys are camelCase, but the property names are still used in the messages:

{
  "errors": {
    "to": [
      "The field To must be between 1 and 6."
    ],
    "from": [
      "The field From must be between 1 and 6."
    ]
  }
}

I’m sure I’m doing something wrong here, so if someone could tell me what it is, that would be great, and I can update this post ? There are clearly problems with the feature related to the new ProblemDetailsService, but I couldn’t see why it doesn’t work in this case, when the response is entirely generated by MVC ?‍♂️

Summary

In this post, I described some of the new features introduced for MVC in .NET 7. Many of the features are focused on bringing parity with minimal APIs, or make working with both MVC and minimal APIs in the same application a bit easier. The final feature, using camelCase for property names should address a long-standing request. If only I could get it to work ?‍♂️

error: Content is protected !!