in ASP.NET Core Routing ~ 6 min read.

Detecting duplicate routes in ASP.NET Core
Visualizing ASP.NET Core 3.0 endpoints using GraphvizOnline - Part 5

In this series, I have shown how to view all the routes in your application using an endpoint graph. In this post I show how to do something more useful—detecting duplicate routes that would throw an exception at runtime. I show how to use the same DfaGraphWriter primitives as in previous posts to create a unit test that fails if you have any duplicate routes in your application that would cause a failure at runtime.

Duplicate routes and runtime exceptions

ASP.NET Core 3.x uses endpoint routing for all your MVC/API controllers and Razor Pages, as well as dedicated endpoints like a health check endpoint. As you've already seen in this series, those endpoints are built into a directed graph, which is used to route incoming requests to a handler:

A ValuesController endpoint routing application with different styling

Unfortunately, it's perfectly possible to structure your controllers/Razor Pages in such as way as to have multiple endpoints be a match for a given route. For example, imagine you have a "values" controller in your project:

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
    [HttpGet]
    public ActionResult<IEnumerable<string>> Get()
    {
        return new string[] { "value1", "value2" };
    }
}

Meanwhile, some joker has also created the following controller too:

[ApiController]
public class DuplicateController
{
    [HttpGet("api/values")]
    public string Get()
    {
        return "oops";
    }
}

As routing is only handled at runtime, everything will appear to be fine when you build and deploy your application. Health checks will pass, and all other routes will behave normally. But when someone hits the URL /api/values, you'll get this beauty:

AmbiguousMatchException: The request matched multiple endpoints

The good thing here at least is that the error message tells you where the problem is. But wouldn't it be nice to know that before you've deployed to production?

Trying to detect this error ahead of time was what started me down the graph-drawing rabbit-hole of this series. My goal was to create a simple unit test that can run as part of the build, to identify errors like these.

Creating a duplicate endpoint detector

If you've read the rest of this series, then you may have already figured out how this is going to work. The DfaGraphWriter that is built-in to the framework, which can be used to visualize the endpoint graph, knows how to visit each node (route) in the graph. We'll adapt this somewhat: instead of outputting a graph, we'll return a list of routes which match multiple endpoints.

The detector class we'll create, DuplicateEndpointDetector uses the same ImpromptuInterface technique as previous posts, reusing the IDfaNode and IDfaMatcherBuilder interfaces, shown below for completeness:

public interface IDfaNode
{
    public string Label { get; set; }
    public List<Endpoint> Matches { get; }
    public IDictionary Literals { get; }
    public object Parameters { get; }
    public object CatchAll { get; }
    public IDictionary PolicyEdges { get; }
}

public interface IDfaMatcherBuilder
{
    void AddEndpoint(RouteEndpoint endpoint);
    object BuildDfaTree(bool includeLabel = false);
}

These interfaces give us a slightly nicer way to work with the internal classes used by the default DfaGraphWriter.

The bulk of the DuplicateEndpointDetector is identical to the CustomDfaGraphWriter from my previous post. The only difference is that we a GetDuplicateEndpoints() method instead of a Write() method. We also don't use a TextWriter (as we're not trying to build a graph for display) and we return a dictionary of duplicated endpoints (keyed on the route).

I've shown almost the complete code below, with the exception of the LogDuplicates method which we'll get to shortly.

public class DuplicateEndpointDetector
{
    private readonly IServiceProvider _services;
    public DuplicateEndpointDetector(IServiceProvider services)
    {
        _services = services;
    }

    public Dictionary<string, List<string>> GetDuplicateEndpoints(EndpointDataSource dataSource)
    {
        // Use ImpromptuInterface to create the required dependencies as shown in previous post
        Type matcherBuilder = typeof(IEndpointSelectorPolicy).Assembly
            .GetType("Microsoft.AspNetCore.Routing.Matching.DfaMatcherBuilder");

        // Build the list of endpoints used to build the graph
        var rawBuilder = _services.GetRequiredService(matcherBuilder);
        IDfaMatcherBuilder builder = rawBuilder.ActLike<IDfaMatcherBuilder>();

        // This is the same logic as the original graph writer
        var endpoints = dataSource.Endpoints;
        for (var i = 0; i < endpoints.Count; i++)
        {
            if (endpoints[i] is RouteEndpoint endpoint && (endpoint.Metadata.GetMetadata<ISuppressMatchingMetadata>()?.SuppressMatching ?? false) == false)
            {
                builder.AddEndpoint(endpoint);
            }
        }

        // Build the raw tree from the registered routes
        var rawTree = builder.BuildDfaTree(includeLabel: true);
        IDfaNode tree = rawTree.ActLike<IDfaNode>();

        // Store a list of nodes that have already been visited 
        var visited = new Dictionary<IDfaNode, int>();

        // Store a dictionary of duplicates
        var duplicates = new Dictionary<string, List<string>>();

        // Build the graph by visiting each node, and calling LogDuplicates on each
        Visit(tree, LogDuplicates);

        // done
        return duplicates;

        void LogDuplicates(IDfaNode node)
        {
            /* Details shown later in this post*/
        }
    }

