Комплексная валидация данных в Scala

от автора

Валидация данных — отнюдь не новое явление в разработке программного обеспечения. Она была, есть и будет оставаться актуальной задачей, которая не терпит халатности. Комплексная (comprehensive) валидация данных преследует две следующие цели:

  • Препятствовать внедрению недопустимых состояний в наши приложения

  • Аккумулировать максимально полный список причин отказа в валидации (Это не может быть достигнуто с помощью Either[E, A], так как это монадическая структура — поддерживает вычисление по сокращенной схеме (short circuiting) в вызовах flatMap и производит возврат, как только встречает первый Left, поэтому нам нужно что-то другое)

В этой статье мы с вами будем решать практическую задачу — реализуем комплексную валидацию для ситуации, когда пользователь пытается создать пароль для своего профиля.

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

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

  • Он должен быть не пустым (кто бы мог подумать?!)

  • Он должен содержать хотя бы один символ в верхнем регистре

  • Чтобы считаться достаточно длинным, он должен содержать более 8 символов

  • Он должен содержать хотя бы один из этих специальных символов: !@#$%^&*()

С этим нам поможет знаменитая библиотека cats, а именно конструкции, которые можно найти в пакете cats.data, такие как: Validated (избавляет нас от ограничений Either[E, A]), NonEmptyList и специальный псевдоним типа, связанный с упомянутым выше.

Прежде всего, давайте познакомимся с Validated и NonEmptyList, а затем перейдем к реализации.

Validated — это структура данных, представляющая из себя тип-сумму из двух case-классов Valid или Invalid, которая оборачивает базовые данные, подлежащие проверке.

Простой пример, демонстрирующий иерархию Validated:

