CodeGym /Kursy /Spring REST & MVC /Adres błędu: path i indeksy

Adres błędu: path i indeksy

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

1. Błąd bez adresu: rola path

Pomyśl, że błąd walidacji — to nie „kara" dla klienta za złe żądanie, a podpowiedź: co dokładnie trzeba poprawić. I tutaj pojawia się prosty, ale bolesny problem: bez adresu błąd zamienia się w zagadkę poziomu „gdzieś w JSON coś jest nie tak". W małym payloadzie to jeszcze do zniesienia, ale gdy tylko pojawią się zagnieżdżone struktury, listy, filtry i reguły cross-field, bez precyzyjnego wskazania miejsca zamieniasz klienta w archeologa.

Sama forma odpowiedzi już istnieje: na górze status, code, summary, errors, a w liście szczegółów leżą konkretne naruszenia. Teraz krytyczne jest następne pytanie — jak nazwać miejsce błędu tak, żeby klient nie zgadywał, jakie dokładnie pole lub element listy jest zepsute.

Path w szczegółach błędu odpowiada na pytanie „gdzie dokładnie jest problem?". Powinien być wystarczająco prosty, żeby klient mógł się na nim oprzeć, i wystarczająco precyzyjny, żeby można było podświetlić konkretne pole w UI, złapać precyzyjny log lub pokazać użytkownikowi dokładną przyczynę. Ważne, że path — to nie „ładny tekst", a część kontraktu: uzgodniłeś, że tak adresowane są dane na wejściu, i dalej trzymasz ten styl wszędzie.

Wyobraź sobie żądanie tworzenia zadania, gdzie klient przesłał dziesięć pól, listę tagów i jeszcze kilka zagnieżdżonych obiektów. Jeśli zwrócisz tylko „title jest obowiązkowy" — klient jeszcze może zrozumieć. Ale jeśli zwrócisz „jest błąd w tags" — klient będzie musiał zgadywać, który dokładnie tag jest zepsute, szczególnie jeśli jest ich dużo. A jeśli zwrócisz tags[3] — wszystko jest oczywiste: popraw czwarty element listy (i tak, indeksy — to osobny temat, zaraz do niego dojdziemy).

Żeby nie być gołosłownym, zafixujmy tylko tę część szczegółu, która potrzebna jest do rozmowy o adresie błędu. Pełny kanoniczny obiekt szczegółu u nas jest szerszy, ale teraz interesuje nas właśnie path.

// Uproszczony schemat szczegółu tylko do rozmowy o path.
// Pełny kanoniczny obiekt szczegółu jest szerszy, ale teraz interesuje nas adres błędu.
record ValidationErrorDetail(
    String source, // skąd przyszedł błąd: body/query/path
    String path,   // adres problematycznego miejsca w danych wejściowych
    String message // komunikat czytelny dla człowieka
) {}

Tutaj path — to właśnie „adres". W następnych sekcjach uzgodnimy gramatykę adresu: jak wygląda dla prostego pola, dla zagnieżdżonego obiektu, dla listy i dla błędów grupowych.

2. Field path: błąd jednego pola

Najczęstszy przypadek walidacji — naruszenie reguły dla jednego pola: pusty title, za długi assigneeName, ujemny size w paginacji i tak dalej. W takich sytuacjach path powinien wskazywać na konkretne pole tak, żeby klient nie musiał „domyślać się", o czym mówisz. Im mniej magii — tym lepiej: jeśli w żądaniu jest pole title, to ścieżka błędu powinna być title. Nie task.title, nie request.title, nie field_1_title, a dokładnie ta nazwa, którą klient widzi w publicznym kontrakcie.

Przykłady z Task Tracker API. Dla scenariusza body z TaskCreateRequest najprostszy przypadek wygląda tak:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",                // błąd dotyczy ciała JSON żądania
    "title",               // konkretne pole w wejściowym JSON
    "Pole title jest obowiązkowe" // tekst, który można pokazać użytkownikowi
);

Tutaj source="body" po prostu mówi, że błąd dotyczy ciała JSON żądania, a path="title" — konkretnego pola. To pole można podświetlić w formularzu, złapać w testach, zebrać statystyki błędów — i wszystko to bez parsowania tekstu komunikatu.

Teraz parametr query. W GET /api/v1/tasks mamy np. size (rozmiar strony). Jeśli klient przesłał size=200, a my dopuszczamy maksimum 100, to możemy podać ścieżkę po prostu jako size. Nie trzeba wymyślać criteria.size, jeśli klient nie widzi żadnego criteria — on widzi query string.

ValidationErrorDetail error = new ValidationErrorDetail(
    "query",                  // błąd dotyczy query string
    "size",                   // nazwa query-parametru z publicznego API
    "size powinien być nie większy niż 100"
);

I trzeci typ — parametr path (np. taskId). Jeśli taskId nie wygląda jak UUID (lub nie przechodzi twojej walidacji), to ścieżka logicznie powinna być taskId. Znowu: to nazwa parametru kontraktu, a nie nazwa zmiennej w kodzie Java.

ValidationErrorDetail error = new ValidationErrorDetail(
    "path",                   // błąd dotyczy parametrów path URL
    "taskId",                 // nazwa parametru w kontrakcie: /tasks/{taskId}
    "taskId ma nieprawidłowy format"
);

Zwróć uwagę na subtelny moment. Nie robimy ścieżki w stylu /api/v1/tasks/{taskId}. To byłoby „gdzie się stało", ale nie „co poprawić". Ścieżka błędu walidacji powinna wskazywać na dane wejściowe, a nie na endpoint.

Jeśli chcesz zobaczyć to „oczami JSON", możesz mentalnie trzymać taki obrazek:

{
  "title": "",
  "description": "ok",
  "tags": ["java"]
}

Pole title — to właśnie path="title". Bez mistycyzmu: po prostu adres wewnątrz wejścia.

3. Zagnieżdżone struktury: notacja parent.child

Gdy tylko masz zagnieżdżoną strukturę, prostej nazwy pola już nie wystarczy. I tutaj ważne jest uzgodnienie jednego stylu. Najbardziej zrozumiały dla człowieka i dość uniwersalny — notacja kropkowa, gdzie zagnieżdżenie podane jest przez kropkę: assignee.email, metadata.description, contact.phone. Przypomina to odwołanie do pól obiektu w kodzie i łatwo czyta się wzrokowo.

Nawet jeśli w bieżącej wersji Task Tracker API większość DTO jest płaska, zagnieżdżone DTO omawialiśmy już na poziomie walidacji i takie struktury mogą pojawić się w innych żądaniach (lub w przyszłych rozszerzeniach projektu). Ważne nie to, czy zagnieżdżenie jest teraz w każdym endpoincie, a to, że wybierasz gramatykę, która nie rozpadnie się przy pierwszym zagnieżdżonym obiekcie.

Mały przykład edukacyjny (żeby zobaczyć ideę na kodzie). Wyobraźmy sobie, że żądanie tworzenia zadania ma zagnieżdżony blok wykonawcy:

// Zagnieżdżony obiekt żądania: ma własne pola i własną walidację
record AssigneeRequest(String name, String email) {}

record TaskCreateRequest(
    String title,
    AssigneeRequest assignee // zagnieżdżony obiekt w JSON: { "assignee": { ... } }
) {}

Jeśli assignee.email ma nieprawidłowy format, to path powinien pokazywać pełną trasę:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",                        // JSON body
    "assignee.email",              // ścieżka do zagnieżdżonego pola przez kropkę
    "Email ma nieprawidłowy format"
);

A tak to wygląda w JSON (dla powiązania z rzeczywistością, a nie abstrakcją):

{
  "title": "Fix bug",
  "assignee": {
    "name": "Jan",
    "email": "not-an-email"
  }
}

Notacja kropkowa czyta się tu prawie jak instrukcja: „wejdź do assignee, potem do email". I dokładnie tego potrzebuje klient: wie, które pole podświetlić w formularzu i gdzie szukać problemu.

Jest pokusa, żeby robić ścieżkę w stylu JSON Pointer (/assignee/email) lub „jak w Java" (assigneeEmail). Ale im więcej wariantów dopuszczasz, tym szybciej klient zaczyna się mylić. Dlatego w ramach kursu wybieramy jeden format i trzymamy go stabilnie. Dla zagnieżdżenia — kropka.

4. Kolekcje i indeksy: tags[0] i tags[1]

Gdy tylko w danych wejściowych pojawia się lista, ścieżka błędu bez indeksu staje się zbyt ogólna. Błąd „w tags" — to jak komunikat „masz problem w tablicy" w Javie: dziękuję oczywiście, ale gdzie dokładnie? Walidacja kolekcji ma często dwa typy: ograniczenia na samą listę (np. „nie więcej niż 10 tagów") i ograniczenia na elementy (np. „każdy tag o długości 1..30"). A jeszcze oddzielnie stoi unikalność tagów, gdzie w ogóle pojawia się „konflikt elementów".

