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>();