null

Валидация по условию в приложении на Kotlin

В этой статье мы рассмотрим как реализовать валидацию модели данных по условию - валидацию, которая зависит от связи между несколькими свойствами объекта.

В качестве примера кодовой базы для этой статьи возьмем существующий проект "Реактивное АПИ онлайн магазина обучающих курсов", который мы создавали с вами в предыдущих статьях: здесь  и здесь.

И так, приступим.

Предположим, что класс 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. Как видно, все работает как ожидалось.

 

Конец статьи.

Благодарю вас за чтение.

Исходный код, приведенный в этой статье, Вы сможете найти по ссылке здесь