Dla błędów elementów potrzebny jest indeks. W formie znajomej ze świata Java — tags[0], tags[1] itd. Będziemy uważać indeksowanie za 0-based, bo to naturalna dla Javy i dla większości formatów technicznych. I tak, to jest ten rzadki moment, gdy „jak w programowaniu" jest wygodne i dla klienta: deweloperzy po stronie klienta też są przyzwyczajeni do liczenia od zera.

Najpierw najprostszy przykład: drugi tag jest pusty.

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[1]",              // indeks konkretnego elementu listy (0-based)
    "Tag nie powinien być pusty"
);

Jeśli zwrócisz po prostu tags, klient będzie musiał przejrzeć całą tablicę i zgadywać, gdzie jest puste. Jeśli zwrócisz tags[1], natychmiast rozumie: „zepsuty element z indeksem 1".

Teraz przykład z realnej reguły projektu: tagi powinny być unikalne bez uwzględnienia wielkości liter. Niech klient przesłał:

{
  "title": "Write tests",
  "tags": ["java", "Java"]
}

Formalnie problem jest „między elementami", ale użytkownik i tak potrzebuje podświetlenia konkretnego miejsca. W edukacyjnym API możemy przypisać błąd do drugiego powtarzającego się elementu:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[1]", // podświetlamy „drugą próbę" powtórzenia już istniejącego tagu
    "Tagi powinny być unikalne (bez uwzględnienia wielkości liter)"
);

Czasem robi się inaczej: przypisuje błąd do obu elementów (tags[0] i tags[1]), albo robi object path jak tags, albo nawet tagsUnique. Ale kluczowe — indeks pomaga ci nie tracić precyzji. Jeśli na liście jest dziesięć tagów, a dwa się powtarzają, klientowi jest bardzo trudno pokazać „który dokładnie jest powtórzony". Indeks — twój przyjaciel.

Przydatne jest też odróżnianie błędu całej kolekcji od błędu elementu. Jeśli klient przesłał 25 tagów, a maksimum 10, to błąd dotyczy „kontenera" i ścieżka może być po prostu tags:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags", // błąd dotyczy listy jako całości, a nie konkretnego elementu
    "Liczba tagów nie powinna przekraczać 10"
);

A jeśli lista jest w porządku co do rozmiaru, ale jeden element jest za długi — ścieżka powinna mieć indeks:

ValidationErrorDetail error = new ValidationErrorDetail(
    "body",
    "tags[3]", // błąd w konkretnym elemencie
    "Długość tagu nie powinna przekraczać 30 znaków"
);

Żeby gramatyka nie rozszerzała się po projekcie (i żebyś nie zbierał ciągu ręcznie w trzech różnych miejscach, zapominając gdzieś ]), możesz zrobić mały utylitarny pomocnik. Nie jest obowiązkowy, ale dobrze ilustruje ideę:

final class Path {
    static String field(String name) {
        // Proste pole: "title"
        return name;
    }

    static String nested(String parent, String child) {
        // Zagnieżdżone pole: "assignee.email"
        return parent + "." + child;
    }

    static String index(String list, int i) {
        // Element listy: "tags[1]"
        return list + "[" + i + "]";
    }
}

I używać go tak:

String p1 = Path.field("title");              // "title"
String p2 = Path.index("tags", 1);            // "tags[1]"
String p3 = Path.nested("assignee", "email"); // "assignee.email"

Tak, wygląda „zbyt prosto". Ale właśnie takie proste umowy niespodziewanie ratują projekt od chaosu, gdy liczba endpointów rośnie, a błędów jest dużo.

5. Object path: błąd grupy pól

Nie wszystkie reguły walidacji można przypisać do jednego pola. Czasem błąd dotyczy dwóch (lub więcej) wartości jednocześnie, bo problem tkwi w ich kombinacji. To klasyczny scenariusz cross-field: na przykład w wyszukiwaniu dueAfter powinien być wcześniejszy niż dueBefore. Formalnie każde pole oddzielnie może być poprawne (obie daty prawidłowe), ale razem tworzą bezsensowne żądanie.

