Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 6 de noviembre de 2018
ASP.NET Core MVCEn el framework ASP.NET Core MVC es muy sencillo establecer los mensajes de error de validación de campos utilizando propiedades de las data annotations, como en el siguiente ejemplo:
namespace LocalizationDemo.ViewModels
{
    public class PersonViewModel
    {
        [Required(ErrorMessage ="The name is required")]
        public string Name { get; set; }
    }
}
Incluso es bastante fácil hacer que este texto aparezca traducido atendiendo a la cultura del hilo actual. Para ello, simplemente debemos configurar los servicios de localización apropiadamente, e indicar en la propiedad ErrorMessage la clave del mensaje de error en el archivo RESX asociado a la clase:
// En Startup.cs:
public void ConfigureServices()
{
    ...
    services.AddLocalization(opt=>opt.ResourcesPath="Resources");
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
        .AddViewLocalization()
        .AddDataAnnotationsLocalization(); // Esta línea añade la localización 
                                           // de data annotations 
    }

// En el view model:
namespace LocalizationDemo.ViewModels
{
    public class PersonViewModel
    {
        // El mensaje de error está definido 
        // en Resources/ViewModels.PersonViewModel.resx
        [Required(ErrorMessage ="NameIsRequired")] 
        public string Name { get; set; }
    }
}
Esto es así de fácil para las validaciones que declaramos de forma explícita en nuestro código, mediante atributos de anotaciones de datos. Sin embargo, existen otro tipo de validaciones que se realizan de forma implícita (por ejemplo, cuando en un campo entero introducimos un valor no numérico) y cuyos mensajes de error no son tan sencillos de establecer.

Por ejemplo, observad el siguiente view model:
public class PersonViewModel
{
    [Required(ErrorMessage = "NameIsRequired")]
    [Display(Name="Name")]
    public string Name { get; set; }

    [Display(Name="Height")]
    public int Height { get; set; }
    
    [Display(Name="Birthdate")]
    public DateTime Birthdate { get; set; }
}
Las propiedades Height y Birthdate son de tipo valor y por tanto intrínsecamente obligatorias, por lo que el framework mostrará un texto de error por defecto si se dejan en blanco. Sin embargo, podemos cambiar fácilmente este mensaje de error utilizando la anotación [Required] y estableciendo en ella el parámetro ErrorMessage, tal y como hacemos con el campo Name.
Ojo, que esto también se puede automatizar, es decir, podemos asignarle automáticamente a las propiedades de tipo valor un atributo [Required] con los mensajes ya preconfigurados, o incluso definir los textos por defecto para ese tipo de validadores. Pero para no extendernos mucho, lo veremos en este otro post: https://www.variablenotfound.com/2018/11/establecer-textos-por-defecto-y.html.
Pero además, existen otros problemas de validación que pueden darse cuando el binder intente establecer el valor de estas propiedades a partir de los datos que llegan en la petición, por ejemplo, si el usuario introduce "abc" en el campo numérico o de fecha.

En estos casos, por mucho que hayamos configurado el sistema de localización y todo funcione aparentemente bien, podremos encontrarnos con pantallas como la siguiente, donde los mensajes de error generados durante el binding están en el idioma por defecto (inglés), mientras que los que establecemos nosotros mediante anotaciones han sido traducidos al idioma actual:

Formulario donde aparecen traducidos algunos errores de validación, pero otros aparecen en el idioma por defecto

Para solucionarlo tendremos que cambiar la forma en que el binder busca los textos de estos errores :)

¿De dónde obtiene el binder esos mensajes de error?

Como otros muchos mecanismos del framework, la obtención de estos mensajes se basa en proveedores, es decir, existe un componente especializado en proveer al binder de estos mensajes.

Por defecto, el proveedor mensajes de validación para el binder se implementa en la clase DefaultModelBindingMessageProvider, cuya única instancia es depositada durante el arranque de la aplicación en la propiedad ModelBindingMessageProvider del objeto MvcOptions que configura el comportamiento del framework MVC.
services.AddMvc(opt=>
{
    // opt.ModelBindingMessageProvider tiene una instancia 
    // de DefaultModelBindingMessageProvider
})
DefaultModelBindingMessageProvider dispone de métodos para establecer y obtener las factorías que generarán los textos de cada uno de los errores que el binder es capaz de detectar, que son los siguientes:
  • MissingBindRequiredValue
  • MissingKeyOrValue
  • MissingRequestBodyRequiredValue
  • ValueMustNotBeNull
  • AttemptedValueIsInvalid
  • NonPropertyAttemptedValueIsInvalid
  • UnknownValueIsInvalid
  • NonPropertyUnknownValueIsInvalid
  • ValueIsInvalid
  • ValueMustBeANumber
  • NonPropertyValueMustBeANumber
Cada uno de estos mensajes de error dispone de un método en la clase DefaultModelBindingMessageProvider donde es posible establecer su factoría que, en la práctica, es un delegado que se encargará de retornar el texto de error para cada caso. Por ejemplo, el siguiente método de esta clase permitiría establecer el mensaje a mostrar cuando el binder encuentre un valor no válido para la propiedad que intenta establecer:
services.AddMvc(opt=>
{
    opt.ModelBindingMessageProvider.SetValueIsInvalidAccessor(
        value => $"El valor '{value}' no es válido"
    );
    [...] // Establecer el texto para el resto de errores
});
Observad que cada tipo de error soportado dispone de un método para establecer su factoría, de la forma Set{ErrorType}Accessor(), como en SetValueIsInvalidAccessor() o SetValueMustNotBeNullAccessor().

Establecer los textos desde recursos localizados

Visto lo anterior, si hacemos eso mismo con todos los mensajes de error soportados, y aprovechamos para personalizar o localizar el texto en cada caso, tendremos el trabajo hecho.

Pero vamos a dar una vuelta de tuerca más: ¿qué ocurre si queremos que los textos a emplear en cada caso provengan de archivos RESX? Pues no es mucho más complicado que lo que hemos visto antes: bastará con obtener acceso al IStringLocalizer apropiado y utilizarlo para tener acceso a los recursos.

En el siguiente bloque de código vemos cómo podríamos obtenerlos, por ejemplo, del archivo de recursos localizados "ModelBindingDefaultMessages.resx" presente el proyecto "LocalizationDemo":
services.AddMvc(opt=>
{
    var stringLocalizerFactory = mvcBuilder.Services
                .BuildServiceProvider().GetService<IStringLocalizerFactory>();
    var loc = stringLocalizerFactory
                .Create("ModelBindingDefaultMessages", "LocalizationDemo");
    
    opt.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(
        prop => loc["MissingBindRequired", prop]);
    opt.ModelBindingMessageProvider.SetMissingKeyOrValueAccessor(
        () => loc["MissingKeyOrValue"]);
    opt.ModelBindingMessageProvider.SetMissingRequestBodyRequiredValueAccessor(
        () => loc["MissingRequestBodyRequired"]);
    opt.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(
        prop => loc["ValueMustNotBeNull"]);
    opt.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor(
        (value, prop) => loc["AttemptedValueIsInvalid", value, prop]);
    opt.ModelBindingMessageProvider.SetNonPropertyAttemptedValueIsInvalidAccessor(
        value => loc["NonPropertyAttemptedValue", value]);
    opt.ModelBindingMessageProvider.SetUnknownValueIsInvalidAccessor(
        prop => loc["UnknownValueIsInvalid", prop]);
    opt.ModelBindingMessageProvider.SetNonPropertyUnknownValueIsInvalidAccessor(
        () => loc["NonPropertyUnknownValueIsInvalid"]);
    opt.ModelBindingMessageProvider.SetValueIsInvalidAccessor(
        value => loc["ValueIsInvalid", value]);
    opt.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(
        prop => loc["ValueMustBeANumber", prop]);
    opt.ModelBindingMessageProvider.SetNonPropertyValueMustBeANumberAccessor(
        ()=> loc["NonPropertyValueMustBeNumber"]);
});
Tras esto, podemos crear los archivos de recursos "Resources/ModelBindingDefaultMessages.resx" con los textos para cada uno de estos errores:

Contenido del archivo de recursos
Obviamente, para que esto funcione debemos tener registrados y configurados los servicios de localización de .NET Core para que los recursos sean tomados de la carpeta /Resources.

