martes, 1 de junio de 2010

ASP.NET MVC: Custom Model Binders vs ValueProviders y un ejemplo con JSON…

Hola a todos!

Este post es el cuarto sobre la serie que podríamos llamar “el interior de ASP.NET MCV” y viene a ser un resumen de los tres anteriores. Los anteriores posts fueron:

En este vamos a ver como podemos implementar una característica que no viene de serie en ASP.NET MVC y que es casi imprescindible si estáis implementando una API REST usando MVC: que los controladores MVC sean capaces de procesar peticiones POST que vengan con datos JSON.

El código de la vista que vamos a usar para probar que todo funciona es una vista con un solo botón con id=”btnSend” y el siguiente código javascript:

<script type="text/javascript">
$(document).ready(function () {
$("#btnSend").click(function () {
var data = {
Name: 'edu',
Urls: ['http://twitter.com/eiximenis', 'http://geeks.ms/blogs/etomas']
};
$.ajax({
type: "POST", data: $.toJSON(data), contentType: "application/json; charset=utf-8",
dataType: "json", url: "/Home/Index"
});
});
});
</script>





Cuando se pulse en el botón se serializará el objeto “data” y se enviará via POST a la url /Home/Index. Evidentemente el controlador Home tiene una acción Index que espera datos via POST:




[HttpPost]
public ActionResult Index(UserData data)
{
return View();
}





Y UserData es la clase del modelo que deberá contener los datos de la petición:




public class UserData
{
public string Name { get; set; }
public IEnumerable<string> Urls { get; set; }
public int Id { get; set; }
}





Estamos listos para empezar… :)



Usando un ModelBinder propio…



La verdad es que usar un ModelBinder es casi, casi trivial: basta con derivar de DefaultModelBinder y comprobar si la Request tiene el content-type de application/json, y si es el caso usar JavascriptSerializer para deserializar la cadena json:




public class JsonModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
if (!IsJSONRequest(controllerContext))
{
return base.BindModel(controllerContext, bindingContext);
}
var request = controllerContext.HttpContext.Request;
var jsonStringData = new StreamReader(request.InputStream).ReadToEnd();
return new JavaScriptSerializer()
.Deserialize(jsonStringData, bindingContext.ModelMetadata.ModelType);
}
private static bool IsJSONRequest(ControllerContext controllerContext)
{
var contentType = controllerContext.HttpContext.Request.ContentType;
return contentType.Contains("application/json");
}
}





Recordad de registrar este model binder como el model binder por defecto, colocando lo siguiente en el Application_Start:




ModelBinders.Binders.DefaultBinder = new JsonModelBinder();





Y listos… funciona! O más bien dicho…. parece que funciona



P.ej… ¡hemos perdido las validaciones! P.ej. si añadís la siguiente validación en UserData:




[Range(1,10)]
public int Id { get; set; }





Y ejecutáis de nuevo vereis que el Id es 0 y el modelo sigue siendo válido (ModelState.IsValid vale true). ¿Y eso porque? Pues recordad que es el Model Binder quien las aplica, y nuestro Model Binder simplemente está obviando todas las validaciones.



Pero no sólo esto… si modificamos la vista para que en lugar de enviar el post a /Home/Index lo envíe a /Home/Index?Id=2 cuando recibamos el objeto UserData, su propiedad Id seguirá valiendo 0. Es decir hemos perdido la capacidad de ASP.NET MVC de crear modelos combinando elementos de la request que estén en POST y en querystring.



La razón de todo esto es simple: Un Model Binder no es la mejor manera para realizar esta tarea. Os acordáis cuando hablamos de los Value Providers? Comentamos que su responsabilidad era recoger los datos de la request para después pasárselos a los model binders que los usarán para crear los modelos.



Aquí precisamente tenemos un caso clarísimo de uso de un Value Provider: Debemos inspeccionar los datos de la request y decodificarlos, pero no tenemos ninguna necesidad de redefinir las reglas de creación del modelo.



Usando un Value Provider



Si recordáis el post sobre los value providers, no damos de alta value providers directamente en el sistema sinó factorías de value providers. El siguiente código da de alta una factoría de value providers que leen datos JSON:




public class JsonValueProviderFactory : ValueProviderFactory
{

public override IValueProvider GetValueProvider(ControllerContext controllerContext)
{
object jsonData = GetDeserializedJson(controllerContext);
if (jsonData == null)
{
return null;
}

Dictionary<string, object> backingStore = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

// El DefaultModelBinder es capaz de "bindear" colecciones si los elementos se llaman x[0], x[1], x[2], así
// que si dentro del objeto json tenemos alguna propiedad que sea array vamos a crear una entrada por
// cada elemento del array

AddToBackingStore(backingStore, String.Empty, jsonData);
return new DictionaryValueProvider<object>(backingStore, CultureInfo.CurrentCulture);
}
private static void AddToBackingStore(Dictionary<string, object> backingStore, string prefix, object value)
{
{ // dictionary?
IDictionary<string, object> d = value as IDictionary<string, object>;
if (d != null)
{
foreach (var entry in d)
{
AddToBackingStore(backingStore, MakePropertyKey(prefix, entry.Key), entry.Value);
}
return;
}
}

{ // list?
IList l = value as IList;
if (l != null)
{
for (int i = 0; i < l.Count; i++)
{
AddToBackingStore(backingStore, MakeArrayKey(prefix, i), l[i]);
}
return;
}
}

// primitive
backingStore[prefix] = value;
}

/// <summary>
/// Deserializa el código json que se encuentra dentro del body de la request
/// </summary>
private static object GetDeserializedJson(ControllerContext controllerContext)
{
if (!controllerContext.HttpContext.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
{
// not JSON request
return null;
}

StreamReader reader = new StreamReader(controllerContext.HttpContext.Request.InputStream);
string bodyText = reader.ReadToEnd();
if (String.IsNullOrEmpty(bodyText))
{
// no JSON data
return null;
}

JavaScriptSerializer serializer = new JavaScriptSerializer();
object jsonData = serializer.DeserializeObject(bodyText);
return jsonData;
}


private static string MakeArrayKey(string prefix, int index)
{
return prefix + "[" + index.ToString(CultureInfo.InvariantCulture) + "]";
}

private static string MakePropertyKey(string prefix, string propertyName)
{
return (String.IsNullOrEmpty(prefix)) ? propertyName : prefix + "." + propertyName;
}

}





Ya… el código es más largo y más complejo que en el caso anterior, pero básicamente hace lo siguiente:




  1. Deserializa el contenido JSON de la request y obtiene un objeto .NET


  2. Insepcciona via reflection dicho objeto y va creando entradas (clave, valor) para cada propiedad. Además trata arrays (colecciones) y subobjetos. P.ej. Si el objeto deserializado tiene una colección de dos elementos llamada Foo, creará dos entradas con claves Foo[0] y Foo[1]. Igualmente si el objeto tiene un subobjeto llamado Bar que tiene dos propiedades, pongamos Baz1 y Baz2 creará dos entradas llamadas Bar.Baz1 y Bar.Baz2.



P.ej. en el el caso que nos ocupa, creará las siguientes entradas:




  • Name


  • Urls[0]


  • Urls[1]



No crea entrada para la propiedad Id, porque dicha propiedad no la estamos enviando via POST en el JSON.



Como vimos en el post sobre el DefaultModelBinder, éste entiende estos nombres de las entradas y con ellas es capaz de crear el modelo y aplicar las validaciones.



Así ahora podemos observar que:




  1. Si la vista manda los datos a Home/Index, el modelo no se valida correctamente, ya que Id vale 0.


  2. Si la vista manda los datos a Home/Index?Id=2 el modelo se valida correctamente, ya que Id vale 2 (el DefaultModelBinder ha combinado los datos de todos los value providers).



Espero que este post os sirva para terminar de comprender cuando usar un Model Binder propio y cuando usar un Value Provider… Recordad: Si queréis modificar de donde (y cómo) de la request se sacan los datos, debéis usar un Value Provider. Si lo que queréis modificar es cómo se interpretan esos datos debéis usar un Model Binder.



Referencias:





Un saludo!!!



PD: Esto es, para variar, un crosspost desde mi blog en geeks.ms! Pásate mejor por allí!

No hay comentarios: