Artur
Artur

Reputation: 5532

Asp.Net Core comma separated array in query string binder

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());
})

Upvotes: 4

Views: 3826

Answers (1)

Iridium
Iridium

Reputation: 63

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;
    }
}

Upvotes: 4

Related Questions