Thursday, November 9, 2023

AmbiguousMatchException on a simple NET.Core Minimal API

One of my students pointed out to an unexpected issue in a simple .NET.Core app using the Minimal API
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.MapGet("/{param}", (string param) =>
{
    return $"Parametr: {param}";
});

app.MapGet("/{param:int}", (int param) =>
{
    return $"Parametr typu int: {param}";
});

app.MapGet("/{param:length(1,10)}", (string param) =>
{
    return $"Parametr z ograniczeniem długości: {param}";
});

app.MapGet("/{param:required}", (string param) =>
{
    return $"Parametr wymagany: {param}";
});

app.MapGet("/{param:regex(^\\d{{4}}$)}", (string param) =>
{
    return $"Parametr spełniający wyrażenie regularne: {param}";
});

app.Run();
The problem here is that routes overlap and it's not easily discoverable until a runtime exception is thrown when you try to navigate to a path that is matched by multiple endpoints. The AmbiguousMatchException is thrown.
My first attempt to workaround this was to wrap routes with UseRouting / UseEndpoints, hoping that this makes routing explicit. However, a closer look at the documentation reveals that
URL matching operates in a configurable set of phases. In each phase, the output is a set of matches. The set of matches can be narrowed down further by the next phase. The routing implementation does not guarantee a processing order for matching endpoints. All possible matches are processed at once. The URL matching phases occur in the following order.
and also
there are other matches with the same priority as the best match, an ambiguous match exception is thrown.
This is kind of complicated to people coming with .NET.Framework background where even if routes overlap, a first matching route is picked up and its handler is executed. The .NET.Core introduces the idea of priorities in routes, clearly covered by the documentation. In case there are multiple routes with the same priority, we have an exception.
Fortunately enough, the docs also give at least one hint on what to do - a custom EndpointSelector can be used. A custom selector could just replace the DefaultEndpointSelector that is normally registered in the service container, the default selector is useless as it's internal and its method is not virtual. But the custom selector is just a few lines of code.
using Microsoft.AspNetCore.Routing.Matching;
using System.Text.Json;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<EndpointSelector, CustomEndpointSelector>();
var app = builder.Build();
 
app.UseRouting();
 
app.MapGet( "/", () => "Hello World!" );
 
app.MapGet( "/{param}", ( string param ) =>
{
    return $"Parametr: {param}";
} );
 
app.MapGet( "/{param:int}", ( int param ) =>
{
    return $"Parametr typu int: {param}";
} );
 
app.MapGet( "/{param:length(1,10)}", ( string param ) =>
{
    return $"Parametr z ograniczeniem długości: {param}";
} );
 
app.MapGet( "/{param:required}", ( string param ) =>
{
    return $"Parametr wymagany: {param}";
} );
 
app.MapGet( "/{param:regex(^\\d{{4}}$)}", ( string param ) =>
{
    return $"Parametr spełniający wyrażenie regularne: {param}";
} );
 
 
app.UseEndpoints( (endpoints) => { } );
 
app.Run();
 
class CustomEndpointSelector : EndpointSelector
{
    public override async Task SelectAsync( HttpContext httpContext, CandidateSet candidates )
    {
        CandidateState selectedCandidate = new CandidateState();
 
        for ( var i=0; i < candidates.Count; i++ )
        {
            if ( candidates.IsValidCandidate( i ) )
            {
                selectedCandidate = candidates[i];
                break;
            }
        }
 
        httpContext.SetEndpoint( selectedCandidate.Endpoint );
        httpContext.Request.RouteValues = selectedCandidate.Values;
    }
}
Please use this as a starting point to build a more sophisticated approach, fulfilling any specific requirements.