Java Bean Validation + Spring Boot – sprawdzanie poprawności danych w Spring Boocie

Bean Validation świetnie współpracuje ze Spring Bootem. Za jego pomocą możemy bardzo łatwo sprawdzać poprawność danych przesłanych do naszej aplikacji. Postaram się pokazać na przykładach jak to zrobić.

Wpis ten jest kontynuacją wpisu o Java Bean Validation, omawiałem w nim podstawowe wbudowane walidatory danych oraz jak wywołać walidację programatycznie. Cały wpis znajduje się tutaj: Java Bean Validation – sprawdzanie poprawności przesłanych danych.

Kod projektu użytego we wpisie znajduje się w repozytorium Gita. Możesz je znaleźć tutaj: https://github.com/mloza/spring-boot-bean-validation.

Tworzymy projekt

Zacznijmy od stworzenia prostego projektu i dodania zależności w Mavenie. W naszym pom.xml znajdą się zależności do Spring Boota, Java Validation API oraz do Hibernate Validator. Możemy również dodać Spring Boot Starter Test, aby testować nasze rozwiązania. Wygląda on następująco.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>pl.mloza</groupId>
<artifactId>bean-validation</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
<version>2.0.0.Final</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>6.1.5.Final</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.3.2.RELEASE</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<release>14</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
view raw pom.xml hosted with ❤ by GitHub

Po stworzeniu poma, do całości brakuje nam jeszcze tylko klasy głównej, która będzie nam uruchamiać projekt. Stwórzmy ją od razu, będzie ona miała następującą postać.

@SpringBootApplication
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class);
}
}
view raw Main.java hosted with ❤ by GitHub

Mamy w tym momencie już gotowy projekt. Zobaczmy więc w jaki sposób możemy wykorzystać Bean Validation.

Walidacja danych wejściowych do kontrolera

Dane do kontrolera możemy przekazywać na kilka sposobów: przez url, query params lub body w zapytaniach POST. Wszystkie te sposoby wspierają sprawdzanie poprawności danych. W kolejnych akapitach omówimy sobie poszczególne możliwości.

Walidacja danych w URL i QueryParam

Najprostszym sposobem przekazania danych do kontrolera jest ich zaszycie w URL. Możemy je umieścić bezpośrednio w adresie (czyli np. http://blog.mloza.pl/site/1234 – 1234 będzie naszym parametrem) lub w query path (wtedy to będzie wyglądało następująco: http://blog.mloza.pl/site?id=1234). Zobaczmy, jak to może wyglądać w kodzie.

@Controller
@Validated // #1
public class ValidateController {
@GetMapping("/site/{id}")
@ResponseBody
public String validationInPath(
@PathVariable("id") @Min(10) @Max(20) int id // #2
) {
return "OK! id: " + id;
}
@GetMapping("/query")
@ResponseBody
public String validationInQuery(
@RequestParam("id") @Min(10) int id // #3
) {
return "OK! id: " + id;
}
}

Jak możecie się domyślać, jest to zwykły kontroler z kilkoma dodatkowymi adnotacjami. Aby parametry były sprawdzane, przy kontrolerze musi być adnotacja @Validated (#1). Następnie przy deklaracji parametrów przyjmowanych przez metodę określamy, jakie musi spełnić warunki. W pierwszym przypadku mówimy, że @PathVariable – zmienna przekazywana w url, musi być większa lub równa 10 oraz mniejsza, lub równa 20 (#2). W drugim przypadku mówimy, że zmienna przekazywana w query o nazwie id musi mieć wartość, co najmniej 10. Jeśli te warunki nie zostaną spełnione, zwrócony zostanie błąd 500 i wyrzucony stacktrace na konsolę.

2020-11-11 17:16:45.529 ERROR 90294 --- [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is javax.validation.ConstraintViolationException: validationInQuery.id: musi być równe lub większe od 10] with root cause

javax.validation.ConstraintViolationException: validationInQuery.id: musi być równe lub większe od 10
	at org.springframework.validation.beanvalidation.MethodValidationInterceptor.invoke(MethodValidationInterceptor.java:116) ~[spring-context-5.2.8.RELEASE.jar:5.2.8.RELEASE]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) ~[spring-aop-5.2.8.RELEASE.jar:5.2.8.RELEASE]
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749) ~[spring-aop-5.2.8.RELEASE.jar:5.2.8.RELEASE]
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:691) ~[spring-aop-5.2.8.RELEASE.jar:5.2.8.RELEASE]
	at pl.mloza.controller.ValidateController$$EnhancerBySpringCGLIB$$8fc85953.validationInQuery(<generated>) ~[classes/:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na]
[....]

Jak ładnie obsłużyć błędy z walidacji pokażę później.

Walidacja danych w POST body

Jeśli przesyłamy dane w POST, to zazwyczaj nie są to już typy proste. Obiekty złożone tez możemy sprawdzać w podobny sposób. Zobaczmy przykład poniżej.

@PostMapping("/data")
@ResponseBody
public String validationBody(
@RequestBody @Valid PostBody body // #1
) {
return "OK! Everything is valid";
}
view raw Post.java hosted with ❤ by GitHub

Jak widzisz, nasze body jest obiektem. Aby wymusić sprawdzenie, obiektu używamy adnotacji @Valid. Mówi ona Springowi, że tak zaadnotowany obiekt musi być prawidłowy. Oznacza to tyle, że sprawdzane są ograniczenia wewnątrz obiektu. Nasz obiekt może wyglądać jak poniżej.

public class PostBody {
@Email
private String email;
@Min(value = 18, message = "You're too young")
private int age;
@Size(min = 2, max = 10)
private String name;
@Pattern(regexp = "^[0-9]{2}-[0-9]{3}$")
private String zipCode;
}
view raw PostBody.java hosted with ❤ by GitHub

W skrócie dodajemy adnotacje znane nam już z poprzednich przykładów do pól obiektu. Ważne! Jeśli nasza klasa posiada inne typy złożone, to muszą one być zaadnotowane przez @Valid, aby zostały sprawdzone.

Testowanie naszej walidacji

Teraz już tak łatwo nie wyślemy zapytania przez przeglądarkę. Najłatwiej będzie skonstruować test, który wyśle zapytanie i sprawdzi poprawność naszych reguł. Przykładowy test może wyglądać tak jak poniżej.

@WebMvcTest
public class ValidateControllerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper objectMapper;
@Test
public void shouldReturnErrorWhenInputIsInvalid() throws Exception {
mvc.perform(post("/data")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidBody()))
.andDo(mvcResult -> System.out.println(mvcResult.getResponse().getContentAsString()))
.andExpect(status().isBadRequest());
}
private String invalidBody() throws JsonProcessingException {
return objectMapper.writeValueAsString(
new PostBody(
"not-valid-email",
2,
"Very long name that is beyond constraint",
"10-1000"));
}
}

Po uruchomieniu testu w konsoli możemy zobaczyć ostrzeżenie, że kontroler dostał nieprawidłowe dane ze szczegółowym opisem, co się nie zgadzało.

Obsługa błędów

Aktualnie jeśli otrzymamy błąd walidacji, nie dostajemy żadnej informacji, co było nie tak. Można temu zaradzić, tworząc ControllerAdvice. Z jego pomocą powiemy Springowi, jak ma obsługiwać błędy w przypadku niepoprawnych danych, jak wyciągnąć dodatkowe informacje i jak je przekazać użytkownikowi. Może on wyglądać tak jak poniżej.

@ControllerAdvice // #1
public class ValidationErrorControllerAdvice {
@ExceptionHandler(ConstraintViolationException.class) // #2
@ResponseStatus(HttpStatus.BAD_REQUEST) // #3
@ResponseBody
public ValidationErrorResponse validationError(ConstraintViolationException exception) {
ValidationErrorResponse response = new ValidationErrorResponse();
for(ConstraintViolation error: exception.getConstraintViolations()) {
response.addError(
new ValidationError(
error.getPropertyPath().toString(),
error.getMessage()));
}
return response;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ValidationErrorResponse validationError(MethodArgumentNotValidException exception) {
ValidationErrorResponse response = new ValidationErrorResponse();
for(FieldError error: exception.getBindingResult().getFieldErrors()) {
response.addError(
new ValidationError(
error.getField(),
error.getDefaultMessage()));
}
return response;
}
}

Klasa taka musi zostać zaadnotowana przez @ControllerAdvice (#1). Następnie za pomocą adnotacji @ExceptionHandler (#2) na poziomie meotdy, mówimy jakie wyjątki chcemy obsługiwać. W naszym przypadku są to dwa typy wyjątków, więc potrzebujemy dwóch oddzielnych metod. MethodArgumentNotValid jest rzucany gdy mamy nieprawidłowe body, a ConstraintViolationException gdy przekazany do metody argument, na przykład przez URL lub QueryPath, jest nieprawidłowy.

Możesz zauważyć też, że używam klas ValidationError i ValidationErrorResponse. Są to klasy, które trzeba sobie stworzyć, nie pochodzą z frameworka. Mogą one wyglądać jak poniżej.

public class ValidationErrorResponse {
private List<ValidationError> errors = new ArrayList<>();
// setters, getters
public ValidationErrorResponse addError(ValidationError error) {
this.errors.add(error);
return this;
}
}
public class ValidationError {
private String fieldName;
private String message;
public ValidationError(String fieldName, String message) {
this.fieldName = fieldName;
this.message = message;
}
public ValidationError() {
}
// Setters, getters
}

Jeśli teraz uruchomimy nasz test, lub przejdziemy przeglądarką pod adres z nieprawidłowym parametrem, otrzymamy pełne informacje na temat błędu. Test nam zwróci następująco odpowiedź w response body.

{
  "errors": [
    {
      "fieldName": "email",
      "message": "must be a well-formed email address"
    },
    {
      "fieldName": "age",
      "message": "You're too young"
    },
    {
      "fieldName": "name",
      "message": "size must be between 2 and 10"
    },
    {
      "fieldName": "zipCode",
      "message": "must match \"^[0-9]{2}-[0-9]{3}$\""
    }
  ]
}

Pole message jest uzupełnione automatycznie przez domyślne wiadomości. Przy dodawaniu adnotacji możemy umieścić swoją wiadomość, która ma zostać zwrócona w przypadku błędu.

Walidacja danych przekazywanych do serwisu

Oprócz sprawdzania poprawności danych przekazanych do kontrolera możemy również sprawdzać, czy przekazujemy poprawne dane do serwisu.

@Validated
@Service
public class ValidationInService {
void validatePostBody(@Valid PostBody postBody) {
System.out.println("PostBody is valid!");
}
}

Dla uproszczenia użyłem klasy PostBody wykorzystywanej już w kontrolerze. Aby walidacja w serwisie zadziałała, musimy dodać adnotację @Validated na poziomie klasy oraz @Valid przy parametrze, który ma zostać sprawdzony.

Możemy teraz dodać test, który sprawdzi, czy nasza walidacja działa.

@SpringBootTest
public class ValidationInServiceTest {
@Autowired
private ValidationInService validationInService;
@Test
public void shouldValidateDataPassedToService() {
assertThrows(
ConstraintViolationException.class,
() -> validationInService.validatePostBody(invalidBody()));
}
private PostBody invalidBody() {
return new PostBody(
"not-valid-email",
2,
"Very long name that is beyond constraint",
"10-1000");
}
}

Jak widzimy, w przypadku przesłania niepoprawnych danych zostanie wyrzucony wyjątek ConstraintViolationException.

Walidacja danych w JPA Entities

Ostatnim elementem, który chce poruszyć w artykule, jest walidacja danych zapisywanych w bazie danych. Jest to trochę późno na sprawdzanie danych, ponieważ może to oznaczać, że cała nasza logika biznesowa wcześniej pracowała na niepoprawnych danych. Jednak w niektórych wypadkach może się przydać.

Zacznijmy od dodania zależności do Spring JPA oraz do bazy danych H2.

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.199</version>
</dependency>
view raw pom-jpa.xml hosted with ❤ by GitHub

Teraz możemy stworzyć nasze entity z odpowiednimi adnotacjami.

@Entity
public class ValidEntity {
@Id
@GeneratedValue
private Long id;
@Column
@Min(18)
@Max(130)
private Integer age;
@Size(min = 2, max=128)
@Column
private String name;
@Column
@Pattern(regexp = "[0-9]{2}-[0-9]{3}")
private String zipCode;
// getters, setters etc...
}
view raw ValidEntity.java hosted with ❤ by GitHub

Tutaj już nie musimy dodawać adnotacji @Validated, wystarczy dodać adnotacje przy polach. Następnie możemy stworzyć test, który nam sprawdzi czy walidacja działa.

@SpringBootTest
public class ValidEntityTest {
@Autowired
private ValidEntityRepository validEntityRepository;
@Test
public void shouldThrowErrorOnInvalidEntity() {
ValidEntity invalidEntity = new ValidEntity(10, "O", "123-123");
assertThrows(
TransactionSystemException.class,
() -> validEntityRepository.save(invalidEntity));
}
}
view raw ValidEntityTest.java hosted with ❤ by GitHub

Tym razem przy próbie zapisania dostaniemy wyjątek TransactionSystemException, pod którym kryje się RollbackException, a pod nim ConstraintViolationException.

Podsumowanie

Jak widzicie, można bardzo łatwo sprawdzać poprawność danych w systemie na różnych poziomach. Od kontrolera, przez serwisy po bazę danych. Wystarczy dodać kilka adnotacji i mamy gotową walidację. W kolejnym poście pokażę jak stworzyć swój własny walidator. Można go będzie użyć przy sprawdzaniu nietypowych danych, których nie można sprawdzić za pomocą standardowych walidatorów.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *