При переходе с 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.
Например:

@PostMapping
    @ApiOperation("Save speech audiometry history")
    fun save(@Validated @RequestBody audiometryHistory: AudiometryHistoryRequest): ResponseEntity<BaseSingleObjectResponseDto> =
        ResponseEntity.ok().body(BaseSingleObjectResponseDto(data = audiometryHistoryService.save(audiometryHistory)))

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

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

errorMessages.properties
errorMessages.properties

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

Комментарии (10)


  1. ganqqwerty
    19.08.2021 11:04

    А в спринге есть готовая поддержка для api фильтрации по многим параметрам с логическими функциями и операторами? Ну типа как в elastic search когда я хочу найти только автомобили или мотоциклы 2018-2019 года, только фирмы bmw и удовлетворяющие full-text запросу rusty junk:

    /search?q=rusty junk&category=auto|moto&company=bmw&production_date>=01.01.2018&production_date<=2019

    помнится когда прилетела такая задача, я подумал что это решается тремя строчками, и спринг все операторы распарсит. А спрингеры сказали что надо будет все ручками писать


    1. ultrinfaern
      19.08.2021 13:54

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


  1. ily_a_inykh
    19.08.2021 23:39

    Непонятно что тут хотели сказать:

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

    Чтобы вызвать проверки по аннотации Valid на RequestBody (или другой составной параметр метода), то не надо указывать Validated над контроллером, достаточно аннотации Valid над параметром. Это можно точно увидеть вот тут в документации. То есть Valid отработается самим Spring для контроллера. Для отработки других (NotNull, NotBlank) аннотаций надо уже указать Validated (или если это не контроллер). Также про это можно почитать у меня в канале телеграм раз, два. Или у меня в GitHub.

    Также там есть в тексте "например", но его нет.


    1. ElenaSpb Автор
      21.08.2021 22:06

      Так и описано было изначально не "над контроллером", а перед DTO, с примером ниже.

      Разница @Validatedи @Validподробнее описана здесь.

      "In Spring, we use JSR-303's @Valid annotation for method level validation. Moreover, we also use it to mark a member attribute for validation. However, this annotation doesn't support group validation.

      Groups help to limit the constraints applied during validation. One particular use case is UI wizards. Here, in the first step, we may have a certain sub-group of fields. In the subsequent step, there may be another group belonging to the same bean. Hence we need to apply constraints on these limited fields in each step, but @Valid doesn't support this.

      In this case, for group-level, we have to use Spring's @Validated, which is a variant of this JSR-303's @Valid.  This is used at the method-level. And for marking member attributes, we continue to use the @Valid annotation."


      1. ily_a_inykh
        21.08.2021 22:32

        По ссылкам, что вы отправили рассматривается только один случай применения — группы.

        Есть другой пример использования, про который я писал в комментарии выше. По ссылкам там есть примеры этого использования. Я продублирую их тут.

        Пример 1.

        @Getter
        // Без неё не будет происходить проверка, потому что не будет применен аспект.
        @Validated
        @ConstructorBinding
        @AllArgsConstructor
        @ConfigurationProperties("defaults")
        public class DefaultsProperties {
            @NotNull
            private Author author;
        
            @Getter
            @AllArgsConstructor
            public static class Author {
                @NotBlank
                private String name;
                @NotBlank @Email @EmailDomains
                private String email;
            }
        }

        В документации.

        Can also be used with method level validation, indicating that a specific class is supposed to be validated at the method level (acting as a pointcut for the corresponding validation interceptor), but also optionally specifying the validation groups for method-level validation in the annotated class. Applying this annotation at the method level allows for overriding the validation groups for a specific method but does not serve as a pointcut; a class-level annotation is nevertheless necessary to trigger method validation for a specific bean to begin with. Can also be used as a meta-annotation on a custom stereotype annotation or a custom group-specific validated annotation.

        Выделение мое.

        То есть аннотация Validated служит индикатором по которому к классу применяется обработчик валидация.

        Пример 2.

        @Service
        // Также необходимо
        @Validated
        @RequiredArgsConstructor
        public class CrudNewsService implements NewsService {
        	  // ...
        
            @Override
            public News save(@Valid CreateNewsDto createNewsDto) {
                News news = conversionService.convert(createNewsDto, News.class)
                        .applyDefaults(authorDefaults, defaultsAuthorFactory);
        
                NewsModel newsModel = conversionService.convert(news, NewsModel.class);
                NewsModel savedModel = newsRepository.save(newsModel);
                return convertNewsModel(savedModel);
            }
        
        	  // ...
        }

        Также см. что я прикреплял выше:

        Аннотация @Valid включает валидация на параметре. NOTE! Над классом не требуется указывать @Validated, чтобы это работало для @Controller классов. Чтобы валидация работала для других элементов контекста, добавьте над ним аннотацию @Validated. Но чтобы работали аннотации @NotNull@NotBlank и другие, надо ОБЯЗАТЕЛЬНО ставить аннотацию @Validated. Также важно, что Spring MVC по-умолчанию обработает проверки, которые не требуют @Validated на @Controller 

        Подробнее можно почитать по ссылкам, что я отправил.


  1. MrMango
    21.08.2021 21:48

    А в этом есть вообще смысл?

    Мое понимаени - поле уже и так не нулл и котлин не даст записать туда нулл еще до того как выполнится валидатор по аннотации

        @field:NotNull
        var startTime: LocalDateTime,


    1. ElenaSpb Автор
      21.08.2021 21:55

      В случае с полями с nonnull типами (это не касается Int/Long), да, при вызове end-point со значением null исполнение свалится с Exception, но таким: HttpMessageNotReadableException

      org.springframework.http.converter.: JSON parse error: Cannot construct instance of `com.epam.brn.dto.StudyHistoryDto`, problem: Parameter specified as non-null is null

      Нам показалось разумнее сделать всю валидацию с помощью Spring однообразно для всех типов. Потому мы реализовали вышеописанный вариант.


    1. ElenaSpb Автор
      21.08.2021 21:57

      Да.


  1. Divinenickname
    21.08.2021 21:55

    @ElenaSpb подскажите, а почему было отдано предпочтение к описанию ошибок через файл в ресурсах? Если это валидация в DTO контроллера, то, как правило, на один метод - один DTO. Можно ведь явно указать руками сообщение


    1. ElenaSpb Автор
      21.08.2021 21:57

      Да, Вы правы, можно сообщение и сразу непосредственно вставить в поле message, но в таком случае сообщения будут раскиданы по коду. Мы решили сделать централизованное место для сообщений ошибок, потому записываем в файл. Тут вопрос может не очень категоричный и можно делать по разному - кому как удобнее.