I have this web api method:
[HttpGet]
[Route(\"WorkPlanList/{clientsId}/{date:datetime}\")]
public async Task
Your problem intrigued me so I wanted to come up with a solution that was a bit more generic than the answer Nkosi provided. While Nkosi's answer will work, I'm not a fan of the ModelBinder syntax as well as defining a new ModelBinder for each type. I've been playing around with ParameterBindingAttribute before and really like the syntax so I wanted to start with this. This allows you to define a [FromUri] or [FromBody] like syntax. I also wanted to be able to use different "array" types such as int[] or List or HashSet or best yet, IEnumerable.
Step 1: Create a HttpParameterBinding
Step 2: Create a ParameterBindingAttribute
Step 3: Put it all together
An HttpParameterBinding allows you to parse any RouteData and pass them to your methed by setting the actionContext's ActionArguments dictionary. You simply inherit from HttpParameterBinding and override the ExecuteBindingAsync method. You can throw exception here if you want, but you can also just let it flow through and the method will receive null if it wasn't able to parse the RouteData. For this example, I am creating a JSON string of an array made from the RouteData. Since we know Json.NET is amazing at parsing data types, it seemed natural to use it. This will parse the RouteData for a CSV value. This works best for ints or dates.
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Web.Http.Controllers;
using System.Web.Http.Metadata;
using Newtonsoft.Json;
public class CsvParameterBinding : HttpParameterBinding
{
public CsvParameterBinding(HttpParameterDescriptor descriptor) : base(descriptor)
{
}
public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, HttpActionContext actionContext, CancellationToken cancellationToken)
{
var paramName = this.Descriptor.ParameterName;
var rawParamemterValue = actionContext.ControllerContext.RouteData.Values[paramName].ToString();
var rawValues = rawParamemterValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
//To convert the raw value int a true JSON array we need to make sure everything is quoted.
var jsonString = $"[\"{string.Join("\",\"", rawValues)}\"]";
try
{
var obj = JsonConvert.DeserializeObject(jsonString, this.Descriptor.ParameterType);
actionContext.ActionArguments[paramName] = obj;
}
catch
{
//There was an error casting, the jsonString must be invalid.
//Don't set anything and the action will just receive null.
}
return Task.FromResult<object>(null);
}
}
A ParameterBindingAttribute allows us to use the clean syntax of declaring the binding right in the method signature. I decided I wanted to use [FromUriCsv] as the syntax so the class is named appropriately. The only thing to overrid is the GetBinding method in which we wire up the CsvParameterBinding class we just made.
using System.Web.Http;
using System.Web.Http.Controllers;
public class FromUriCsvAttribute : ParameterBindingAttribute
{
public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter)
{
return new CsvParameterBinding(parameter);
}
}
Now to put it on the controller and use it.
[Route("WorkPlanList/{clientsId}/{date:datetime}")]
public async Task<IHttpActionResult> WorkPlanList([FromUriCsv] List<int> clientsId, [FromUri] DateTime date)
{
//matches WorkPlanList/2,3,4/7-3-2016
}
[Route("WorkPlanList/{clientsId}")]
public async Task<IHttpActionResult> WorkPlanList([FromUriCsv] HashSet<int> clientsId)
{
//matches WorkPlanList/2,3,4,5,2
//clientsId will only contain 2,3,4,5 since it's a HashSet the extra 2 won't be included.
}
[Route("WorkPlanList/{clientsId}/{dates}")]
public async Task<IHttpActionResult> WorkPlanList([FromUriCsv] IEnumerable<int> clientsId, [FromUriCsv] IEnumerable<DateTime> dates)
{
//matches WorkPlanList/2,3,4/5-2-16,6-17-16,7-3-2016
}
I really liked how this turned it. It works really well for ints and dates, but failed with decimals because the period in the route really jacks it up. For now, this solves your problem very well. If decimal numbers are needed, the routing should be able to be tweaked using regex or mvc style routing. I've used a similar method to this in order to pull complex types out of header values which worked really well with [FromHeader("headerName")]
syntax.
You get 0
on clientsId
because the framework is unable to bind the value 4,5
in your example to a List<int>
. In this case you use a custom model binder that will parse the value into the type you want and bind it to your action parameter:
[RoutePrefix("blabla/api/workplan")]
public class WorkPlanController : ApiController {
[HttpGet]
[Route("WorkPlanList/{clientsId}/{date:datetime}")]
public IHttpActionResult WorkPlanList([ModelBinder(typeof(ClientsIdBinder))]List<int> clientsId, [FromUri]DateTime date) {
var result = new { clientsId, date };
return (Ok(result));
}
}
public class ClientsIdBinder : IModelBinder {
public bool BindModel(System.Web.Http.Controllers.HttpActionContext actionContext, ModelBindingContext bindingContext) {
if (!typeof(IEnumerable<int>).IsAssignableFrom(bindingContext.ModelType)) {
return false;
}
var val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (val == null) {
return false;
}
var ids = val.RawValue as string;
if (ids == null) {
return false;
}
var tokens = ids.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length > 0) {
var clientsId = tokens.Select(s => int.Parse(s));
if (bindingContext.ModelType.IsArray) {
bindingContext.Model = clientsId.ToArray();
} else {
bindingContext.Model = clientsId.ToList();
}
return true;
}
bindingContext.ModelState.AddModelError(
bindingContext.ModelName, "Cannot convert client ids");
return false;
}
}
Reference: Parameter Binding in ASP.NET Web API
Custom model binding is one option. But easier would be to pass the values in the request body rather than in the URI.
As best practice, complex data should not be present in the URI. So workaround in your case would be to :
create a JSON array and include it in the request body.
write [FromBody]
before List<int> clientsId
, which will force the framework to retrieve the data from request body. Model binding will happen automatically.
tried submitting as an edit to @ManOVision's answer, but it might be more appropriate to show separately.
In implementing his answer, i found that it does not support binding params that are optional. i've made some updates to support that as shown below.
the error i was receiving when not passing the param was:
{
"Message": "The request is invalid.",
"MessageDetail": "The parameters dictionary does not contain an entry for parameter 'skus' of type 'System.String[]' for method 'System.Web.Http.IHttpActionResult Get(System.String[], System.String, System.String, System.String, System.String, Boolean)' in 'eGAPI.Controllers.GiftCardsController'. The dictionary must contain an entry for each parameter, including parameters that have null values."
}
implementation:
[Route("{skus}")]
public IHttpActionResult Get([FromUriCsv] string[] skus = null)
updated code:
public override Task ExecuteBindingAsync(ModelMetadataProvider metadataProvider, HttpActionContext actionContext, CancellationToken cancellationToken)
{
var paramName = Descriptor.ParameterName;
try
{
if (actionContext.ControllerContext.RouteData.Values.ContainsKey(paramName))
{
var rawParamemterValue = actionContext.ControllerContext.RouteData.Values[paramName]?.ToString();
if (!string.IsNullOrEmpty(rawParamemterValue))
{
var rawValues = rawParamemterValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
actionContext.ActionArguments[paramName] = JsonConvert.DeserializeObject($"[\"{string.Join("\",\"", rawValues)}\"]", Descriptor.ParameterType);
}
else
{
actionContext.ActionArguments[paramName] = null;
}
}
else
{
actionContext.ActionArguments[paramName] = null;
}
}
catch (Exception)
{
actionContext.ActionArguments[paramName] = null;
}
return Task.FromResult<object>(null);
}