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:
- BuildServiceProviderAnalyzer
- UseAuthorizationAnalyzer
- UseMvcAnalyzer
- DetectMismatchedParameterOptionalityRuleId
- DetectMisplacedLambdaAttribute
- DisallowMvcBindArgumentsOnParameters
- DisallowReturningActionResultFromMapMethods
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:
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:
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:
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:
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 theResults.
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.
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!