1. Request body vs path i query
Szczerze mówiąc, request body to nie żadna „sztuczka Spring'a". To normalny, kanoniczny sposób przekazywania w żądaniu HTTP dużej ilości danych naraz. Szczególnie gdy tworzysz lub modyfikujesz zasób – zazwyczaj nie chodzi o jedno pole, lecz o cały zestaw. Im bardziej dojrzała aplikacja, tym mocniej to czujesz (i tym mniej masz ochotę patrzeć na URL długości połowy ekranu).
Wyobraź sobie, że chcemy utworzyć zadanie w Task Tracker API. Zadanie ma co najmniej title i description. To już dwa pola. Do tego może być wykonawca, termin, tagi… Jeśli zaczniesz przekazywać to wszystko przez parametry query, wyjdzie coś w stylu „wymuszonego drobnego druku na marginesie":
# Przykład „można, ale lepiej nie": dane tworzenia zasobu w query
POST /api/v1/tasks?title=Fix%20login&description=...&assigneeName=...&dueDate=...
Technicznie możesz to zrobić, ale kontrakt stanie się trudniejszy do odczytania i mniej stabilny. Parametry query świetnie sprawdzają się przy filtrach, paginacji i „trybach" listy, a nie do przekazywania „nowego stanu zasobu".
Warto mieć w głowie taki obraz (bez traktowania go jak dogmatu). To nie „jedyna prawda", lecz praktyczny drogowskaz, który pomaga projektować przewidywalne API:
| Gdzie umieszczamy dane | Jak to wygląda | Co to oznacza | Przykład w Task Tracker API |
|---|---|---|---|
| Path (/tasks/{id}) | część URI | „kogo/co adresuję" | GET /api/v1/tasks/{taskId} |
| Query (?status=...) | parametry po ? | „jak chcę filtrować/wyświetlać" | GET /api/v1/tasks?status=TODO |
| Headers | Content-Type, Accept itp. | „w jakim formacie się komunikujemy" | Content-Type: application/json |
| Body | JSON (lub inne) | „jakie dane przekazuję do operacji" | POST /api/v1/tasks + JSON z polami zadania |
Ciało żądania to jak paczka. Path to adres dostawy, query to adnotacje „dostarczyć po 18:00" i „nie dzwonić domofonem". A jeśli próbujesz wcisnąć samą paczkę w adnotacje – kurier będzie zdziwiony.
2. Rola @RequestBody w Spring MVC
Gdy pierwszy raz widzisz @RequestBody, bardzo łatwo pomyśleć: „O, to jakaś adnotacja, która po prostu robi obiekt z JSONa". I tak, odczucie jest dokładnie takie. Ale ważna kwestia – to nie „magia", lecz część zrozumiałego kontraktu między tobą a Spring MVC: jawnie mówisz, skąd brać wartość parametru metody.
Pomysł jest prosty: w metodzie kontrolera chcesz dostać nie ciąg JSON, lecz gotowy obiekt Java, z którym wygodnie się pracuje. Zamiast ręcznie czytać strumień bajtów, składać ciąg, parsować JSON i ręcznie wyciągać pola, opisujesz kształt danych wejściowych klasą Java – a Spring robi wiązanie automatycznie.
Minimalnie wygląda to tak:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController // Mówimy Spring'owi: to kontroler zwracający odpowiedzi bezpośrednio (zazwyczaj JSON/tekst)
public class DemoController {
@PostMapping("/demo") // Trasa przyjmująca żądania POST
public String demo(@RequestBody DemoRequest request) {
// @RequestBody: weź ciało żądania HTTP i zmapuj je na DemoRequest (przez konwerter JSON)
return "title=" + request.getTitle(); // np.: title=Fix login
}
}
Tutaj @RequestBody oznacza dosłownie: „Spring, weź ciało żądania HTTP i spróbuj zamienić je na DemoRequest".
Żeby to w ogóle było możliwe, klient zazwyczaj przysyła nagłówek Content-Type: application/json, żeby serwer nie zgadywał, co leży w body. Omawialiśmy już Content-Type jako „paszport formatu". Bez niego serwer może zacząć wątpić i odpowiadać błędem – ale teraz ważniejsze jest zrozumienie zasady: @RequestBody wiąże body -> obiekt.
Jeszcze jedna ważna kwestia, którą początkujący często pomijają: ciało żądania to jeden strumień danych. Dlatego w jednej metodzie kontrolera zazwyczaj może być tylko jeden parametr oznaczony @RequestBody. Nie można „rozłożyć jednego JSONa na dwa niezależne ciała". Technicznie to jak próba dwukrotnego przeczytania tej samej kartki papieru, którą już spaliłeś w kominku. Brzmi efektownie, ale nie ma już czego czytać.
3. Klasa dla JSON body żądania
Kiedy przyjmujesz request body, musisz dać Spring MVC czytelny „formularz" – klasę Java opisującą oczekiwaną strukturę JSON. To właśnie początek dyscypliny kontraktu: bezpośrednio w kodzie ustalasz, jakich pól oczekujesz, jak się nazywają i jakich są typów. Klient nie może już przysłać „czego bądź" i liczyć, że serwer „jakoś sobie poradzi".
W naszym projekcie będzie to żądanie tworzenia zadania. Stwórzmy klasę TaskCreateRequest. Zgodnie ze strukturą projektu logiczne miejsce to pakiet api.dto.request, bo to model wejścia na granicy API.
package com.example.tasktracker.api.dto.request;
public class TaskCreateRequest {
// Pola, których oczekujemy w JSONie od klienta
private String title;
private String description;
// Getter potrzebny, żeby kod kontrolera/serwisu mógł odczytać wartość
public String getTitle() { return title; }
// Setter potrzebny, żeby maper JSON (np. Jackson) mógł zapisać wartość do obiektu
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
Dlaczego potrzebne są gettery/settery? Bo Spring (a właściwie mapowanie JSON wewnątrz jego web-stacku) zazwyczaj działa w stylu JavaBean: potrafi stworzyć obiekt i wypełnić jego właściwości przez settery. To nie jedyny możliwy styl w Javie w ogóle, ale dla początkującego dewelopera jest najprostszy i najpopularniejszy.
Teraz klient może wysłać JSON mniej więcej takiej postaci:
# Tworzenie zadania: dane idą w body, a format opisujemy przez Content-Type
POST /api/v1/tasks
Content-Type: application/json
{
"title": "Fix login",
"description": "Users cannot login after password reset"
}
I jeśli wszystko dobrze, do metody kontrolera przyleci obiekt TaskCreateRequest, u którego:
// Te wartości zostaną wzięte z pól JSON "title" i "description"
request.getTitle(); // "Fix login"
request.getDescription(); // "Users cannot login after password reset"
Jest tu bardzo praktyczna logika mapowania: klucz JSON "title" trafia do właściwości Java title, bo istnieje setTitle(...). Klucz JSON "description" trafia do description, bo istnieje setDescription(...). Nie trzeba tego zapamiętywać jako „reguły z podręcznika" – wystarczy pamiętać, że nazwy kluczy JSON muszą zgadzać się z nazwami pól/właściwości, których oczekujesz.
Teraz ważna kwestia dla początkujących, która często wychodzi w najmniej spodziewanym momencie. Jeśli zrobisz pole nie String, lecz powiedzmy int, a klient go nie prześle, Java i tak nada wartość domyślną 0. Czyli nie odróżnisz „klient przysłał 0" od „klient nie przysłał nic". Dlatego dla opcjonalnych liczb/flag w modelach requestów częściej używa się Integer, Long, Boolean, a nie int/long/boolean. Dla naszych title/description to nieistotne, bo są stringami, ale lepiej wyrobić sobie ten nawyk od razu – potem sam sobie podziękujesz (i swoim przyszłym raportom o błędach).
Jeszcze jedna myśl: klasa requestu powinna opisywać tylko to, co faktycznie przychodzi od klienta. Jeśli spróbujesz przyjąć „pełne zadanie" w całości, łącznie z id, createdAt i resztą, tym samym pozwolisz klientowi „rządzić" polami serwera. W naszym projekcie identyfikator zadania generuje serwer, dlatego w TaskCreateRequest nie ma id. To bardzo zdrowy nawyk dla API: klient przysyła to, co ma prawo ustalać, a serwer – to, co musi kontrolować sam.
4. @RequestBody + @PathVariable
Kiedy tworzysz zasób, nie ma jeszcze czym adresować konkretnej encji – jej id nie istnieje przed utworzeniem. Ale gdy mówimy o aktualizacji, robi się ciekawiej: musimy jednocześnie wskazać, który dokładnie zasób zmieniamy, i jakimi danymi go zmieniamy. I właśnie tu pięknie działa połączenie path + body.
Path pozostaje „adresem" zasobu, a body staje się „nową zawartością". To bardzo podobne do życia codziennego: możesz wysłać list pod adres (path), a co jest w środku koperty (body) – to już dane operacji.
Przykład sygnatury metody dla „pełnego zastąpienia" (nie wnikając teraz w semantykę PUT i PATCH, interesuje nas tylko mechanika przekazywania danych):
import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import com.example.tasktracker.domain.model.Task;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
public class TaskController {
@PutMapping("/api/v1/tasks/{taskId}")
public Task replace(@PathVariable String taskId,
@RequestBody TaskUpdateRequest request) {
// taskId — „kogo zmieniamy" (adres zasobu w URI)
// request — „na co zmieniamy" (dane operacji w body)
return null;
}
}
Zauważ, że taskId jest tu właśnie w path, a nie w JSONie. To sprawia, że kontrakt jest mniej dwuznaczny: URI jednoznacznie adresuje zasób, a ciało żądania jednoznacznie opisuje dane zmiany. Gdy ludzie zaczynają duplikować id i tam, i tu, niemal zawsze kończy się to pytaniem „a co zrobić, jeśli są różne?". Prawidłowa odpowiedź zazwyczaj brzmi: „nie duplikować".
5. POST /api/v1/tasks z @RequestBody
Teraz zrobimy praktyczny krok w naszym projekcie: dodamy endpoint tworzenia zadania. W tej lekcji ważne jest, żebyśmy zrozumieli, jak przyjąć JSON jako obiekt i przekazać go dalej do serwisu, nie zamieniając kontrolera w miejsce do ręcznego parsowania i logiki.
Zacznijmy od kontrolera. Załóżmy, że mamy już TaskController z metodami GET z poprzednich dni, i jest TaskService odpowiedzialny za operacje aplikacyjne. Dodajemy @PostMapping i @RequestBody:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;
@RestController // Kontroler REST: zwracamy obiekt, który zostanie zserializowany do JSON
@RequestMapping("/api/v1/tasks") // Bazowa ścieżka dla wszystkich metod tego kontrolera
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
// Wstrzykiwanie zależności: kontroler sam nie „tworzy zadań", deleguje do serwisu
this.taskService = taskService;
}
@PostMapping // POST /api/v1/tasks
public Task create(@RequestBody TaskCreateRequest request) {
// request przychodzi z body, a dalej wyciągamy potrzebne pola i przekazujemy do serwisu
return taskService.create(request.getTitle(), request.getDescription());
}
}
Zauważ, że serwis wywołujemy prostymi typami (String, String). Pomaga to utrzymać granicę warstw w porządku: warstwa serwisowa nie musi wiedzieć, jak dokładnie wyglądało żądanie HTTP. Zna tylko sens operacji: „utwórz zadanie z takim tytułem i opisem".
Teraz rzućmy okiem na przykład serwisu. Na obecnym etapie kursu wystarczy nam prosta implementacja in-memory (bez bazy danych). Niech serwis przechowuje zadania w Map, generuje id i zwraca utworzone zadanie:
package com.example.tasktracker.domain.service;
import com.example.tasktracker.domain.model.Task;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class TaskService {
// Magazyn in-memory: żyje tylko dopóki działa aplikacja
private final Map<String, Task> tasks = new ConcurrentHashMap<>();
public Task create(String title, String description) {
// Generujemy id po stronie serwera (klient nie powinien zarządzać tym polem)
String id = UUID.randomUUID().toString();
// Składamy encję domenową z danych wejściowych
Task task = new Task(id, title, description);
// Zapisujemy do naszego tymczasowego magazynu
tasks.put(id, task);
// Zwracamy utworzoną encję (Spring ją potem zserializuje)
return task;
}
}
Ważne wyjaśnienie: celowo pokazuję uproszczony model Task z konstruktorem new Task(id, title, description). W twoim projekcie Task może już mieć więcej pól (np. status), i wtedy konstruktor będzie inny – to normalne. Dla nas teraz kluczowe jest co innego: nie przyjmujemy Task z request body bezpośrednio, przyjmujemy TaskCreateRequest, a potem tworzymy Task na serwerze.
Teraz możemy sprawdzić, że Spring naprawdę „składa" obiekt z JSONa. Najprostszy sposób – żądanie .http:
### Create task
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Fix login",
"description": "Users cannot login after password reset"
}
Jeśli wszystko zrobiono prawidłowo, dostaniesz odpowiedź z JSON (jego postać zależy od twojego Task):
{
"id": "b63a3e79-430b-4f1e-8f2b-1f8e8b4a1e6b",
"title": "Fix login",
"description": "Users cannot login after password reset"
}
I to jest kluczowy wynik tej lekcji: wysłałeś JSON jako tekst, a w kodzie Java pracowałeś z normalnym obiektem, jakby „zawsze tam był".
6. Dyscyplina kontraktu dla request body
Sztuczka z @RequestBody wydaje się prosta, ale wbudowuje w projekt bardzo ważną dyscyplinę: zaczynasz traktować kształt wejściowego JSON jak część zewnętrznej umowy. To nie „jakoś to rozparsujemy", lecz „oczekujemy konkretnej struktury". I właśnie dlatego request body lepiej zbierać w jeden obiekt, a nie rozsmarowywać po dziesięciu parametrach.
Zły, ale często spotykany wariant wygląda tak: tworzenie zasobu przez mnóstwo parametrów (zazwyczaj query), bo „tak szybciej napisać" i „co tam". W efekcie kontrakt staje się mniej czytelny, a ciało żądania świeci pustką:
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
public class BadController {
@PostMapping("/api/v1/tasks")
public String create(@RequestParam String title,
@RequestParam String description) {
// Tutaj dane „tworzenia" przychodzą nie w body, lecz w parametrach query,
// przez co kontrakt rozrasta się i jest gorzej czytelny.
return "ok";
}
}
Dobry wariant – jeden parametr @RequestBody, który opisuje strukturę wejścia w całości. Wtedy JSON sam staje się „dokumentem", a klasa Java – „opisem dokumentu":
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
public class GoodController {
@PostMapping("/api/v1/tasks")
public String create(@RequestBody TaskCreateRequest request) {
// Cały kontrakt wejścia zebrany w jednym miejscu: w klasie request
return "title=" + request.getTitle();
}
}
I jeszcze jedna rada anty-chaosowa, która oszczędza godziny życia: nie duplikuj identyfikatora zasobu i w path, i w body. Jeśli masz PUT /tasks/{taskId}, to taskId żyje w path. Jeśli dodasz też id do JSONa, sam tworzysz sytuację „a który z nich jest prawidłowy". A backend i tak ma dość zajęć, nie trzeba mu dokładać jeszcze jednego.
7. Typowe błędy przy pracy z @RequestBody
Błąd nr 1: przekazywanie danych tworzenia/aktualizacji przez parametry query „bo tak prościej".
Tak, napisać to jeden raz rzeczywiście prościej, ale kontrakt robi się brudniejszy i trudniejszy do utrzymania. Przy operacji tworzenia, gdzie zasób ma wiele pól, ciało żądania z JSON niemal zawsze daje bardziej czytelny i stabilny interfejs.
Błąd nr 2: ręczne czytanie JSON jako ciągu (HttpServletRequest, getReader()), a potem parsowanie go własnoręcznie.
To klasyczna droga do „własnoręcznego frameworka". Tracisz czas na infrastrukturę, którą Spring MVC już umie robić za ciebie, i zaczynasz płodzić bugi w najcieńszym miejscu – na granicy wejścia danych. Kontroler powinien dostać obiekt i delegować pracę do serwisu, a nie zamieniać się w miniparser.
Błąd nr 3: oczekiwanie, że można mieć dwa parametry @RequestBody w jednej metodzie.
Ciało HTTP jest jedno. Nie może „rozdzielić się" na dwa niezależne obiekty. Jeśli wydaje ci się, że potrzebujesz dwóch ciał, znaczy że potrzebujesz jednego obiektu z dwoma polami lub jednego obiektu z zagnieżdżoną strukturą.
Błąd nr 4: niezgodność nazw pól JSON i właściwości Java.
Jeśli klient przysłał "taskTitle", a twoja klasa czeka na title, to pole title się nie wypełni i zostanie null. To nie „kaprys Spring'a", to naturalna cena dyscypliny kontraktowej: serwer oczekuje konkretnej struktury. Dlatego trzymaj przykłady JSON pod ręką i sprawdzaj, czy nazwy się zgadzają.
Błąd nr 5: używanie prymitywów (int, boolean) tam, gdzie pole może być opcjonalne.
Jeśli pole nie nadeszło, int stanie się 0, a boolean stanie się false. I stracisz znaczenie: czy klient to przysłał, czy to po prostu wartość domyślna. Dla potencjalnie nieobecnych pól używaj wrapperów (Integer, Boolean) i jawnie rozróżniaj „brak wartości" od „wartość równa zero/false".
GO TO FULL VERSION