3

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());
})
Artur
  • 4,595
  • 25
  • 38
  • Why not use the normal binder for lists and arrays instead of creating a custom binder? – Jonathan Alfaro Jan 03 '21 at 05:13
  • I'm migrating NancyFx to Asp.Net Core. NancyFx supported passing list in query string as comma separated values: `?list=val1,val2,val3`, while Asp.Net Core only supports this representation: `?list=val1&list=val2&list=val3`. So to keep the system backward compatible I need to support old style. – Artur Jan 03 '21 at 06:36

1 Answers1

3

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;
    }
}
Iridium
  • 53
  • 5
  • 3
    Great job! It worked great. I just posted this as a single file gist: https://gist.github.com/copernicus365/74aff0b560b985f7d2b9c61a608c0a64 – Nicholas Petersen Dec 08 '21 at 20:28