blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Analyzers for ASP.NET Core in .NET 6

Exploring .NET 6 - Part 7

Analyzers have been built into the C# compiler for some time now. Anyone can write their own analyzer, distribute it as a NuGet package, and hook into the compiler to find potential code issues. In this post I look at the changes to the existing ASP.NET Core analyzers in .NET 6, as well as the new ones added for the new minimal APIs.

This post ended up a little long, so I've included shortcuts to each of the analyzers I describe in case you want to skip ahead:

Analyzers in ASP.NET Core

The ASP.NET Core team have been shipping analyzers for common configuration errors since ASP.NET Core 3.1. For example, calling BuildServiceProvider() on an IServiceCollection is a code smell that can lead to incorrect behaviour, so an analyzer warns you if you call this API inside the Startup class:

Warning when calling BuildServiceProvider() in Startup

Of course, the minimal hosting APIs in .NET 6 don't have a Startup class, but you can still call BuildServiceProvider() incorrectly. The existing analyzers had to be updated so that they are now aware of top-level programs, and will warn of incorrect API usages here too:

Warning when calling BuildServiceProvider() with minimal hosting APIs

All of the existing analyzers in ASP.NET Core have been updated to work with the minimal hosting APIs, and some additional ones have been added. In the remainder of the post I'll walk through each of the analyzers, show the error it's designed to catch, why, and how to fix your code. I start with the existing analyzers that were updated to work with .NET 6, and then move on to the new analyzers.

All of the links and code in this post are true as of .NET 6 RC2, so there may be some minor changes between now and the final release.

Updates to existing analyzers

We'll start by looking at the analyzers which are available in earlier versions of ASP.NET Core. These have apparently been available since ASP.NET Core 3.1, though I must confess, I didn't realise that! Must be because I write perfect code, right?😉

StartupAnalyzer

The first analyzer we come to is StartupAnalyzer. This analyzer is kind of an "aggregate" analyzer, that does most of the heavy lifting. It "looks" for important features like the Startup class (if there is one) and ConfigureServices() methods, collects the details, and passes them to the "sub" analyzers we'll look at shortly.

The main change required in this class was to update it to consider code inside the entrypoint of the application, not just code in Startup. This was necessary to support the common top-level programs in .NET 6's minimal hosting APIs. As well as a top level program, it also checks your Program.Main() for cases where you have an explicit entrypoint rather than a top-level program.

Incorrect use of BuildServiceProvider()

The first "real" analyzer we come to is the one I've already mentioned, the BuildServiceProviderAnalyzer. As already described, this analyzer ensures that you don't call BuildServiceProvider() in your startup code.

This logic was trivially updated in .NET 6 to allow analyzing usages outside of Startup too, so that usages in minimal hosting APIs are also flagged.

For example, the following code:

var builder = WebApplication.CreateBuilder(args);
IServiceProvider provider = builder.Services.BuildServiceProvider(); // warning
provider.GetService<IMyService>()
// ...

will now give the following build warning:

warning ASP0000: Calling 'BuildServiceProvider' from application code results 
in an additional copy of singleton services being created. Consider alternatives 
such as dependency injecting services as parameters to 'Configure'.

Instead of calling BuildServiceProvider(), you should consider re-architecting your application to defer accessing the service until you've finished configuring your DI container.

With minimal hosting API, you can access the IServiceProvider after calling builder.Build(), so there's no good reason to use this pattern really:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Services.GetService<IMyService>(); // IServiceProvider is exposed as WebApplication.Service

Ensure UseAuthorization() is in the correct place

The next analyzer we'll look at is UseAuthorizationAnalyzer. This analyzer enforces that that UseAuthorization() is placed after UseRouting() and before UseEndpoints(). In an ASP.NET Core 3.x/5 application, the correct placement looks like:

public void Configure(IApplicationBuilder app)
{
    app.UseRouting();
    app.UseAuthorization();
    app.UseEndpoints(x => x.MapRazorPages());
}

If you get the ordering wrong, for example by placing UseAuthorization() before UseRouting():

public void Configure(IApplicationBuilder app)
{
    app.UseAuthorization(); // warning

    app.UseRouting();
    app.UseEndpoints(x => x.MapRazorPages());
}

then you get a warning:

warning ASP0001: The call to UseAuthorization should appear between 
app.UseRouting() and app.UseEndpoints(..) for authorization to be 
correctly evaluated

Things are a bit weird with minimal hosting. As I discussed at length in a previous post, WebApplication sometimes adds additional middleware to your middleware pipeline. For example, if you add an endpoint (e.g. using MapGet()) to your application, it will automatically add UseRouting() at the start of your middleware pipeline, and UseEndpoints() at the end of the pipeline. So an app with a pipeline like this:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();

app.MapGet("/", () => $"Hello World!");
app.Run();

Actually builds a middleware pipeline that looks like this:

The WebApplication has added the the RoutingMiddleware and EndpointMiddleware to the pipeline

Given that UseRouting() and UseEndpoints() automatically wrap the pipeline, then wherever you use UseAuthorization in your app, you won't get a warning:

