I'm trying to implement custom binder to allow comma separated list in query string. Based on this blog post and official documentation I have created some solution. But instead of using attributes to decorate wanted properties I want to make this behavior default for all collections of simple types (IList<T>, List<T>, T[], IEnumerable<T>... where T is int, string, short...)
But this solution looks very hacky because of manual creation of ArrayModelBinderProvider, CollectionModelBinderProvider and replacing bindingContext.ValueProvider with CommaSeparatedQueryStringValueProvider and I believe there should be a better way to achieve the same goal.
public class CommaSeparatedQueryBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context is null)
        {
            throw new ArgumentNullException(nameof(context));
        }
        var bindingSource = context.BindingInfo.BindingSource;
        if (bindingSource != null && bindingSource != BindingSource.Query)
        {
            return null;
        }
        if (!context.Metadata.IsEnumerableType)
        {
            return null;
        }
        if (context.Metadata.ElementMetadata.IsComplexType)
        {
            return null;
        }
        IModelBinderProvider modelBinderProvider;
        if (context.Metadata.ModelType.IsArray)
        {
            modelBinderProvider = new ArrayModelBinderProvider();
        }
        else
        {
            modelBinderProvider = new CollectionModelBinderProvider();
        }
        var binder = modelBinderProvider.GetBinder(context);
        return new CommaSeparatedQueryBinder(binder);
    }
}
public class CommaSeparatedQueryBinder : IModelBinder
{
    private readonly IModelBinder _modelBinder;
    public CommaSeparatedQueryBinder(IModelBinder modelBinder)
    {
        _modelBinder = modelBinder;
    }
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }
        var valueProviderLazy = new Lazy<CommaSeparatedQueryStringValueProvider>(() =>
            new CommaSeparatedQueryStringValueProvider(bindingContext.HttpContext.Request.Query));
        if (bindingContext.ValueProvider is CompositeValueProvider composite
            && composite.Any(provider => provider is QueryStringValueProvider))
        {
            var queryStringValueProvider = composite.First(provider => provider is QueryStringValueProvider);
            var index = composite.IndexOf(queryStringValueProvider);
            composite.RemoveAt(index);
            composite.Insert(index, valueProviderLazy.Value);
            await _modelBinder.BindModelAsync(bindingContext);
            composite.RemoveAt(index);
            composite.Insert(index, queryStringValueProvider);
        }
        else if(bindingContext.ValueProvider is QueryStringValueProvider)
        {
            var originalValueProvider = bindingContext.ValueProvider;
            bindingContext.ValueProvider = valueProviderLazy.Value;
            await _modelBinder.BindModelAsync(bindingContext);
            bindingContext.ValueProvider = originalValueProvider;
        }
        else
        {
            await _modelBinder.BindModelAsync(bindingContext);
        }
    }
}
public class CommaSeparatedQueryStringValueProvider : QueryStringValueProvider
{
    private const string Separator = ",";
    public CommaSeparatedQueryStringValueProvider(IQueryCollection values)
        : base(BindingSource.Query, values, CultureInfo.InvariantCulture)
    {
    }
    public override ValueProviderResult GetValue(string key)
    {
        var result = base.GetValue(key);
        if (result == ValueProviderResult.None)
        {
            return result;
        }
        if (result.Values.Any(x => x.IndexOf(Separator, StringComparison.OrdinalIgnoreCase) > 0))
        {
            var splitValues = new StringValues(result.Values
                .SelectMany(x => x.Split(Separator))
                .ToArray());
            return new ValueProviderResult(splitValues, result.Culture);
        }
        return result;
    }
}
Startup.cs
services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new CommaSeparatedQueryBinderProvider());
})
I've found this to be useful, though it only binds to arrays. This is code that combines answers from https://damieng.com/blog/2018/04/22/comma-separated-parameters-webapi/ and https://raw.githubusercontent.com/sgjsakura/AspNetCore/master/Sakura.AspNetCore.Extensions/Sakura.AspNetCore.Mvc.TagHelpers/FlagsEnumModelBinderServiceCollectionExtensions.cs. See those answers for code/blog comments.
Startup
services.AddMvc(options =>
{
    options.AddCommaSeparatedArrayModelBinderProvider();
})
Provider
public class CommaSeparatedArrayModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));
        return CommaSeparatedArrayModelBinder.IsSupportedModelType(context.Metadata.ModelType) ? new CommaSeparatedArrayModelBinder() : null;
    }
}
Binder
public class CommaSeparatedArrayModelBinder : IModelBinder
{
    private static Task CompletedTask => Task.CompletedTask;
    private static readonly Type[] supportedElementTypes = {
        typeof(int), typeof(long), typeof(short), typeof(byte),
        typeof(uint), typeof(ulong), typeof(ushort), typeof(Guid)
    };
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (!IsSupportedModelType(bindingContext.ModelType)) return CompletedTask;
        var providerValue = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (providerValue == ValueProviderResult.None) return CompletedTask;
        // Each value self may contains a series of actual values, split it with comma
        var strs = providerValue.Values.SelectMany(s => s.Split(',', StringSplitOptions.RemoveEmptyEntries)).ToList();
        if (!strs.Any() || strs.Any(s => String.IsNullOrWhiteSpace(s)))
            return CompletedTask;
        var elementType = bindingContext.ModelType.GetElementType();
        if (elementType == null) return CompletedTask;
        var realResult = CopyAndConvertArray(strs, elementType);
        bindingContext.Result = ModelBindingResult.Success(realResult);
        return CompletedTask;
    }
    internal static bool IsSupportedModelType(Type modelType)
    {
        return modelType.IsArray && modelType.GetArrayRank() == 1
                && modelType.HasElementType
                && supportedElementTypes.Contains(modelType.GetElementType());
    }
    private static Array CopyAndConvertArray(IList<string> sourceArray, Type elementType)
    {
        var targetArray = Array.CreateInstance(elementType, sourceArray.Count);
        if (sourceArray.Count > 0)
        {
            var converter = TypeDescriptor.GetConverter(elementType);
            for (var i = 0; i < sourceArray.Count; i++)
                targetArray.SetValue(converter.ConvertFromString(sourceArray[i]), i);
        }
        return targetArray;
    }
}
Helpers
public static class CommaSeparatedArrayModelBinderServiceCollectionExtensions
{
    private static int FirstIndexOfOrDefault<T>(this IEnumerable<T> source, Func<T, bool> predicate)
    {
        var result = 0;
        foreach (var item in source)
        {
            if (predicate(item))
                return result;
            result++;
        }
        return -1;
    }
    private static int FindModelBinderProviderInsertLocation(this IList<IModelBinderProvider> modelBinderProviders)
    {
        var index = modelBinderProviders.FirstIndexOfOrDefault(i => i is FloatingPointTypeModelBinderProvider);
        return index < 0 ? index : index + 1;
    }
    public static void InsertCommaSeparatedArrayModelBinderProvider(this IList<IModelBinderProvider> modelBinderProviders)
    {
        // Argument Check
        if (modelBinderProviders == null)
            throw new ArgumentNullException(nameof(modelBinderProviders));
        var providerToInsert = new CommaSeparatedArrayModelBinderProvider();
        // Find the location of SimpleTypeModelBinder, the CommaSeparatedArrayModelBinder must be inserted before it.
        var index = modelBinderProviders.FindModelBinderProviderInsertLocation();
        if (index != -1)
            modelBinderProviders.Insert(index, providerToInsert);
        else
            modelBinderProviders.Add(providerToInsert);
    }
    public static MvcOptions AddCommaSeparatedArrayModelBinderProvider(this MvcOptions options)
    {
        if (options == null)
            throw new ArgumentNullException(nameof(options));
        options.ModelBinderProviders.InsertCommaSeparatedArrayModelBinderProvider();
        return options;
    }
    public static IMvcBuilder AddCommaSeparatedArrayModelBinderProvider(this IMvcBuilder builder)
    {
        builder.AddMvcOptions(options => AddCommaSeparatedArrayModelBinderProvider(options));
        return builder;
    }
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With