CodeGym /Kursy /Spring REST & MVC /Walidacja listy tagów i elementów

Walidacja listy tagów i elementów

Spring REST & MVC
Poziom 16, Lekcja 1
Dostępny

1. Walidacja list na przykładzie tagów

Projektując DTO, bardzo łatwo myśleć tylko o „zwykłych polach": title, description, assigneeName. Ale w prawdziwym API prawie od razu pojawiają się kolekcje: lista tagów, lista załączników, lista filtrów, lista czegokolwiek innego. I właśnie kolekcje najczęściej stają się źródłem chaosu, bo mają dwa problemy jednocześnie: ile elementów przyszło i na ile każdy element jest „rozsądny". Tagi w Task Tracker API to świetny przykład do nauki: to po prostu List<String>, ale ma zupełnie poważny biznesowy sens.

Wyobraź sobie, że pozwoliłeś klientowi przesłać tags, ale w ogóle nie ograniczyłeś tego pola. Pewnego pięknego dnia ktoś wyśle 500 tagów (przypadkowo, oczywiście… po prostu przycisk „dodaj tag" się przykleił). Albo wyśle ["", " ", "\n"], bo UI na frontendzie „z jakiegoś powodu" nie zablokował pustego wejścia. A potem będziesz długo zastanawiać się, dlaczego wyszukiwanie po tagach nie działa i skąd się wziął tag „pustka".

Walidacja kolekcji to dyscyplina, która pomaga utrzymać kontrakt. I tutaj warto przyzwyczaić się do prostego modelu: lista ma kontener (samą listę) i elementy. Jeśli sprawdziłeś tylko kontener, to jak sprawdzić, że autobus nie jest przepełniony, ale w ogóle nie sprawdzać, kto do niego wsiadł. Jeśli sprawdziłeś tylko elementy, to jak upewnić się, że każdy pasażer jest przytomny, ale pozwolić autobusowi przyjechać ze 200 osobami do salonu na 40 miejsc.

2. Dwie warstwy: kontener i elementy

Kolekcje w Bean Validation sprawdzane są „na dwóch piętrach" i jest to najlepszy sposób, żeby się nie pogubić. Pierwsze piętro — ograniczenia na samą kolekcję jako obiekt: rozmiar listy, czy jest obowiązkowa, minimalna liczba elementów. Drugie piętro — ograniczenia na każdy element w środku: puste ciągi, długość ciągu, format, a jeśli elementy to DTO, to i walidacja kaskadowa ich pól. To przypomina sprawdzanie paczki na poczcie: najpierw patrzymy, czy pudełko nie jest cięższe niż limit i w ogóle istnieje, a potem sprawdzamy, czy w środku nie ma „powietrza w torebce" zamiast towaru.

Ważne, żeby pamiętać o jednej podstawowej właściwości Bean Validation: większość adnotacji z ograniczeniami (łącznie z @Size) uważa null za poprawną wartość. Zrobiono to celowo, żeby null oznaczał „nie ma co sprawdzać" i żeby obowiązkowość była definiowana osobną regułą (@NotNull, @NotEmpty, @NotBlank). Dla kolekcji oznacza to prostą rzecz: @Size(max = 10) ograniczy rozmiar listy, ale jeśli pole w ogóle jest null, przejdzie walidację. I czasem dokładnie tego potrzebujesz (np. tags są opcjonalne), a czasem — to niespodzianka.

Żeby nie zamieniać tego w zestaw magicznych domysłów, wygodnie jest trzymać w głowie taki obrazek:

flowchart TD
    A["Pole tags: List String"] --> B["@Size(max=10) na polu tags ograniczamy liczbę elementów"]
    A --> C["Każdy element tags[i]: String"]
    C --> D["@NotBlank + @Size(max=30) na typie elementu
ograniczamy jakość każdego tagu"]

Dalej zastosujemy to do naszego TaskCreateRequest i zobaczymy, jak dokładnie „podpisywać kontrakt" adnotacjami tak, żeby był czytelny i naprawdę działał.

3. @Size dla listy tagów

Gdy stawiasz @Size na kolekcji, sprawdzasz liczbę elementów, a nie długość każdego ciągu. To pomyłka spotykana bardzo często: deweloper widzi @Size(max = 10) i myśli „aha, tag maksymalnie 10 znaków", ale w rzeczywistości powiedział „tagów maksymalnie 10 sztuk". Jeśli więc w projekcie obowiązuje reguła „na zadaniu maksymalnie 10 tagów", to @Size(max = 10) — dokładnie to, co doktor przepisał (w przenośni; w dosłownym sensie doktor zwykle przepisuje sen).

Przykład dla naszego projektu: do TaskCreateRequest dodamy ograniczenie na liczbę tagów. Zwróć uwagę: na razie nie robimy tags polem obowiązkowym — może być nieobecne.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

// DTO do tworzenia zadania: walidujemy zarówno zwykłe pola, jak i kolekcje
public record TaskCreateRequest(
        @NotBlank @Size(min = 3, max = 120) String title,
        @Size(max = 10) List<String> tags // Ograniczenie na kontener: maksymalnie 10 tagów; null/brak pola jest dopuszczalny
) {}

Co to oznacza na poziomie kontraktu:

  • jeśli tags nie przekazano (pole brakuje w JSON) lub przekazano jako null, sprawdzenie @Size(max=10) nie zadziała, bo null jest uważane za poprawną wartość;
  • jeśli tags przekazano jako lista, jej długość musi wynosić nie więcej niż 10.

Przykład żądania, które przejdzie tę konkretną walidację:

{
  "title": "Kup mleko",
  "tags": ["home", "shopping"]
}

A przykład żądania, które nie przejdzie (11 tagów — to już za dużo):

{
  "title": "Kup mleko",
  "tags": ["t1","t2","t3","t4","t5","t6","t7","t8","t9","t10","t11"]
}

Teraz ważne pytanie o projekt API: czy w ogóle chcemy, żeby tags mogło być null? W naszym projekcie to pole może być opcjonalne: użytkownik tworzy zadanie, a tagi dodaje potem. W takim przypadku null i brak pola to nie problem. Ale jeśli chcesz (świadomie!) zmusić klienta, żeby zawsze przesyłał pole, nawet jeśli jest puste, to samo @Size nie wystarczy — trzeba dodać „obowiązkowość" osobną regułą.

Jeden prosty i czytelny wariant — @NotNull plus @Size:

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.util.List;

public record TaskCreateRequest(
        @NotNull // Pole obowiązkowe: klient musi przesłać tags (może być [] — to jest poprawne)
        @Size(max = 10) // Ograniczenie na kontener: maksymalnie 10 elementów
        List<String> tags
) {}

Tutaj kontrakt jest ostrzejszy: tags nie można nie przesłać. Ale pamiętaj, że @NotNull nie wymaga „niepustej listy". To ważna różnica: lista [] będzie poprawna. Jeśli potrzebujesz wymagać co najmniej jednego elementu, to już inna semantyka: @Size(min = 1) lub @NotEmpty (ale to kwestia konkretnego kontraktu; dla tagów zwykle nie jest wymagane).

4. Ograniczenia elementów listy

Ograniczenie liczby tagów to połowa historii. Druga połowa — jakość każdego tagu. Jeśli nie sprawdzimy elementów, klient będzie mógł przesłać ["", " ", " "], i formalnie lista „ma nie więcej niż 10" elementów, ale w sensie merytorycznym to śmieć. Dlatego dodajemy ograniczenia wewnątrz generic-typu: adnotacje piszemy na typie elementu, czyli na String wewnątrz List<...>. Nazywa się to container element constraints i jest wspierane przez Hibernate Validator (a mamy go w naszym projekcie bazowym).

Przykład kanoniczny dla TaskCreateRequest, który od razu ustala dwie rzeczy: „tagów maksymalnie 10" i „każdy tag niepusty i nie dłuższy niż 30 znaków".

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.util.List;

public record TaskCreateRequest(
        @NotBlank @Size(min = 3, max = 120) String title,

        // Reguła kontenerowa: ograniczamy liczbę elementów listy
        @Size(max = 10)
        // Reguły dla elementów: każdy tag niepusty i nie dłuższy niż 30 znaków
        List<@NotBlank @Size(max = 30) String> tags
) {}

Czytaj to jak pisanie umowy:

  • @Size(max = 10) odnosi się do tags jako listy: „nie więcej niż 10 elementów";
  • @NotBlank i @Size(max = 30) odnoszą się do każdego String wewnątrz listy: „każdy tag — niepusty ciąg, maksymalnie 30 znaków".

Teraz dobre pytanie od początkującego: a dlaczego nie można po prostu postawić @NotBlank na polu tags? Bo @NotBlank jest przeznaczony dla String (i ogólnie CharSequence), a tags to List. W najlepszym przypadku dostaniesz błąd przy starcie aplikacji (walidator powie, że ograniczenie zastosowano do nieobsługiwanego typu), w najgorszym — dziwne zachowanie, które będziesz „naprawiał" godzinami, bo adnotacja niby stoi, a sens jest nie ten.

Warto też wiedzieć, że @NotBlank sprawdza „po trim": ciąg " " jest uważany za niepoprawny. To idealne dla tagów, bo „tag ze spacji" to prawie filozofia, ale na pewno nie wartość biznesowa.

Przykład JSON, który nie przejdzie walidacji elementów:

{
  "title": "Kup mleko",
  "tags": ["home", "   "]
}

Z punktu widzenia kontraktu to bardzo dobrze: błąd jest wychwytywany na granicy API, przed serwisem, przed repozytorium, przed „a dlaczego pusty tag przebija się do wyników".

5. null, [] i [""]: różne stany

Gdy mowa o kolekcjach w JSON, pojawia się subtelna, ale bardzo praktyczna kwestia: null, [] i [""] to nie to samo. I jeśli będziesz traktować te przypadki jako „mniej więcej jedno", kontrakt szybko stanie się nieczytelny. Tutaj warto wejść w tryb nieco pedantycznego projektanta API, bo właśnie taki pedant ratuje projekt od chaosu po kilku miesiącach.

Patrz na te trzy stany tak:

Co przesłał klient Jak to wygląda w Javie Zwykły sens Jakie walidacje zadziałają
pole brakuje lub "tags": null tags == null „tagi nie są wskazane" (lub „nie zmieniaj tagów" dla PATCH) @Size nie zadziała, ograniczenia elementów też nie zadziałają
"tags": [] tags.size() == 0 „brak tagów" @Size(max=10) zadziała (poprawne), ograniczenia elementów też (nie ma co sprawdzać)
"tags": ["", " "] lista ciągów „tagi są wskazane, ale złe" ograniczenia elementów (@NotBlank) nie przejdą

Ta tabela wyjaśnia, dlaczego @NotNull i @Size to różne narzędzia. Jeśli chcesz zakazać null i wymagać, żeby pole zawsze było obecne, stawiasz @NotNull. Jeśli chcesz ograniczyć liczbę elementów, stawiasz @Size. Jeśli chcesz zakazać pustych ciągów wewnątrz listy, stawiasz @NotBlank na elemencie.

Teraz małe praktyczne pytanie z naszego projektu: jeśli tags są opcjonalne, czy wygodnie jest w warstwie serwisowej ciągle pisać if(request.tags() == null) ...? Zazwyczaj nie. Dlatego wiele zespołów robi bardzo prostą normalizację: jeśli tags przyszły jako null, uważamy je za pustą listę. To nie walidacja, a wygoda.

Na przykład na poziomie mappera można zrobić tak (mikro-utylita, bez magii):

import java.util.List;

public final class TagMapper {

    private TagMapper() {
        // Klasa utylitarna: instancje nie są potrzebne
    }

    public static List<String> nullToEmpty(List<String> tags) {
        // Normalizacja dla wygody serwisu: „brak pola" -> „pusta lista"
        // Ważne: to nie zastępuje walidacji, a jedynie upraszcza dalszą pracę z danymi
        return tags == null ? List.of() : tags;
    }
}

Tak, to wygląda prawie zbyt prosto. Ale właśnie takie „zbyt proste" funkcje bardzo pomagają utrzymać serwisy w czystości. I co najważniejsze — nie zastępują walidacji. Po prostu zamieniają „brakuje" w „puste", jeśli zgodnie z twoim kontraktem to jest dopuszczalne.

6. Lista DTO: List<@Valid ...>

Gdy tagi to lista ciągów, wystarczą ograniczenia elementów (@NotBlank, @Size). Ale w życiu (i w innych częściach projektu) prawie na pewno spotkasz kolekcje obiektów: lista elementów formularza, lista bloków, lista jakichś DTO „item". Tutaj włącza się ta sama idea co przy zagnieżdżonych DTO z poprzedniego wykładu, tylko w trybie „masowo": trzeba jawnie powiedzieć „waliduj każdy element listy".

Dla elementów-DTO działa walidacja kaskadowa przez @Valid, ale jest ważna subtelność: @Valid nie oznacza, że element nie jest null. Czyli jeśli klient przesłał items: [null], @Valid po prostu nie może „wejść" do null i… milczy. Dlatego dla listy obiektów zazwyczaj robi się „podwójny zapis": @NotNull i @Valid na elemencie.

Mały przykład poglądowy (jest ilustracyjny; nie zamieniamy Task Tracker API w system operacji zbiorczych, po prostu ćwiczymy mechanikę):

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.util.List;

public record CommentItemRequest(
        @NotBlank @Size(max = 80) String authorName, // Ograniczamy autora: niepuste i nie za długie
        @NotBlank @Size(max = 1000) String text // Ograniczamy tekst komentarza: niepuste i w rozsądnych granicach
) {}

public record CommentListRequest(
        @Size(max = 5) // Reguła kontenerowa: maksymalnie 5 komentarzy w jednym żądaniu
        List<@NotNull @Valid CommentItemRequest> items // Na elemencie: zakazujemy null i kaskadowo walidujemy pola DTO
) {}

Tutaj sprawdzane są oba „piętra":

  • @Size(max = 5) ogranicza liczbę elementów listy;
  • @NotNull @Valid na CommentItemRequest mówi: „każdy element musi istnieć i być poprawny zgodnie ze swoimi polami".

Ta kombinacja czyni kontrakt bardzo przejrzystym. Klient nie może już przesłać null jako elementu i liczyć na to, że „jakoś przejdzie". A Spring będzie mógł powiedzieć, przy którym dokładnie elemencie jest błąd (zazwyczaj wyraża się to jako ścieżka items[2].text — indeks staje się częścią ścieżki błędu). Na razie nie budujemy własnego formatu błędów, ale nawet standardowy mechanizm Springa już pokazuje, że dzieje się to adresowo, a nie w stylu „coś gdzieś jest nie tak".

7. Unikalność tagów

Na koniec najbardziej podstępna część tagów: unikalność. W naszym projekcie tagi powinny być unikalne w ramach jednego zadania z pominięciem wielkości liter. W tym miejscu u początkującego dewelopera pojawia się bardzo naturalna myśl: „A może zrobić Set<String> — i gotowe, bez duplikatów!". To pomysł z serii „jak sprawić, żeby problem zniknął, a nie się rozwiązał". Bo Set rzeczywiście usunie duplikaty… ale po cichu. Klient przesłał ["Urgent", "urgent"], a serwer „starannie" zapisał jeden tag. To już nie walidacja, to ciche zmienianie danych, i klient zaczyna detektywistykę: „na pewno wysyłałem dwa tagi, gdzie się podział drugi?".

Jeśli kontrakt mówi „duplikaty są zabronione", lepiej ich zakazywać, a nie „połykać". Problem w tym, że standardowe adnotacje Bean Validation nie potrafią sprawdzać unikalności listy ciągów „z pudełka". I to normalne: @Size i @NotBlank to podstawowe klocki, a nie encyklopedia wszystkich reguł biznesowych.

Co możemy zrobić na obecnym poziomie rozumienia — przynajmniej zobaczyć, jak technicznie wygląda sprawdzanie unikalności. Na przykład prosta utylita do wykrywania duplikatów z pominięciem wielkości liter:

import java.util.List;

public final class TagRules {

    private TagRules() {
        // Klasa utylitarna: instancje nie są potrzebne
    }

    public static boolean hasDuplicatesIgnoreCase(List<String> tags) {
        // Idea: normalizujemy tagi do „normalnej formy" (trim + lowerCase),
        // liczymy liczbę unikalnych i porównujemy z oryginalnym rozmiarem listy.
        long unique = tags.stream()
                .map(t -> t.trim().toLowerCase())
                .distinct()
                .count();

        // Jeśli unikalnych jest mniej niż było elementów — są duplikaty (według reguł normalizacji)
        return unique != tags.size();
    }
}

Ta funkcja sama w sobie nie „wbudowuje" błędu do odpowiedzi API i nie decyduje, jaki status zwrócić. Pokazuje tylko granicę: niektóre reguły wyrażamy adnotacjami, a niektóre wymagają osobnej logiki. I to przy okazji dobra wiadomość: nie musisz próbować wcisnąć wszystkiego w jedną adnotację i oczekiwać, że Bean Validation stanie się twoim mini-prawnikiem, mini-analitykiem biznesowym i mini-egzorcystą błędów jednocześnie.

Praktyczna reguła na dziś jest prosta: reguły kontenerowe (rozmiar listy) i reguły elementu (pustość, długość, format) świetnie żyją w Bean Validation. Bardziej „semantyczne" sprawy jak unikalność często wymagają osobnego rozwiązania — i ważne, żeby przynajmniej nie próbować rozwiązywać ich przez Set „po cichu".

8. Typowe błędy przy walidacji kolekcji

Błąd nr 1: walidowanie tylko kontenera lub tylko elementów.
Często sprawdza się albo rozmiar listy (@Size(max = 10)), albo tylko elementy (@NotBlank wewnątrz List). W pierwszym przypadku przechodzą puste ciągi, w drugim — można przesłać setki poprawnych elementów. Kontener i elementy to różne poziomy, oba wymagają jawnej walidacji.

Błąd nr 2: stawianie ograniczenia na nieodpowiedni typ.
@NotBlank na List<String> wydaje się „logiczne", ale technicznie to błąd: adnotacja jest przeznaczona dla ciągów, a nie dla kolekcji. W efekcie walidator albo pada, albo zachowuje się nieoczekiwanie. Dla listy stosuje się @NotNull / @NotEmpty, a dla elementów — osobne ograniczenia wewnątrz List<...>.

Błąd nr 3: zapominanie o null wewnątrz kolekcji.
Jeśli używasz @Valid dla elementów-DTO, ale nie dodajesz @NotNull, to element null jest po prostu „przepuszczany" jako nie ma co walidować. To tworzy ukryte dziury w danych. Dla kolekcji ważne jest osobne zakazanie null, jeśli nie ma on sensu w kontrakcie.

Błąd nr 4: próba „rozwiązania" unikalności przez Set.
Tak, Set usunie duplikaty — ale zrobi to po cichu. Klient przesłał jedne dane, serwer zwrócił inne i nikt nie rozumiał, gdzie nastąpiła zmiana. Jeśli reguła to „duplikaty są zabronione", trzeba je walidować jawnie i zwracać błąd, a nie cicho przepisywać wejście.

Błąd nr 5: postrzeganie walidacji jako „dekoracji", a nie części kontraktu.
Gdy adnotacje są dodawane „dla odhaczenia", bez przemyślanego modelu, API zaczyna przyjmować dziwne kombinacje danych. Walidacja to nie kwestia wygody dewelopera, a jasne reguły interakcji z klientem. Jeśli reguła nie jest opisana jawnie, system zaczyna zachowywać się nieprzewidywalnie.

1
Zadanie
Spring REST & MVC, poziom 16, lekcja 1
Niedostępne
Notatka z listą etykiet
Notatka z listą etykiet
1
Zadanie
Spring REST & MVC, poziom 16, lekcja 1
Niedostępne
Playlista z listą obiektów
Playlista z listą obiektów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION