Как использовать

предоставляет расширяемый набор строготипизированных валидаторов с компактным синтаксисом использования.

Характеристики:

  • Простой и компактный синтаксис.
  • Сложные валидаторы создаются путём сцепления (piping) простых.
  • Статическая типизация: совместимость смежных валидаторов в цепочке проверяется на этапе компиляции.
  • Как следствие, результат валидации имеет правильный тип. Например, если вы проверяете, что параметр содержит целое число, на выходе вы получите Int.
  • Гибкая поддержка сообщений об ошибках.
  • Валидаторы stateless и поэтому потокобезопасные.
  • Легко добавлять свои валидаторы.

Содержание этой страницы:

Основы: цепочки валидаторов

содержит множество примитивных валидаторов в качестве строительных кирпичиков и позволяет комбинировать (сцеплять) их между собой в более сложные валидаторы, например:

					import ru.dimgel.lib.validator._
					
					val v = VInt & VRange(0, 100)
				

Данная цепочка валидаторов делает две вещи:

  1. Проверяет, что её входное значение содержит число в диапазоне [0, 100], включительно.
  2. Возвращает это число если валидация была успешной.

Таким образом, валидаторы являются также преобразователями данных. Каждый следующий валидатор в цепи берёт в качестве входного значения результат предыдущего. Это как каналы (pipes) в Unix shell. Разумеется, сцеплённые валидаторы должны иметь совместимые типы, иначе вы получите ошибку компиляции: то есть, результат предыдущего валидатора должен иметь тот же тип, что и входное значение для следующего, или быть его подтипом.

Не все валидаторы преобразовывают данные. Например, VInt преобразовывает: на входе у него String, а на выходе Int. А VRange выполняет только проверку диапазона без преобразований, и на входе и на выходе у него один и тот же тип: T <: Ordered[T].

Конструкторы классов валидаторов - protected, для инстанциирования используйте объекты-компаньоны. Это я таким образом форсирую использование компактного синтаксиса; если это вам доставляет неудобства, дайте мне знать.

Теперь давайте используем эту цепочку:

					import Validator.{Data, Failure}
					
					val s = "42"  // String
					val r = v.validate(s)  // Validator.Result[Int]
					r.match {
						case Data(i) => ... // мы попадём сюда, i == 42
						case Failure(msg) => throw new Exception(msg)
					}
				

Вы также можете проверить булево свойство r.ok если вам не требуется ни i, ни msg.

Класс Param. Специальные "chain-head" валидаторы: VRequired* и VOptional*

В вебе (для которого эта библиотека изначально создавалась) параметры и заголовки HTTP-запросов могут иметь несколько значений. Класс Param инкапсулирует список значений параметра:

					val p0  = Param(Nil)  // параметр отсутствует
					val p0a = Param()     // параметр отсутствует
					val p1  = Param("42")  // одно значение
					val p2  = Param("42" :: "43" :: Nil)  // несколько значений
				

Есть специальная группа валидаторов, чьи имена начинаются с VRequired и VOptional. Они наследуются из VChain.Head, имеют входной тип Param и всегда находятся в начале цепи.

  • VRequired* проверяют значение на непустоту (семантика непустоты отличается у классов с разным суффиксом имени). Если значение пусто, то возвращают ошибку; иначе передают значение дальше по цепи.
  • VOptional* отличаются от VRequired* только в том, что в случае пустого значения возвращают успешный результат Empty.

Слегка изменённый пример:

					val p = Param(" 42 ")
					val v = VOptionalTrim & VInt & VRange(0, 100)
					val r = v.validate(p)
					r.match {
						case Empty => ...
						case Data(i) => ... // мы попадаем сюда, i == 42
						case Failure(msg) => throw new Exception(msg)
					}
				

Обратите внимание на следующие моменты:

  • Значение параметра " 42 " содержит пробелы вокруг числа. Если вы передадите его прямо в VInt, то получите ошибку, но VOptionalTrim удаляет лидирующие и хвостовые пробелы перед тем, как проверить его на непустоту и передать дальше по цепи.
  • Внутри блока r.match {...} добавилась ещё одна опция: case Empty. Это успешный результат (и его свойство ok возвращает true), который возвращается только VOptional*-валидаторами.