sealed trait Validated[+E, +A] {   // куча абстрактных методов }  object Validated {   final case class Valid(data: A) extends Validated[Nothing, A]    final case class Invalid(error: E) extends Validated[E, Nothing] }

Таким образом, очевидно, что левая часть +E предназначена для хранения ошибок валидации, а правая часть +A — для хранения переданных значений.

На первый взгляд это выглядит так же, как и Either[+E, +A] с его Left и Right частями, но на практике работает по-другому.

Простой пример создания Valid данных:

import cats.data.Validated  val validPass: Validated[Nothing, String] =    Validated.Valid("MyPassword!2#")

Простой пример создания Invalid данных:

import cats.data.Validated  val invalidPass: Validated[String, Nothing] =    Validated.Invalid("The password is invalid due to ...")

А для того, чтобы обернуть что-либо в Validated, мы можем использовать методы расширения:

import cats.syntax.all._  val validPass: Validated[Nothing, String] =    "MyPassword!2#".valid  val invalidPass: Validated[String, Nothing] =    "The password is invalid due to ...".invalid

Достаточно просто, не так ли?

Теперь давайте разберемся с NonEmptyList. Как следует из названия, это, по сути, непустой список. Иногда NonEmptyList называют Nel (тут намеренный каламбур с Nil, который является пустым списком), и он основан на реализации односвязного списка.

Простая демонстрация использования NonEmptyList:

import cats.data.NonEmptyList  val oneElementList: NonEmptyList[Int] =    NonEmptyList.one(1)  val maybeNonEmptyList1: Option[NonEmptyList[Int]] =    NonEmptyList.fromList(Nil) // None, из-за параметра Nil  val maybeNonEmptyList2: Option[NonEmptyList[Int]] =    NonEmptyList.fromList(List(1, 2, 3)) // Some(...) из-за параметра List(1, 2, 3)  NonEmptyList.fromListUnsafe(Nil) //выбрасывает исключение из-за параметра Nil

Еще проще, не правда ли?

А теперь посмотрим на тот самый псевдоним типа, который подходит для наших целей:

type ValidatedNel[+E, +A] = Validated[NonEmptyList[E], A]

Таким образом, после применения правил валидации мы получаем либо NonEmptyList ошибок — E, либо данные — A, которые удовлетворяют нашим правилам валидации.

Отлично! Теперь давайте создадим доменную модель для пароля и связанную с ней ADT иерархии ошибок:

final case class Password(value: String) extends AnyVal // value class  object Password {   // ADT ошибок валидации    sealed trait ValidationError    object ValidationError {     case object IsEmpty  extends ValidationError          case object LacksUppercaseChar extends ValidationError          case object IsShort extends ValidationError          case object LacksSpecialSymbol extends ValidationError   } }

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

import Password.ValidationError  trait PasswordValidations {   def validate(password: String): ValidatedNel[ValidationError, Password] }

И, наконец, напишем реализацию в объекте-компаньоне PasswordValidations:

def create: PasswordValidations = password => {    import Password.ValidationError   import Password.ValidationError._    def validate(     condition: Boolean,     failureReason: ValidationError   ): ValidatedNel[ValidationError, Unit] =     if (!condition) failureReason.invalidNel      else ().valid    def isNonEmpty =     validate(password.nonEmpty, IsEmpty)    def containsUpperCaseCharacter =     validate(password.exists(_.isUpper), LacksUppercaseChar)    def isLong =     validate(password.length > 8, IsShort)    def hasSpecialSymbol =     validate(       password.exists(c => "!@#$%^&*()".contains(c.toString)),       LacksSpecialSymbol     )    // Здесь нам необходимо вызвать следующие методы: // 1. isNonEmpty // 2. containsUpperCaseCharacter // 3. isLong // 4. hasSpecialSymbol // и аккумулировать ошибки в канале Invalid, если таковые имеются // или вернуть проверенный пароль, обернутый конструктором Valid     ???   } }

Мы почти у цели. Теперь нам нужен способ задействовать все четыре внутренние функции валидации и:

  • Если хотя бы один или несколько из них не пройдены — возвращается конкатенированный Invalid(NonEmptyList[ValidationError](...))

  • Если все пройдены — возвращаем Valid(Password(...))

Существует несколько способов сделать это, но здесь я покажу вам только свой любимый подход, заключающийся в использовании метода расширения .tupled, который определяется на кортежах произвольных значений, обернутых чем-то вроде Option, Either или, в нашем случае, Validated.

Под капотом .tupled использует typeclass Semigroupal и операцию произведения для достижения следующих целей:

import cats.syntax.all._  val maybe1 = Option(1) val maybe2 = Option(2)  val result: Option[(Int, Int)] = (maybe1, maybe2).tupled // Some(1, 1)

В нашем случае кортеж будет содержать вызовы внутренних функций с типом возврата: ValidatedNel[ValidationError, Unit].

Таким образом, мы можем написать что-то вроде:

import cats.syntax.all._  // .tupled объединяет списки NonEmptyList[ValidationError], если они являются невалидными (Invalid) // и перебирает значения Unit, если они являются валидными (Valid) val result: ValidatedNel[ValidationError, (Unit, Unit, Unit, Unit)] =  (   isNonEmpty,   containsUpperCaseCharacter,   isLong,   hasSpecialSymbol ).tupled

Но… этого недостаточно, мы хотим иметь в конце такую сигнатуру: ValidatedNel[ValidationError, Password], чтобы мы могли легко вызвать map на результае как:

result.map(_ => Password(password)) // ValidatedNel[ValidationError, Password]

или использовать метод расширения .as, что делает его еще более лаконичным и читабельным:

result.as(Password(password)) // ValidatedNel[ValidationError, Password]

Итак, теперь давайте соберем все воедино:

object PasswordValidations {    def create: PasswordValidations = password => {      import Password.ValidationError     import Password.ValidationError._      def validate(       condition: Boolean,       failureReason: ValidationError     ): ValidatedNel[ValidationError, Unit] =       if (!condition) failureReason.invalidNel else ().valid      def isNonEmpty =       validate(password.nonEmpty, IsEmpty)      def containsUpperCaseCharacter =       validate(password.exists(_.isUpper), LacksUppercaseChar)      def isLong =       validate(password.length > 8, IsShort)      def hasSpecialSymbol =       validate(         password.exists(c => "!@#$%^&*()".contains(c.toString)),         LacksSpecialSymbol       )          (       isNonEmpty,       containsUpperCaseCharacter,       isLong,       hasSpecialSymbol     ).tupled.as(Password(password))   } }

​Теперь давайте напишем тесты для нашего сервиса валидации:

Ожидается Invalid(NonEmptyList(IsEmpty, LacksUppercaseChar, IsShort, LacksSpecalSymbol)), поскольку все четыре правила не удовлетворены:

import Password.ValidationError._  val service = PasswordValidations.create val expected = NonEmptyList.fromListUnsafe {   List(IsEmpty, LacksUppercaseChar, IsShort, LacksSpecialSymbol) }.invalid  assert(service.validate("") == expected)

Ожидается Invalid(NonEmptyList(LacksUppercaseChar, IsShort)):

import Password.ValidationError._  val service = PasswordValidations.create val expected = NonEmptyList.fromListUnsafe {   List(LacksUppercaseChar, IsShort) }.invalid  assert(service.validate("pa$$") == expected)

Ожидается Valid(Password(...)), так как он соответствует всем четырем правилам:

import Password.ValidationError._  val service = PasswordValidations.create val pass = "Very$trongPassword123" val expected = Password(pass).valid  assert(service.validate(pass) == expected)

В заключение приглашаем всех, у кого есть опыт работы со Scala, на открытый урок по теме «Type classes в Scala», который пройдет 14 декабря. В результате вебинара сформируется понимание того, что такое type class паттерн, из каких компонентов он состоит и как с его помощью решаются конкретные задачи. Записаться на урок можно на странице курса.


ссылка на оригинал статьи https://habr.com/ru/articles/778644/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *