Валидация DTO в Spring Boot

Валидация DTO

Во многих веб-приложениях есть формы, на которых пользователь вводит данные (например, форма регистрации на сайте). Почти всегда нужно проводить валидацию этих данных: заполнены ли обязательные поля, записан ли email и телефон в нужном формате и так далее.

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

В данной статье будет рассмотрена валидация полей ДТО (DTO — Data Transfer Object) с использованием пакета javax.validation.

Создание приложения

Для создания приложения воспользуемся Spring Initializr по адресу https://start.spring.io. Заполним поля group и artifact, а затем добавим в проект зависимости Spring Web и Lombok. Скачаем проект и откроем его в IDE.

Мы разработаем простое приложение, которое будет имитировать сохранение информации о сотрудниках некой организации. Состоять наше приложение будет из следующих частей:

  • DTO с данными, которые мы будем валидировать
  • Тест контроллера для демонстрации работы 
  • REST-контроллер, который будет обрабатывать HTTP-запрос и запускать валидацию

ДТО

Создадим в проекте класс EmployeeDto:

import lombok.AllArgsConstructor;
import lombok.Data;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Past;
import javax.validation.constraints.Pattern;
import java.util.Date;

@Data
@AllArgsConstructor
public class EmployeeDto {

    @NotBlank(message = "Необходимо указать имя")
    private String name;

    @Email(message = "Email должен быть корректным адресом электронной почты")
    private String email;

    @Pattern(regexp = "\\+7[0-9]{10}", message = "Телефонный номер должен начинаться с +7, затем - 10 цифр")
    private String phone;

    @Past(message = "Дата приёма на работу не должна быть больше текущей")
    private Date hireDate;
}

Аннотации над классом относятся к библиотеке Lombok. Благодаря @Data будут сгенерированы геттеры и сеттеры для всех полей, а @AllArgsConstructor создаст конструктор, инициализирующий все поля класса.

Аннотации над полями — это основа нашей валидации. Каждая аннотация задаёт своё правило:

@NotBlank: поле обязательно для заполнения.
@Email: значение должно по формату подходить для использования в качестве email.
@Pattern: значение должно удовлетворять регулярному выражению.
@Past: дата/время должно быть в прошлом

REST-контроллер

Создим класс EmployeeController:

import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import EmployeeDto;

import javax.validation.Valid;

@RestController
public class EmployeeController {

    @PostMapping("/employees")
    public ResponseEntity<Void> add(@RequestBody @Valid EmployeeDto dto) {
        // Сохранение...
        return ResponseEntity.ok().build();
    }
}

Это простой контроллер, который обрабатывает запросы, поступающие методом  POST на адрес http://localhost:8080/employees

Аннотация @Valid — ключевой момент: благодаря ей будет запускаться валидация аргумента dto в соответствии с аннотациями @NotBlank, @Email и т.д, которые мы описали в EmployeeDto.

Тест контроллера

Создадим класс EmployeeControllerTest для проверки работы контроллера:

@WebMvcTest(controllers = EmployeeController.class)
public class EmployeeControllerTest {

    @Autowired
    MockMvc mockMvc;

}

Использование аннотации @WebMvcTest над классом позволяет не поднимать весь Spring-контекст, а инициализировать только веб-слой приложения (в частности создать контроллеры). Параметр controllers говорит о том, что нужно создать только указанные контроллеры. Если его не указывать, то будут созданы все контроллеры приложения.

Класс MockMvc, входящий состав в Spring, предоставляет удобные средства для тестирования контроллеров. Используя его можно не поднимать реальный сервер, такой как Tomcat, а тестировать с того момента, где Spring передаёт запрос в наш контроллер. Таким образом для контроллера всё будет выглядеть так, как будто был отправлен и получен реальный HTTP-запрос, но при этом не нужно тратить ресурсы на поднятие сервера.

Добавим тест, в котором будем проверять поведение контроллера, когда в ДТО нет поля name:

@WebMvcTest(controllers = EmployeeController.class)
public class EmployeeControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    @DisplayName("Если нет поля name, то возвращается код 400")
    void addTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/employees")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content("{\"email\":\"user@server.com\"}"))
                .andExpect(MockMvcResultMatchers.status().isBadRequest());
    }
}

В этом тесте отправляется неполная версия EmployeeDto в виде JSON на адрес /employees. В отправляемом JSON нет поля name. А поскольку это поле обязательно для заполнения, то валидация ДТО должна “свалиться” с кодом 400 (Bad Request). Запустите тест — он должен пройти успешно.

Если мы хотим убедиться, что ошибка валидации вызвана именно отсутствием поля name, а не чем-то другим, мы можем модифицировать наш тест:

@Test
@DisplayName("Передача объекта без поля name возвращает код 400")
void addTest() throws Exception {
    MvcResult response = mockMvc.perform(MockMvcRequestBuilders.post("/employees")
            .contentType(MediaType.APPLICATION_JSON_UTF8)
            .content("{\"email\":\"user@server.com\"}"))
            .andExpect(MockMvcResultMatchers.status().isBadRequest())
            .andReturn();

    String message = response.getResolvedException().getMessage();
    assertTrue(message.contains("default message [name]"));
    assertTrue(message.contains("default message [Необходимо указать имя]"));
}

Также мы можем предусмотреть ситуацию, когда контроллер ничего не вернёт:

@Test
@DisplayName("Передача объекта без поля name возвращает код 400")
void addTest() throws Exception {
    MvcResult response = mockMvc.perform(MockMvcRequestBuilders.post("/employees")
            .contentType(MediaType.APPLICATION_JSON_UTF8)
            .content("{\"email\":\"user@server.com\"}"))
            .andExpect(MockMvcResultMatchers.status().isBadRequest())
            .andReturn();

    String message = requireNonNull(response.getResolvedException(), "Не получено сообщение от контроллера").getMessage();
    assertTrue(message.contains("default message [name]"));
    assertTrue(message.contains("default message [Необходимо указать имя]"));
}

Принудительный запуск валидации

Аннотация @Valid выглядит так, будто одно лишь её наличие запускает валидацию, однако это не совсем так. Убедиться в этом можно, “вручную” вызвав метод контроллера.

Напишем новый тест:

@Test
@DisplayName("add бросает исключение, если поле name = null")
void manualValidationTest() {
    EmployeeController controller = new EmployeeController();
    EmployeeDto emp = new EmployeeDto(null, "email@server.com", null, null);
    assertThrows(MethodArgumentNotValidException.class, () -> controller.add(emp));
}

Он “свалится”, поскольку исключение MethodArgumentNotValidException не будет брошено.  В чём же причина? Дело в том, что просто добавить аннотации @Valid и @NotBlank — недостаточно. Вся “магия” происходит внутри Spring, а когда мы вызываем метод “вручную”, то Spring не задействован.

Но мы можем принудительно запустить валидацию. Изменим тест:

@Test
@DisplayName("Принудительная валидация находит ошибку")
void manualValidationTest() {
    EmployeeDto emp = new EmployeeDto(null, "email@server.com", null, null);
        Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
        Set<ConstraintViolation<EmployeeDto>> violations = validator.validate(emp);

        ConstraintViolation<EmployeeDto> violation = violations.stream().findFirst().orElseThrow(() -> new RuntimeException("Отсутствует ошибка валидации"));
        assertEquals("name", violation.getPropertyPath().toString());
        assertEquals("Необходимо указать имя", violation.getMessageTemplate());
}

Этот тест пройдёт успешно, а в переменной violations будет один элемент с информацией об ошибке валидации поля name.

Заключение

Рассмотренные варианты валидации — это неполный список того, что умеет пакет javax.validation. Благодаря ему можно быстро настроить валидацию в Spring Boot-приложении и сократить время разработки. 

Код приложения из этой статьи можно взять на Github.

Удачной разработки!