Ещё несколько примеров для лучшего понимания:

					(VOptionalTrim & VInt).validate(Param("  "))
					// возвращает Empty, т.к. VOptionalTrim удаляет лидирующие и хвостовые
					// пробелы перед тем, как проверять на непустоту.
				
					(VOptional & VInt & VRange(0, 100)).validate(Param("  "))
					(VRequired & VInt & VRange(0, 100)).validate(Param("  "))
					// В обоих случаях будет возвращена ошибка "not an integer", 
					// т.к. VRequired/VOptional считают, что значение не пусто 
					// и передают его далее к VInt. 
				
					(VOptional & VInt & VRange(0, 100)).validate(Param(""))
					(VOptional & VInt & VRange(0, 100)).validate(Param(Nil))
					(VOptional & VInt & VRange(0, 100)).validate(Param(null :: Nil))
					// Возвращает Empty во всех трёх случаях.
				
					(VRequired & VInt & VRange(0, 100)).validate(Param(""))
					// Возвращает ошибку "missing value", т.к. VRequired требует непустое значение.
				
					(VRequiredTrim & VInt & VRange(0, 100)).validate(Param(" 123 "))
					// Возвращает ошибку "not in range", т.к. VRequiredTrim удаляет лидирующие
					// и хвостовые пробелы, так что VInt отрабатывает успешно, а VRange - нет. 
				

Я сделал два отдельных подтипа для Validator.Result - Data[T](data: T) и Empty вместо одного Successful[T](data: Option[T]), чтобы сделать сигнатуру валидаторов более строгой. Как я уже говорил, Empty может быть возвращён только VOptional*-валидаторами. То есть, я прошиваю правило бизнес-логики в систему типов. Это полезно и при использовании валидаторов, и при написании.

А сами chain-head валидаторы являются продолжением идеи "каждый следующий берёт на вход результат предыдущего" и исключают дублирование кода: поскольку VOptionalTrim уже удалил лидирующие и хвостовые пробелы и выполнил проверку на непустоту, следующим за ним уже не должны делать то же самое самостоятельно. Точно так же VRange не должен самостоятельно преобразовывать своё входное значение к Ordered[T], поскольку это уже сделано валидатором VInt.

Задание типов аргументов методов, принимающих цепочки валидаторов

Если у вас есть метод, аргумент которого принимает цепочки валидаторов, рекомендуется следовать следующим правилам при задании типа этого аргумента:

  • Если он должен принимать любые цепочки (VRequired*, VRequired* & ..., VOptional*, VOptional* & ...), объявите его с типом VChain[Out].
  • Если он должен принимать только цепочки, начинающиеся с VRequired* (VRequired*, VRequired* & ...), объявите его с типом VChain.Required[Out]. Кстати, этот класс переобъявляет абстрактный метод validate() с возвращаемым типом NonEmpty.
  • Если он должен принимать только цепочки, начинающиеся с VOptional* (VOptional*, VOptional* & ...), объявите его с типом VChain.Optional[Out].

Вы можете найти примеры в lib.web: поля форм принимают VChain, а две overloaded-версии вспомогательного метода PARAM() в классе ru.dimgel.lib.web.page.AbstractPage берут VChain.Required и VChain.Optional соответственно.

Валидация multi-valued параметров

Здесь мало что сделано, поскольку все валидаторы параметров и HTTP-заголовков, имеющих несколько значений, которые мне пригождались, были слишком специфичные. Подавляющее же большинство валидаторов работали на одиночных строках.

Существует только два chain-head валидатора, берущих Param и возвращающих List[String]: VRequiredList и VOptionalList. Они считают пустым только параметр с пустым списком значений, т.е. Param(Nil). Если список значений не пуст (даже если сами значения - пустые строки или null), то параметр считается непустым.

Эти два валидатора могут быть использованы как reference implementation если вы захотите написать свои собственные chain-head валидаторы. На данный момент не существует валидаторов, принимающих на вход List[String], но вы можете написать свои если вам потребуется.

Param.apply() и Param.Result

