简体   繁体   中英

How to implement custom controller action selection in ASP.Net Core?

I have an ASP.Net Core API project. I want to be able to write a custom routing logic to be able to choose different controller actions based on HTTP Body parameters. To illustrate my problem, this is my Controller class:

[ApiController]
[Route("api/[controller]")]
public class TestController
{
    // should be called when HTTP Body contains json: '{ method: "SetValue1" }'
    public void SetValue1()
    {
        // some logic
    }

    // should be called when HTTP Body contains json: '{ method: "SetValue2" }'
    public void SetValue2()
    {
        // some logic
    }
}

As you can see from my comments I want to choose different action methods based on HTTP body. Here is my Startup class:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        // I assume instead of this built in routing middleware, 
        // I will have to implement custom middleware to choose the correct endpoints, from HTTP body,
        // any ideas how I can go about this?
        app.UseRouting();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}

One of the option I could use is having one entry Action method that will call differnt methods based on the HTTP Body content, but I would like to avoid that and encapsulate this logic somewhere in a custom routing.

In the old APS.Net Web API there was a handy class ApiControllerActionSelector that I could extend, and define my custom logic of selecting Action methods, however this is not supported in new ASP.Net Core . I think I will have to implement my own version of app.UseRouting middleware. Any ideas on how I can do it?

In the old asp.net core (before 3.0 ), we can implement a custom IActionSelector and it's especially convenient when the ActionSelector is still made public. But with the new endpoint routing, it's changed to the so-called EndpointSelector . The implementation is fairly the same, the point is how we extract the ActionDescriptor which is put in the Endpoint as metadata. The following implementation requires a default EndpointSelector (which is DefaultEndpointSelector ) but that's unfortunately made internal. So we need to use a trick to get an instance of that default implementation to use in our custom implementation.

public class RequestBodyEndpointSelector : EndpointSelector
{
    readonly IEnumerable<Endpoint> _controllerEndPoints;
    readonly EndpointSelector _defaultSelector;
    public RequestBodyEndpointSelector(EndpointSelector defaultSelector, EndpointDataSource endpointDataSource)
    {
        _defaultSelector = defaultSelector;
        _controllerEndPoints = endpointDataSource.Endpoints
                                                 .Where(e => e.Metadata.GetMetadata<ControllerActionDescriptor>() != null).ToList();
    }
    public override async Task SelectAsync(HttpContext httpContext, CandidateSet candidates)
    {
        var request = httpContext.Request;
        request.EnableBuffering();
        //don't use "using" here, otherwise the request.Body will be disposed and cannot be used later in the pipeline (an exception will be thrown).
        var sr = new StreamReader(request.Body);
        try
        {
            var body = sr.ReadToEnd();
            if (!string.IsNullOrEmpty(body))
            {
                try
                {
                    var actionInfo = Newtonsoft.Json.JsonConvert.DeserializeObject<ActionInfo>(body);
                    var controllerActions = new HashSet<(MethodInfo method, Endpoint endpoint, RouteValueDictionary routeValues, int score)>();
                    var constrainedControllerTypes = new HashSet<Type>();
                    var routeValues = new List<RouteValueDictionary>();
                    var validIndices = new HashSet<int>();
                    for (var i = 0; i < candidates.Count; i++)
                    {
                        var candidate = candidates[i];
                        var endpoint = candidates[i].Endpoint;
                        var actionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
                        if (actionDescriptor == null) continue;
                        routeValues.Add(candidate.Values);
                        constrainedControllerTypes.Add(actionDescriptor.MethodInfo.DeclaringType);
                        if (!string.Equals(actionInfo.MethodName, actionDescriptor.MethodInfo.Name,
                                           StringComparison.OrdinalIgnoreCase)) continue;
                        if (!controllerActions.Add((actionDescriptor.MethodInfo, endpoint, candidate.Values, candidate.Score))) continue;
                        validIndices.Add(i);
                    }
                    if (controllerActions.Count == 0)
                    {
                        var bestCandidates = _controllerEndPoints.Where(e => string.Equals(actionInfo.MethodName,
                                                                                           e.Metadata.GetMetadata<ControllerActionDescriptor>().MethodInfo.Name,
                                                                                           StringComparison.OrdinalIgnoreCase)).ToArray();
                        var routeValuesArray = request.RouteValues == null ? routeValues.ToArray() : new[] { request.RouteValues };
                        candidates = new CandidateSet(bestCandidates, routeValuesArray, new[] { 0 });
                    }
                    else
                    {
                        for(var i = 0; i < candidates.Count; i++)
                        {
                            candidates.SetValidity(i, validIndices.Contains(i));                                
                        }                            
                    }
                    //call the default selector after narrowing down the candidates
                    await _defaultSelector.SelectAsync(httpContext, candidates);
                    //if some endpoint found
                    var selectedEndpoint = httpContext.GetEndpoint();
                    if (selectedEndpoint != null)
                    {
                        //update the action in the RouteData to found endpoint                            
                        request.RouteValues["action"] = selectedEndpoint.Metadata.GetMetadata<ControllerActionDescriptor>().ActionName;
                    }
                    return;
                }
                catch { }
            }
        }
        finally
        {
            request.Body.Position = 0;
        }
        await _defaultSelector.SelectAsync(httpContext, candidates);
    }
}

The registration code is a bit tricky like this:

//define an extension method for registering conveniently
public static class EndpointSelectorServiceCollectionExtensions
{
    public static IServiceCollection AddRequestBodyEndpointSelector(this IServiceCollection services)
    {
        //build a dummy service container to get an instance of 
        //the DefaultEndpointSelector
        var sc = new ServiceCollection();
        sc.AddMvc();
        var defaultEndpointSelector = sc.BuildServiceProvider().GetRequiredService<EndpointSelector>();            
        return services.Replace(new ServiceDescriptor(typeof(EndpointSelector),
                                sp => new RequestBodyEndpointSelector(defaultEndpointSelector, 
                                                                      sp.GetRequiredService<EndpointDataSource>()),
                                ServiceLifetime.Singleton));
    }
}

//inside the Startup.ConfigureServices
services.AddRequestBodyEndpointSelector();

The old solution for the old conventional routing used in asp.net core 2.2

Your requirement is a bit weird and you may have to accept some trade-off for that. First that requirement may require you to read the Request.Body twice (when the selected action method has some arguments to model-bind). Even when the framework supports the so-called EnableBuffering on the HttpRequest , it's still a bit trade-off to accept. Secondly in the method to select the best action (defined on IActionSelector ), we cannot use async so reading the request body of course cannot be done with async .

For high performance web apps, that definitely should be avoided. But if you can accept that kinds of trade-off, we have a solution here by implementing a custom IActionSelector , at best let it inherit from the default ActionSelector . The method we can override is ActionSelector.SelectBestActions . However that method does not provide the RouteContext (we need access to that to update the RouteData ), so we'll re-implement another method of IActionSelector named IActionSelector.SelectBestCandidate which provides a RouteContext .

Here's the detailed code:

//first we define a base model for parsing the request body
public class ActionInfo
{
    [JsonProperty("method")]
    public string MethodName { get; set; }
}

//here's our custom ActionSelector
public class RequestBodyActionSelector : ActionSelector, IActionSelector
{        
    readonly IEnumerable<ActionDescriptor> _actions;
    public RequestBodyActionSelector(IActionDescriptorCollectionProvider actionDescriptorCollectionProvider, 
        ActionConstraintCache actionConstraintCache, ILoggerFactory loggerFactory) 
        : base(actionDescriptorCollectionProvider, actionConstraintCache, loggerFactory)
    {            
        _actions = actionDescriptorCollectionProvider.ActionDescriptors.Items;            
    }
    ActionDescriptor IActionSelector.SelectBestCandidate(RouteContext context, IReadOnlyList<ActionDescriptor> candidates)
    {
        var request = context.HttpContext.Request;
        //supports reading the request body multiple times
        request.EnableBuffering();
        var sr = new StreamReader(request.Body);
        try
        {
            var body = sr.ReadToEnd();
            if (!string.IsNullOrEmpty(body))
            {
                try
                {
                    //here I use the old Newtonsoft.Json
                    var actionInfo = JsonConvert.DeserializeObject<ActionInfo>(body);
                    //the best actions should be on these controller types.
                    var controllerTypes = new HashSet<TypeInfo>(candidates.OfType<ControllerActionDescriptor>().Select(e => e.ControllerTypeInfo));
                    //filter for the best by matching the controller types and 
                    //the method name from the request body
                    var bestActions = _actions.Where(e => e is ControllerActionDescriptor ca &&
                                                          controllerTypes.Contains(ca.ControllerTypeInfo) &&
                                                         string.Equals(actionInfo.MethodName, ca.MethodInfo.Name, StringComparison.OrdinalIgnoreCase)).ToList();
                    //only override the default if any method name matched 
                    if (bestActions.Count > 0)
                    {
                        //before reaching here, 
                        //the RouteData has already been populated with 
                        //what from the request's URL
                        //By overriding this way, that RouteData's action
                        //may be changed, so we need to update it here.
                        var newActionName = (bestActions[0] as ControllerActionDescriptor).ActionName;                            
                        context.RouteData.PushState(null, new RouteValueDictionary(new { action = newActionName }), null);

                        return SelectBestCandidate(context, bestActions);
                    }
                }
                catch { }
            }
        }
        finally
        {
            request.Body.Position = 0;
        }
        return SelectBestCandidate(context, candidates);
    }        
}

To register the custom IActionSelector in the Startup.ConfigureServices :

services.AddSingleton<IActionSelector, RequestBodyActionSelector>();

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM