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> |
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); | |
} | |
} |
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"; | |
} |
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 { | |
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; | |
} |
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> |
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... | |
} |
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)); | |
} | |
} |
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.