    // Identical to the version shown in the previous post
    static void Visit(IDfaNode node, Action<IDfaNode> visitor)
    {
        if (node.Literals?.Values != null)
        {
            foreach (var dictValue in node.Literals.Values)
            {
                IDfaNode value = dictValue.ActLike<IDfaNode>();
                Visit(value, visitor);
            }
        }

        // Break cycles
        if (node.Parameters != null && !ReferenceEquals(node, node.Parameters))
        {
            IDfaNode parameters = node.Parameters.ActLike<IDfaNode>();
            Visit(parameters, visitor);
        }

        // Break cycles
        if (node.CatchAll != null && !ReferenceEquals(node, node.CatchAll))
        {
            IDfaNode catchAll = node.CatchAll.ActLike<IDfaNode>();
            Visit(catchAll, visitor);
        }

        if (node.PolicyEdges?.Values != null)
        {
            foreach (var dictValue in node.PolicyEdges.Values)
            {
                IDfaNode value = dictValue.ActLike<IDfaNode>();
                Visit(value, visitor);
            }
        }

        visitor(node);
    }
}

This class follows the same approach as the graph writer. It uses ImpromptuInterface to create an IDfaMatcherBuilder proxy, which it uses to build a graph of all the endpoints in the system. It then visits each of the IDfaNodes in the graph and calls the LogDuplicates() method, passing in each node in turn.

LogDuplicates() is a lot simpler than the WriteNode() implementation from the previous post. All we're concerned about here is whether a node has multiple entries in its Matches property. If it does, we have an ambiguous route, so we add it to the dictionary. I'm using the DisplayName to identify the ambiguous endpoints, just as the exception message shown earlier does.

// LogDuplicates is a local method. visited and duplicates are defined in the calling function
var visited  = new Dictionary<IDfaNode, int>();
var duplicates = new Dictionary<string, List<string>>();

void LogDuplicates(IDfaNode node)
{
    // Add the node to the visited node dictionary if it isn't already
    // Generate a zero-based integer label for the node
    if (!visited.TryGetValue(node, out var label))
    {
        label = visited.Count;
        visited.Add(node, label);
    }

    // We can safely index into visited because this is a post-order traversal,
    // all of the children of this node are already in the dictionary.

    // Does this node have multiple matches?
    var matchCount = node?.Matches?.Count ?? 0;
    if(matchCount > 1)
    {
        // Add the node to the dictionary!
        duplicates[node.Label] = node.Matches.Select(x => x.DisplayName).ToList();
    }
}

After executing GetDuplicateEndpoints() you'll have a dictionary containing all the duplicate routes in your application. The final thing to do is create a unit test, so that we can fail the build if we detect a problem.

Creating a unit test to detect duplicate endpoints

I like to put this check in a unit test, because performance is much less of an issue. You could check for duplicate routes on app startup or using a health check But given that we use reflection heavily this will add to startup time, and also given that the routes are fixed once you deploy your application, a unit test seems like the best place for it.

Just as in a previous post where I draw the graph in a unit test, in this post I'll use an "integration test" to check for duplicate endpoints in my main app.

  • Create a new xUnit project using VS or by running dotnet new xunit.
  • Install Microsoft.AspNetCore.Mvc.Testing by running dotnet add package Microsoft.AspNetCore.Mvc.Testing
  • Update the <Project> element of your test project to <Project Sdk="Microsoft.NET.Sdk.Web">
  • Reference your ASP.NET Core project from the test project

You can now create a simple integration test using the WebApplicationFactory<> as a class fixture. I also inject ITestOutputHelper so that we can list out the duplicate endpoints for the test:

public class DuplicateDetectorTest
    : IClassFixture<WebApplicationFactory<ApiRoutes.Startup>>
{
    // Inject the factory and the output helper
    private readonly WebApplicationFactory<ApiRoutes.Startup> _factory;
    private readonly ITestOutputHelper _output;
    public DuplicateDetectorTest(WebApplicationFactory<Startup> factory, ITestOutputHelper output)
    {
        _factory = factory;
        _output = output;
    }

    [Fact]
    public void ShouldNotHaveDuplicateEndpoints()
    {
        // Create an instance of the detector using the IServiceProvider from the app
        // and get an instance of the endpoint data
        var detector = new DuplicateEndpointDetector(_factory.Services);
        var endpointData = _factory.Services.GetRequiredService<EndpointDataSource>();

        // Find all the duplicates
        var duplicates = detector.GetDuplicateEndpoints(endpointData);

        // Print the duplicates to the output
        foreach (var keyValuePair in duplicates)
        {
            var allMatches = string.Join(", ", keyValuePair.Value);
            _output.WriteLine($"Duplicate route: '{keyValuePair.Key}'. Matches: {allMatches}");
        }

        // If we have some duplicates, then fail the CI build!
        Assert.Empty(duplicates);
    }
}

When you run the test, the assertion will fail, and if you view the output, you'll see a list of the routes with issues, as well as the endpoints that caused the problem:

Using a unit test to detect duplicate endpoints

If you're running your unit tests as part of your CI build, then you'll catch the issue before it gets to production. Pretty handy!

Summary

In this post I show how you can build a duplicate endpoint detector using the endpoint graph building facilities included in ASP.NET Core. The end result requires quite a few tricks, as I've shown in the previous posts of this series, such as using ImpromptuInterface to create proxies for interacting with internal types.

The end result is that you can detect when multiple endpoints (e.g. API controllers) are using the same route in your application. Given that those throw routes would throw an AmbiguousMatchException at runtime, being able to detect issues using a unit test is pretty handy!

Loading comments powered by Disqus, please wait…
Andrew Lock | .Net Escapades

Stay up to the date with the latest posts!

Oops! Check your details and try again.
Thanks! Check your email for confirmation.