Asp.Net Core Mvc y las convenciones

Asp.Net Core Mvc incluye un completo sistema de convenciones con el que podemos modificar completamente el comportamiento por defecto que tiene el framework.

Concretamente tenemos las siguientes interfaces:

  • IApplicationModelConvention
  • IControllerModelConvention
  • IActionModelConvention
  • IParameterModelConvention

Todas definen un sólo método (Apply) que recibe un modelo que se corresponde al contexto  con el que estamos trabajando(aplicación, controlador, método de acción o parámetro de método de acción) .

public interface IApplicationModelConvention
{
    void Apply(ApplicationModel application);
}

Lo realmente interesante, es que ese modelo que recibimos no es de sólo lectura, sino que podemos modificar a nuestro antojo las características de los controladores, acciones y parámetros, es decir, modificar el comportamiento del framework de forma implícita al cargar nuestra aplicación.

Podemos, por ejemplo, añadir un filtro a una acción si cumple una determinada condición. Vamos a verlo con un ejemplo sencillo.

A mi me gusta tener un ActionFilter que valide el ModelState en vez de tener que repetir el código de validación en todos los métodos de acción. Algo como esto:

public class ValidateModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.ModelState.IsValid == false)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
    }
}

Este filtro se ejecuta antes de que se ejecute la acción pero habiendo hecho ya el ModelBinding de la petición, por lo que ya se han hecho las validaciones del modelo y, si ha habido errores, estos están reflejados en el ModelStateDictionary. Por lo tanto, si hay errores en el ModelState, no queremos que se ejecute el método de acción, y para eso, establecemos en el filtro el resultado. En este caso, la respuesta será un BadRequest  (400) incluyendo en la respuesta la información de los campos que no cumplen las reglas de validación.

Para utilizarlo en un método de acción, símplemente decoramos el método de acción con este atributo.

[HttpPost("api/products")]
[Authorize(Policies.ManageProducts)]
[ValidateModelState]
public async Task CreateProduct([FromBody]ProductBindingModel model)
{
    ...
}

Pero si me quiero evitar ir añadiendo este atributo a todos los métodos que lo necesitan, lo que me ofrecen las convenciones, es la posibilidad de añadir este atributo de forma automática a todos los métodos que lo necesiten, que serán todos los que tengan parámetros con tipos complejos.

La verdad es que con el api que expone el modelo de la aplicación, no es una tarea complicada. Quedaría algo así:

public class AddValidateModelStateAttributeConvention : IApplicationModelConvention
{
    public void Apply(ApplicationModel application)
    {
        var actionsWithComplexTypeParameters = application.Controllers
            .SelectMany(c => c.Actions.Where(a => a.Parameters.Any(p => p.ParameterInfo.ParameterType.IsComplexType())));
 
        foreach (var action in actionsWithComplexTypeParameters)
        {
            if (action.Attributes.Any(a => a.GetType() == typeof(ValidateModelStateAttribute)) == false)
            {
                action.Filters.Add(new ValidateModelStateAttribute());
            }
        }
    }
}

En primer lugar recuperamos todos los métodos de acción que tienen parámetros de tipos complejos (clases). Sobre estos métodos de acción iteramos y, si no tienen ya aplicado el atributo, lo añadimos a su colección de filtros. Así de sencillo!

Para añadir la convención, sólo tenemos que ir al método Configure de nuestro Startup.cs y añadirlo a las convenciones que usa Mvc

services.AddMvc()
    .AddMvcOptions(options =>
    {
        options.Conventions.Add(new ValidateModelStateAttributeConvention());
    });

Problema

Desde mi prespectiva, a la definición y uso de convenciones personalizadas le veo un par de problemas que para mi son importantes.

En función del proyecto y de las convenciones que se hayan aplicado al mismo, código que va a tener el mismo comportamiento puede llegar a estar escrito de forma sensíblemente diferente. Pueden faltar atributos que se aplican por convención, no se valida el ModelState porque se hace por convención, y escenarios parecidos. Desde mi punto de vista, eso dificulta enormemente las revisiones de código en escenarios de trabajo multiproyecto y hace más dificil el revisar, de un vistazo, si nos hemos dejado algo en la implementación o no.

De la misma manera, un desarrollador acostumbrado a tener aplicadas unas convenciones en un proyecto, cuando se mueve a otro en el que no se han aplicado las mimas convenciones, es más fácil que escriba código con errores.

Llamadme antíguo, pero a mi me gusta que el código sea lo más explícito posible, no dando lugar a la interpretación. Es como el establecer el ámbito de visibilidad de clases y variables. Hay una visibilidad por defecto, pero creo que es mejor establecer siempre la visibilidad para que sea explícita y nadie se pueda equivocar al interpretarla.

Esto me ha llevado a no prestarles atención a las convencionas durante un tiempo. Hasta ahora…

¿Pero y si no sirven sólo para eso?

Pero hace poco, se me pasó por la cabeza un escenario en el que si que porían ser muy útiles las convenciones. El de los test de integración.

En vez de aplicar las convenciones de manera automática, vamos a utilizarlas para verificar que se han establecido unos atributos determinados en los métodos que los necesitan. Al final, con el modelo que exponen las convenciones ya hemos visto que este escenario sería muy sencillo.

Con esta perspectiva, en el ejemplo que hemos visto antes, mi convención debería validar que todos los métodos de acción que tienen tipos complejos como parámetros tienen aplicados este atributo, y si no lo tienen, lanzar una excepción con el nombre del controlador y del método de acción que no lo tiene.

De esta forma la nueva convención quedaría así:

public void Apply(ApplicationModel application)
{
    var actionsWithComplexTypeParameters = application.Controllers
        .SelectMany(c => c.Actions.Where(a => a.Parameters.Any(p => p.ParameterInfo.ParameterType.IsComplexType())));
    foreach (var action in actionsWithComplexTypeParameters)
    {
        if (action.Attributes.Any(a => a.GetType() == typeof(ValidateModelStateAttribute)) == false)
        {
            throw new InvalidOperationException(string.Format(
                "The action [{0}] in the controller [{1}] has a complexType parameter and has not a [ValidateModelState] attribute",
                action.ActionName,
                action.Controller.ControllerName));
        }
     }
}

Básicamente es el mismo código que hemos escrito antes, pero en vez de añadir el filtro, en este caso lanzará una excepción.

Esta nueva convención la añadiríamos sólo en el Startup.cs de nuestro proyecto de test de integración y, como parte de la validación de nuestra Api, verificaríamos que no nos hemos dejado nada importante en nuestra implementación.

La verdad es que las convenciones, aplicadas desde esta perspectiva, aportan mucho valor para validar el modelo de nuestra aplicación, permitiéndonos validar aspectos que sólo podríamos identificar de otra forma con test de integración atacando directamente al Api para validar estos escenarios.

¡Que lo disfrutéis!