9

How do you model bind an array from the URI with GET in ASP.NET Core 1 Web API (implicitly or explicitly)?

In ASP.NET Web API pre Core 1, this worked:

[HttpGet]
public void Method([FromUri] IEnumerable<int> ints) { ... }

How do you do this in ASP.NET Web API Core 1 (aka ASP.NET 5 aka ASP.NET vNext)? The docs have nothing.

bzlm
  • 9,626
  • 6
  • 65
  • 92
marras
  • 91
  • 1
  • 2

3 Answers3

18

The FromUriAttribute class combines the FromRouteAttribute and FromQueryAttribute classes. Depending the configuration of your routes / the request being sent, you should be able to replace your attribute with one of those.

However, there is a shim available which will give you the FromUriAttribute class. Install the "Microsoft.AspNet.Mvc.WebApiCompatShim" NuGet package through the package explorer, or add it directly to your project.json file:

"dependencies": {
  "Microsoft.AspNet.Mvc.WebApiCompatShim": "6.0.0-rc1-final"
}

While it is a little old, I've found that this article does a pretty good job of explaining some of the changes.

Binding

If you're looking to bind comma separated values for the array ("/api/values?ints=1,2,3"), you will need a custom binder just as before. This is an adapted version of Mrchief's solution for use in ASP.NET Core.

public class CommaDelimitedArrayModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelMetadata.IsEnumerableType)
        {
            var key = bindingContext.ModelName;
            var value = bindingContext.ValueProvider.GetValue(key).ToString();

            if (!string.IsNullOrWhiteSpace(value))
            {
                var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];
                var converter = TypeDescriptor.GetConverter(elementType);

                var values = value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
                    .Select(x => converter.ConvertFromString(x.Trim()))
                    .ToArray();

                var typedValues = Array.CreateInstance(elementType, values.Length);

                values.CopyTo(typedValues, 0);
                
                bindingContext.Result = ModelBindingResult.Success(typedValues);
            }
            else
            {
                // change this line to null if you prefer nulls to empty arrays 
                bindingContext.Result = ModelBindingResult.Success(Array.CreateInstance(bindingContext.ModelType.GetElementType(), 0));
            }

            return TaskCache.CompletedTask;
        }

        return TaskCache.CompletedTask;
    }
}

You can either specify the model binder to be used for all collections in Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddMvc().AddMvcOptions(opts =>
        {
            opts.ModelBinders.Insert(0, new CommaDelimitedArrayModelBinder());
        });
}

Or specify it once in your API call:

[HttpGet]
public void Method([ModelBinder(BinderType = typeof(CommaDelimitedArrayModelBinder))] IEnumerable<int> ints)
Community
  • 1
  • 1
Will Ray
  • 10,621
  • 3
  • 46
  • 61
  • The question is about *implicit array* model binding specifically. Using `FromQuery` or `FromRoute` in ASP.NET Core 1 does *not* enable comma separated implicit array model binding for `HttpGet` (which it does in ASP.NET Web API and ASP.NET MVC). – bzlm May 01 '16 at 18:36
  • @bzlm There's no mention of binding comma separated array values in the question. Where are you getting that from? – Will Ray May 01 '16 at 20:16
  • The word "array" is in the question title ("model binding *array*"), and the code example has `IEnumerable ints` as the parameter to the action. :) Edited the question for clarity. Do you know the answer (using `FromQuery` or not)? – bzlm May 02 '16 at 07:39
  • There is no mention of the phrase "comma separated", which is where I'm asking for clarity. A request like `/api/values?ints=1&ints=2&ints3` will work without the need for any attribute. A request like `/api/values?ints=1,2,3` will need a custom model binder, just as before. I have added a binder to the answer. – Will Ray May 02 '16 at 14:04
  • 1
    This doesnt work anymore, i have tried and tried....and although modelBinder runs, the parameter on the controller says it is null – Gillardo Dec 02 '16 at 16:17
  • In .net core 1.1 the signture of interface 'IModelBinder' was updated. See this link "https://learn.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding", how to adapt it. Add in the end of "BindModelAsync" Task.CompletedTask or TaskCache.CompletedTask and set "bindingContext.Result = ModelBindingResult.Success(typedValues)" – rock_walker Jul 21 '17 at 10:05
7

ASP.NET Core 1.1 Answer

@WillRay's answer is a little outdated. I have written an 'IModelBinder' and 'IModelBinderProvider'. The first can be used with the [ModelBinder(BinderType = typeof(DelimitedArrayModelBinder))] attribute, while the second can be used to apply the model binder globally as I've show below.

.AddMvc(options =>
{
    // Add to global model binders so you don't need to use the [ModelBinder] attribute.
    var arrayModelBinderProvider = options.ModelBinderProviders.OfType<ArrayModelBinderProvider>().First();
    options.ModelBinderProviders.Insert(
        options.ModelBinderProviders.IndexOf(arrayModelBinderProvider),
        new DelimitedArrayModelBinderProvider());
})

public class DelimitedArrayModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.IsEnumerableType && !context.Metadata.ElementMetadata.IsComplexType)
        {
            return new DelimitedArrayModelBinder();
        }

        return null;
    }
}

public class DelimitedArrayModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        var modelName = bindingContext.ModelName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
        var values = valueProviderResult
            .ToString()
            .Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
        var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];

        if (values.Length == 0)
        {
            bindingContext.Result = ModelBindingResult.Success(Array.CreateInstance(elementType, 0));
        }
        else
        {
            var converter = TypeDescriptor.GetConverter(elementType);
            var typedArray = Array.CreateInstance(elementType, values.Length);

            try
            {
                for (int i = 0; i < values.Length; ++i)
                {
                    var value = values[i];
                    var convertedValue = converter.ConvertFromString(value);
                    typedArray.SetValue(convertedValue, i);
                }
            }
            catch (Exception exception)
            {
                bindingContext.ModelState.TryAddModelError(
                    modelName,
                    exception,
                    bindingContext.ModelMetadata);
            }

            bindingContext.Result = ModelBindingResult.Success(typedArray);
        }

        return Task.CompletedTask;
    }
}
Muhammad Rehan Saeed
  • 35,627
  • 39
  • 202
  • 311
  • I went ahead and updated my original answer, just to be functional in 1.1. Really like the solution you've created for the provider! – Will Ray Jul 28 '17 at 06:30
  • 2
    `bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0];` throws an exception when binding against `string[]` using this instead `var elementType = bindingContext.ModelType.GetElementType();` – JJS Aug 08 '17 at 18:07
  • Unfortunately this code doesn't allow a single element, only assumes that there is always a delimiter present. – mko Apr 15 '20 at 06:15
0

There are some changes in the .NET Core 3.

Microsoft has split out the functionality from the AddMvc method (source).

As AddMvc also includes support for View Controllers, Razor Views and etc. If you don't need to use them in your project (like in an API), you might consider using services.AddControllers() which is for Web API controllers.

So, updated code will look like this:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
            .AddMvcOptions(opt =>
            {
                var mbp = opt.ModelBinderProviders.OfType<ArrayModelBinderProvider>().First();
                    opt.ModelBinderProviders.Insert(opt.ModelBinderProviders.IndexOf(mbp), new DelimitedArrayModelBinderProvider());
            });
}
Dimitri
  • 141
  • 2
  • 4
  • 18