Creando formateadores de salida en asp.net core

Cuando salió WebApi lo hizo con la negociación de contenido incorporada de serie en el framework. Eso venía a significar, básicamente, que el framework intentaba suministrar los datos en el formato en que el cliente los había pedido. La negociación de contenido se basa (generalmente) en el uso de la cabecera accept de HTTP: el cliente manda en esa cabecera cual, o cuales, son sus formatos de respuesta preferidos. WebApi soporta de serie devolver datos en JSON y XML y el sistema es extensible para crear nuestros propios formatos.

“MVC clásico” (es decir hasta MVC5) no incluye soporte de negociación de contenido: en MVC si queremos devolver datos en formato JSON, debemos devolver explícitamente un JsonResult y si los queremos devolver en XML debemos hacerlo también explícitamente.

En ASP.NET Core tenemos a MVC6 que unifica a WebApi y MVC clásico en un solo framework. ¿Como queda el soporte para negociación de contenido en MVC6? Pues bien, existe soporte para ella, pero dependiendo de que IActionResult devolvamos en nuestros controladores. Así, si en WebApi la negociación de contenido se usaba siempre y en MVC clásico nunca, en MVC6 la negociación de contenido aplica solo si la acción del controlador devuelve un ObjectResult (o derivado). Esto nos permite como desarrolladores decidir sobre qué acciones de qué controladores queremos aplicar la negociación de contenido. Es evidente que aplicarla siempre no tiene sentido: si devolvemos una vista Razor su resultado debe ser sí o sí un HTML que se envía al cliente. No tendría sentido aplicar negociación de contenido sobre una acción que devolviese una vista. De hecho la negociación de contenido tiene sentido en APIs que devuelvan datos (no vistas) y en MVC6 para devolver datos tenemos a ObjectResult, así que es lógico que sea sobre este resultado donde se aplique la negociación de contenido.

En WebApi la negociación de contenido estaba gestionada por los formateadores (formatters). Básicamente a cada content-type se le asociaba un formateador. Si el cliente pedía datos en un determinado content-type se miraba que formateador podía devolver datos en dicho formato. Si no existía se usaba por defecto el formateador de JSON. En MVC6 se ha mantenido básicamente dicho esquema.

La principal diferencia es que en WebApi los formateadores se encargaban realmente de dos tareas totalmente separadas: por un lado procesaban (leían) los datos de entrada (es decir definían que tipos de content-types aceptaba el servidor) y también procesaban (serializaban) los datos de salida. El problema es fácil de ver: el hecho de que una API devuelva datos en un determinado formato (pongamos XML) no significa que deba aceptar datos (p. ej. un POST) en dicho formato. Pero en WebApi al implementar el formateador de XML debíamos implementar tanto el método para leer datos en XML como para escribirlos. En MVC6 se ha solucionado este detalle separando los formateadores en dos: los de entrada (leen los datos enviados por el cliente) y los de salida (envían los datos al cliente). Esto separa mejor las responsabilidades.

Vamos a ver como podemos crear un formateador que nos permita devolver datos en CSV. Dado que CSV es un formato de tipo “tabular”, solo vamos a aceptar devolver datos en este formato, siempre que esos datos sean un IEnumerable.

Creando un formateador de salida

Para crear un formateador de salida basta con implementar la interfaz IOutputFormatter, que define dos métodos:

  1. CanWriteResult: Que debe indicar si el formateador puede enviar el resultado al cliente
  2. WriteAsync: Que debe enviar los datos formateados

Una posible implementación podría ser tal y como sigue:

  1. public class CsvOutputFormatter : IOutputFormatter
  2. {
  3.     public bool CanWriteResult(OutputFormatterCanWriteContext context)
  4.     {
  5.         if (context.ContentType.MediaType != "text/csv")
  6.         {
  7.             return false;
  8.         }
  9.         var type = context.ObjectType.GetTypeInfo();
  10.         if (type.ImplementedInterfaces.Any(ii => ii == typeof(IEnumerable)))
  11.         {
  12.             return true;
  13.         }
  14.         return false;
  15.     }
  16.  
  17.     public async Task WriteAsync(OutputFormatterWriteContext context)
  18.     {
  19.         var response = context.HttpContext.Response;
  20.         response.ContentType = "text/csv";
  21.         using (var writer = context.WriterFactory(response.Body, Encoding.UTF8))
  22.         {
  23.             await CsvSerializer.SerializeAsync(context.Object, writer);
  24.             await writer.FlushAsync();
  25.         }
  26.     }
  27. }

La implementación es muy sencilla, simplemente en el CanWriteResult miramos que el valor de la cabecera accept sea “text/csv” y que el objeto a serializar implemente IEnumerable (en la realidad quizás haríamos aquí más comprobaciones).

En el método WriteAsync simplemente serializamos los datos en formato CSV. Para ello usamos una clase CsvSerializer, cuya implementación es trivial:

  1. static class CsvSerializer
  2. {
  3.     public static async Task SerializeAsync(object obj, TextWriter writer)
  4.     {
  5.         var collection = obj as IEnumerable;
  6.         if (collection != null)
  7.         {
  8.             var properties = GetItemsProperties(collection);
  9.             await WriteHeadersAsync(properties, writer);
  10.             await WriteItemsAsync(collection, properties, writer);
  11.         }
  12.     }
  13.  
  14.     private static async Task WriteItemsAsync(IEnumerable collection, PropertyInfo[] properties, TextWriter writer)
  15.     {
  16.         foreach (var item in collection)
  17.         {
  18.             await writer.WriteLineAsync(string.Join(",",
  19.                 properties.Select(pi => Convert.ToString(pi.GetValue(item), CultureInfo.InvariantCulture))));
  20.         }
  21.     }
  22.     private static Task WriteHeadersAsync(IEnumerable<PropertyInfo> properties, TextWriter writer)
  23.     {
  24.         return writer.WriteLineAsync(string.Join(",",
  25.             properties.Select(pi => pi.Name)));
  26.     }
  27.     private static PropertyInfo[] GetItemsProperties(IEnumerable collection)
  28.     {
  29.         object first = null;
  30.         foreach (var item in collection)
  31.         {
  32.             first = item;
  33.             break;
  34.         }
  35.         var type = first.GetType().GetTypeInfo();
  36.         return type.DeclaredProperties.ToArray();
  37.     }
  38. }

Ahora solo nos falta un punto, que es añadir nuestro formateador de salida a MVC6. Para ello cuando añadimos los servicios de MVC6 en Startup, debemos agregar el formateador:

  1. services.AddMvc(opt =>
  2. {
  3.     opt.OutputFormatters.Add(new CsvOutputFormatter());
  4. });

¡Y listos! Ya no debemos hacer nada más. Ahora nos podemos crear una acción en un controlador:

  1. public IActionResult Data()
  2. {
  3.     var beers = new[] {
  4.         new Beer() { Id=1, Name = "Punk IPA", Abv = 4.5 },
  5.         new Beer() { Id=2, Name = "Mahou", Abv = 4.0 }
  6.         };
  7.     return new ObjectResult(beers);
  8. }

Y si llamamos a esta acción con el valor “text/csv” en la cabecera accept obtendremos los datos en CSV:

image

Observa como el valor que colocamos en accept es “text/csv, text/html” (el valor de accept puede ser compuesto) y que a pesar de nosotros comprobamos con “text/csv” funciona igualmente, ya que MVC6 parsea la cabecera por nosotros (de hecho si en lugar de usar “text/csv,text/html” usaras “text/html,text/cvs”, primero MVC6 intentaria encontrar un formateador de HTML y si no lo encuentra intentaría encontrar del de CSV (si colocas un breakpoint en el método CanWriteResult verías que pasa dos veces).

Y eso es todo… como puedes ver es muy sencillo adaptar MVC6 para que use tus propios formatos de salida 😉 

Saludos!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *