https://pixabay.com/pl/kamery-parking-strze%C5%BCony-nadz%C3%B3r-1944039/

Własne reguły walidacji beanów zgodnej ze specyfikacją JSR-303

Walidacja jest istotnym mechanizmem niezbędnym w aplikacjach, gdzie pewne informacje są wprowadzane do programu przez użytkownika. Stosując zasadę domniemania niewinności, użytkownik wprowadza niepoprawne dane w sposób niezamierzony, jednak nie zawsze musi tak być. Skutki mogą mieć szeroki zakres, od problemów z dalszym przetwarzaniem informacji przedstawionych w niestandaryzowanym formacie, do problemów bezpieczeństwa aplikacji.
Skuteczna walidacja przebiega w sposób dwuetapowy – po stronie klienta, umożliwiając poinformowanie użytkownika o wadliwych danych nawet bez konieczności przeładowania strony oraz po stronie serwera, zapewniając odrzucenie potencjalnie niebezpiecznych danych w sytuacji, gdy użytkownik komunikując się z serwerem korzysta z innej aplikacji (lub np. wyłączył obsługę JavaScript). Walidacja dwuetapowa to podstawowa zasada skutecznej walidacji danych wprowadzanych przez użytkownika np. w formularzu na stronie internetowej.

W dalszej części tego wpisu opiszę sposób niestandardowej (własnej) walidacji formularzy w aplikacji opartej o Spring MVC, wykorzystując referencyjną implementację Hibernate Validator zgodną z JEE Bean Validation (JSR-303).

Mechanizm implementacji własnego walidatora przedstawię na mocno uproszczonym przykładzie. Załóżmy, że tworzymy bazę danych, w której przechowywane będą adresy. Jak wiadomo, każdy adres opisany jest ciągiem parametrów, jak np. miasto, kod pocztowy, ulica, itd. Aby nie tworzyć rozbudowanej logiki, adres będzie posiadał jedynie kod pocztowy.

Pracę nad wdrożeniem mechanizmu walidacji rozpoczynamy od dodania zależności do projektu. W moim przypadku przykład ten testowałem w projekcie wykorzystującym Mavena. Do sekcji dependencies w pliku pom.xml dodajemy hibernate-validator.

<dependency>
   <groupId>org.hibernate</groupId>
   <artifactId>hibernate-validator</artifactId>
   <version>5.2.4.Final</version>
</dependency>

Zmiany należy wykonać również w pliku zawierającym konfigurację kontekstu aplikacji internetowej. Poniższy wpis odpowiedzialny jest za wiele operacji, natomiast w przypadku walidacji istotnym jest, że umożliwia użycie adnotacji @Valid, która jest niezbędna w dalszej implementacji.

<mvc:annotation-driven/>

W kolejnym kroku należy utworzyć model danych, czyli klasę Address. Jak wspominałem, adres będzie posiadał jedynie pole postcode (kod pocztowy), którego poprawność podczas wysyłania formularza chcę sprawdzić. Pole postcode oznaczone jest dwoma adnotacjami @Length oraz @PostcodePLFormat. Pierwsza z nich służy do sprawdzenia czy długość ciągu znaków wpisanych do formularza mieści się w granicach określonych przez parametry min oraz max. Druga adnotacja (@PostcodePLFormat) jest jednym z dwóch dzisiejszych bohaterów dnia, którą zastosowałem w celu oznaczenia pola przeznaczonego do kontroli poprawnego formatu kodu pocztowego (xx-xxx). W tej chwili adnotacja ta nie została jeszcze utworzona, zajmę się tym w kolejnych krokach.
Z ciekawszych rzeczy w klasie modelu, jednak niezwiązanych z tematem wpisu, jest adnotacja @Data pochodząca z projektu Lombok, która zwalnia programistę z implementowania typowych dla modelu metod jak settery, gettery, toString czy equals.

@Data
public class Address implements Serializable {
    @Length(min = 6, max = 6, message = "Kod pocztowy ma 6 znaków")
    @PostcodePLFormat
    String postcode;
}

W dalszej kolejności przygotowałem prosty widok (postcodeForm.jsp) zawierający formularz z etykietą, polem tekstowym, przyciskiem wysyłającym formularz oraz polem wyświetlającym komunikat błędu. Formularz zbudowany został w oparciu o dwie biblioteki znaczników Springa, zaimportowane poprzez dyrektywę JSP taglib. Istotnym elementem w tagu <form> jest atrybut modelAttribute, który zawiera nazwę modelu danych przekazanych do widoku. Model ten będzie uzupełniony danymi z formularza po wykonaniu akcji submit i przekazany do dalszej walidacji w kontrolerze.
Z rzeczy niezwiązanych z tematyką wpisu, w formularzu znajduje się ukryte pole z liczbą pseudolosową, które jest stosowane w celu utrudnienia spreparowania ataku typu CSRF. Żądania bez takiej liczby lub z wartością niezgodną z tą, którą zna serwer są ignorowane. Nie będę wchodził w szczegóły, bo jest to temat na oddzielny wpis.

<%@ taglib prefix="form" uri= "http://www.springframework.org/tags/form" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
(...)
<form:form modelAttribute="address" class="form-horizontal" enctype="multipart/form-data">
      <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
      <div class="form-group">
           <label class="control-label col-lg-4" for="postcode">
               <spring:message code="address.postcode"/>
           </label>
           <div class="col-lg-8">
               <form:input id="postcode" path="postcode" name="postcode"/>
               <form:errors path="postcode"/>
           </div>
       </div>
 
       <div class="form-group">
           <div class="col-lg-offset-4 col-lg-8">
               <input type="submit" id="btnAdd" class="btn btn-primary" value ="Zapisz"/>
           </div>
       </div>
</form:form>

Kontroler odpowiedzialny jest za mapowanie żądań, czyli przekazanie sterowania do odpowiedniej metody, w zależności od URL żądania. MainController posiada dwie metody, reagujące na ten sam URL („/”), jednak w różny sposób zależny od tego, czy żądanie jest typu GET czy POST. Metoda welcome tworzy pusty model danych o nazwie address, który jest przekazywany do widoku postcodeForm zawierającego niewypełniony formularz.
Metoda processAddressForm jest wykonywana w momencie, gdy użytkownik zwalnia przycisk wysyłający formularz. Najistotniejszym jej parametrem jest address, który oznaczony został dwiema adnotacjami. @ModelAttribute(„address”) wiąże parametr metody z wartością przechwyconą z widoku z formularzem. W tej wartości zapisane są informacje wprowadzone przez użytkownika, które w dalszej kolejności możemy przetwarzać lub zapisać w bazie danych. Adnotacja @Valid zapewnia przeprowadzenie walidacji dla parametru address. Wynik wiązania przechowywany w obiekcie typu BindingResult można wykorzystać do sterowania przepływem aplikacji w przypadku wystąpienia błędów.

@Controller
public class MainController {
 
    @RequestMapping(name = "/", method = RequestMethod.GET)
    public String welcome(Model model) {
        Address address = new Address();
        model.addAttribute("address", address);
        return "postcodeForm";
    }
 
    @RequestMapping(value = "/", method = RequestMethod.POST)
    public String processAddressForm(@Valid @ModelAttribute("address") Address address, BindingResult result) throws Exception {
 
        if (result.hasErrors()) {
            // w przypadku błędów wyświetl ponownie formularz z komunikatami błędów
            return "postcodeForm";
        } else {
            // brak błędów umożliwia dalsze przetwarzanie, np. zapis do bazy danych
            // w przykładzie dla uproszczenia ponowne wyświetlenie widoku z formularzem
            return "postcodeForm";
        }
    }
 
}

Musimy teraz wrócić do klasy modelu, w której pole postcode zostało oznaczone adnotacją @PostcodePLFormat. Nasze IDE wskazuje, że w tym miejscu jest błąd. Wszystko się zgadza, taka adnotacja jeszcze nie istnieje, więc należy ją utworzyć. W tym wypadku można powiedzieć, że adnotacja jest fragmentem kodu, którym oznaczamy pole klasy, do którego chcemy dodać nasze własne reguły walidacji. Po szczegółowe informacje odsyłam do dokumentacji oraz do znajdującej się na dole strony literatury obowiązkowej – znajdują się tam dwa dobre teksty o adnotacjach. W tym miejscu zaznaczę tylko temat i w skrócie opiszę co ustawiam.

@Target({ElementType.FIELD})
@Retention(value = RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PostcodePLFormatValidator.class)
public @interface PostcodePLFormat {
 
    Class<>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
 
    String message() default "Prawidłowy format: xx-xxx";
}

@Target({ElementType.FIELD}) oznacza, że nasza adnotacja może być użyta jedynie do oznaczania pól (atrybutów) klasy.
@Retention(value = RetentionPolicy.RUNTIME) oznacza, że adnotacja @PostcodePLFormat będzie zachowana w maszynie wirtualnej podczas wykonywania programu.
@Constraint(validatedBy = PostcodePLFormatValidator.class) oznacza, że adnotacja przeznaczona jest dla mechanizmu walidacji (jest to adnotacja walidująca) i wykorzystuje walidator PostcodePLFormatValidator.

Metody groups(), payload() oraz message() są obowiązkowe w adnotacjach powiązanych z walidacją. W tej ostatniej można ustawić domyślny komunikat błędu.

Nadeszła pora na najistotniejszą część, jednak wcale nie najtrudniejszą – stworzenie implementacji walidatora, który jest klasą implementującą interfejs ConstraintValidator. Do zaimplementowania pozostają dwie metody – initialize oraz isValid. Pierwsza z nich stosowana jest w celu przygotowania do wykonania walidacji i jest wykonywana przed metodą isValid. W tym miejscu można pobrać parametry przekazane w adnotacji (przykład znajduje się na końcu wpisu). Sama metoda isValid zawierać ma logikę walidacyjną i zwraca true w przypadku gdy wartość value typu String jest poprawna, false w przeciwnym razie.

public class PostcodePLFormatValidator implements ConstraintValidator<PostcodePLFormat, String> {
 
    @Override
    public void initialize(PostcodePLFormat format) {
 
    }
 
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value.isEmpty()) {
            return false;
        }
 
        boolean isValid;
 
        String postcodePLPattern = "^[0-9]{2}-[0-9]{3}$";
 
        Pattern pattern = Pattern.compile(postcodePLPattern);
        Matcher matcher = pattern.matcher(value);
 
        isValid = matcher.matches();
 
        return isValid;
    }
}

Działanie walidatora widać na rysunku przedstawionym poniżej. Część (1) pokazuje sytuację po wciśnięciu przycisku Zapisz, w której wyświetlane są 2 komunikaty błędów – standardowy związany z adnotacją @Length oraz ten dodany przez nas (@PostcodPLFormat również sprawdza, czy wpisany ciąg znaków nie jest pusty). Jak widać, wszystko działa prawidłowo, takiego efektu się spodziewaliśmy. W części (2) wprowadzono ciąg znaków o prawidłowej długości, natomiast niepoprawny jest format danych – jest to przykład działania utworzonego, własnego walidatora. Sekcja oznaczona jako (3) prezentuje reakcję aplikacji na poprawnie wprowadzony kod pocztowy.

Validation results

Na zakończenie chciałbym przedstawić modyfikację walidatora pozwalającą na dodatkową parametryzację. Taka sytuacja występuje m. in. w adnotacji @Length(min=X, max=Y), którą wykorzystujemy do sprawdzenia czy długość ciągu znaków mieści się w przedziale [X,Y]. W przykładzie przedstawionym powyżej możemy zrobić podobną modyfikację. Załóżmy, że z jakiś powodów chcielibyśmy sprawdzać, czy w wprowadzony kod pocztowy znajduje się w zakresie tzw. okręgów kodowych. Za okręg kodowy odpowiada pierwsza cyfra kodu pocztowego, a dokładny podział przedstawia lista:

  • 0 – okręg warszawski (woj. warszawskie)
  • 1 – okręg olsztyński (woj. olsztyńskie i białostockie)
  • 2 – okręg lubelski (woj. lubelskie i kieleckie)
  • 3 – okręg krakowski (woj. krakowskie i rzeszowskie)
  • 4 – okręg katowicki (woj. katowickie i opolskie)
  • 5 – okręg wrocławski (woj. wrocławskie)
  • 6 – okręg poznański (woj. poznańskie i zielonogórskie)
  • 7 – okręg szczeciński (woj. szczecińskie i koszalińskie)
  • 8 – okręg gdański (woj. gdańskie i bydgoskie)
  • 9 – okręg łódzki (woj. łódzkie)

Wróćmy do implementacji. W pierwszej kolejności należy zmienić adnotację uzupełniając ją o dodatkową definicję precinct (okręg) typu int, która przyjmuje domyślną wartość równą 0. Jak widać, dodanie parametru do adnotacji sprowadza się do dodania odpowiedniej metody do jej ciała.

@Target({ElementType.FIELD})
@Retention(value = RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PostcodePLFormatValidator.class)
public @interface PostcodePLFormat {
 
    Class<>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
 
    String message() default "Prawidłowy format: xx-xxx";
 
    int precinct() default 0; // okręg kodowy 
}

W kolejnym kroku wykonujemy edycję walidatora poprzez dodanie kolejnego warunku sprawdzającego, czy pierwsza cyfra kodu pocztowego równa jest parametrowi adnotacji. Parametr adnotacji (precinct) jest przepisywany w metodzie initialize do pola klasy walidatora o takiej samej nazwie. W przypadku gdy pierwsza cyfra kodu pocztowego jest różna od parametru przekazanego w adnotacji, metoda isValid zwróci wartość false, informując o nieprawidłowej wartości kodu pocztowego.

public class PostcodePLFormatValidator implements ConstraintValidator<PostcodePLFormat, String> {
 
    int precinct = 0;
 
    @Override
    public void initialize(PostcodePLFormat postcode) {
        this.precinct = postcode.precinct();
    }
 
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value.isEmpty()) {
            return false;
        }
 
        String precinctFromPostcode = value.substring(0, 1);
        if (!precinctFromPostcode.equals(Integer.toString(precinct))) {
            return false;
        }
 
        boolean isValid;
 
        String postcodePLPattern = "^[0-9]{2}-[0-9]{3}$";
 
        Pattern pattern = Pattern.compile(postcodePLPattern);
        Matcher matcher = pattern.matcher(value);
 
        isValid = matcher.matches();
 
        return isValid;
    }
}

Działanie powyższej modyfikacji możemy zweryfikować zmieniając klasę modelu Address wprowadzając następującą modyfikację:

@Length(min = 6, max = 6, message = "Kod pocztowy ma 6 znaków")
@PostcodePLFormat(precinct = 8)
String postcode;

Uzupełnienie adnotacji o parametr precinct równy 8 spowoduje, że wszystkie kody pocztowe, które różnią się id 8x-xxx, gdzie x jest dowolną cyfrą, zostaną uznane za błędne.

Przykłady oparte o następujące wersje zależności:

  • javaee-web-api 7.0
  • spring-webmvc 4.3.4
  • jstl 1.2
  • javax.servlet-api 3.1.0
  • lombok 1.16.10
  • hibernate-validator 5.2.4

Literatura obowiązkowa:

Adnotacje w języku Java
Własne adnotacje. Definiowanie i zastosowanie.
Hibernate Validator
JSR 303: Bean Validation
Cross Site Request Forgery (CSRF)
Projekt Lombok

Dodaj komentarz

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