ASP.NET Core middleware with Roslyn Analyzers - Part 1

ASP.NET Core middleware is a clever way to configure features like authentication, routing, and error logging in ASP.NET Core. Adding the different pieces of middleware and in the right order can be quite a nightmare, though. In this post, I'll show you how Roslyn Analyzers can help.

Middleware works like pearls on a string. The order you add your middleware determines which middleware is called first and which is called last. This can cause serious problems, like error logging middleware that is never notified of the errors happening. The problem is already nicely described in Andrew Lock's Inserting middleware between UseRouting() and UseEndpoints() as a library author - Part 1 and Part 2, so make sure to read through those if you have felt the pain in your projects already.

I wanted to create a couple of Roslyn Analyzers to help elmah.io users insert the call to UseElmahIo and in the right place. Analyzers are small pieces of C# code running inside Visual Studio that analyzes the project source code and report any problems to the Visual Studio Error List window.

To create new Roslyn Analyzers, the simplest approach is to base it on the template already available in Visual Studio. Create a new project and select the Analyzer with Code Fix (.NET Standard) template (install the .NET Compiler Platform SDK from the Visual Studio Installer if not already installed). This generates a new project with a MakeConst analyzer and code fix. You can delete the two C# files since we will create new ones for this demo.

Let's start by creating an analyzer that verifies that the UseElmahIo method is even added to the Configure method in the Startup.cs file. I'm creating a new C# class named CallUseElmahIoAnalyzer.cs. I've pretty much copied the basic structure from the MakeConst analyzer previously there:

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class CallUseElmahIoAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "EIO1001";

    private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        DiagnosticId,
        "Configure must call UseElmahIo",
        "Configure must call UseElmahIo",
        "Elmah.Io.CSharp.AspNetCoreRules",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

    public override void Initialize(AnalysisContext context)
    {
    }
}

There are a few things to notice. Analyzers declare and extend the DiagnosticAnalyzer class. This will make Visual Studio pick up your analyzer automatically once installed as a VSIX (Visual Studio extension). Metadata for the analyzer is declared as a DiagnosticDescriptor and includes an id, the message to show to the user, as well as a couple of other things. The Initialize method is where the magic happens.

Analyzers are implemented using a visitor pattern. You register handlers for code pieces you are interested in and Roslyn automatically parses the source code under analysis and provides your code with metadata. The code may look a bit weird and over-engineered at first, but turns out pretty strong once you get acquainted with the model.

For this first analyzer, we want to see if the Configure method calls the UseElmahIo method. For this simple example, we assume that the call should be made inside a method named Configure in a class named Startup:

public override void Initialize(AnalysisContext context)
{
    context.RegisterCodeBlockStartAction<SyntaxKind>(cb =>
    {
        // We only care about method bodies.
        if (cb.OwningSymbol.Kind != SymbolKind.Method) return;
        var method = (IMethodSymbol)cb.OwningSymbol;
        // We only care about methods named ConfigureServices
        if (method.Name != "Configure") return;
        if (method.ContainingType.Name != "Startup") return;
    });
}

The action provided for the RegisterCodeBlocStartAction method is called by Roslyn every time a new code block begins. A code block can be a class, a field, a method, etc. By inspecting metadata, the example returns if the current callback doesn't fulfill the requirements already mentioned.

If the code hasn't returned after the last if statement, we know that we are currently inside the Configure method. Roslyn allows for nested syntax actions, why we can register a new callback:

public override void Initialize(AnalysisContext context)
{
    context.RegisterCodeBlockStartAction<SyntaxKind>(cb =>
    {
        // the code from the previous example

        bool useElmahIoInvocationFound = false;

        cb.RegisterSyntaxNodeAction(ctx =>
        {
            var node = ctx.Node as InvocationExpressionSyntax;
            if (node == null) return;
            var expression = node.Expression as MemberAccessExpressionSyntax;
            if (expression == null) return;
            var methodName = expression.Name?.Identifier.ValueText;
            if (methodName == "UseElmahIo") useElmahIoInvocationFound = true;
        }, SyntaxKind.InvocationExpression);
    });
}

The new code registers a syntax node action to get a notification of each invocation expression inside the Configure method (by specifying SyntaxKind.InvocationExpression as a parameter to the RegisterSyntaxNodeAction method). On each syntax node, we check if it's a method call and if the method call is UseElmahIo. In case we find a call to UseElmahIo we set the useElmahIoInvocationFound boolean to true.

The last thing missing is to register an end action. When using the RegisterCodeBlockStartAction method, you need to provide a callback for Roslyn to call when on the end of the semantic analysis for the method analyzed (in this case Configure). Confused? Let's look at the code:

public override void Initialize(AnalysisContext context)
{
    context.RegisterCodeBlockStartAction<SyntaxKind>(cb =>
    {
        // the code from the previous example

        cb.RegisterCodeBlockEndAction(ctx =>
        {
            if (!useElmahIoInvocationFound)
            {
                var diag = Diagnostic.Create(Rule, method.Locations[0]);
                ctx.ReportDiagnostic(diag);
            }
        });
    });
}

In case the useElmahIoInvocationFound boolean is still set to false we use the ReportDiagnostic method to show a warning inside Visual Studio. The metadata used for the warning is taken from the Rule field that we already discussed as well as the location of the Configure method.

Let's look at the entire Initialize method:

public override void Initialize(AnalysisContext context)
{
    context.RegisterCodeBlockStartAction<SyntaxKind>(cb =>
    {
        // We only care about method bodies.
        if (cb.OwningSymbol.Kind != SymbolKind.Method) return;
        var method = (IMethodSymbol)cb.OwningSymbol;
        // We only care about methods named ConfigureServices
        if (method.Name != "Configure") return;
        if (method.ContainingType.Name != "Startup") return;

        bool useElmahIoInvocationFound = false;

        cb.RegisterSyntaxNodeAction(ctx =>
        {
            var node = ctx.Node as InvocationExpressionSyntax;
            if (node == null) return;
            var expression = node.Expression as MemberAccessExpressionSyntax;
            if (expression == null) return;
            var methodName = expression.Name?.Identifier.ValueText;
            if (methodName == "UseElmahIo") useElmahIoInvocationFound = true;
        }, SyntaxKind.InvocationExpression);

        cb.RegisterCodeBlockEndAction(ctx =>
        {
            if (!useElmahIoInvocationFound)
            {
                var diag = Diagnostic.Create(Rule, method.Locations[0]);
                ctx.ReportDiagnostic(diag);
            }
        });
    });
}

To test the new analyzer, make sure to set the VSIX project as the startup project and hit F5. This will start an experimental instance of Visual Studio. Create a new ASP.NET Core project or open an existing one and navigate to the Startup class. If no UseElmahIo call is made, you should see the warning directly in Visual Studio:

To distribute your new analyzer, either upload the generated VSIX file to the Visual Studio Marketplace or publish the project containing the analyzers as a NuGet package.

That's it! In the next post, I will continue the example by creating an analyzer that verifies the correct order of middleware.