Валидация данных — отнюдь не новое явление в разработке программного обеспечения. Она была, есть и будет оставаться актуальной задачей, которая не терпит халатности. Комплексная (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 паттерн, из каких компонентов он состоит и как с его помощью решаются конкретные задачи. Записаться на урок можно на странице курса.