W takich przypadkach używamy object path — logicznej ścieżki dla „błędu na poziomie obiektu". Nie musi ona pokrywać się z rzeczywistym polem wejściowego DTO. To raczej „nazwa reguły" lub „nazwa grupy danych". W naszym kursie dobry przykład — dateRange. Klientowi nie jest tak ważne, które pole jest „winne", ważne że zakres dat jest nieprawidłowy.

Przykład szczegółu błędu:

ValidationErrorDetail error = new ValidationErrorDetail(
    "query",
    "dateRange", // logiczna ścieżka: błąd nie jednego pola, a ich kombinacji
    "dueAfter powinien być wcześniejszy niż dueBefore"
);

Z punktu widzenia UX jest to wygodne: klient może pokazać błąd obok obu pól lub nad blokiem filtrów dat, nie próbując decydować, które z pól jest „bardziej winne".

Jeszcze jeden przykład object path — gdy sprawdzasz nie konkretne pole, a spójność struktury żądania. Powiedzmy, masz regułę „musi być przekazane albo q, albo tag" (to warunkowy przykład do demonstracji). Wtedy logiczna ścieżka może być searchCriteria lub query, ale lepiej wybrać coś bardziej przedmiotowego — np. searchMode. Najważniejsze, żeby ta ścieżka była stabilna i czytelna.

Dlaczego nie przypisujemy takich błędów do dueAfter lub dueBefore? Można, ale wtedy dostajesz „sztuczną winę" jednego pola, choć problem tkwi w kombinacji. Poza tym ryzykujesz dwa błędy jednocześnie i użytkownik widzi dwa czerwone komunikaty, choć błąd merytorycznie jeden. Object path pozwala elegancko trzymać „jeden problem" jako „jeden szczegół".

6. path powinien mówić językiem API, a nie językiem Javy

Jedną z najbardziej przykrych pułapek błędów walidacji jest zacząć zwracać ścieżki, które są zrozumiałe tylko dla serwera. Na przykład pole Java nazywa się assigneeName, a w JSON kiedyś zdecydowałeś (lub poproszono cię) nazwać je assignee_name. Albo parametr query w kontrakcie nazywa się dueBefore, a w metodzie kontrolera nazwałeś argument before. Klient nie widział twojego kodu Java i nie musi go widzieć. On widział kontrakt.

Dlatego reguła jest prosta: path powinien używać publicznych nazw, czyli tych, które figurują w API. Omawialiśmy już w module o DTO/Jackson, że zewnętrzna nazwa pola może różnić się od wewnętrznej nazwy w Javie. Oto dlaczego path powinien być „po stronie zewnętrznego świata".

Mini-demonstracja na kodzie. Niech masz DTO (teraz nie omawiamy, czy potrzebny snake_case — to nie temat wykładu; ważna nam jest zasada):

import com.fasterxml.jackson.annotation.JsonProperty;

record TaskCreateRequest(
    String title,

    // Zewnętrzna (publiczna) nazwa pola w JSON — "assignee_name"
    @JsonProperty("assignee_name")
    String assigneeName // wewnętrzna nazwa w Javie może się różnić
) {}

Jeśli walidacja narzekała na assigneeName, to z punktu widzenia klienta błąd powinien przyjść po ścieżce assignee_name, bo właśnie tak pole nazywa się w JSON. Inaczej klient będzie patrzył na odpowiedź i myślał: „Co to za assigneeName? Ja czegoś takiego nie wysyłałem". A dalej zaczyna się ulubiona gra wszystkich deweloperów: „serwer mówi jedno, klient widzi drugie".

To samo dotyczy query i path. Jeśli w kontrakcie parametr nazywa się taskId, to i w odpowiedzi przy błędzie powinno być taskId, nawet jeśli w kodzie nazwałeś zmienną id. Nasz cel — sprawić, żeby odpowiedź można było zrozumieć, nie otwierając źródeł serwera.

7. Gramatyka path w Task Tracker API

Żeby nie zamieniać path w zoo formatów, warto raz ustalić reguły. I dalej — po prostu ich nie naruszać. To brzmi nudno, jak „wypełnij formularz 3-B", ale w praktyce usuwa całą klasę bugów: różne kontrolery zwracają różne ścieżki, klienci się łamią, testy żyją osobno od rzeczywistości, a potem ktoś dodaje prowizorię „jeśli path zaczyna się od /, to…".

Poniżej — kompaktowa „konstytucja ścieżek" dla naszego projektu edukacyjnego. Nie jest jedyna możliwa na świecie, ale jest wystarczająco prosta, żeby trzymać w głowie, i wystarczająco wyrazista, żeby pokryć body/query/path i zagnieżdżenie.

Sytuacja na wejściu Jak wygląda path Przykład
Proste pole (body) nazwa pola JSON title
Proste pole (query) nazwa query-parametru size
Proste pole (path) nazwa path-parametru taskId
Zagnieżdżone pole parent.child assignee.email
Element listy list[index] (0-based) tags[1]
Błąd na poziomie grupy pól logiczna nazwa dateRange

I jeszcze jedna tabela, żeby utrwalić właśnie „gramatykę", a nie przykłady:

Co wyrażamy Symbol Dlaczego tak
Zagnieżdżenie . łatwo się czyta i znajome ze stylów kodu
Indeks elementu listy [n] znajome z kolekcji i property-path
„Grupa pól" / object-level osobna nazwa pozwala nie przypisywać „winnego pola" sztucznie

Z tych reguł wynika praktyczny wniosek: path — to nie URL, nie JSONPath ze specjalnymi znakami, nie wewnętrzna nazwa walidatora i nie nazwa pola Java „jak wyszło". To kompaktowy i stabilny adres w danych wejściowych klienta.

Jeśli trzymasz umowę, błędy stają się bardzo przewidywalne. Zarówno w scenariuszach .http, jak i w Postmanie, i w testach klient nie musi zgadywać, jak serwer „nazywa" problem — widzi dokładnie to, co wysyłał, plus zrozumiałą nawigację do miejsca awarii.

8. Typowe błędy przy projektowaniu path

Błąd nr 1: zbyt ogólna ścieżka jak request lub field.
Taki path wygląda jak próba „coś napisać", ale nie jako pomoc dla klienta. W efekcie UI nie może podświetlić konkretnego miejsca, a deweloper klienta zaczyna pisać debugowy output i ręcznie szukać, co jest nie tak. Lepiej zwróć title, size, tags[1] lub assignee.email i skończ ten dramat zanim stanie się serialem na 8 sezonów.

Błąd nr 2: brak indeksu w listach.
Gdy zwracasz błąd dla tags, ale nie podajesz tags[3], ukrywasz główną wartość listy szczegółów: precyzję. Szczególnie boli w przypadkach unikalności lub nieprawidłowego formatu elementu. Klient jest zmuszony przeglądać całą listę, żeby znaleźć, „co miał na myśli serwer". A jeśli znasz indeks — po prostu go powiedz.

Błąd nr 3: mieszanie gramatyk w jednym projekcie (tags.1 i tags[1]).
Pierwsze kilka razy to „nie straszne". Potem pojawiają się testy, dokumentacja, walidacja frontendowa, i nagle okazuje się, że w jednym miejscu indeks jest w nawiasach kwadratowych, w innym — przez kropkę, w trzecim — przez podkreślnik. Klient musi obsługiwać kilka parserów ścieżek, a to już poziom bólu „przypadkowo napisaliśmy mini-język, ale zapomniano ostrzec".

Błąd nr 4: używanie wewnętrznych nazw Javy zamiast publicznych nazw API.
Jeśli zwracasz assigneeName, a w JSON jest to assignee_name (lub odwrotnie), klient zaczyna myśleć, że serwer narzeka „na coś innego". Path powinien żyć w logice publicznego kontraktu, inaczej nieświadomie „wyciekasz" wewnętrznym modelem na zewnątrz. To ten sam problem co „serializować model domenowy bezpośrednio", tylko w świecie błędów.

Błąd nr 5: próba „przypisania winnego pola" tam, gdzie błąd dotyczy kombinacji pól.
W regułach cross-field (np. zakres dat) przypisywanie błędu do jednego pola — to prawie zawsze dyskusyjne. UI klienta albo pokaże dwa błędy, albo pokaże jeden, ale użytkownik nie zrozumie, dlaczego „winne" jest dueAfter, a nie dueBefore. Logiczna object path jak dateRange pozwala powiedzieć prawdę: problem w kombinacji, a nie w jednym polu.

1
Zadanie
Spring REST & MVC, poziom 18, lekcja 1
Niedostępne
Sciezka kropkowa dla zagniezdzonego pola
Sciezka kropkowa dla zagniezdzonego pola
1
Zadanie
Spring REST & MVC, poziom 18, lekcja 1
Niedostępne
Indeks kolekcji i ścieżka obiektu w jednej odpowiedzi
Indeks kolekcji i ścieżka obiektu w jednej odpowiedzi
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION