
В этой статье мы рассмотрим как реализовать валидацию модели данных по условию - валидацию, которая зависит от связи между несколькими свойствами объекта.
В качестве примера кодовой базы для этой статьи возьмем существующий проект "Реактивное АПИ онлайн магазина обучающих курсов", который мы создавали с вами в предыдущих статьях: здесь и здесь.
И так, приступим.
Предположим, что класс CourseRequest является тем доменным классом, к которому должны быть применены некоторые проверки:
data class CourseRequest(
@field:NotBlank(message = "name should be specified") val name : String,
@field:PositiveOrZero(message = "price must be >= 0") val price : Float,
@field:NotBlank(message = "author should be specified") val author : String,
@field:NotBlank(message = "direction should be specified") val direction : String,
val startDate : LocalDate,
val endDate: LocalDate?=null
)
Как видно, к некоторым полям класса уже применены готовые проверки из фреймворка, такие как: NotBlank, PositiveOrZero.
Но, что если нам необходимо, чтобы одно из полей класса к примеру было обязательным, только если не заполнено другое и наоборот. Или как быть, если мы хотим, чтобы в зависимости от значения определенного поля изменялся и набор других связанных с ним полей. Стандартными проверками, которые предоставляет нам фреймворк Jakarta Validation, такую задачу не решить.
На помощь приходит создание нашей собственной пользовательской проверки.
Давайте добавим в наш класс два поля conditional1 и conditional2:
data class CourseRequest(
@field:NotBlank(message = "name should be specified") val name : String,
@field:PositiveOrZero(message = "price must be >= 0") val price : Float,
@field:NotBlank(message = "author should be specified") val author : String,
@field:NotBlank(message = "direction should be specified") val direction : String,
val startDate : LocalDate,
val endDate: LocalDate?=null,
val conditional1: String?,
val conditional2: String?
)
Эти поля мы добавили, что на них наглядно показать как будет работать наша кастомная валидация.
Логика проверки будет следующей: должно быть заполнено хотя бы одно поле: conditional1 или conditional2. Если оба поля не заполнены, выдаем ошибку валидации. Если заполнено первое поле, то заполненность второго уже не проверяем и наоборот. Таким образом, мы реализуем зависимость двух полей друг от друга, а также обязательность заполнения какого-либо одного из полей.
Давайте введем пользовательскую аннотацию на уровне класса, которая определяет условия этой логики. Назовем её - @RequiredByCondition:
@RequiredByCondition(
conditionalProperty = "conditional1",
triggerValues = ["NULL", "null"],
requiredProperties = ["conditional2"],
message = "'conditional1' or 'conditional2' must be specified"
)
@RequiredByCondition(
conditionalProperty = "conditional2",
triggerValues = ["NULL", "null", "EMPTY", "empty"],
requiredProperties = ["conditional1"],
message = "'conditional1' or 'conditional2' must be specified"
)
data class CourseRequest(
@field:NotBlank(message = "name should be specified") val name : String,
@field:PositiveOrZero(message = "price must be >= 0") val price : Float,
@field:NotBlank(message = "author should be specified") val author : String,
@field:NotBlank(message = "direction should be specified") val direction : String,
val startDate : LocalDate,
val endDate: LocalDate?=null,
val conditional1: String?,
val conditional2: String?
)
Как читать вышеприведенный код? Все очень просто. Мы добавили к классу две аннотации @RequiredByCondition, одну для поля "conditional1", другую для - "conditional2".
Первая аннотация говорит нам о том, что если поле класса "conditional1" (определено как conditionalProperty = "conditional1") содержит в себе значения "NULL" или "null" (triggerValues), что также в нашей реализации означает пустую строку, то мы ожидаем что поле класса "conditional2" (определено как requiredProperties = ["conditional2"]) будет содержать в себе непустое значение и не будет равно "null". В обратном случае, мы выдадим ошибку валидации с сообщением, определенным свойством message = "'conditional1' or 'conditional2' must be specified".
Абсолютно похожее условие содержится во второй аннотации, только применяется оно уже к полю класса "conditional2".
Мы могли задать свойства аннотаций и по другому. Например так:
@RequiredByCondition(
conditionalProperty = "conditional1",
triggerValues = ["Some good value"],
requiredProperties = ["name", "price"],
message = "'name' and 'price' must be specified"
)
@RequiredByCondition(
conditionalProperty = "author",
triggerValues = ["Some famous author"],
requiredProperties = ["conditional1", "conditional2"],
message = "'conditional1' and 'conditional2' must be specified"
)
data class CourseRequest(
@field:NotBlank(message = "name should be specified") val name : String,
@field:PositiveOrZero(message = "price must be >= 0") val price : Float,
@field:NotBlank(message = "author should be specified") val author : String,
@field:NotBlank(message = "direction should be specified") val direction : String,
val startDate : LocalDate,
val endDate: LocalDate?=null,
val conditional1: String?,
val conditional2: String?
)
В этом случае мы достигли бы того, что в зависимости от значения определенного поля изменялся бы набор обязательных других связанных с ним полей.
Например, если поле "conditional1" имеет значение "Some good value", то мы ожидаем, что поля "name" и "price" будут заполнены.
А если поле "author" равно "Some famous author", то мы ожидаем, что оба поля "conditional1" и "conditional2" не будут пустыми или равными null.
И так, мы рассмотрели как добавить нашу аннотацию к классу и как задать ее свойства.
Теперь давайте рассмотрим как наша аннотация реализована "под капотом".
Добавим файл Validatrs.kt в пакет model со следующим содержимым:
@Constraint(validatedBy = [RequiredByConditionValidator::class])
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@JvmRepeatable(
RequiredByCondition.List::class
)
annotation class RequiredByCondition(
val message: String = "must be specified",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val conditionalProperty: String,
val triggerValues: Array<String>,
val requiredProperties: Array<String>
) {
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class List(vararg val value: RequiredByCondition)
}
class RequiredByConditionValidator : ConstraintValidator<RequiredByCondition, Any?> {
private var conditionalProperty: String? = null
private lateinit var triggerValues: Array<String>
private lateinit var requiredProperties: Array<String>
private var message: String? = null
override fun initialize(constraint: RequiredByCondition) {
conditionalProperty = constraint.conditionalProperty
triggerValues = constraint.triggerValues
requiredProperties = constraint.requiredProperties
message = constraint.message
}
override fun isValid(o: Any?, context: ConstraintValidatorContext): Boolean {
try {
val conditionalPropertyValue: Any? = o?.getField(conditionalProperty!!)
if (isValidationNeeded(conditionalPropertyValue)) {
return validateRequiredProperties(o, context)
}
} catch (e: Exception) {
return false
}
return true
}
private fun isValidationNeeded(conditionalPropertyValue: Any?): Boolean {
val values = listOf(*triggerValues)
val isEmpty: Boolean = ObjectUtils.isEmpty(conditionalPropertyValue)
return if (isEmpty && values.stream().anyMatch {
it: String ->
it.equals("NULL", ignoreCase = true) || it.equals("EMPTY", ignoreCase = true) })
true
else values.contains(conditionalPropertyValue)
}
private fun validateRequiredProperties(o: Any?, context: ConstraintValidatorContext): Boolean {
var isValid = true
listOf(*requiredProperties).forEach(Consumer { it: String? ->
val requiredPropertyValue: Any? = o?.getField(it!!)
val isNotSpecified: Boolean = ObjectUtils.isEmpty(requiredPropertyValue)
if (isNotSpecified) {
isValid = false
context.disableDefaultConstraintViolation()
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(it)
.addConstraintViolation()
}
})
return isValid
}
@Throws(IllegalAccessException::class, ClassCastException::class)
inline fun <reified T> Any.getField(fieldName: String): T? {
this::class.memberProperties.forEach { kCallable ->
if (fieldName == kCallable.name) {
return kCallable.getter.call(this) as T?
}
}
return null
}
}
Для начала рассмотрим саму аннотацию:
@Constraint(validatedBy = [RequiredByConditionValidator::class])
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@JvmRepeatable(
RequiredByCondition.List::class
)
annotation class RequiredByCondition(
val message: String = "must be specified",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val conditionalProperty: String,
val triggerValues: Array<String>,
val requiredProperties: Array<String>
) {
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class List(vararg val value: RequiredByCondition)
}
Здесь важно отметить несколько моментов:
С помощью @Constraint(validatedBy = [RequiredByConditionValidator::class]) мы указываем, что наша аннотация будет ограничивающей аннотацией и для проверки элементов, которые мы пометим ей, будет использоваться валидатор RequiredByConditionValidator.
С помощью @Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS) мы указываем область применения нашей аннотации, - классы.
С помощью @JvmRepeatable(RequiredByCondition.List::class) мы указываем, что наша аннотация будет повторяющейся, то есть ее можно будет применить несколько раз к одному и тому же элементу. Место хранения повторений указываем как RequiredByCondition.List
Теперь перейдем к классу RequiredByConditionValidator, который выполняет фактическую логику валидации. Этот валидатор реализует интерфейс ConstraintValidator из пакета jakarta.validation.
class RequiredByConditionValidator : ConstraintValidator<RequiredByCondition, Any?> {
private var conditionalProperty: String? = null
private lateinit var triggerValues: Array<String>
private lateinit var requiredProperties: Array<String>
private var message: String? = null
override fun initialize(constraint: RequiredByCondition) {
conditionalProperty = constraint.conditionalProperty
triggerValues = constraint.triggerValues
requiredProperties = constraint.requiredProperties
message = constraint.message
}
override fun isValid(o: Any?, context: ConstraintValidatorContext): Boolean {
try {
val conditionalPropertyValue: Any? = o?.getField(conditionalProperty!!)
if (isValidationNeeded(conditionalPropertyValue)) {
return validateRequiredProperties(o, context)
}
} catch (e: Exception) {
return false
}
return true
}
private fun isValidationNeeded(conditionalPropertyValue: Any?): Boolean {
val values = listOf(*triggerValues)
val isEmpty: Boolean = ObjectUtils.isEmpty(conditionalPropertyValue)
return if (isEmpty && values.stream().anyMatch {
it: String ->
it.equals("NULL", ignoreCase = true) || it.equals("EMPTY", ignoreCase = true) })
true
else values.contains(conditionalPropertyValue)
}
private fun validateRequiredProperties(o: Any?, context: ConstraintValidatorContext): Boolean {
var isValid = true
listOf(*requiredProperties).forEach(Consumer { it: String? ->
val requiredPropertyValue: Any? = o?.getField(it!!)
val isNotSpecified: Boolean = ObjectUtils.isEmpty(requiredPropertyValue)
if (isNotSpecified) {
isValid = false
context.disableDefaultConstraintViolation()
context.buildConstraintViolationWithTemplate(message)
.addPropertyNode(it)
.addConstraintViolation()
}
})
return isValid
}
@Throws(IllegalAccessException::class, ClassCastException::class)
inline fun <reified T> Any.getField(fieldName: String): T? {
this::class.memberProperties.forEach { kCallable ->
if (fieldName == kCallable.name) {
return kCallable.getter.call(this) as T?
}
}
return null
}
}
Функция initialize() предоставляет нам доступ к значениям атрибутов, используемых в нашей аннотации, и позволяет хранить их в полях валидатора.
Функция isValid() содержит конкретную логику валидации.
В ней вначале мы получаем с помощью рефлексии (реализовано нами как inline функция getField) значение поля класса, название которого мы указали в свойстве conditionalProperty нашей аннотации.
Далее с помощью функции isValidationNeeded проверяем нужно ли валидировать это значение или нет. Если валидировать не нужно, функция isValid() просто возвращает true, что означает что валидатор отработал и результат проверки положительный. В обратном случае, когда валидация значения требуется, мы выполняем функцию validateRequiredProperties и возвращаем ее результат. В случае если результат отрицательный, то мы генерируем ошибку валидации ConstraintViolation с сообщением, которое мы указали в свойстве message аннотации.
Теперь давайте протестируем нашу аннотацию и валидатор и проверим, ведут ли они себя так, как ожидалось.
Для начала напишем, а затем прогоним тесты:
@SpringBootTest
class CoursesShopReactiveApplicationTests {
private var request: CourseRequest? = null
var validator: Validator = Validation.buildDefaultValidatorFactory().validator
@Test
fun GIVEN_course_request_without_conditional1_or_conditional2_WHEN_validate_THEN_constraintViolations() {
request = CourseRequest("test-name",
200f,
"test-author",
"test-direction",
LocalDate.now(),
null,
null,
null)
val messages = messages(validator.validate(request))
Assertions.assertEquals(messages.size, 1)
Assertions.assertTrue(messages.contains("'conditional1' or 'conditional2' must be specified"))
}
@Test
fun GIVEN_course_request_with_conditional1_WHEN_validate_THEN_success() {
request = CourseRequest("test-name",
200f,
"test-author",
"test-direction",
LocalDate.now(),
null,
"conditional-1",
null)
val messages = messages(validator.validate(request))
Assertions.assertEquals(messages.size, 0)
}
private fun messages(constraintViolations: Set<ConstraintViolation<Any>>): Set<String> {
return constraintViolations
.stream()
.map { obj: ConstraintViolation<Any> -> obj.message }
.collect(Collectors.toSet())
}
}
Результат прогона тестов:

Tests passed: 2 of 2. Perfect. Как видно, все работает как ожидалось.
Конец статьи.
Благодарю вас за чтение.
Исходный код, приведенный в этой статье, Вы сможете найти по ссылке здесь