Right now if you want to bind to an IEnumerable<int> parameter you have to pass a query string with repeated parameters foo=1&foo=2&foo=3. It would be nice if you could use a single delimited query parameter instead like foo=1,2,3. It would be nice if this were built in but failing that here is my attempt.
I've implemented this as below and it works but I want to fallback to the default ArrayModelBinder or CollectionModelBinder if no delimiters are present in the query parameter, so I can add my DelimitedArrayModelBinderProvider to the MvcOptions. How can this be achieved? The ModelBinderProviderContext does not give you access to the query parameters, only to the model metadata, so I can't check that in the IModelBinderProvider.
.AddMvc(options =>
{
var arrayModelBinderProvider = options.ModelBinderProviders.OfType<ArrayModelBinderProvider>().First();
options.ModelBinderProviders.Insert(
options.ModelBinderProviders.IndexOf(arrayModelBinderProvider),
new DelimitedArrayModelBinderProvider());
})
public class DelimitedArrayModelBinderProvider : IModelBinderProvider
{
private readonly IModelBinder modelBinder;
public DelimitedArrayModelBinderProvider()
: this(',')
{
}
public DelimitedArrayModelBinderProvider(params char[] delimiters)
{
if (delimiters == null)
{
throw new ArgumentNullException(nameof(delimiters));
}
this.modelBinder = new DelimitedArrayModelBinder(delimiters);
}
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.IsEnumerableType && !context.Metadata.ElementMetadata.IsComplexType)
{
return this.modelBinder;
}
return null;
}
}
public class DelimitedArrayModelBinder : IModelBinder
{
private readonly char[] delimiters;
public DelimitedArrayModelBinder(char[] delimiters)
{
if (delimiters == null)
{
throw new ArgumentNullException(nameof(delimiters));
}
this.delimiters = delimiters;
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
{
throw new ArgumentNullException(nameof(bindingContext));
}
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
var values = valueProviderResult
.ToString()
.Split(this.delimiters, StringSplitOptions.RemoveEmptyEntries);
var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];
if (values.Length == 0)
{
bindingContext.Result = ModelBindingResult.Success(Array.CreateInstance(elementType, 0));
}
else
{
var converter = TypeDescriptor.GetConverter(elementType);
var typedArray = Array.CreateInstance(elementType, values.Length);
try
{
for (int i = 0; i < values.Length; ++i)
{
var value = values[i];
var convertedValue = converter.ConvertFromString(value);
typedArray.SetValue(convertedValue, i);
}
}
catch (Exception exception)
{
bindingContext.ModelState.TryAddModelError(
modelName,
exception,
bindingContext.ModelMetadata);
}
bindingContext.Result = ModelBindingResult.Success(typedArray);
}
return Task.CompletedTask;
}
}
The simpler approach that you could try would be to implement this as an IValueProvider. That affect any query string with a comma in it - but maybe that's what you wanted after all. To do this you'd probably want to subclass QueryStringValueProvider and override GetValue. You can also use an IResourceFilter to plug in your value provider for specific actions, OR you can replace it globally in MVC options
If that's not not, the approach that you're doing certainly will work, what I would do is hold on to ArrayModelBinderProvider and pass that into the constructor of your binder provider. Then when you create your binder, call into ArrayModelBinderProvider and create a fallback binder that will handle the case where there isn't a comma.
Here is my IValueProvider implementation. I've got a few questions:
ValueProviderFactories in MvcOptions matter and replaced QueryStringValueProviderFactory with DelimitedQueryStringValueProviderFactory. Wanted to confirm that this is correct. If order is important, where should I put it?IModelBinder, what are the advantages of that approach.Applied to single action method.
[HttpGet("bar")]
[DelimitedQueryString(',', '|')]
public IActionResult Bar(IEnumerable<int> ids) => this.Ok(ids);
Applied globally
services.AddMvc(options => options.ValueProviderFactories.AddDelimitedValueProviderFactory(',', '|'));
DelimitedQueryStringValueProvider.cs
public class DelimitedQueryStringValueProvider : QueryStringValueProvider
{
private readonly CultureInfo culture;
private readonly char[] delimiters;
private readonly IQueryCollection queryCollection;
public DelimitedQueryStringValueProvider(
BindingSource bindingSource,
IQueryCollection values,
CultureInfo culture,
char[] delimiters)
: base(bindingSource, values, culture)
{
this.queryCollection = values;
this.culture = culture;
this.delimiters = delimiters;
}
public char[] Delimiters { get { return this.delimiters; } }
public override ValueProviderResult GetValue(string key)
{
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
var values = this.queryCollection[key];
if (values.Count == 0)
{
return ValueProviderResult.None;
}
else if (values.Any(x => this.delimiters.Any(y => x.Contains(y))))
{
var stringValues = new StringValues(values
.SelectMany(x => x.Split(this.delimiters, StringSplitOptions.RemoveEmptyEntries))
.ToArray());
return new ValueProviderResult(stringValues, this.culture);
}
else
{
return new ValueProviderResult(values, this.culture);
}
}
}
DelimitedQueryStringValueProviderFactory.cs
/// <summary>
/// A <see cref="IValueProviderFactory"/> that creates <see cref="IValueProvider"/> instances that
/// read optionally delimited values from the request query-string.
/// </summary>
public class DelimitedQueryStringValueProviderFactory : IValueProviderFactory
{
private static readonly char[] DefaultDelimiters = new char[] { ',' };
private readonly char[] delimiters;
public DelimitedQueryStringValueProviderFactory()
: this(DefaultDelimiters)
{
}
public DelimitedQueryStringValueProviderFactory(params char[] delimiters)
{
if (delimiters == null || delimiters.Length == 0)
{
this.delimiters = DefaultDelimiters;
}
else
{
this.delimiters = delimiters;
}
}
/// <inheritdoc />
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var valueProvider = new DelimitedQueryStringValueProvider(
BindingSource.Query,
context.ActionContext.HttpContext.Request.Query,
CultureInfo.InvariantCulture,
this.delimiters);
context.ValueProviders.Add(valueProvider);
return TaskCache.CompletedTask;
}
}
DelimitedQueryStringAttribute.cs
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
public class DelimitedQueryStringAttribute : Attribute, IResourceFilter
{
private readonly char[] delimiters;
public DelimitedQueryStringAttribute(params char[] delimiters)
{
this.delimiters = delimiters;
}
/// <summary>
/// Executes the resource filter. Called after execution of the remainder of the pipeline.
/// </summary>
/// <param name="context">The <see cref="T:Microsoft.AspNetCore.Mvc.Filters.ResourceExecutedContext" />.</param>
public void OnResourceExecuted(ResourceExecutedContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
// Don't need to do anything.
}
public void OnResourceExecuting(ResourceExecutingContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
context.ValueProviderFactories.AddDelimitedValueProviderFactory(this.delimiters);
}
}
ValueProviderFactoriesExtensions.cs
public static class ValueProviderFactoriesExtensions
{
public static void AddDelimitedValueProviderFactory(
this IList<IValueProviderFactory> valueProviderFactories,
params char[] delimiters)
{
var queryStringValueProviderFactory = valueProviderFactories
.OfType<QueryStringValueProviderFactory>()
.FirstOrDefault();
if (queryStringValueProviderFactory == null)
{
valueProviderFactories.Insert(
0,
new DelimitedQueryStringValueProviderFactory(delimiters));
}
else
{
valueProviderFactories.Insert(
valueProviderFactories.IndexOf(queryStringValueProviderFactory),
new DelimitedQueryStringValueProviderFactory(delimiters));
valueProviderFactories.Remove(queryStringValueProviderFactory);
}
}
}
I've assumed that the order of the ValueProviderFactories in MvcOptions matter and replaced QueryStringValueProviderFactory with DelimitedQueryStringValueProviderFactory. Wanted to confirm that this is correct. If order is important, where should I put it?
This is correct. Order is important and replacing QueryStringValueProviderFactory with your own is the right thing to do.
This seems far simpler than IModelBinder, what are the advantages of that approach.
IModelBinder changes how a type/property/parameter/object gets instantiated and how its properties get set. IValueProvider gathers the input strings from the request. Generally we think of the value providers as the inputs to the model binders. You don't need a custom array binder because you're not changing how the array gets bound, you're changing what data gets used.
Can this be built in to ASP.NET Core?
I would tentatively say no. These kinds of conventions are largely a product of a particular javascript client that's in use and tend to come and go. We ended up (a long time ago) putting one in the box for a particular way that jQuery likes to build form posts, and we haven't been able to get rid of it because of the level that it would break compatibility.
I think it's a very common thing to want to do when writing an API. It would be cool to have it in ASP.NET Core but maybe not hooked up. Thanks for the pointers.
@RehanSaeed's IValueProvider implementation will break the standard behavior for binding to flagged enums. for example ?enum=A,B will only bind to the first flag A.
One workaround would be to also add the FlagsEnumModelBinder from Sakura.AspNetCore.Extensions
Most helpful comment
I think it's a very common thing to want to do when writing an API. It would be cool to have it in ASP.NET Core but maybe not hooked up. Thanks for the pointers.