var builder = WebApplication.CreateBuilder(args).
builder.Services.AddAuthorization();
var app = builder.Build();

app.MapGet("/", [Authorize] () => $"Hello World!");

// can put this anywhere.
app.UseAuthorization();
app.Run();

This makes sense when you consider the resulting pipeline.

However, there is currently an issue with the analyzer if you call UseRouting() in your pipeline. As I described in the previous post, if you do add a call to UseRouting(), then the routing middleware isn't added to the start of the pipeline. Instead it's added at the specified point. That means the following configuration is actually invalid:

var builder = WebApplication.CreateBuilder(args).
builder.Services.AddAuthorization();
var app = builder.Build();

app.MapGet("/", [Authorize] () => $"Hello World!");

app.UseAuthorization();
app.UseRouting(); // <- this is in the wrong place, should be before UseAuthorization()
app.Run()

Unfortunately, the analyzer won't detect this situation. At runtime, when you hit a route that requires authorization, you'll get an exception:

InvalidOperationException: Endpoint HTTP: GET / contains authorization metadata, but a middleware was not found that supports authorization.
Configure your application startup by adding app.UseAuthorization() inside the call to Configure(..) in the application startup code. The call to app.UseAuthorization() must appear between app.UseRouting() and app.UseEndpoints(...)

On the other hand, you will get the warning if you explicitly call UseEndpoints() too:


var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization();
var app = builder.Build();

app.UseAuthorization(); // warning
app.UseRouting();
app.UseEndpoints(x => x.MapGet("/", () => $"Hello World!"));

app.Run();

Obviously the whole point of the minimal APIs is to hide away the complexities of the routing middleware anyway, so this shouldn't be a problem for most people. But if you are explicitly calling UseRouting(), make sure you're definitely calling it in the right place!

Using legacy MVC routing

The UseMvcAnalyzer analyzer detects when you're using the legacy MVC implementation from ASP.NET Core 1.x/2.x in a more recent ASP.NET Core application by calling UseMvc(). UseMvc uses the pre-endpoint-routing implementation, so you can't use it as-is without some changes.

For example, creating an app with UseMvc():

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseMvc(); //warning

app.Run();

gives the following warning:

warning MVC1005: Using 'UseMvc' to configure MVC is not supported while 
using Endpoint Routing. To continue using 'UseMvc', please set 
'MvcOptions.EnableEndpointRouting = false' inside '<Main>$'.

UseMvc() without endpoint routing is pretty well deprecated now, so do you really want to get rid of the warning? If the answer is still "yes", you can follow the instructions from the warning to configure the MvcOptions object, and disable endpoint routing:

var builder = WebApplication.CreateBuilder(args);

// disable endpoint routing for mvc
builder.Services.Configure<MvcOptions>(opts => opts.EnableEndpointRouting = false); 
var app = builder.Build();

app.UseMvc(); // no warning
app.Run();

You get the same warning if you call UseMvcWithDefaultRoute().

New analyzers for minimal APIs

That covers the existing analyzers, where the change required in most cases was to just remove the assertion that the offending code was in a Startup class. In the final half of this post, we look at some of the new analyzers introduced to help with minimal APIs.

Mismatch between route parameters and arguments

The first minimal API analyzer we'll look at is called DetectMismatchedParameterOptionalityRuleId and is used to detect when your route template contains an optional parameter (indicated by a ? in the route parameter), but where the provided lambda method or method group doesn't specify that the parameter is optional.

For example, consider this very simple app. Notice that the {name?} parameter is marked optional, but the lambda's name argument is a string, not a string?:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/{name?}", (string name) => $"Hello {name}!"); //warning

app.Run();

When you build this app, you'll get a warning on the endpoint configuration line:

warning ASP0006: 'name' argument should be annotated as optional 
or nullable to match route parameter

Nullable reference types act as "Required" indicators on parameters, so if you ignore this warning, and hit the endpoint without a name parameter, you'll get a 400 response at runtime:

An unhandled exception occurred while processing the request.
BadHttpRequestException: Required parameter "string name" was not provided from route.
Microsoft.AspNetCore.Http.RequestDelegateFactory+Log.RequiredParameterNotProvided(HttpContext httpContext, string parameterTypeName, string parameterName, string source, bool shouldThrow)

Note that ASP.NET Core will only infer "required" from nullability when you have nullable reference types enabled (the default in .NET 6 templates). If you have nullable reference types disabled, you won't get an error, and you won't see a warning.

One solution to fix the warning is to mark the argument as nullable in your lambda:

// Nullable annotation added  ⬇ 
app.MapGet("/{name?}", (string? name) => $"Hello {name}!");

Alternatively, if you're using a MethodGroup as the route handler, you can make the parameter optional (or add the nullable annotation)

app.MapGet("/{name?}", SayHello);
// Made argument optional     ⬇ 
string SayHello(string name = "World") => $"Hello {name}!";

Misplaced ASP.NET Core attributes

The next analyzer, DetectMisplacedLambdaAttribute, is used to detect when you are directly calling a method inside a lambda in your route handler, and you have added ASP.NET Core attributes to that method. For example, in the following code, the route handler is calling a function SayHello. That method has been decorated with the [Authorize] attribute

app.MapGet("/{name}", (string name) => SayHello(name)); //warning

[Authorize]
string SayHello(string name) => $"Hello {name}!";

Unfortunately, this likely won't work as expected. The /{name} endpoint does not require authorization, as ASP.NET Core won't "see" the [Authorize] attribute on the nested function. This gives the following warning:

warning ASP0005: 'AuthorizeAttribute' should be placed directly on the 
route handler lambda to be effective

To fix the error, move the [Authorize] attribute to the lambda directly (a new C# 10 feature):

app.MapGet("/{name}", [Authorize] (string name) => SayHello(name)); //no warning

string SayHello(string name) => $"Hello {name}!";

Alternatively, you could use the method group directly, instead of wrapping it in a lambda:

app.MapGet("/{name}",  SayHello); //no warning

[Authorize]
string SayHello(string name) => $"Hello {name}!";

Note that this analyzer won't flag more complex lambda expressions, it'll only check for single expression/statements. So if you did:

app.MapGet("/{name}", [Authorize] (string name) => {
    var result = SayHello(name)
    return result;
}); //no warning

string SayHello(string name) => $"Hello {name}!";

then you won't see an analyzer warning.

[Bind] isn't valid on minimal APIs

The next analyzer, DisallowMvcBindArgumentsOnParameters, prevents you using the [Bind] attribute with minimal hosting route handlers. For example, if you have a Person class, you might expect that you could do the following:

app.MapGet("/{name}",  ([Bind("Name")] Person person) => person); // warning

In MVC, this would ensure that only the Name property of Person was model bound, but this attribute can't be used with minimal hosting APIs. Instead, you'll get a warning:

warning ASP0003: BindAttribute should not be specified for a MapGet Delegate parameter

If you try and run your application despite that, you'll get a more detailed error about the problem:

Unhandled exception. System.InvalidOperationException: Body was inferred but the method does not allow inferred body parameters.
Below is the list of parameters that we found:

Parameter           | Source
---------------------------------------------------------------------------------
person              | Body (Inferred)


Did you mean to register the "Body (Inferred)" parameter(s) as a Service or apply the [FromService] or [FromBody]  attribute?

So, yeah, don't use [Bind]!

Use IResult not IActionResult

The final analyzer we'll look at is DisallowReturningActionResultFromMapMethods. This analyzer ensures that you're using the new minimal hosting API results that implement IResult, not the MVC action results, which implement IActionResult.

For example, if you had the following (obviously pointless) API which you want to return a 404:

app.MapGet("/", () => new NotFoundResult()); //warning

then you'll see the following build warning:

warning ASP0004: IActionResult instances should not be returned from a MapGet Delegate parameter. 
Consider returning an equivalent result from Microsoft.AspNetCore.Http.Results.

You can see the problem with your code if you run the application. Instead of returning a 404 response, the IActionResult will be serialized to JSON directly, rather than executed as it would be in the MVC pipeline. So for the example above, a 200 response would be sent, with the JSON body {"statusCode":404}.

Instead, as the warning states, you should use the Results type in the Microsoft.AspNetCore.Http namespace:

using Microsoft.AspNetCore.Http;

app.MapGet("/", () => Results.NotFound());

This will return a 404 response, as you probably intended originally.

You could also add a static using for the Results type, using static Microsoft.AspNetCore.Http.Results, which will allow you to omit the Results. qualifier: app.MapGet("/", () => NotFound());

Coming soon, to a .NET near you

In a previous post, when I was talking about WebApplicationBuilder, I mentioned that the Host and WebHost properties are exposed for backwards compatibility/integration reasons, but they aren't intended to be complete implementations, and various methods will throw at runtime:

IHostBuilder ISupportsConfigureWebHost.ConfigureWebHost(Action<IWebHostBuilder> configure, Action<WebHostBuilderOptions> configureOptions)
{
    throw new NotSupportedException($"ConfigureWebHost() is not supported by WebApplicationBuilder.Host. Use the WebApplication returned by WebApplicationBuilder.Build() instead.");
}

Obviously that's not ideal from an API usability point of view. The good news is that incorrect usage results in an exception on app startup, which at least gives fast and immediate feedback. Still, analyzers could help highlight this incorrect usage at compile time, so an issue has been raised to track this.

Issue https://github.com/dotnet/aspnetcore/issues/35814

Unfortunately, this one didn't make it into .NET 6, so we'll have to wait till next year. No doubt, there'll be even more analyzers added then too!

Summary

In this post I described some of the analyzers for ASP.NET Core applications to ensure correct usage of APIs. Some of these have been available for a long time, and just had to be tweaked to support minimal hosting APIs. Other analyzers were added specifically to catch incorrect usages in the minimal hosting APIs. Some of these analyzers are especially useful, in that they catch insidious errors which wouldn't cause your app to obviously fail, but rather would cause incorrect behaviour. Those are some of the hardest bugs to track down, so having the compiler track them for you is so useful!

Andrew Lock | .Net Escapades
Want an email when
there's new posts?