Часто значение по умолчанию нужно передавать для пустого опционального параметра, или же когда валидация не успешна. Вот так это делается:

					import Param._
					
					val r = Param("42")(VOptional & VInt & VRange(0, 100), 0)
					// r имеет тип Param.Result
					r match {
						case Success(data_or_defaultValue_?) => ...
						case Failure(defaultValue_?, message) => ...
					}
				

Класс Param содержит методы:

					def apply[T](validator: VChain[T], default_? : Option[T] = None): Param.Result[T]
					def apply[T](validator: VChain[T], default : T): Param.Result[T]
				

Второй делегирует к первому. Оба подкласса Param.Result - Param.Success и Param.Failure содержат свойство data_? : Option[T]. Метод apply() возвращает следующее:

  • Если validator возвращает Validator.Data(data), то Param.Success(Some(data)).
  • Если validator возвращает Validator.Empty, то Param.Success(default_?).
  • Если validator возвращает Validator.Failure(message), то Param.Failure(default_?, message).

Пользовательские сообщения об ошибках

Фабричные методы всех валидаторов, кроме VOptional* (который всегда отрабатывает успешно) принимают опциональный последний параметр message - сообщение об ошибке. Точнее, есть три overloaded-версии фабричных методов для валидаторов.

Первая, наиболее гибкая версия объявляет message как функцию от параметров валидатора и входного значения:

					object VInt ... {
						def apply(message: String => String) = 
							new VInt(message)
						...
					}
					object VRegex {
						def apply(regex: Regex, message: (Regex, String) => String) = 
							new VRegex(regex, message)
						...
					}
					object VRange {
						def apply[T <% Ordered[T]](min: T, max: T, message: (T, T, T) => String) = 
							new VRange[T](min, max, message)
						...
					}
				

Обратите внимание, что аргументы функции message идут в том же порядке, что и параметры валидатора, а входное значение всегда идёт последним. Сигнатура может выглядеть запутанной в случае VRange, поскольку и оба параметра, и входное значение имеют одинаковый тип T, поэтому для ясности я добавил пример VRegex.

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

					object VInt ... {
						def apply(message: => String) = 
							new VInt(in => message)
						// ...
					}
					object VRange {
						// ...
						def apply[T <% Ordered[T]](min: T, max: T, message: => String) = 
							new VRange[T](min, max, (min, max, in) => message)
					}
				

Обратите внимание, что message здесь - by-name аргумент. Это позволяет, например, поддерживать локализованные сообщения, как описано в документации lib.i18n.

Третья версия (которую я до сих пор использовал в примерах) использует сообщение по умолчанию, захародкоженное в валидатор. Если валидатор не имеет параметров, вместо фабричного метода используется сам объект-компаньон:

					object VInt extends VInt(in => "Value is not an integer") {
						// ...
					}
					object VRange {
						// ...
						def apply[T <% Ordered[T]](min: T, max: T) = new VRange[T](
							min, 
							max, 
							(min, max, in) => "Value is not in range [" + min + ", " + max + "]"
						)
					}
				

Сообщения по умолчанию - на английском. Они обычно содержат параметры валидатора, но не содержат входного значения. Причина такого выбора в том, что я использую валидаторы и с параметрами запросов, и с полями форм, и я оптимизировал сообщения для использования с формами. Если валидация даёт ошибку на параметре запроса, это обычно глюк, и разработчик обычно может без проблем получить ошибочное значение из URL или из POST-параметров с помощью сниффера. Но будет выглядеть глупо, если сообщение об ошибке, выводимое прямо над полем формы, будет содержать дубликат значения этого поля. По этой же причине, VRegex не включает свой параметр в сообщение по умолчанию: регулярные выражения выглядят непонятно для пользователей.

Повторное использование цепочек валидаторов

Поскольку все валидаторы stateless (не хранят изменяемого состояния) и потому потокобезопасны, можно часто используемые цепочки создать один раз и использовать многократно в разных местах. Например, для часто встречающегося use case, когда страница просмотра или редактирования сущности требует параметр "id", у меня в веб-проектах есть такое сокращение:

					object Util {
						val VId = VRequired & VInt & VMin(1)
					}