8

I want to send a complex object with a nested array in the uri to an MVC action method in a GET request.

Consider the following code:

 public ActionResult AutoCompleteHandler([FromUri]PartsQuery partsQuery){ ... }

 public class PartsQuery
 {
     public Part[] Parts {get; set; }
     public string LastKey { get; set; }
     public string Term { get; set; }
 }

 $.ajax({ 
    url: "Controller/AutoCompleteHandler", 
    data: $.param({                                        
                      Parts: [{ hasLabel: "label", hasType: "type", hasIndex : 1 }],
                      LastKey : "Last Key",
                      Term : "Term"                             
                   }),
    dataType: "json", 
    success: function(jsonData) { ... }
 });

This works just fine and binds correctly using the default model binder in MVC Web Api.

However, switch this to plain MVC not WebApi and the default model binder breaks down and cannot bind the properties on objects in the nested array:

Watch List

partsQuery      != null          //Good
--LastKey       == "Last Key"    //Good
--Term          == "Term"        //Good
--Parts[]       != null          //Good
----hasLabel    == null          //Failed to bind
----hasType     == null          //Failed to bind
----hasIndex    == 0             //Failed to bind

I would like to know why this breaks down in plain MVC and how to make FromUriAttribute bind this object correctly in plain MVC

6
  • What happens if you change Part[] Parts to ICollection<Part> Parts? And can you show the class definition for Part? Commented Jul 10, 2013 at 19:17
  • Trying that now. Part is simple POCO with public { get; set; } props Commented Jul 10, 2013 at 19:28
  • Does Part have a parameterless constructor? Commented Jul 10, 2013 at 19:29
  • ICollection didn't fix the binding issue. It previously did not have a parameterless constructor but I just tried with one and still no go (tried with ICollection). Commented Jul 10, 2013 at 19:32
  • Is this Ajax call still required to be a Get? Commented Jul 10, 2013 at 21:14

2 Answers 2

10

Core issue here is that MVC and WebApi use different model binders. Even base interfaces are different.

Mvc - System.Web.Mvc.IModelBinder
Web API - System.Web.Http.ModelBinding.IModelBinder

When you send data with your $.ajax call, you are sending following query string parameters:

Parts[0][hasLabel]:label
Parts[0][hasType]:type
Parts[0][hasIndex]:1
LastKey:Last Key
Term:Term

While, proper format that would bind with MVC default model binder has different naming convention for parameter names:

Parts[0].hasLabel:label
Parts[0].hasType:type
Parts[0].hasIndex:1
LastKey:Last Key
Term:Term

So, this method call would work:

$.ajax({ 
    url: "Controller/AutoCompleteHandler?Parts[0].hasLabel=label&Parts[0].hasType=type&Parts[0].hasIndex=1&LastKey=Last+Key&Term=Term",
    dataType: "json", 
    success: function(jsonData) { ... }
});

You need to construct your query string respecting MVC model binder naming conventions.

Additionally [FromUri] attribute in your example action is completely ignored, since it's not known to MVC DefaultModelBinder.

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks for the response but I already knew that much. 2 possible solutions would be to coerce the url string into the correct convention on the client side, my question here: stackoverflow.com/questions/17580471/… , OR make a custom model binder to handle the default $.param() convention, my question here: stackoverflow.com/q/17558735/1267778.. But I'm thinking the cleanest solution might be to make MVC support FromUri, which is what this question is for. If nobody can answer that I will mark your answer
Regex is a sufficiently clean solution so I'll mark yours as answer because it's valuable nonetheless. But if someone can actually answer the real question of how to make FromUri work in MVC I'll mark that. Thanky ou
I think making Web API binding work in MVC controller would be huge effort. Pipelines are completely different and to invoke Web API binding you would have to instantiate properly bunch of lower-level web-api specific objects (HttpControllerDescriptor, HttpActionDescriptor, ModelBindingContext, etc).
0

You can write a custom binder. This is for NET 9, but it should work for NET Core in general (I think).

Here's a sample of one I wrote for DataTables.Net, which submits GETs in a similar way...

This are my classes

public class DataTableRequest : IDataTableRequest
{
    public int? Draw { get; set; }
    public IEnumerable<Column>? Columns { get; set; }
    public IEnumerable<Order>? Order { get; set; }
    public int? Start { get; set; }
    public int? Length { get; set; }
    public ISearch? Search { get; set; }
    public Dictionary<string, string?>? OtherValues { get; set; }
}

public class Column : IColumn
{
    public string? Data { get; set; }
    public string? Name { get; set; }
    public bool? Searchable { get; set; }
    public bool? Orderable { get; set; }
    public Search? Search { get; set; }
}

