You might like to be able to request for multiple users with a URL like /users/1,2,3
(1, 2, and 3 being the user ids).
On the back-end, we'd like to implement the controller action as something like this:
[HttpGet("/users/{ids}")]
public async Task<IActionResult> GetUsersByIds([FromRoute]int[] ids)
{
var model = await _userRepository.GetUsersByIdsAsync(ids);
return View(model);
}
By default this won't work. You could accept a string and split it in the action, but that wouldn't be very clean.
After spelunking a bit in the ASP.NET Core source code, I found it is the value provider that gives the value to the model binder which then sets the action parameters. So I took the existing RouteValueProvider, and modified it so that it splits the value if the target is an array:
/// <summary>
/// Based on: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ModelBinding/RouteValueProvider.cs
/// </summary>
public class ArraySupportingRouteValueProvider : BindingSourceValueProvider
{
private const string ValueSeparator = ",";
private readonly RouteValueDictionary _values;
private readonly ActionDescriptor _actionDescriptor;
private PrefixContainer _prefixContainer;
public ArraySupportingRouteValueProvider(
BindingSource bindingSource,
RouteValueDictionary values,
ActionDescriptor actionDescriptor)
: base(bindingSource)
{
_values = values;
_actionDescriptor = actionDescriptor;
}
protected PrefixContainer PrefixContainer
{
get
{
if (_prefixContainer == null)
{
_prefixContainer = new PrefixContainer(_values.Keys);
}
return _prefixContainer;
}
}
public override bool ContainsPrefix(string prefix)
{
return PrefixContainer.ContainsPrefix(prefix);
}
public override ValueProviderResult GetValue(string key)
{
if (key is null)
{
throw new ArgumentNullException(nameof(key));
}
if (key.Length == 0)
{
return ValueProviderResult.None;
}
if (_values.TryGetValue(key, out var value))
{
var stringValue = value as string ?? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty;
var targetIsArrayParam = _actionDescriptor
.Parameters
.FirstOrDefault(p => p.Name.Equals(key))
?.ParameterType.IsArray ?? false;
if (targetIsArrayParam)
{
var values = stringValue.Split(ValueSeparator, StringSplitOptions.RemoveEmptyEntries);
return new ValueProviderResult(new StringValues(values), CultureInfo.InvariantCulture);
}
else
{
return new ValueProviderResult(stringValue, CultureInfo.InvariantCulture);
}
}
else
{
return ValueProviderResult.None;
}
}
}
The idea is that if you are binding a string from array, this will work as the standard value provider. Only if you bind to an array, then we do the split and return that as the result.
Then we need a value provider factory for MVC:
/// <summary>
/// Based on: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/ModelBinding/RouteValueProviderFactory.cs
/// </summary>
public class ArraySupportingRouteValueProviderFactory : IValueProviderFactory
{
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var valueProvider = new ArraySupportingRouteValueProvider(
BindingSource.Path,
context.ActionContext.RouteData.Values,
context.ActionContext.ActionDescriptor);
context.ValueProviders.Add(valueProvider);
return Task.CompletedTask;
}
}
And finally we can replace the default value provider in the Startup
class:
services.AddControllersWithViews(mvc =>
{
mvc.ValueProviderFactories.RemoveType<RouteValueProviderFactory>();
mvc.ValueProviderFactories.Add(new ArraySupportingRouteValueProviderFactory());
});
Now if you call the endpoint with /users/1,2,3
, it should work!
You can also accept string or Guid arrays etc. as the model binder knows how to handle them by default.