Unificação de regras de validação pelo exemplo do núcleo Asp + VueJS


Este artigo descreve uma maneira simples de unificar regras de validação de entrada do usuário para um aplicativo cliente-servidor. Usando um projeto simples como exemplo, mostrarei como isso pode ser feito usando o Asp net core e o Vue js.


Ao desenvolver aplicativos da Web, via de regra, enfrentamos a tarefa de dupla validação da entrada do usuário. Por um lado, a entrada do usuário deve ser validada no cliente para reduzir solicitações redundantes ao servidor e acelerar a validação em si para o usuário. Por outro lado, falando em validação, o servidor não pode aceitar “de fé” que a validação do cliente realmente funcionou antes de enviar a solicitação, porque o usuário pode desativar ou modificar o código de validação. Ou faça uma solicitação manualmente da API do cliente.


Portanto, a interação cliente-servidor clássica possui 2 nós, com regras de validação de entrada do usuário geralmente idênticas. Este artigo como um todo foi discutido em mais de um artigo; uma solução leve será descrita aqui usando o servidor ASP.Net Core API e o cliente Vue js como exemplo.


Para começar, determinaremos que validaremos exclusivamente as solicitações do usuário (equipe), não da entidade e, do ponto de vista da arquitetura clássica de três camadas, nossa validação está na Camada de Apresentação.


Lado do servidor


Enquanto estiver no Visual Studio 2019, criarei um projeto para um aplicativo de servidor, usando o modelo de aplicativo Web ASP.NET Core, com um tipo de API. O ASP pronto para uso possui um mecanismo de validação bastante robusto e extensível - validação de modelo, segundo a qual, as propriedades de solicitação de modelo são marcadas com atributos de validação específicos.


Considere isso com um controlador simples como exemplo:


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

A solicitação para registrar um novo usuário terá a seguinte aparência:


RegistrationRequest
  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; } } 

Ele usa atributos de validação predefinidos do espaço para nome System.ComponentModel.DataAnnotations. As propriedades necessárias são marcadas com o atributo Requerido. Assim, ao enviar JSON vazio ("{}"), nossa API retornará:


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

O primeiro problema que pode ser encontrado nesta fase é a localização das descrições de erros. A propósito, o ASP possui ferramentas de localização integradas; consideraremos isso posteriormente.


Para verificar o comprimento dos dados da sequência, você pode usar os atributos com nomes falantes: StringLength, MaxLength e MinLength. Ao mesmo tempo, formatando cadeias (chaves), os parâmetros de atributo podem ser integrados a uma mensagem. Por exemplo, para o nome de usuário, inserimos o comprimento mínimo e máximo na mensagem e para a senha "nome de exibição" especificada no atributo com o mesmo nome. O atributo Range é responsável por verificar o valor que deve estar no intervalo especificado.
Vamos enviar uma solicitação com um nome de usuário e senha inaceitavelmente curtos:


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

Na resposta do servidor, você pode encontrar novas mensagens de erro de validação:


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

O problema, que por enquanto não é óbvio, é que os valores de limite para o comprimento do nome e da senha também devem estar presentes no aplicativo cliente. Uma situação em que os mesmos dados são definidos manualmente em dois ou mais locais é um potencial terreno fértil para bugs, um dos sinais de um design inadequado. Vamos consertar.


Armazenaremos tudo o que o cliente precisará em arquivos de recursos. Mensagens em Controllers.AccountController.ru.resx e dados culturalmente independentes em um recurso compartilhado: Controllers.AccountController.resx. Eu aderir a este formato:


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

Assim, temos a seguinte imagem


Observe que, para validar o endereço de email mail é usado expressão regular. E para validação de cultura, uma regra personalizada é usada - "Valores" (lista de valores). Você também precisará verificar o campo de confirmação da senha, que veremos mais adiante na interface do usuário.


Para acessar arquivos de recursos para uma cultura específica, adicionamos suporte à localização no método Startup.ConfigureServices, indicando o caminho para os arquivos de recursos:


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

E também no método Startup.Configure, determinando a cultura pelo cabeçalho da solicitação do usuário:


  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() } }); 

Agora, para que dentro do controlador tenhamos acesso à localização, implementaremos uma dependência do tipo IStringLocalizer no construtor e modificaremos a expressão de retorno da ação Registration:

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

A classe ResxValidatior será responsável por verificar as regras, que usarão os recursos criados. Ele contém uma lista reservada de palavras-chave, rolos predefinidos e um método para verificá-las.


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; } } 

Crie um atributo de validação personalizado que nosso validador chamará. Aqui, a lógica padrão é obter o valor da propriedade verificada do modelo, seu nome e chamar o 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); } } 

Por fim, você pode substituir todos os atributos da solicitação pelo universal, indicando o nome do recurso:


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

Testaremos a funcionalidade enviando a mesma solicitação:


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

Para localização, adicione Controllers.AccountController.en.resx com mensagens em inglês e cabeçalho, com informações sobre a cultura: Accept-Language: en-US.


Observe que agora podemos substituir as configurações de uma cultura específica. No arquivo * .en.resx, especifiquei o tamanho mínimo da senha de 8 e recebi uma mensagem apropriada:


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

Para exibir mensagens idênticas durante a validação do cliente, você deve exportar de alguma forma a lista inteira de mensagens para a parte do cliente. Para simplificar, criaremos um controlador separado que fornecerá tudo o que você precisa para um aplicativo cliente no 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); } } 

Dependendo do idioma aceito, ele retornará:


Idioma de aceitação: 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 aceitação: 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},  !" } } 

A última coisa a fazer é permitir solicitações de origem cruzada para o cliente. Para fazer isso, em Startup.ConfigureServices, adicione:


  services.AddCors(); 

e em Startup.Configure, adicione:


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

Parte do cliente


Eu prefiro ver todas as partes do aplicativo em um IDE, portanto, criarei um projeto de aplicativo cliente usando o modelo do Visual Studio: aplicativo básico da Web Vue.js. Se você não o possui / não deseja entupir o VS com esses modelos, use o vue cli, pois instalarei vários pacotes antes: vuetify, axios, vue-i18n.


Solicitamos os arquivos de recursos convertidos do LocaleController, indicando a cultura no cabeçalho Accept-Language, e colocamos a resposta nos arquivos en.json e ru.json no diretório locales.


Em seguida, precisamos de um serviço para analisar a resposta com erros do 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 } 

O validador vee-validate embutido no Vutify.js requer que você especifique uma matriz de funções de validação com o atributo rules, solicitaremos a elas do serviço para trabalhar com 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 } 

Não faz sentido descrever completamente esse serviço, pois duplica parcialmente a classe ResxValidator. Observo que, para verificar a regra Comparar, onde é necessário comparar o valor da propriedade atual com outra, o objeto de opções é passado, no qual o delegado é comparado, que retorna o valor para comparação.


Assim, um campo de formulário típico terá a seguinte aparência:


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

Para o rótulo, a função wrapper é chamada em locale.service, que passa o nome completo do recurso, bem como o nome da propriedade para a qual você deseja obter o nome de exibição. Da mesma forma para as regras. O modelo v especifica um modelo para armazenar dados inseridos.
Para a propriedade de confirmação da senha, passe os valores da própria senha no objeto de opções:


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

O v-select para seleção de idioma possui uma lista predefinida de elementos (isso é feito para simplificar o exemplo) e onChange é um manipulador. Porque como fazemos SPA, queremos alterar a localização quando o usuário seleciona um idioma; portanto, na seleção onChange, o idioma selecionado é verificado e, se foi alterado, o código de idioma da interface muda:


  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(); } } 

Isso é tudo, o repositório com um aplicativo ativo está aqui .


Resumindo, gostaria de observar que seria ótimo armazenar os recursos em um único formato JSON, que solicitamos ao LocaleController, a fim de desatá-lo das especificidades da estrutura ASP. No ResxValidatior, é possível adicionar suporte para extensibilidade e regras personalizadas em particular, além de destacar código idêntico ao locale.service e reescrevê-lo no estilo JS para simplificar o suporte. No entanto, neste exemplo, concentro-me na simplicidade.

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


All Articles