Unificaci贸n de reglas de validaci贸n por el ejemplo de Asp core + VueJS


Este art铆culo describe una manera simple de unificar las reglas de validaci贸n de entrada del usuario para una aplicaci贸n cliente-servidor. Usando un proyecto simple como ejemplo, mostrar茅 c贸mo se puede hacer esto usando Asp net core y Vue js.


Al desarrollar aplicaciones web, como regla, nos enfrentamos a la tarea de doble validaci贸n de la entrada del usuario. Por un lado, la entrada del usuario debe validarse en el cliente para reducir las solicitudes redundantes al servidor y acelerar la validaci贸n del usuario. Por otro lado, hablando de validaci贸n, el servidor no puede aceptar "en fe" que la validaci贸n del cliente realmente funcion贸 antes de enviar la solicitud, porque el usuario podr铆a deshabilitar o modificar el c贸digo de validaci贸n. O incluso haga una solicitud desde la API del cliente manualmente.


Por lo tanto, la interacci贸n cl谩sica cliente-servidor tiene 2 nodos, con reglas de validaci贸n de entrada del usuario a menudo id茅nticas. Este art铆culo en su conjunto se ha discutido en m谩s de un art铆culo; aqu铆 se describir谩 una soluci贸n ligera utilizando el servidor ASP.Net Core API y el cliente Vue js como ejemplo.


Para comenzar, determinaremos que validaremos exclusivamente las solicitudes del usuario (equipo), no la entidad, y, desde el punto de vista de la arquitectura cl谩sica de 3 capas, nuestra validaci贸n est谩 en la capa de presentaci贸n.


Lado del servidor


Mientras est茅 en Visual Studio 2019, crear茅 un proyecto para una aplicaci贸n de servidor, utilizando la plantilla de aplicaci贸n web ASP.NET Core, con un tipo de API. La ASP lista para usar tiene un mecanismo de validaci贸n bastante bueno y extensible: validaci贸n del modelo, seg煤n el cual, las propiedades de solicitud del modelo est谩n marcadas con atributos de validaci贸n espec铆ficos.


Considere esto con un controlador simple como ejemplo:


[Route("[controller]")] [ApiController] public class AccountController : ControllerBase { [HttpPost(nameof(Registration))] public ActionResult Registration([FromBody]RegistrationRequest request) { return Ok($"{request.Name},  !"); } } 

La solicitud para registrar un nuevo usuario se ver谩 as铆:


Solicitud de registro
  public class RegistrationRequest { [StringLength(maximumLength: 50, MinimumLength = 2, ErrorMessage = "     2  50 ")] [Required(ErrorMessage = " ")] public string Name { get; set; } [Required(ErrorMessage = "  . ")] [EmailAddress(ErrorMessage = "  . ")] public string Email { get; set; } [Required(ErrorMessage = " ")] [MaxLength(100, ErrorMessage = "{0}    {1} ")] [MinLength(6, ErrorMessage ="{0}    {1} ")] [DisplayName("")] public string Password { get; set; } [Required(ErrorMessage = " ")] [Range(18,150, ErrorMessage = "      18  150")] public string Age { get; set; } [DisplayName("")] public string Culture { get; set; } } 

Utiliza atributos de validaci贸n predefinidos del espacio de nombres System.ComponentModel.DataAnnotations. Las propiedades requeridas est谩n marcadas con el atributo requerido. Por lo tanto, al enviar JSON vac铆o ("{}"), nuestra API devolver谩:


 { ... "errors": { "Age": [ " " ], "Name": [ " " ], "Email": [ "  . " ], "Password": [ " " ] } } 

El primer problema que se puede encontrar en esta etapa es la localizaci贸n de descripciones de error. Por cierto, ASP tiene herramientas de localizaci贸n integradas, lo consideraremos m谩s adelante.


Para verificar la longitud de los datos de la cadena, puede usar los atributos con nombres de voz: StringLength, MaxLength y MinLength. Al mismo tiempo, formateando cadenas (llaves), los par谩metros de los atributos se pueden integrar en un mensaje. Por ejemplo, para el nombre de usuario, insertamos la longitud m铆nima y m谩xima en el mensaje, y para la contrase帽a "nombre para mostrar" especificada en el atributo del mismo nombre. El atributo Range es responsable de verificar el valor que debe estar en el rango especificado.
Enviemos una solicitud con un nombre de usuario y contrase帽a inaceptablemente cortos:


  { "Name": "a", "Password" : "123" } 

En la respuesta del servidor, puede encontrar nuevos mensajes de error de validaci贸n:


  "Name": [ "     2  50 " ], "Password": [ "    6 " ] 

El problema, que puede ser, por el momento, no obvio, es que los valores l铆mite para la longitud del nombre y la contrase帽a tambi茅n deben estar presentes en la aplicaci贸n del cliente. Una situaci贸n en la que los mismos datos se configuran manualmente en dos o lugares de bodey es un posible caldo de cultivo para los insectos, uno de los signos de un mal dise帽o. Vamos a arreglarlo


Almacenaremos todo lo que el cliente necesitar谩 en los archivos de recursos. Mensajes en Controllers.AccountController.ru.resx, y datos culturalmente independientes en un recurso compartido: Controllers.AccountController.resx. Me adhiero a este formato:


 {PropertyName}DisplayName {PropertyName}{RuleName} {PropertyName}{RuleName}Message 

Por lo tanto, obtenemos la siguiente imagen


Tenga en cuenta que para validar la direcci贸n de correo electr贸nico Se utiliza el correo de expresi贸n regular. Y para la validaci贸n cultural, se utiliza una regla personalizada: "Valores" (lista de valores). Tambi茅n deber谩 verificar el campo de confirmaci贸n de contrase帽a, que veremos m谩s adelante, en la interfaz de usuario.


Para acceder a los archivos de recursos para una cultura espec铆fica, agregamos soporte de localizaci贸n en el m茅todo Startup.ConfigureServices, indicando la ruta a los archivos de recursos:


 services.AddLocalization(options => options.ResourcesPath = "Resources"); 

Y tambi茅n en el m茅todo Startup.Configure, determinando la cultura por el encabezado de la solicitud del usuario:


  app.UseRequestLocalization(new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture("ru-RU"), SupportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("ru-RU") }, RequestCultureProviders = new List<IRequestCultureProvider> { new AcceptLanguageHeaderRequestCultureProvider() } }); 

Ahora, para que dentro del controlador, tengamos acceso a la localizaci贸n, implementaremos una dependencia del tipo IStringLocalizer en el constructor y modificaremos la expresi贸n de retorno de la acci贸n de Registro:

  return Ok(string.Format(_localizer["RegisteredMessage"], request.Name)); 

La clase ResxValidatior ser谩 responsable de verificar las reglas, que utilizar谩n los recursos creados. Contiene una lista reservada de palabras clave, rollos preestablecidos y un m茅todo para verificarlos.


ResxValidatior
 public class ResxValidator { public const char ValuesSeparator = ','; public const char RangeSeparator = '-'; public enum Keywords { DisplayName, Message, Required, Pattern, Length, MinLength, MaxLength, Range, MinValue, MaxValue, Values, Compare } private readonly Dictionary<Keywords, Func<string, string, bool>> _rules = new Dictionary<Keywords, Func<string, string, bool>>() { [Keywords.Required] = (v, arg) => !string.IsNullOrEmpty(v), [Keywords.Pattern] = (v, arg) => !string.IsNullOrWhiteSpace(v) && Regex.IsMatch(v, arg), [Keywords.Range] = (v, arg) => !string.IsNullOrWhiteSpace(v) && long.TryParse(v, out var vLong) && long.TryParse(arg.Split(RangeSeparator)[0].Trim(), out var vMin) && long.TryParse(arg.Split(RangeSeparator)[1].Trim(), out var vMax) && vLong >= vMin && vLong <= vMax, [Keywords.Length] = (v, arg) => !string.IsNullOrWhiteSpace(v) && long.TryParse(arg.Split(RangeSeparator)[0].Trim(), out var vMin) && long.TryParse(arg.Split(RangeSeparator)[1].Trim(), out var vMax) && v.Length >= vMin && v.Length <= vMax, [Keywords.MinLength] = (v, arg) => !string.IsNullOrWhiteSpace(v) && v.Length >= int.Parse(arg), [Keywords.MaxLength] = (v, arg) => !string.IsNullOrWhiteSpace(v) && v.Length <= int.Parse(arg), [Keywords.Values] = (v, arg) => !string.IsNullOrWhiteSpace(v) && arg.Split(ValuesSeparator).Select(x => x.Trim()).Contains(v), [Keywords.MinValue] = (v, arg) => !string.IsNullOrEmpty(v) && long.TryParse(v, out var vLong) && long.TryParse(arg, out var argLong) && vLong >= argLong, [Keywords.MaxValue] = (v, arg) => !string.IsNullOrEmpty(v) && long.TryParse(v, out var vLong) && long.TryParse(arg, out var argLong) && vLong <= argLong }; private readonly IStringLocalizer _localizer; public ResxValidator(IStringLocalizer localizer) { _localizer = localizer; } public bool IsValid(string memberName, string value, out string message) { var rules = _rules.Select(x => new { Name = x.Key, Check = x.Value, String = _localizer.GetString(memberName + x.Key) }).Where(x => x.String != null && !x.String.ResourceNotFound); foreach (var rule in rules) { if (!rule.Check(value, rule.String?.Value)) { var messageResourceKey = $"{memberName}{rule.Name}{Keywords.Message}"; var messageResource = _localizer[messageResourceKey]; var displayNameResourceKey = $"{memberName}{Keywords.DisplayName}"; var displayNameResource = _localizer[displayNameResourceKey] ?? displayNameResourceKey; message = messageResource != null && !messageResource.ResourceNotFound ? string.Format(messageResource.Value, displayNameResource, rule.String?.Value) : messageResourceKey; return false; } } message = null; return true; } } 

Cree un atributo de validaci贸n personalizado que llamar谩 nuestro validador. Aqu铆, la l贸gica est谩ndar es obtener el valor de la propiedad verificada del modelo, su nombre y llamar al validador.


ResxAttribute
 public sealed class ResxAttribute : ValidationAttribute { private readonly string _baseName; private string _resourceName; public ResxAttribute(string sectionName, string resourceName = null) { _baseName = sectionName; _resourceName = resourceName; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (_resourceName == null) _resourceName = validationContext.MemberName; var factory = validationContext .GetService(typeof(IStringLocalizerFactory)) as IStringLocalizerFactory; var localizer = factory?.Create(_baseName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Name); ErrorMessage = ErrorMessageString; var currentValue = value as string; var validator = new ResxValidator(localizer); return validator.IsValid(_resourceName, currentValue, out var message) ? ValidationResult.Success : new ValidationResult(message); } } 

Finalmente, puede reemplazar todos los atributos en la solicitud con nuestro universal, indicando el nombre del recurso:


  [Resx(sectionName: "Controllers.AccountController")] 

Probamos la funcionalidad enviando la misma solicitud:


  { "Name": "a", "Password" : "123" } 

Para la localizaci贸n, agregue Controllers.AccountController.en.resx con mensajes en ingl茅s, as铆 como el encabezado, con informaci贸n sobre la cultura: Accept-Language: en-US.


Tenga en cuenta que ahora podemos anular la configuraci贸n de una cultura espec铆fica. En el archivo * .en.resx, especifiqu茅 la longitud m铆nima de contrase帽a de 8 y recib铆 un mensaje apropiado:


  "Password": [ "Password must be at least 8 characters" ] 

Para mostrar mensajes id茅nticos durante la validaci贸n del cliente, de alguna manera debe exportar la lista completa de mensajes para la parte del cliente. Por simplicidad, crearemos un controlador separado que le dar谩 todo lo que necesita para una aplicaci贸n cliente en formato i18n.


Controlador local
  [Route("[controller]")] [ApiController] public class LocaleController : ControllerBase { private readonly IStringLocalizerFactory _factory; private readonly string _assumbly; private readonly string _location; public LocaleController(IStringLocalizerFactory factory) { _factory = factory; _assumbly = System.Reflection.Assembly.GetExecutingAssembly().GetName().Name; _location = Path.Combine(Directory.GetCurrentDirectory(), "Resources"); } [HttpGet("Config")] public IActionResult GetConfig(string culture) { if (!string.IsNullOrEmpty(culture)) { CultureInfo.CurrentCulture = new CultureInfo(culture); CultureInfo.CurrentUICulture = new CultureInfo(culture); } var resources = Directory.GetFiles(_location, "*.resx", SearchOption.AllDirectories) .Select(x => x.Replace(_location + Path.DirectorySeparatorChar, string.Empty)) .Select(x => x.Substring(0, x.IndexOf('.'))) .Distinct(); var config = new Dictionary<string, Dictionary<string, string>>(); foreach (var resource in resources.Select(x => x.Replace('\\', '.'))) { var section = _factory.Create(resource, _assumbly) .GetAllStrings() .OrderBy(x => x.Name) .ToDictionary(x => x.Name, x => x.Value); config.Add(resource.Replace('.', '-'), section); } var result = JsonConvert.SerializeObject(config, Formatting.Indented); return Ok(result); } } 

Dependiendo del idioma de aceptaci贸n, devolver谩:


Aceptar-Idioma: en-US
 { "Controllers-AccountController": { "AgeDisplayName": "Age", "AgeRange": "18 - 150", "AgeRangeMessage": "{0} must be {1}", "EmailDisplayName": "Email", "EmailPattern": "^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[Az]{2,4}$", "EmailPatternMessage": "Incorrect email", "EmailRequired": "", "EmailRequiredMessage": "Email required", "LanguageDisplayName": "Language", "LanguageValues": "ru-RU, en-US", "LanguageValuesMessage": "Incorrect language. Possible: {1}", "NameDisplayName": "Name", "NameLength": "2 - 50", "NameLengthMessage": "Name length must be {1} characters", "PasswordConfirmCompare": "Password", "PasswordConfirmCompareMessage": "Passwords must be the same", "PasswordConfirmDisplayName": "Password confirmation", "PasswordDisplayName": "Password", "PasswordMaxLength": "100", "PasswordMaxLengthMessage": "{0} can't be greater than {1} characters", "PasswordMinLength": "8", "PasswordMinLengthMessage": "{0} must be at least {1} characters", "PasswordRequired": "", "PasswordRequiredMessage": "Password required", "RegisteredMessage": "{0}, you've been registered!" } } 

Idioma de aceptaci贸n: en-RU
 { "Controllers-AccountController": { "AgeDisplayName": "", "AgeRange": "18 - 150", "AgeRangeMessage": "{0}     {1}", "EmailDisplayName": " . ", "EmailPattern": "^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[Az]{2,4}$", "EmailPatternMessage": "  . ", "EmailRequired": "", "EmailRequiredMessage": "  . ", "LanguageDisplayName": "", "LanguageValues": "ru-RU, en-US", "LanguageValuesMessage": " .  : {1}", "NameDisplayName": "", "NameLength": "2 - 50", "NameLengthMessage": "    {1} ", "PasswordConfirmCompare": "Password", "PasswordConfirmCompareMessage": "  ", "PasswordConfirmDisplayName": " ", "PasswordDisplayName": "", "PasswordMaxLength": "100", "PasswordMaxLengthMessage": "{0}    {1} ", "PasswordMinLength": "6", "PasswordMinLengthMessage": "{0}    {1} ", "PasswordRequired": "", "PasswordRequiredMessage": " ", "RegisteredMessage": "{0},  !" } } 

Lo 煤ltimo que queda por hacer es permitir solicitudes de origen cruzado para el cliente. Para hacer esto, en Startup.ConfigureServices agregue:


  services.AddCors(); 

y en Startup.Configure add:


  app.UseCors(x => x .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); 

Parte del cliente


Prefiero ver todas las partes de la aplicaci贸n en un IDE, por lo que crear茅 un proyecto de aplicaci贸n cliente utilizando la plantilla de Visual Studio: Aplicaci贸n web Basic Vue.js. Si no lo tiene / no desea obstruir VS con tales plantillas, use vue cli, con 茅l instalar茅 varios paquetes antes: vuetify, axios, vue-i18n.


Solicitamos los archivos de recursos convertidos de LocaleController, indicando la cultura en el encabezado Accept-Language, y colocamos la respuesta en los archivos en.json y ru.json en el directorio local.


A continuaci贸n, necesitamos un servicio para analizar la respuesta con errores del servidor.


errorHandler.service.js
 const ErrorHandlerService = { parseResponseError (error) { if (error.toString().includes('Network Error')) { return 'ServerUnavailable' } if (error.response) { let statusText = error.response.statusText; if (error.response.data && error.response.data.errors) { let message = ''; for (let property in error.response.data.errors) { error.response.data.errors[property].forEach(function (entry) { if (entry) { message += entry + '\n' } }) } return message } else if (error.response.data && error.response.data.message) { return error.response.data.message } else if (statusText) { return statusText } } } }; export { ErrorHandlerService } 

El validador vee-validate integrado en Vutify.js requiere que especifique una serie de funciones de validaci贸n con el atributo de reglas, las solicitaremos del servicio para trabajar con recursos.


locale.service.js
 import i18n from '../i18n'; const LocaleService = { getRules(resource, options) { let rules = []; options = this.prepareOptions(options); this.addRequireRule(rules, resource, options); this.addPatternRule(rules, resource, options); this.addRangeRule(rules, resource, options); this.addLengthRule(rules, resource, options); this.addMaxLengthRule(rules, resource, options); this.addMinLengthRule(rules, resource, options); this.addValuesRule(rules, resource, options); this.addCompareRule(rules, resource, options); return rules; }, prepareOptions(options){ let getter = v => v; let compared = () => null; if (!options){ options = { getter: getter, compared: compared }; } if (!options.getter) options.getter = getter; if (!options.compared) options.compared = compared; return options; }, addRequireRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Required'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); } }, addPatternRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Pattern'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => new RegExp(settings.value).test(options.getter(v)) || settings.message); } }, addRangeRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Range'); if(settings){ let values = settings.value.split('-'); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => parseInt(options.getter(v)) >= values[0] && parseInt(options.getter(v)) <= values[1] || settings.message); } }, addLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Length'); if(settings){ let values = settings.value.split('-'); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length >= values[0] && options.getter(v).length <= values[1] || settings.message); } }, addMaxLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'MaxLength'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length <= settings.value || settings.message); } }, addMinLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'MinLength'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length >= settings.value || settings.message); } }, addValuesRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Values'); if(settings) { let values = settings.value.split(','); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => !!values.find(x => x.trim() === options.getter(v)) || settings.message); } }, addCompareRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Compare'); if(settings) { rules.push(() => { return settings.value === '' || !!settings.value || settings.message }); rules.push(v => { return options.getter(v) === options.compared() || settings.message; }); } }, getRuleSettings(resource, rule){ let value = this.getRuleValue(resource, rule); let message = this.getRuleMessage(resource, rule, value); return value === '' || value ? { value: value, message: message } : null; }, getRuleValue(resource, rule){ let key =`${resource}${rule}`; return this.getI18nValue(key); }, getDisplayName(resource){ let key =`${resource}DisplayName`; return this.getI18nValue(key); }, getRuleMessage(resource, rule, value){ let key =`${resource}${rule}Message`; return i18n.t(key, [this.getDisplayName(resource), value]); }, getI18nValue(key){ let value = i18n.t(key); return value !== key ? value : null; } }; export { LocaleService } 

No tiene sentido describir completamente este servicio, ya que duplica parcialmente la clase ResxValidator. Observo que para verificar la regla Comparar, donde es necesario comparar el valor de la propiedad actual con otra, se pasa el objeto de opciones, en el que se compara el delegado, que devuelve el valor para la comparaci贸n.


Por lo tanto, un campo de formulario t铆pico se ver谩 as铆:


 <v-text-field :label="displayFor('Name')" :rules="rulesFor('Name')" v-model="model.Name" type="text" prepend-icon="mdi-account"></v-text-field> 

Para la etiqueta, la funci贸n de contenedor se llama en locale.service, que pasa el nombre completo del recurso, as铆 como el nombre de la propiedad para la que desea obtener el nombre para mostrar. Del mismo modo para las reglas. El modelo v especifica un modelo para almacenar los datos ingresados.
Para la propiedad de confirmaci贸n de contrase帽a, pase los valores de la contrase帽a en el objeto de opciones:


 :rules="rulesFor('PasswordConfirm', { compared:() => model.Password })" 

v-select para la selecci贸n de idioma tiene una lista predefinida de elementos (esto se hace por simplicidad de ejemplo) y onChange es un controlador. Porque hacemos SPA, queremos cambiar la localizaci贸n cuando el usuario selecciona un idioma, por lo tanto, en la selecci贸n onChange, se verifica el idioma seleccionado y, si ha cambiado, la configuraci贸n regional de la interfaz cambia:


  onCultureChange (value) { let culture = this.cultures.find(x => x.value === value); this.model.Culture = culture.value; if (culture.locale !== this.$i18n.locale) { this.$i18n.locale = culture.locale; this.$refs.form.resetValidation(); } } 

Eso es todo, el repositorio con una aplicaci贸n que funciona est谩 aqu铆 .


Resumiendo, me gustar铆a se帽alar que ser铆a genial almacenar los recursos en un solo formato JSON, que solicitamos de LocaleController, para desatarlo de los detalles del marco ASP. En ResxValidatior, uno podr铆a agregar soporte para extensibilidad y reglas personalizadas en particular, as铆 como resaltar c贸digo id茅ntico a locale.service y reescribirlo en estilo JS para simplificar el soporte. Sin embargo, en este ejemplo, me centro en la simplicidad.

Source: https://habr.com/ru/post/473776/


All Articles