Reputation: 943
I am trying to parse the list of parameters (long in my case) from the route in dot net core. Therefore I want something like this
[HttpGet("{ids?}")]
public async Task<IActionResult> Get([FromRoute, Optional]long[] ids)
{
}
I know it doesn't work by default, I also know it works via the query string. However, for consistency, I would like to keep it as route parameter.
Is it possible to extend FromRoute in dot net core and implement this behavior?
So far I managed to create an action filter which technically works, but it requires the extra attribute, default FromRoute is still creating errors in the model state (especially the last part is obviously not acceptable).
My current code for attribute parts of which might be reusable for a proper implementation.
public class ArrayInputAttribute : ActionFilterAttribute
{
private readonly List<string> _ParameterNames;
public string Separator { get; set; }
public ArrayInputAttribute(params string[] parameterName)
{
_ParameterNames = parameterName.ToList();
Separator = ",";
}
public void ProcessArrayInput(ActionExecutingContext actionContext, string parameterName)
{
if (actionContext.ActionArguments.ContainsKey(parameterName))
{
var parameterDescriptor = actionContext.ActionDescriptor.Parameters.FirstOrDefault(p => p.Name == parameterName);
if (parameterDescriptor != null && parameterDescriptor.ParameterType.IsArray)
{
var type = parameterDescriptor.ParameterType.GetElementType();
var parameters = String.Empty;
if (actionContext.RouteData.Values.ContainsKey(parameterName))
{
parameters = (string)actionContext.RouteData.Values[parameterName];
}
else
{
var queryString = actionContext.HttpContext.Request.Query;
if (queryString[parameterName].Count > 0)
{
parameters = queryString[parameterName];
}
}
try
{
var values = parameters.Split(new[] { Separator }, StringSplitOptions.RemoveEmptyEntries)
.Select(TypeDescriptor.GetConverter(type).ConvertFromString).ToArray();
var typedValues = Array.CreateInstance(type, values.Length);
values.CopyTo(typedValues, 0);
actionContext.ActionArguments[parameterName] = typedValues;
}
catch (System.Exception)
{
(actionContext.Controller as Controller).ViewData.ModelState.AddModelError(parameterDescriptor.Name, "");
}
}
}
}
public override void OnActionExecuting(ActionExecutingContext actionContext)
{
_ParameterNames.ForEach(parameterName => ProcessArrayInput(actionContext, parameterName));
}
}
You can use it like this
[HttpGet("{ids?}")]
[ArrayInput("ids")]
[Produces(typeof(TestWebResponseDTO))]
public async Task<IActionResult> Get(long[] ids)
{
}
Upvotes: 5
Views: 3366
Reputation: 943
I honestly don't understand how is it possible that more people haven't encountered this problem before. I though I'll be lazy and look for answers but as usual, it seems it's best idea to take a swing at it myself. Here's a finished, working somewhat of a prototype but so far I am happy with it.
Usage:
[HttpGet("{ids:" + RouteArrayConstants.NUMBER_ARRAY + "}")]
[Produces(typeof(TestWebResponseDTO))]
public async Task<IActionResult> Get([FromRoute, Required]long[] ids)
{
}
ArrayBinder:
public class RouteArrayModelBinder : IModelBinder
{
private char separator;
public RouteArrayModelBinder(char Separator = ',')
{
separator = Separator;
}
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
throw new ArgumentNullException(nameof(bindingContext));
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult != ValueProviderResult.None)
{
var valueAsString = valueProviderResult.FirstValue;
try
{
var type = bindingContext.ModelType.GetElementType();
var values = valueAsString.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
.Select(TypeDescriptor.GetConverter(type).ConvertFromString).ToArray();
var typedValues = Array.CreateInstance(type, values.Length);
values.CopyTo(typedValues, 0);
bindingContext.Result = ModelBindingResult.Success(typedValues);
}
catch (System.Exception)
{
bindingContext.Result = ModelBindingResult.Failed();
bindingContext.ModelState.AddModelError(bindingContext.ModelName, $@"Failed to convert ""{valueAsString}"" to ""{bindingContext.ModelType.FullName}""");
}
}
}
}
ArrayBinderProvider:
public class RouteArrayModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType.IsArray)
{
return new RouteArrayModelBinder();
}
return null;
}
}
Constants:
public static class RouteArrayConstants
{
public const string NUMBER_ARRAY = "regex(^\\d+(,\\d+)*$)";
public const string STRING_ARRAY = "regex(^\\s+(,\\s+)*$)";
}
Setup:
services.AddMvc(cfg =>
{
cfg.ModelBinderProviders.Insert(0, new RouteArrayModelBinderProvider());
});
Sidenote: If you are using Swagger to document your api (which you should), not that swagger specification forces route parameters to be required. That's why you will need an extra action with no ids if you want to fetch all resources regardless of their ids.
Upvotes: 3