CodeGym /Kursy /Spring REST & MVC /Jak działa @RequestBody

Jak działa @RequestBody

Spring REST & MVC
Poziom 7, Lekcja 0
Dostępny

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".

1
Zadanie
Spring REST & MVC, poziom 7, lekcja 0
Niedostępne
Tworzenie notatki z treści JSON
Tworzenie notatki z treści JSON
1
Zadanie
Spring REST & MVC, poziom 7, lekcja 0
Niedostępne
Zastąpienie książki po identyfikatorze przez path i body
Zastąpienie książki po identyfikatorze przez path i body
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION