Unification of validation rules by the example of Asp core + VueJS



This article describes a simple way to unify user input validation rules for a client-server application. Using a simple project as an example, I will show how this can be done using Asp net core and Vue js.







When developing web applications, we usually face the task of double validation of user input. On the one hand, user input must be validated on the client to reduce redundant requests to the server and speed up the validation itself for the user. On the other hand, speaking of validation, the server cannot accept “on faith” that client validation actually worked before sending the request, because the user could disable or modify the validation code. Or even make a request from the client API manually.







Thus, the classic client-server interaction has 2 nodes, with often identical user input validation rules. This article as a whole has been discussed in more than one article; a lightweight solution will be described here using the ASP.Net Core API server and Vue js client as an example.







To begin with, we will determine that we will validate exclusively the requests of the user (team), not the entity, and, from the point of view of the classical 3-layer architecture, our validation is in the Presentation Layer.







Server side



Being in Visual Studio 2019 I will create a project for a server application, using the ASP.NET Core Web Application template, with an API type. ASP out of the box has a fairly robust and extensible validation mechanism - model validation, according to which the model’s request properties are marked with specific validation attributes.







Consider this with a simple controller as an example:







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





The request for registering a new user will look like this:







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





It uses predefined validation attributes from the System.ComponentModel.DataAnnotations namespace. Required properties are marked with the Required attribute. Thus, when sending empty JSON ("{}"), our API will return:







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





The first problem that you may encounter at this stage is the localization of error descriptions. By the way, ASP has built-in localization tools, we will consider this later.







To check the length of string data, you can use the attributes with speaking names: StringLength, MaxLength and MinLength. At the same time, by formatting strings (curly braces), attribute parameters can be integrated into a message. For example, for the username, we insert the minimum and maximum lengths in the message, and for the password "display name" specified in the attribute of the same name. The Range attribute is responsible for checking the value that should be in the specified range.

Let's send a request with an invalid short name and password:







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





In the response from the server, you can find new validation error messages:







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





The problem, which may be, for the time being, not obvious, is that the boundary values ​​for the length of the name and password must also be present in the client application. A situation in which the same data is manually set in two or bodey places is a potential breeding ground for bugs, one of the signs of poor design. Let's fix it.







We will store everything that the client will need in resource files. Messages in Controllers.AccountController.ru.resx, and culturally independent data in a shared resource: Controllers.AccountController.resx. I adhere to this format:







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





Thus, we get the following picture







Please note that to validate the email address mail is used regular expression. And for culture validation, a custom rule is used - "Values" (list of values). You will also need to check the password confirmation field, which we will see later, on the UI.







To access resource files for a specific culture, we add localization support in the Startup.ConfigureServices method, indicating the path to the resource files:







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





And also in the Startup.Configure method, determining the culture by the header of the user request:







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





Now, so that inside the controller, we have access to localization, we will implement a dependency of the IStringLocalizer type in the constructor, and modify the return expression of the Registration action:





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





The ResxValidatior class will be responsible for checking the rules, which will use the created resources. It contains a reserved list of keywords, preset rolls, and a method for checking them.







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





Create a custom validation attribute that our validator will call. Here, the standard logic is obtaining the value of the verified property of the model, its name and calling the validator.







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





Finally, you can replace all the attributes in the request with our universal one, indicating the name of the resource:







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





We’ll test the functionality by sending the same request:







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





For localization, add Controllers.AccountController.en.resx with messages in English, as well as header, with information about the culture: Accept-Language: en-US.







Please note that we can now override the settings for a specific culture. In the * .en.resx file, I specified the minimum password length of 8, and I received an appropriate message:







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





To display identical messages during client validation, you must somehow export the entire list of messages for the client part. For simplicity, we will create a separate controller that will give everything you need for a client application in i18n format.







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





Depending on the Accept-Language, it will return:







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





The last thing left to do is to allow cross-origin requests for the client. To do this, in Startup.ConfigureServices add:







  services.AddCors();
      
      





and in Startup.Configure add:







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





Client part



I prefer to see all parts of the application in one IDE, so I’ll create a client application project using the Visual Studio template: Basic Vue.js Web Application. If you don’t have it / you don’t want to clog VS with such templates, use vue cli, with it I will install a number of packages before: vuetify, axios, vue-i18n.







We request the converted resource files from the LocaleController, indicating the culture in the Accept-Language header, and place the response in the en.json and ru.json files in the locales directory.







Next, we need a service for parsing the response with errors from the server.







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 }
      
      





The vee-validate validator built into Vutify.js requires the attribute attributes to specify an array of validation functions, we will request them from the service for working with resources.







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 }
      
      





It makes no sense to fully describe this service, as it partially duplicates the ResxValidator class. I note that to check the Compare rule, where it is necessary to compare the value of the current property with another, the options object is passed, in which the delegate is compared, which returns the value for comparison.







Thus, a typical form field will look like this:







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





For the label, the wrapper function is called on locale.service, which passes the full name of the resource, as well as the name of the property for which you want to get the display name. Similarly for rules. The v-model specifies a model for storing entered data.

For the password confirmation property, you need to pass the password values ​​in the options object:







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





v-select for language selection has a predefined list of elements (this is done for simplicity of example) and onChange is a handler. Because we do SPA, we want to change the localization when the user selects a language, therefore, in the onChange select, the selected language is checked, and if it has changed, the interface locale changes:







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





That's all, the repository with a working application is here .







Summing up, I note that the resources themselves would be great to initially store in the single JSON format that we request from the LocaleController in order to untie it from the specifics of the ASP framework. In ResxValidatior, one could add support for extensibility and custom rules in particular, as well as highlight code identical to locale.service and rewrite it in JS style to simplify support. However, in this example, I focus on simplicity.












All Articles