I have an ASP.NET Web API (version 4) REST service where I need to pass an array of integers.
Here is my action method:
As Filip W points out, you might have to resort to a custom model binder like this (modified to bind to actual type of param):
public IEnumerable<Category> GetCategories([ModelBinder(typeof(CommaDelimitedArrayModelBinder))]long[] categoryIds)
{
// do your thing
}
public class CommaDelimitedArrayModelBinder : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
var key = bindingContext.ModelName;
var val = bindingContext.ValueProvider.GetValue(key);
if (val != null)
{
var s = val.AttemptedValue;
if (s != null)
{
var elementType = bindingContext.ModelType.GetElementType();
var converter = TypeDescriptor.GetConverter(elementType);
var values = Array.ConvertAll(s.Split(new[] { ","},StringSplitOptions.RemoveEmptyEntries),
x => { return converter.ConvertFromString(x != null ? x.Trim() : x); });
var typedValues = Array.CreateInstance(elementType, values.Length);
values.CopyTo(typedValues, 0);
bindingContext.Model = typedValues;
}
else
{
// change this line to null if you prefer nulls to empty arrays
bindingContext.Model = Array.CreateInstance(bindingContext.ModelType.GetElementType(), 0);
}
return true;
}
return false;
}
}
And then you can say:
/Categories?categoryids=1,2,3,4
and ASP.NET Web API will correctly bind your categoryIds
array.
DELETE /api/items/1,2
DELETE /api/items/1
public class CustomBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Metadata.ModelType == typeof(int[]) || context.Metadata.ModelType == typeof(List<int>))
{
return new BinderTypeModelBinder(typeof(CommaDelimitedArrayParameterBinder));
}
return null;
}
}
public class CommaDelimitedArrayParameterBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ActionContext.RouteData.Values[bindingContext.FieldName] as string;
// Check if the argument value is null or empty
if (string.IsNullOrEmpty(value))
{
return Task.CompletedTask;
}
var ints = value?.Split(',').Select(int.Parse).ToArray();
bindingContext.Result = ModelBindingResult.Success(ints);
if(bindingContext.ModelType == typeof(List<int>))
{
bindingContext.Result = ModelBindingResult.Success(ints.ToList());
}
return Task.CompletedTask;
}
}
services.AddMvc(options =>
{
// add custom binder to beginning of collection
options.ModelBinderProviders.Insert(0, new CustomBinderProvider());
});
/// <summary>
/// Deletes a list of items.
/// </summary>
/// <param name="itemIds">The list of unique identifiers for the items.</param>
/// <returns>The deleted item.</returns>
/// <response code="201">The item was successfully deleted.</response>
/// <response code="400">The item is invalid.</response>
[HttpDelete("{itemIds}", Name = ItemControllerRoute.DeleteItems)]
[ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)]
public async Task Delete(List<int> itemIds)
=> await _itemAppService.RemoveRangeAsync(itemIds);
EDIT: Microsoft recommends using a TypeConverter for these kids of operations over this approach. So follow the below posters advice and document your custom type with a SchemaFilter.
I originally used the solution that @Mrchief for years (it works great). But when when I added Swagger to my project for API documentation my end point was NOT showing up.
It took me a while, but this is what I came up with. It works with Swagger, and your API method signatures look cleaner:
In the end you can do:
// GET: /api/values/1,2,3,4
[Route("api/values/{ids}")]
public IHttpActionResult GetIds(int[] ids)
{
return Ok(ids);
}
WebApiConfig.cs
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
// Allow WebApi to Use a Custom Parameter Binding
config.ParameterBindingRules.Add(descriptor => descriptor.ParameterType == typeof(int[]) && descriptor.ActionDescriptor.SupportedHttpMethods.Contains(HttpMethod.Get)
? new CommaDelimitedArrayParameterBinder(descriptor)
: null);
// Allow ApiExplorer to understand this type (Swagger uses ApiExplorer under the hood)
TypeDescriptor.AddAttributes(typeof(int[]), new TypeConverterAttribute(typeof(StringToIntArrayConverter)));
// Any existing Code ..
}
}
Create a new class: CommaDelimitedArrayParameterBinder.cs
public class CommaDelimitedArrayParameterBinder : HttpParameterBinding, IValueProviderParameterBinding
{
public CommaDelimitedArrayParameterBinder(HttpParameterDescriptor desc)
: base(desc)
{
}
/// <summary>
/// Handles Binding (Converts a comma delimited string into an array of integers)
/// </summary>
public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider,
HttpActionContext actionContext,
CancellationToken cancellationToken)
{
var queryString = actionContext.ControllerContext.RouteData.Values[Descriptor.ParameterName] as string;
var ints = queryString?.Split(',').Select(int.Parse).ToArray();
SetValue(actionContext, ints);
return Task.CompletedTask;
}
public IEnumerable<ValueProviderFactory> ValueProviderFactories { get; } = new[] { new QueryStringValueProviderFactory() };
}
Create a new class: StringToIntArrayConverter.cs
public class StringToIntArrayConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
}
Notes:
I have created a custom model binder which converts any comma separated values (only primitive, decimal, float, string) to their corresponding arrays.
public class CommaSeparatedToArrayBinder<T> : IModelBinder
{
public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
Type type = typeof(T);
if (type.IsPrimitive || type == typeof(Decimal) || type == typeof(String) || type == typeof(float))
{
ValueProviderResult val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (val == null) return false;
string key = val.RawValue as string;
if (key == null) { bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Wrong value type"); return false; }
string[] values = key.Split(',');
IEnumerable<T> result = this.ConvertToDesiredList(values).ToArray();
bindingContext.Model = result;
return true;
}
bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Only primitive, decimal, string and float data types are allowed...");
return false;
}
private IEnumerable<T> ConvertToDesiredArray(string[] values)
{
foreach (string value in values)
{
var val = (T)Convert.ChangeType(value, typeof(T));
yield return val;
}
}
}
And how to use in Controller:
public IHttpActionResult Get([ModelBinder(BinderType = typeof(CommaSeparatedToArrayBinder<int>))] int[] ids)
{
return Ok(ids);
}
I just added the Query key (Refit lib) in the property for the request.
[Query(CollectionFormat.Multi)]
public class ExampleRequest
{
[FromQuery(Name = "name")]
public string Name { get; set; }
[AliasAs("category")]
[Query(CollectionFormat.Multi)]
public List<string> Categories { get; set; }
}
Instead of using a custom ModelBinder, you can also use a custom type with a TypeConverter.
[TypeConverter(typeof(StrListConverter))]
public class StrList : List<string>
{
public StrList(IEnumerable<string> collection) : base(collection) {}
}
public class StrListConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value == null)
return null;
if (value is string s)
{
if (string.IsNullOrEmpty(s))
return null;
return new StrList(s.Split(','));
}
return base.ConvertFrom(context, culture, value);
}
}
The advantage is that it makes the Web API method's parameters very simple. You dont't even need to specify [FromUri].
public IEnumerable<Category> GetCategories(StrList categoryIds) {
// code to retrieve categories from database
}
This example is for a List of strings, but you could do categoryIds.Select(int.Parse)
or simply write an IntList instead.