Unification des règles de validation par l'exemple d'Asp core + VueJS


Cet article décrit un moyen simple d'unifier les règles de validation des entrées utilisateur pour une application client-serveur. En utilisant un projet simple comme exemple, je montrerai comment cela peut être fait en utilisant Asp net core et Vue js.


Lors du développement d'applications Web, en règle générale, nous sommes confrontés à la tâche de la double validation de l'entrée utilisateur. D'une part, l'entrée utilisateur doit être validée sur le client afin de réduire les demandes redondantes au serveur et d'accélérer la validation elle-même pour l'utilisateur. Par contre, en parlant de validation, le serveur ne peut accepter "de foi" que la validation client a bien fonctionné avant d'envoyer la demande, car l'utilisateur pourrait désactiver ou modifier le code de validation. Ou même faire une demande à partir de l'API cliente manuellement.


Ainsi, l'interaction client-serveur classique a 2 nœuds, avec des règles de validation d'entrée utilisateur souvent identiques. Cet article dans son ensemble a été discuté dans plusieurs articles; une solution légère sera décrite ici en utilisant le serveur API Core ASP.Net et le client Vue js comme exemple.


Pour commencer, nous déterminerons que nous validerons exclusivement les demandes de l'utilisateur (équipe), pas de l'entité, et, du point de vue de l'architecture classique à 3 couches, notre validation est dans la couche de présentation.


Côté serveur


Dans Visual Studio 2019, je vais créer un projet pour une application serveur, en utilisant le modèle d'application Web ASP.NET Core, avec un type d'API. ASP prêt à l'emploi dispose d'un mécanisme de validation assez bon et extensible - validation du modèle, selon lequel, les propriétés de demande du modèle sont marquées avec des attributs de validation spécifiques.


Considérez ceci avec un simple contrôleur comme exemple:


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

La demande d'enregistrement d'un nouvel utilisateur ressemblera à ceci:


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

Il utilise des attributs de validation prédéfinis de l'espace de noms System.ComponentModel.DataAnnotations. Les propriétés obligatoires sont marquées avec l'attribut Obligatoire. Ainsi, lors de l'envoi de JSON vide ("{}"), notre API retournera:


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

Le premier problème qui peut être rencontré à ce stade est la localisation des descriptions d'erreurs. Soit dit en passant, ASP possède des outils de localisation intégrés, nous y réfléchirons plus tard.


Pour vérifier la longueur des données de chaîne, vous pouvez utiliser les attributs avec des noms parlants: StringLength, MaxLength et MinLength. Dans le même temps, en formatant des chaînes (accolades), les paramètres d'attribut peuvent être intégrés dans un message. Par exemple, pour le nom d'utilisateur, nous insérons la longueur minimale et maximale dans le message, et pour le mot de passe "nom d'affichage" spécifié dans l'attribut du même nom. L'attribut Range est chargé de vérifier la valeur qui doit se trouver dans la plage spécifiée.
Envoyons une demande avec un nom d'utilisateur et un mot de passe trop courts:


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

Dans la réponse du serveur, vous pouvez trouver de nouveaux messages d'erreur de validation:


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

Le problème, qui n'est peut-être pas évident pour l'instant, est que les valeurs limites pour la longueur du nom et du mot de passe doivent également être présentes dans l'application cliente. Une situation dans laquelle les mêmes données sont définies manuellement dans deux endroits ou bodey est un potentiel de reproduction pour les bogues, l'un des signes d'une mauvaise conception. Corrigeons-le.


Nous stockerons tout ce dont le client aura besoin dans des fichiers de ressources. Messages dans Controllers.AccountController.ru.resx et données culturellement indépendantes dans une ressource partagée: Controllers.AccountController.resx. J'adhère à ce format:


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

Ainsi, nous obtenons l'image suivante


Veuillez noter que pour valider l'adresse e-mail mail est une expression régulière. Et pour la validation de la culture, une règle personnalisée est utilisée - "Valeurs" (liste de valeurs). Vous devrez également vérifier le champ de confirmation du mot de passe, que nous verrons plus tard, sur l'interface utilisateur.


Pour accéder aux fichiers de ressources pour une culture spécifique, nous ajoutons la prise en charge de la localisation dans la méthode Startup.ConfigureServices, en indiquant le chemin d'accès aux fichiers de ressources:


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

Et également dans la méthode Startup.Configure, déterminant la culture par l'en-tête de la demande de l'utilisateur:


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

Maintenant, pour qu'à l'intérieur du contrôleur, nous ayons accès à la localisation, nous allons implémenter une dépendance de type IStringLocalizer dans le constructeur, et modifier l'expression de retour de l'action Registration:

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

La classe ResxValidatior sera chargée de vérifier les règles, qui utiliseront les ressources créées. Il contient une liste réservée de mots clés, des rouleaux prédéfinis et une méthode pour les vérifier.


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

Créez un attribut de validation personnalisé que notre validateur appellera. Ici, la logique standard consiste à obtenir la valeur de la propriété vérifiée du modèle, son nom et à appeler le validateur.


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

Enfin, vous pouvez remplacer tous les attributs de la requête par notre universel, en indiquant le nom de la ressource:


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

Nous testerons la fonctionnalité en envoyant la même demande:


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

Pour la localisation, ajoutez Controllers.AccountController.en.resx avec des messages en anglais, ainsi qu'en-tête, avec des informations sur la culture: Accept-Language: en-US.


Veuillez noter que nous pouvons désormais remplacer les paramètres d'une culture spécifique. Dans le fichier * .en.resx, j'ai spécifié la longueur minimale du mot de passe de 8 et j'ai reçu un message approprié:


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

Pour afficher des messages identiques lors de la validation du client, vous devez en quelque sorte exporter la liste complète des messages pour la partie client. Pour plus de simplicité, nous créerons un contrôleur séparé qui fournira tout ce dont vous avez besoin pour une application client au format i18n.


Localecontroller
  [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); } } 

Selon la langue acceptée, il renverra:


Accept-Language: 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!" } } 

Accept-Language: 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},  !" } } 

La dernière chose qui reste à faire est d'autoriser les demandes d'origine croisée pour le client. Pour ce faire, dans Startup.ConfigureServices, ajoutez:


  services.AddCors(); 

et dans Startup.Configure, ajoutez:


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

Partie client


Je préfère voir toutes les parties de l'application dans un seul IDE. Je vais donc créer un projet d'application client en utilisant le modèle Visual Studio: Application Web Vue.js de base. Si vous ne l'avez pas / vous ne voulez pas obstruer VS avec de tels modèles, utilisez vue cli, avec lui j'installerai un certain nombre de packages avant: vuetify, axios, vue-i18n.


Nous demandons les fichiers de ressources convertis au LocaleController, indiquant la culture dans l'en-tête Accept-Language, et plaçons la réponse dans les fichiers en.json et ru.json dans le répertoire locales.


Ensuite, nous avons besoin d'un service pour analyser la réponse avec des erreurs du serveur.


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 } 

Le validateur vee-validate intégré à Vutify.js vous oblige à spécifier un tableau de fonctions de validation avec l'attribut rules, nous les demanderons au service pour travailler avec les ressources.


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 } 

Cela n'a aucun sens de décrire pleinement ce service, car il duplique partiellement la classe ResxValidator. Je note que pour vérifier la règle de comparaison, où il est nécessaire de comparer la valeur de la propriété actuelle avec une autre, l'objet options est passé, dans lequel le délégué est comparé, qui renvoie la valeur de comparaison.


Ainsi, un champ de formulaire typique ressemblera à ceci:


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

Pour l'étiquette, la fonction wrapper est appelée sur locale.service, qui transmet le nom complet de la ressource, ainsi que le nom de la propriété pour laquelle vous souhaitez obtenir le nom d'affichage. De même pour les règles. Le v-modèle spécifie un modèle pour stocker les données entrées.
Pour la propriété de confirmation de mot de passe, passez les valeurs du mot de passe lui-même dans l'objet options:


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

v-select pour la sélection de la langue a une liste d'éléments prédéfinie (cela est fait pour la simplicité de l'exemple) et onChange est un gestionnaire. Parce que nous faisons du SPA, nous voulons changer la localisation lorsque l'utilisateur sélectionne une langue, par conséquent, la sélection onChange vérifie la langue sélectionnée, et si elle a changé, les paramètres régionaux de l'interface changent:


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

C'est tout, le référentiel avec une application qui fonctionne est ici .


En résumé, je voudrais noter qu'il serait formidable de stocker les ressources elles-mêmes dans un seul format JSON, que nous demandons au LocaleController, afin de le délier des spécificités du framework ASP. Dans ResxValidatior, on pourrait ajouter la prise en charge de l'extensibilité et des règles personnalisées en particulier, ainsi que mettre en évidence le code identique à locale.service et le réécrire dans le style JS pour simplifier la prise en charge. Cependant, dans cet exemple, je me concentre sur la simplicité.

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


All Articles