Как стать автором
Обновить

Унификация правил валидации на примере Asp core + VueJS

Время на прочтение15 мин
Количество просмотров4.3K


В статье описывается простой способ унификации правил валидации пользовательского ввода клиент-серверного приложеия. На примере простого проекта, я покажу как это можно сделать, с использованием Asp net core и Vue js.


Разрабатывая веб приложения, мы как правило сталкаваемся с задачей двойной валидации данных, вводимых пользователем. С одной стороны, пользовательский ввод необходимо валидировать на клиенте, чтобы сократить избыточные запросы к серверу и ускорить саму валидацию для пользователя. С другой стороны, говоря о валидации, сервер не может принимать "на веру" то, что клиентская валидация действительно отработала перед отправкой запроса, т.к. пользователь мог отключить либо модифицировать валидационный код. Или вообще осуществить запрос из API клиента вручную.


Таким образом, классическое клиент-серверное взаимодействие имеет 2 узла, с зачастую идентичными правилами валидации пользовательского ввода. Данной проблеме в целом, посвящена не одна статья, здесь же будет описано легковесное решение на примере ASP.Net Core API сервера и Vue js клиента.


Для начала определимся с тем, что валидаровать мы будем исключительно запросы пользователя (команды), а не сущности, и, с точки зрения классической 3х слойной архитектуры, наша валидация находится в Presentation Layer.


Серверная часть


Находясь в Visual Studio 2019 я создам проект для серверного приложения, по шаблону ASP.NET Core Web Application, c типом API. ASP из коробки имеет достаточно не полохой и расширяемый механизм валидации — model validation, согласно которому, свойства реквест модели помечаются специфичными валидационными атрибутами.


Рассмотрим это на примере простого контроллера:


    [Route("[controller]")]
    [ApiController]
    public class AccountController : ControllerBase
    {
        [HttpPost(nameof(Registration))]
        public ActionResult Registration([FromBody]RegistrationRequest request)
        {
            return Ok($"{request.Name}, вы зарегистрированы!");
        }
    }

Запрос для регистрации нового пользователя будет выглядеть следующим образом:


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

Здесь используются готовые валидационные атрибуты, из пространства имен System.ComponentModel.DataAnnotations. Обязательные свойства помечаются атрибутом Required. Таким образом, при отправке пустого JSON("{}"), наш API вернет:


{
...
    "errors": {
        "Age": [
            "Требуется возраст"
        ],
        "Name": [
            "Требуется имя"
        ],
        "Email": [
            "Требуется адрес эл. почты"
        ],
        "Password": [
            "Требуется пароль"
        ]
    }
}

Первая проблема, с которой можно столкнуться на данном этапе, это локализация описания ошибок. К слову, ASP имеет встроенные средства локализации, рассмотрим это позже.


Для проверки длины строковых данных можно воспользовалься атрибутами с говорящими именами: StringLength, MaxLength и MinLength. При этом, форматированием строк(фигурные скобки), можно интегрировать в сообщение пареметры атрибутов. Например, для имени пользователя мы вставляем в сообщение минимальную и максимальную длину, а для пароля "display name", указанный в одноименном атрибуте. Атрибут Range ответственен за проверку значения, которое долно быть в указанном диапазоне.
Давайте отправим запрос с недопустимо короткими именем и паролем:


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

В ответе от сервера можно найти новые сообщения об ошибках валидации:


    "Name": [
        "Длина имени должна быть от 2 до 50 символов"
    ],
    "Password": [
        "Пароль должен быть минимум 6 символов"
    ]

Проблема, которая может быть, до поры до времени, неочевидна, заключается в том, что граничные значения длины имени и пароля необходимо также иметь в клиентском приложении. Ситуация, при которой, одни и те же данные, вручную задаются в двух или бодее местах, это потенциальный рассадник багов, один из признаков некачественного дизайна. Давайте это исправим.


Будем хранить все, что потребуется клиенту в файлах ресурсов. Сообщения в Controllers.AccountController.ru.resx, а культурно-независимые данные в общем ресурсе: Controllers.AccountController.resx. Я придерживаюсь такого формата:


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

Таким образом, получим следующую картину


Обратите внимание, что для валидации адреса эл. почты используется регуляоное выражение. А для валидации культуры, используется кастомное правило — "Values"(список значений). Также понадобится проверка поля подтверждения пароля, которое мы увидим позже, на UI.


Для доступа к файлам ресурсов для конкретной культуры, добавим поддержку локализации в методе Startup.ConfigureServices, указав путь к файлам ресурсов:


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

А также в методе Startup.Configure определение культуры по header запроса пользовател:


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

Теперь, чтобы внутри контроллера, мы имели доступ к локализации, внедрим в конструктор зависимость типа IStringLocalizer, и модифицируем возвращаемое выражение экшена Registration:

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

За проверку правил будет отвечать класс ResxValidatior, который и будет использовать созданные ресурсы. Он содержит зарезервированный список ключевых слов, пресет рулов, и метод для их проверки.


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

Создадим кастомный валидационный атрибут, который и будет вызывать наш валидатор. Здесь стандартная логика получение значения поверяемого свойства модели, его имени и вызов валидатора.


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

Наконец можно заменить все атрибуты в реквесте на наш универсальный, с указанием имени ресурса:


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

Проверим работоспособность, отправив тот же запрос:


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

Для локализации добавим Controllers.AccountController.en.resx с сообщениями на английском языке, а также header, с информацией о культуре: Accept-Language:en-US.


Обратите внимание, что теперь мы можем переопределять настройки для конкретной культуры. В файле *.en.resx я указал минимальную длину пароля 8, и получил сооответствующее сообщение:


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

Для отображения идентичных сообщенеий в ходе клиентской валидации необходимо как-то экспортировать весь список сообщений для клиентской части. Для простоты, сделаем отдельный контроллер, который будет отдавать все необходимое для клиенсткого приложения в формате 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);
        }
    }

В зависимости от Accept-Language он вернет:


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]+\\.)+[A-z]{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:ru-RU
{
  "Controllers-AccountController": {
    "AgeDisplayName": "Возраст",
    "AgeRange": "18 - 150",
    "AgeRangeMessage": "{0} должен быть в пределах {1}",
    "EmailDisplayName": "Адрес эл. почты",
    "EmailPattern": "^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[A-z]{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}, вы зарегистрированы!"
  }
}

Последнее, что осталось сделать, это разрешить сross-origin запросы для клиента. Для этого в Startup.ConfigureServices добавим:


    services.AddCors();

а в Startup.Configure добавим:


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

Клиентская часть


Я предпочитаю видеть в одной IDE все части приложения, поэтому создам проект клиентского приложения с помощью шаблона Visual Studio: Basic Vue.js Web Application. Если у вас его нет/вы не хотите засорять VS подобными шаблонами, воспользуйтесь vue cli, с помощью него я до установлю ряд пакетов: vuetify, axios, vue-i18n.


Запросим сконвертированные файлы ресурсов у LocaleController, указав в заголовке Accept-Language культуру, и поместим ответ в файлы en.json и ru.json, в каталог locales.


Далее нам понадобится сервис для парсинга ответа с ошибками от сервера.


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 }

Встроенный во Vutify.js валидатор vee-validate требует указать атрибутом rules, массив функций-проверок, их мы будем запрашивать у сервиса для работы с ресурсами.


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 }

Полностью описывать этот сервис большого смысла нет, т.к. он частично дублирует класс ResxValidator. Отмечу, что для проверки правила Compare, где необходимо сравнивать значение текущего свойства с другим, передается объект options, в котором ожидается делегат compared, возвращающий значение для сравнения.


Таким образом, типичное поле формы будет выглядеть следующим образом:


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

Для label вызывается функция-враппер над locale.service, которая передает полное имя ресурса, а также имя свойства, для которого необходимо получить отображаемое имя. Аналогично для rules. В v-model указана модель для хранения введенных данных.
Для свойства подтверждения пароля необходимо в объекте options передать значения самого пароля:


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

v-select для выбора языка имеет предопределенный список элементов(это сделано для простоты примера) а также onChange — обработчик. Т.к. мы делаем SPA, мы хотим при выборе пользователем языка менять локализацию, поэтому в onChange селекта осуществляется проверка выбранного языка, и, если он изменился, меняется локаль интерфейса:


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

На этом все, репозиторий с рабочим приложением находится здесь.


Подытоживая отмечу, что сами ресурсы было бы здорово изначально хранить в едином формате JSON, который мы запрашиваем у LocaleController, дабы отвязать его от специфики ASP фреймворка. В ResxValidatior, можно было бы добавить поддержку расширяемости и кастомных правил в частности, а также выделить код, идентичный locale.service, и переписать его в стиле JS, для упрощения поддержки. Однако в данном примере я делаю акцент именно на простоту.

Теги:
Хабы:
Всего голосов 18: ↑18 и ↓0+18
Комментарии0

Публикации

Истории

Работа

Ближайшие события