Pero, ¿y no es esto demasiado código para Startup?

Pues sí, y para mejorarlo podríamos usar llevarnos el código a un extensor de IMvcBuilder como el siguiente:
public static class MvcOptionsExtensions
{
  public static void ConfigureModelBindingMessages(this IMvcBuilder mvcBuilder, 
           string resourceName = null, string resourceLocation = null)
  {
     mvcBuilder.Services.Configure<MvcOptions>(opt =>
     {
       // By default, the Resx file name is ModelBindingDefaultMessages.resx:
       resourceName = resourceName ?? "ModelBindingDefaultMessages";          

       // By default, resources live in same assembly that the Startup class does:
       resourceLocation = resourceLocation 
             ?? Assembly.GetExecutingAssembly().GetName().Name;

       var stringLocalizerFactory = mvcBuilder.Services
               .BuildServiceProvider().GetService<IStringLocalizerFactory>();
       var loc = stringLocalizerFactory.Create(resourceName, resourceLocation);

       opt.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor(
           prop => loc["MissingBindRequired", prop]);
       opt.ModelBindingMessageProvider.SetMissingKeyOrValueAccessor(
           () => loc["MissingKeyOrValue"]);
       opt.ModelBindingMessageProvider.SetMissingRequestBodyRequiredValueAccessor(
           () => loc["MissingRequestBodyRequired"]);
       opt.ModelBindingMessageProvider.SetValueMustNotBeNullAccessor(
           prop => loc["ValueMustNotBeNull"]);
       opt.ModelBindingMessageProvider.SetAttemptedValueIsInvalidAccessor(
           (value, prop) => loc["AttemptedValueIsInvalid", value, prop]);
       opt.ModelBindingMessageProvider.SetNonPropertyAttemptedValueIsInvalidAccessor(
           value => loc["NonPropertyAttemptedValue", value]);
       opt.ModelBindingMessageProvider.SetUnknownValueIsInvalidAccessor(
           prop => loc["UnknownValueIsInvalid", prop]);
       opt.ModelBindingMessageProvider.SetNonPropertyUnknownValueIsInvalidAccessor(
           () => loc["NonPropertyUnknownValueIsInvalid"]);
       opt.ModelBindingMessageProvider.SetValueIsInvalidAccessor(
           value => loc["ValueIsInvalid", value]);
       opt.ModelBindingMessageProvider.SetValueMustBeANumberAccessor(
           prop => loc["ValueMustBeANumber", prop]);
       opt.ModelBindingMessageProvider.SetNonPropertyValueMustBeANumberAccessor(
           ()=> loc["NonPropertyValueMustBeNumber"]);
    });
  }
}
De esta forma ya podríamos usarlo desde ConfigureServices() como sigue, dejando el código de inicialización de la aplicación mucho más limpio:
services.AddMvc()
  .SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
  .AddViewLocalization()
  .AddDataAnnotationsLocalization()
  .ConfigureModelBindingMessages(); // Por defecto los recursos estarán en el archivo
                                    // de recursos ModelBindingDefaultMessages.resx,
                                    // en el ensamblado actual.

En resumen

En este post hemos visto cómo podemos modificar los mensajes de error generados por el sistema de binding, normalmente debido a problemas encontrados a la hora de asignar los valores presentes en la petición a los parámetros de entrada de nuestras acciones.

Para ello, sólo hemos tenido que acceder al proveedor de mensajes de este componente, DefaultModelBindingMessageProvider y modificar el delegado de generación de dichos mensajes. En este caso, dado que queríamos conseguir una versión localizada de los mensajes, hemos hecho uso de un IStringLocalizerFactory para obtener acceso a los recursos RESX y poder mostrar en cada caso la traducción correspondiente.

En un artículo posterior continuaremos profundizando en este tema y veremos cómo modificar los mensajes por defecto de las anotaciones estándar (Required, Range, etc.), evitando así el tener que personalizar los textos de error en cada una de dichas anotaciones, y aprovechando para ver cómo localizar estos mensajes.

Publicado en: www.variablenotfound.com.

Aún no hay comentarios, ¡sé el primero!