Descargar Código Fuente
El otro día estaba intentando enviar datos a una acción utilizando el AJAX de jQuery. En la mayoría de los casos, podrán utilizar este método y enviar datos a una acción sin problemas, ya que el default binder de MVC se encarga de interpretar los datos.
Mi problema surgió cuando intenté enviar datos más complejos. En el controlador tenía el siguiente método:
1: [AcceptVerbs(HttpVerbs.Post)]
2: public virtual JsonResult Actualizar(Producto producto)
Este método sólo acepta un POST, y tiene un argumento de tipo Producto. Veamos cómo está compuesta esta clase:
3: public int Id { get; set; }
4: public string Nombre { get; set; }
5: public IEnumerable<Promocion> Promociones { get; set; }
7: public class Promocion
9: public string Id { get; set; }
10: public double Descuento { get; set; }
11: public DateTime FechaDesde { get; set; }
12: public DateTime FechaHasta { get; set; }
Para poder enviar esto utilizando jQuery y AJAX, lo más sencillo sería hacer:
1: var data = {
2: "Id": 1,
3: "Nombre": "Cualquier Nombre",
4: "Promociones": [
5: {
6: "Id": 1,
7: "Descuento": 0.15,
8: "FechaDesde": new Date(año, mes, día),
9: "FechaHasta": new Date(año, mes, día)
10: },
11: {
12: "Id": 2,
13: "Descuento": 0.05,
14: "FechaDesde": new Date(año, mes, día),
15: "FechaHasta": new Date(año, mes, día)
16: }
17: ]
18: };
19:
20: $.ajax({
21: type: "POST",
22: dataType: "json", // Tipo de dato que devuelve el server
23: url: 'Controller/Action',
24: data: data, // Datos a enviar
25: success: function(serverResponse) {
26: // Realizar algo cuando la llamada es exitosa
27: },
28: error: function() {
29: // Realizar algo cuando la llamada falla
30: }
31: });
Lo que estoy haciendo es crear un objeto con los miembro Id, Nombre y Promociones y asignándolo a la variable data.
Sin embargo, el miembro Promociones es un objeto de tipo Array. Por default, la función $.ajax() intentará convertir cualquier dato a un string. Como Promociones es un array, al intentar convertirlo a un string, terminará enviando al servidor lo siguiente:
IDDivision=6&IDBonificacion=LB6&Tipo=A&Vigencias=[object+Object]&Vigencias=[object+Object]
Es decir, jQuery serializa los valores de un mismo array con el mismo Key. Por ejemplo, {foo:["bar1", "bar2"]} se convierte en '&foo=bar1&foo=bar2'.
Sin embargo, como el valor de cada elemento de Promociones es otro objeto, lo que termina enviando es el .toString() del objeto, el cual es "[object Object]" justamente. El Default Model Binder de ASP.NET MVC, intentará hacer el bind al objeto Producto, pero debido a que jQuery serializo incorrectamente el objeto, el miembro Promociones terminará siendo null, en lugar de los datos que estábamos intentando enviar realmente.
Para resolver este problema, podemos convertir los objetos javascript a JSON. Para hacer esto, utilicé JSON for jQuery (link), pero se puede utilizar cualquier cosa que tome un objeto javascript y lo transforme a JSON. Al enviar los datos al server, debemos asegurarnos que el content type sea del tipo application/json, que es lo que estoy haciendo en la línea 5 del siguiente código:
1: var jsonString = $.toJSON(data);
2: $.ajax({
3: type: "POST",
4: dataType: "json", // Tipo de dato que devuelve el server
5: contentType: 'application/json', // Tipo de datos que envío
6: url: 'Controller/Action',
7: data: jsonString, // Datos a enviar
8: success: function(serverResponse) {
9: // Realizar algo cuando la llamada es exitosa
10: },
11: error: function() {
12: // Realizar algo cuando la llamada falla
13: }
14: });
Fíjense que antes de hacer la llamada a ajax(), estoy transformando la variable data a JSON, y pasando su resultado a la función $.ajax(), en lugar del objeto. La variable jsonString entonces quedará en:
'{"Id": 1, "Nombre": "Cualquier Nombre", "Promociones": [{ "Id": 1, "Descuento": 0.15, "FechaDesde": "2010-01-01T03:00:00.000Z", "FechaHasta": "2010-05-31T03:00:00.000Z" }, { "Id": 2, "Descuento": 0.05, "FechaDesde": "2010-06-02T03:00:00.000Z", "FechaHasta": "2010-08-01T03:00:00.000Z" } ] }'
Si prestan atención, podrán darse cuenta que el JSON string es exactamente igual que la declaración de la variable data, definida más arriba. La única diferencia es que jsonString es literalmente un string, y data es un objeto javascript.
Ahora, lo único que queda resolver es el Model Binding del lado del servidor. Lamentablemente, ASP.NET MVC 2 no interpreta JSON correctamente, por lo tanto vamos a tener que hacer algo para hacerlo funcionar.
El enfoque más común sería hacer un Custom Model Binder. Sin embargo, esto implica que en cada acción que recibe JSON, debemos indicar explícitamente a MVC que utilice nuestro Model Binder. Si se trata de una sola acción, no habría ningún problema, pero si hay muchas acciones que utilizan el Model Binder, y todas reciben distintos tipos de datos, se va a volver muy tediosa la implementación.
Por suerte, en MVC 2, tenemos algo llamado Value Providers. Mientras que los Model Binders son utilizados para bindear datos que recibe el servidor, Value Providers proveen una abstracción para los datos en sí.
Para resolver mi problema, creé un Custom Value Provider que recibe datos JSON y lo serializa a un diccionario, en lugar de al objeto. Luego, éste diccionario es pasado al Default Model Binder de MVC, el cual realiza el bind al objeto final, e incluso realiza cualquier validación que ustedes hayan indicado.
El Custom Value Provider es el siguiente:
1: public class JsonValueProviderFactory : ValueProviderFactory
2: {
3: public override IValueProvider GetValueProvider
4: (ControllerContext controllerContext)
5: {
6: object jsonData = GetDeserializedJson(controllerContext);
7:
8: if (jsonData == null)
9: return null;
10:
11: var dictionary = new Dictionary<string, object>
12: (StringComparer.OrdinalIgnoreCase);
13:
14: FlattenToDictionary(dictionary, string.Empty, jsonData);
15:
16: return new DictionaryValueProvider<object>
17: (dictionary, CultureInfo.CurrentCulture);
18: }
19:
20: private static object GetDeserializedJson
21: (ControllerContext controllerContext)
22: {
23: if (!controllerContext.HttpContext.Request.ContentType
24: .StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
25: return null;
26:
27: string bodyText = new StreamReader
28: (controllerContext.HttpContext.Request.InputStream).ReadToEnd();
29:
30: if (string.IsNullOrEmpty(bodyText))
31: return null;
32:
33: var serializer = new JavaScriptSerializer();
34:
35: return serializer.DeserializeObject(bodyText);
36: }
37:
38: private static void FlattenToDictionary(
39: IDictionary<string, object> dictionary, string prefix, object value)
40: {
41: var dictionaryValue = value as IDictionary<string, object>;
42:
43: if (dictionaryValue != null)
44: {
45: foreach (KeyValuePair<string, object> entry in dictionaryValue)
46: {
47: string propertyKey;
48:
49: if (!string.IsNullOrEmpty(prefix))
50: propertyKey = prefix + "." + entry.Key;
51: else
52: propertyKey = entry.Key;
53:
54: FlattenToDictionary(dictionary, propertyKey, entry.Value);
55: }
56: }
57: else
58: {
59: var listValue = value as IList;
60: if (listValue != null)
61: {
62: for (int i = 0; i < listValue.Count; i++)
63: FlattenToDictionary(dictionary,
64: prefix + "[" + i + "]", listValue[i]);
65: }
66: else
67: dictionary[prefix] = value;
68: }
69: }
70: }
Y luego, debemos agregar la siguiente línea en el Global.ajax de nuestra aplicación:
1: protected void Application_Start()
3: RegisterRoutes(RouteTable.Routes);
4: ValueProviderFactories.Factories.Add(new JsonValueProviderFactory());
Listo! Ahora al enviar JSON al servidor, nuestro Value Provider interceptará el request, y serializará los datos de tipo JSON a un diccionario que pueda ser interpretado por el Default Model Binder. Lo único que debemos recordar hacer es setear el ContentType a 'application/json', y éstos requests serán procesados por nuestro Value Provider.
Ahora, veamos un poco qué está haciendo el Value Provider.
Al único método que tenemos que hacerle override de la clase ValueProviderFactory es a GetValueProvider(). Lo que hace es deserializar JSON y transformarlo en un objeto .NET, luego lo serializa al diccionario y devuelve un DictionaryValueProvider, el cual es utilizado más tarde por el default model binder.
El método GetDeserializedJson() hace exactamente lo que dice, deserializa JSON. Hay que tener cuidado de pasarle un string que contenga JSON, porque puede arrojar una excepción si se pasa JSON invalido. Probablemente sería conveniente encerrar a la llamada DeserializeObject en un try/catch, y devolver null cuando falle.
Finalmente, el método FlattenToDictionary() es un método recursivo que recorre el objeto deserializado, que para este ejemplo es el siguiente:

Como verán, el objeto deserializado es un Dictionary<string, object>. Para Id y Nombre, el tipo de dato de Value es int y string respectivamente. Para Promociones, en cambio, es un Array de object, y cada elemento de ese Array es un Dictionary<string,object>. Lo que hace FlattenToDictionary() es recorrer todos estos elementos para 'aplanarlo' (flatten) a un diccionario. El método nos devuelve esto:

Si leyeron el post de Andres Stang probablemente esto les resulte familiar. Lo que hace el método es recorrer el objeto, y construir el Key de cada uno de los valores que contiene para poder insertarlo en un diccionario, donde todos los valores de cada elemento sea un tipo de dato primitivo. Este diccionario luego es interpretado por el default model binder. Es decir, lo que estamos haciendo es transformar JSON a un formato que pueda ser interpretado por el default model binder.
Les dejo el código fuente con una aplicación de ejemplo para que prueben:
JsonToMVCAction.rar (153,28 kb)
.NET, ASP.NET, Desarrollo Web, JQuery
MVC, JSON, javascript, jquery