Spring валидация входных DTO в Kotlin. Краткая инструкция для backend-разработчика

Kate

Administrator
Команда форума
При переходе с Java на Kotlin многие вопросы приходится решать заново, а точнее по-другому. Два года назад мы начали социальный open source проект BrainUp, базируясь на Kotlin и Spring. Проект сейчас активно развивается, а мы узнаём на практике, что значит разрабатывать Kotlin-проект с нуля, какие удобства язык вносит в нашу жизнь, вместе с тем привнося свои вопросы, задачи, которые надо решать по-новому.

Например:

  1. Использование, а точнее не использование data-классов в качестве entity и почему. (напишу статью позже при возможности).
  2. Выбор code style плагина. У нас используется ktlint, инструкция настройки описана в отдельной статье.
  3. Выбор фреймворка тестирования. У нас используется Kotest.
  4. Выбор библиотеки для мокирования. У нас выбрана Mockk.
    Варианты использования Kotest и Mockk можно посмотреть у нас в проекте.
  5. Организация валидации входных DTO (Data Transfer Object) с помощью Spring.
  6. Настройка 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:

Stacktrace
Stacktrace
Улучшить данный вариант можно добавив читабельное сообщение message (смотрите 3й шаг откуда это сообщение берется):

data class AudiometryHistoryRequest(
@field:NotNull(message = "{validation.field.audiometryTaskId.notNull}")
var audiometryTaskId: Long?,
...
)
В этом случае defaultMessage будет заменён нашим, и можно будет увидеть именно определённое нами сообщение в response:

Controller response


Controller response
2 шаг. Добавление аннотации @Validated в контроллер.

Добавление аннотации @Validated в контроллер перед DTO, которую необходимо проверить при вызове данного end-point.
Например:

3 шаг. Добавление файла с сообщениями об ошибках.

Добавление файла с сообщениями об ошибках errorMessages.properties в папку resources.

022247cc63eebc07f454f8835ab152a0.JPG

На этом с валидацией всё, всем желаю удачи!

 
Сверху