Reputation: 5532
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
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