public class Search : ISearch
{
    public string? Value { get; set; }
    public bool? Regex { get; set; }
}

public class Order : IOrder
{
    public int? Column { get; set; }
    public string? Dir { get; set; }
    public string? Name { get; set; }
}

And these are my binders

// DataTableRequestBinder.cs

using System.Text.RegularExpressions;
using CorreoNet.Web.Models.DataTablesNet;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace CorreoNet.Web.Binders;

public class DataTableRequestBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await Task.Delay(0);
        
        var request = bindingContext.HttpContext.Request;
        var values = bindingContext.ValueProvider;
        
        // initialize with the simple types
        var model = new DataTableRequest
        {
            Draw = int.TryParse(values.GetValue("draw").FirstValue, out var draw) ? draw : null,
            Start = int.TryParse(values.GetValue("start").FirstValue, out var start) ? start : null,
            Length = int.TryParse(values.GetValue("length").FirstValue, out var length) ? length : null,
            Columns = new List<Column>(),
            OtherValues = new Dictionary<string, string?>(),
        };
        
        // get columns
        if (values.ContainsPrefix("columns"))
        {
            var keys = request.Query.Keys.Where(k => k.StartsWith("columns[")).ToList();
            var colNumbers = keys.Select(k => Regex.Match(k, @"columns\[(\d+)\]").Groups[1].Value).Distinct().ToList();
            var columns = new List<Column>();
            
            foreach (var colNumber in colNumbers)
            {
                var column = new Column
                {
                    Data = values.GetValue($"columns[{colNumber}][data]").FirstValue,
                    Name = values.GetValue($"columns[{colNumber}][name]").FirstValue,
                    Searchable = bool.TryParse(values.GetValue($"columns[{colNumber}][searchable]").FirstValue, out var searchable) ? searchable : null,
                    Orderable = bool.TryParse(values.GetValue($"columns[{colNumber}][orderable]").FirstValue, out var orderable) ? orderable : null,
                    Search = new Search
                    {
                        Value = values.GetValue($"columns[{colNumber}][search][value]").FirstValue,
                        Regex = bool.TryParse(values.GetValue($"columns[{colNumber}][search][regex]").FirstValue, out var searchRegex) ? searchRegex : null,
                    }
                };
                
                columns.Add(column);
            }
            
            model.Columns = columns;
        }

        // get order
        if (values.ContainsPrefix("order"))
        {
            var keys = request.Query.Keys.Where(k => k.StartsWith("order[")).ToList();
            var colNumbers = keys.Select(k => Regex.Match(k, @"order\[(\d+)\]").Groups[1].Value).Distinct().ToList();
            var orders = new List<Order>();
            
            foreach (var colNumber in colNumbers)
            {
                var order = new Order
                {
                    Column = int.TryParse(values.GetValue($"order[{colNumber}][column]").FirstValue, out var column) ? column : null,
                    Dir = values.GetValue($"order[{colNumber}][dir]").FirstValue,
                    Name = values.GetValue($"order[{colNumber}][name]").FirstValue,
                };
                
                orders.Add(order);
            }
            
            model.Order = orders;
        }

        // get search
        if (values.ContainsPrefix("search"))
        {
            model.Search = new Search()
            {
                Value = values.GetValue("search[value]").FirstValue,
                Regex = bool.TryParse(values.GetValue("search[regex]").FirstValue, out var searchRegex) ? searchRegex : null,
            };
        }
        
        
        // get other keys
        var otherKeys = request.Query.Keys.Where(k => 
            !k.StartsWith("columns[") && 
            !k.StartsWith("draw") && 
            !k.StartsWith("order[") && 
            !k.StartsWith("search[") && 
            !k.StartsWith("start") && 
            !k.StartsWith("length")
        ).ToList();

        foreach (var key in otherKeys)
            model.OtherValues.Add(key, values.GetValue(key).FirstValue);       

        // bind!!!
        bindingContext.Result = ModelBindingResult.Success(model);
    }
}

and the provider, which you can add in your program.cs

// DataTableRequestBinderProvider.cs

using CorreoNet.Web.Models.DataTablesNet;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;

namespace CorreoNet.Web.Binders;

public class DataTableRequestBinderProvider: IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        ArgumentNullException.ThrowIfNull(context);

        return (context.Metadata.ModelType == typeof(DataTableRequest)) ? 
            new BinderTypeModelBinder(typeof(DataTableRequestBinder)) : 
            null;
    }
}

I know it isn't exactly what you're looking for, but it did certainly helped me fix the same problem you have. Maybe it helps you a little bit.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.