При переходе с Java на Kotlin многие вопросы приходится решать заново, а точнее по-другому. Два года назад мы начали социальный open source проект BrainUp, базируясь на Kotlin и Spring. Проект сейчас активно развивается, а мы узнаём на практике, что значит разрабатывать Kotlin-проект с нуля, какие удобства язык вносит в нашу жизнь, вместе с тем привнося свои вопросы, задачи, которые надо решать по-новому.
Например:
Использование, а точнее не использование data-классов в качестве entity и почему. (напишу статью позже при возможности).
Выбор code style плагина. У нас используется ktlint, инструкция настройки описана в отдельной статье.
Выбор фреймворка тестирования. У нас используется Kotest.
Выбор библиотеки для мокирования. У нас выбрана Mockk.
Варианты использования Kotest и Mockk можно посмотреть у нас в проекте.
Организация валидации входных DTO (Data Transfer Object) с помощью Spring.
Настройка Sonar для Kotlin.
В этой статье расскажу про наш опыт организации валидации входных DTO с помощью Spring, с какими вопросами мы столкнулись в ходе реализации этой идеи в Kotlin и как их решали.
Итак, для добавления валидации в проект нужно пройти эти три шага:
1 шаг. Добавление аннотаций к полям в DTO
В Java мы пользовались такими аннотациями, как @NotNull, @NotEmpty, @NotBlank и др., например:
@NotNull
private String userId;
Но такой вариант, переписанный на Kotlin, работать не будет:
@NotNull
var userId: String
Kotlin рабочий самый простой вариант будет выглядеть так:
@field:NotNull
var userId: String
Теперь рассмотрим подробнее валидации полей разных типов на реальных примерах.
1.1 Валидации для полей String работает как ожидается, вот интересные примеры из нашего проекта:
const val VALID_EMAIL_ADDRESS_REGEX_WITH_EMPTY_SPACES_ACCEPTANCE: String =
"(^\\s+$)|([a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)"
data class UserAccountCreateRequest(
...
@field:NotEmpty(message = "{validation.field.fullName.empty}")
val name: String,
@field:NotBlank(message = "{validation.field.email.blank}")
@field:Email(message = "{validation.field.email.invalid-format}")
@field:Pattern(
regexp = VALID_EMAIL_ADDRESS_REGEX_WITH_EMPTY_SPACES_ACCEPTANCE,
message = "{validation.field.email.invalid-format.cyrillic.not.allowed}"
)
val email: String,
@field:NotBlank(message = "{validation.field.password.blank}")
@field:Size(min = 4, max = 20, message = "{validation.field.password.invalid-format}")
var password: String,
...
)
1.2 Валидация для типов дат, например LocalDateTime, работает тоже как ожидается:
data class AudiometryHistoryRequest(
@field:NotNull
var startTime: LocalDateTime,
var endTime: LocalDateTime?,
...
)
1.3 Валидация для типов Int, Long - тут несколько неочевидный трюк, потому что такой вариант работать не будет:
data class AudiometryHistoryRequest(
@field:NotNull
var audiometryTaskId: Long,
...
)
То есть отправляя такой json { "audiometryTaskId": null } в контроллер, мы не словим ожидаемую ошибку валидации, а увидим, что было проставлено в поле audiometryTaskId значение 0. Ищем на stackoverflow, да есть такое.
Рабочее решение выглядит несколько несуразно:
data class AudiometryHistoryRequest(
@field:NotNull
var audiometryTaskId: Long,
... )
Здесь поле audiometryTaskId объявлено как nullable, но аннотация говорит об обратном. Для принития этого кода, необходимо иметь в голове фразу: «By making the field nullable, you're allowing it to be constructed, so that the JSR 303 validation can run on the object. As validator doesn't run until the object is constructed», — что означает для этих типов для валидации необходим объект, который сначала должен быть создан, т.е. сделать поля nullable для возможности создания:
var audiometryTaskId: Long?
И уже далее по созданному объекту будет произведена Spring-овая валидация, далее это значение в DTO можно спокойно использовать как не nullable:
audiometryHistoryRequest.audiometryTaskId!!
При вызове функции с audiometryTaskId=null, получим MethodArgumentNotValidException:
Улучшить данный вариант можно добавив читабельное сообщение message (плюс смотрите 3й шаг):
data class AudiometryHistoryRequest(
@field:NotNull(message = "{validation.field.audiometryTaskId.notNull}")
var audiometryTaskId: Long?,
...
)
В этом случае defaultMessage будет заменён нашим, и можно будет увидеть именно определённое нами сообщение в response:
2 шаг. Добавление аннотации @Validated в контроллер.
Добавление аннотации @Validated в контроллер перед DTO, которую необходимо проверить при вызове данного end-point.
Например:
3 шаг. Добавление файла с сообщениями об ошибках.
Добавление файла с сообщениями об ошибках errorMessages.properties в папку resources.
На этом с валидацией всё, всем желаю удачи!