1. Jawna deklaracja formatu w JSON-first API
Na początku wydaje się, że consumes/produces to jakaś biurokracja na poziomie „zaznacz checkbox, że zaznaczyłeś checkbox". Ale w projektowaniu API detale nagłówków są jak podpis w umowie: dopóki wszystko jest dobrze, nikt go nie zauważa, a gdy coś idzie nie tak – nagle okazuje się, że właśnie on rozstrzyga spór. Jeśli nie deklarujemy formatu jawnie, klient może przysłać ciało żądania w nieoczekiwanej postaci (lub z nieoczekiwanym Content-Type), a my potem długo będziemy się zastanawiać, dlaczego kontroler „jakby się nie wywołuje". Podobnie, jeśli klient prosi o jeden format przez Accept, a serwer daje inny, zamienia się to w „pływający" problem, gdzie każdy jest pewien, że to nie jego wina.
W naszym kursie świadomie budujemy JSON-first API, czyli główny format to application/json. Nie oznacza to, że można przestać myśleć o typach mediów. Wręcz przeciwnie: skoro świadomie wybieramy jeden format, logicznie jest też świadomie deklarować go w kontrakcie endpointów. Wtedy każdy developer, otwierając kontroler, w 10 sekund rozumie: „aha, ten endpoint przyjmuje JSON i zwraca JSON", a nie „no, chyba tak domyślnie". To oszczędza czas przy debugowaniu, czas na tłumaczenie kolegom i czas na korespondencję z klientami, którzy przysyłają text/plain, bo „tak wygodniej".
Content-Type i Accept: nagłówki formatu
Żeby poprawnie używać consumes i produces, trzeba znowu szybko przypomnieć sens dwóch kluczowych nagłówków. To nie „jakaś teoria z RFC", lecz dosłownie język, którym klient i serwer dogadują się, jak czytać i jak pisać body. Content-Type dotyczy ciała żądania: klient mówi serwerowi „oto moje body, jest w takim formacie". Accept dotyczy ciała odpowiedzi: klient mówi serwerowi „chcę dostać odpowiedź w takim formacie".
W JSON-first API wygląda to zazwyczaj tak: klient wysyła Content-Type: application/json, a także (często) dodaje Accept: application/json. Jeśli Accept nie jest podany, wiele klientów zachowuje się domyślnie jakby tam był */* (czyli „pasuje mi każdy format"), i serwer daje to, co potrafi. Ale w prawdziwym życiu obecność jawnego Accept bardzo pomaga: zamienia „nadzieję" w kontrakt.
Mała tabelka, żeby wszystko ułożyło się w głowie porządnie (i żebyś nie mylił tego automatycznie):
| Encja | Gdzie żyje | Kto ustawia | O czym mówi | Przykład |
|---|---|---|---|---|
| Content-Type | nagłówek HTTP żądania | klient | „Jaki format ma moje request body" | Content-Type: application/json |
| Accept | nagłówek HTTP żądania | klient | „W jakim formacie chcę response body" | Accept: application/json |
| consumes | adnotacja mapping w Spring MVC | serwer (my) | „Jakie Content-Type jestem gotowy przyjmować w request body" | consumes = "application/json" |
| produces | adnotacja mapping w Spring MVC | serwer (my) | „Jakie formaty jestem gotowy oddawać w response body" | produces = "application/json" |
Zwróć uwagę na lekką „pułapkę dla początkujących": Content-Type i Accept to nagłówki w żądaniu, a consumes/produces to „reguły" po stronie serwera, według których Spring albo wybierze twoją metodę kontrolera, albo powie „nie, tak się nie umawialiśmy". I właśnie tego zachowania nam dziś potrzeba.
2. consumes i produces jako filtr kontraktu
Jeśli powiedzieć to po ludzku, consumes i produces to część reguł dopasowania endpointu. Czyli Spring nie tylko patrzy na ścieżkę i metodę HTTP (POST /api/v1/tasks), ale też sprawdza: „czy to żądanie pasuje formatem?". I tu consumes/produces włączają dyscyplinę: jeśli żądanie przyszło z Content-Type, którego nie przyjmujesz, Spring może zwrócić 415 Unsupported Media Type. Jeśli żądanie przyszło z Accept, którego nie możesz spełnić, Spring może zwrócić 406 Not Acceptable.
Ważne jest uchwycenie właśnie tego momentu: w takich przypadkach twój kod wewnątrz metody kontrolera może w ogóle się nie wykonać. I to normalne. To nie „Spring się zepsuł". Oznacza to, że żądanie nie pasuje do kontraktu na poziomie ramki HTTP. Twoja logika biznesowa nie ma tu nic do rzeczy: po prostu nie wiesz, jak czytać ciało, lub nie możesz dostarczyć odpowiedzi tak, jak prosi klient.
Można to sobie wyobrazić jak kołowrotek w metrze. Masz bilet – przechodzisz, nie masz – nie przechodzisz. Kołowrotek nie musi dyskutować filozoficznych pytań „a dlaczego tak", jego zadanie – szybko i czytelnie filtrować przepływ. consumes i produces to taki sam kołowrotek, tylko dla formatu body.
Schemat na poziomie „wystarczający dla Junior" może wyglądać tak:
flowchart TD
A[Żądanie HTTP] --> B[Spring MVC: szukanie pasującej metody handlera]
B --> C{Ścieżka i metoda pasują?}
C -->|nie| X["404 / no handler"]
C -->|tak| D{consumes pasuje do Content-Type?}
D -->|nie| E[415 Unsupported Media Type]
D -->|tak| F{produces pasuje do Accept?}
F -->|nie| G[406 Not Acceptable]
F -->|tak| H[Wywołanie metody kontrolera]
Na razie nie omawiamy wewnętrznej mechaniki „kto dokładnie czyta body" i „który komponent odpowiada za JSON". Ale już teraz warto mieć w głowie: consumes/produces to część filtrowania żądania przed twoją logiką aplikacyjną, i bezpośrednio dotyczy kontraktu API.
3. Deklarujemy typ mediów w kontrolerze i metodach
produces na poziomie TaskController
Teraz zróbmy praktyczną rzecz w naszym Task Tracker API: na poziomie kontrolera zadeklarujemy, że zwraca JSON. Dlaczego na poziomie kontrolera? Bo wtedy nie będziemy pisać tego samego w każdej metodzie, a kontrakt stanie się czytelny „z zewnątrz": otworzyłeś klasę – od razu wiesz, że tu mamy application/json.
Wyobraź sobie, że kontroler to „folder" z endpointami. Jeśli w tym folderze prawie wszystkie odpowiedzi to JSON, logicznie napisać to raz na folderze, a nie na każdym pliku w środku. Dla kontrolera JSON-first jest to wygodne jeszcze i dlatego, że inny produces potrzebny jest tylko tam, gdzie odpowiedź naprawdę ma inny format. Ale dziś jesteśmy w świecie JSON-first i chcemy to uczynić jawnym.
Minimalny szkielet kontrolera z produces:
package com.example.tasktracker.api.controller;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Mówimy Spring'owi: to kontroler REST, odpowiedzi będą trafiać do body
@RequestMapping(
path = "/api/v1/tasks", // Bazowa ścieżka dla wszystkich endpointów zadań
produces = MediaType.APPLICATION_JSON_VALUE // Domyślnie oddajemy JSON
)
public class TaskController {
// Tu będą metody handlera (GET/POST/PUT/DELETE) do pracy z zadaniami
}
Robimy tu dwie ważne rzeczy. Po pierwsze, deklarujemy bazową ścieżkę /api/v1/tasks, żeby metody wewnątrz mogły być bardziej zwięzłe. Po drugie, mówimy Spring MVC: „wszystko co wychodzi z tego kontrolera jako response body, będzie oddawane jako JSON". I to nie tylko „żeby klientowi było ładnie". To o tym, żeby nasze API było przewidywalne: nawet jeśli zwrócisz obiekt bezpośrednio, Spring ustawi właściwy Content-Type w odpowiedzi.
consumes dla @RequestBody
Następny logiczny krok – zadeklarować format wejściowy dla tych operacji, gdzie czytamy request body. W naszym projekcie to tworzenie i aktualizacja zadań. Gdy w sygnaturze metody pojawia się @RequestBody, semantycznie mówimy: „klient przysyła ciało, my je czytamy". Znaczy, od razu warto powiedzieć: „czytamy tylko JSON". I właśnie to wyraża consumes = application/json.
Dlaczego to nie tylko „ładność"? Bo inaczej klient może przysłać body bez Content-Type (lub z text/plain), i będziesz się dziwił, dlaczego Spring nie chce zamieniać tego w twój obiekt Java. Z consumes wszystko staje się uczciwe: mamy umowę – JSON, i jeśli klient przynosi nie-JSON, żądanie nie przechodzi.
Przykład POST tworzenia zadania:
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import java.net.URI;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) // Przyjmujemy tylko JSON w request body
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// request przyszedł z JSON-ciała żądania i już jest sparsowany przez Spring
Task created = taskService.create(request.getTitle(), request.getDescription());
// Dla 201 Created dobra praktyka to zwrócenie Location z URL stworzonego zasobu
URI location = URI.create("/api/v1/tasks/" + created.getId());
return ResponseEntity
.created(location) // HTTP 201 + nagłówek Location
.body(created); // Ciało odpowiedzi (JSON, bo produces ustawiony na kontrolerze)
}
Zwróć uwagę na niuans: produces tu nie piszemy, bo już zadeklarowaliśmy go na poziomie kontrolera. Jeśli tak jest ci czytelniej, możesz pisać też w metodzie, ale wtedy zaczniesz powtarzać to samo. W projekcie edukacyjnym lepiej trzymać powtórzenia pod kontrolą, żeby kod nie wyglądał jak „tapeta z adnotacji".
Dla PUT i PATCH podejście jest takie samo: jeśli jest @RequestBody, metoda ma format wejściowy, który możemy jawnie zadeklarować:
import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
@PutMapping(path = "/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) // Czytamy JSON z body
public ResponseEntity<Task> replace(@PathVariable String taskId,
@RequestBody TaskUpdateRequest request) {
// taskId przyszedł z URL, request — z JSON-ciała
Task updated = taskService.replace(taskId, request.getTitle(), request.getDescription());
return ResponseEntity.ok(updated); // 200 OK + ciało JSON (produces na kontrolerze)
}
Natomiast DELETE zazwyczaj nie wymaga consumes: usunięcie zwykle nie ma ciała żądania. Z produces też można być ostrożniejszym, bo 204 No Content to sukces bez body. Możesz mieć ogólny produces na kontrolerze i to będzie działać, ale warto pamiętać: 204 to „brak ciała", więc format ciała jest tu mniej istotny niż status.
4. Żądania klientów i 415/406
Teraz przydatny moment, który mocno oszczędza godziny debugowania. Gdy dodałeś consumes/produces, część żądań może zacząć zwracać 415/406, i początkujący developer często odbiera to jako „coś mi się zepsuło w kontrolerze". Ale nie: po prostu dodałeś dyscyplinę kontraktową, i teraz żądania muszą jej odpowiadać.
Przykład poprawnego żądania tworzenia zadania w stylu .http:
# Poprawne żądanie: i Content-Type, i Accept są zgodne z kontraktem kontrolera
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix login bug",
"description": "Users cannot sign in after last deploy"
}
Jeśli przypadkowo wyślesz ciało bez Content-Type lub z niewłaściwym typem, na przykład tak:
# Niepoprawny Content-Type: serwer czeka na JSON, a przyszedł text/plain
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json
{"title":"Fix login bug"}
to Spring może zwrócić 415 Unsupported Media Type. I ważne: to nie błąd „twojej logiki biznesowej". To błąd na poziomie „nie dogadaliśmy się, jak czytać body". Twój taskService.create(...) w tym scenariuszu nie jest winien i prawdopodobnie nawet nie został wywołany.
A teraz przykład z Accept. Załóżmy, że klient mówi: „chcę tekst":
# Niepoprawny Accept: klient prosi text/plain, a serwer według kontraktu daje JSON
GET http://localhost:8080/api/v1/tasks
Accept: text/plain
Jeśli kontroler ma produces = application/json, Spring może zwrócić 406 Not Acceptable. To sytuacja „serwer może dać tylko JSON, a klient chce text/plain". W prawdziwym życiu przeglądarki raczej tak nie będą robić, ale niektóre narzędzia i integracje – owszem.
I tu pojawia się bardzo użyteczny inżynierski nawyk: gdy widzisz 415 lub 406, w pierwszej kolejności patrzysz nie na serwis i nie na repozytorium, lecz na nagłówki żądania. To właśnie tam najczęściej siedzi problem.
5. Jak uniknąć „lasu adnotacji"
Teraz – o dyscyplinie w kodzie. Bardzo łatwo popaść w dwie skrajności. Pierwsza – nie deklarować w ogóle nic i liczyć na „domyślnie i tak JSON". Druga – pisać consumes i produces wszędzie, ciągami, czasem z literówkami, i potem bohatersko debugować, dlaczego połowa endpointów się nie dopasowuje.
Zdrowa droga dla projektu edukacyjnego wygląda zazwyczaj tak: produces deklarujemy na poziomie kontrolera (lub wspólnego @RequestMapping), żeby cały zestaw endpointów był w jednym formacie. A consumes stawiamy tylko tam, gdzie naprawdę czytamy body przez @RequestBody. To czyni kontrakt jawnym, ale nie zamienia kodu w firankę z adnotacji.
Przy tym ważne jest unikanie magicznych ciągów. Pisanie "application/json" ręcznie wszędzie to jak ręczne wpisywanie „localhost" w każdej linijce kodu: raz się pomylisz – i już, „jakoś nie działa". Dlatego używaj stałych Spring:
import org.springframework.http.MediaType;
// Jedno miejsce, gdzie deklarujemy typy mediów naszego API
public final class ApiMediaTypes {
// JSON — bazowy format naszego JSON-first API
public static final String JSON = MediaType.APPLICATION_JSON_VALUE;
private ApiMediaTypes() {
// Zabraniamy tworzenia instancji: to klasa narzędziowa ze stałymi
}
}
I wtedy w kontrolerze można pisać zwięźle i bez literówek:
import static com.example.tasktracker.api.ApiMediaTypes.JSON;
import org.springframework.web.bind.annotation.PostMapping;
@PostMapping(consumes = JSON)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
// Reszta kodu metody (tworzenie zadania, formowanie odpowiedzi itp.)
}
To nie obowiązkowa technika, ale dobrze pokazuje ogólną myśl: format to część kontraktu, a kontrakt chcemy trzymać starannie i jednolicie, a nie przez kopiowanie i wklejanie.
6. Typowe błędy przy pracy z consumes i produces
Błąd nr 1: mylenie „kto za co odpowiada" między Content-Type, Accept, consumes, produces.
Bardzo częsta sytuacja: developer stawia produces, myśląc że to „my to przyjmujemy", albo stawia consumes, myśląc że to „my to dajemy". W efekcie endpoint nagle zaczyna zwracać 415/406, a osoba szuka problemu w serwisie. Prosta weryfikacja: consumes – to o tym, co serwer czyta (jak strawić request body), a produces – o tym, co serwer pisze (jak sformować response body).
Błąd nr 2: mechaniczne stawianie consumes na GET, gdzie nie ma ciała żądania.
Na poziomie sensu to po prostu szum: deklarujesz format czegoś, czego nie ma. A na poziomie zachowania możesz czasem dostać nieoczekiwane efekty, szczególnie jeśli jakiś klient niespodziewanie wysyła Content-Type tam, gdzie go nie oczekujesz. W projekcie edukacyjnym lepiej trzymać się prostej reguły: consumes pojawia się tam, gdzie jest @RequestBody.
Błąd nr 3: pisanie "application/json" ręcznie w dwudziestu miejscach i raz się mylisz.
Literówka w stylu "applicaton/json" wygląda śmiesznie dokładnie do momentu, gdy nie spędziłeś 40 minut na debugowaniu i nie zrozumiałeś, że endpoint „się nie znajduje", bo consumes nie pasował. Używaj MediaType.APPLICATION_JSON_VALUE lub własnej stałej, i usuniesz całą klasę błędów ze swojego życia.
Błąd nr 4: uważanie 415/406 za „błąd mojego kodu w metodzie kontrolera".
W rzeczywistości często oznacza to, że do metody w ogóle nie dotarłeś. Serwis może być idealny, repozytorium – genialne, a @RequestBody – piękny, ale jeśli żądanie przyszło z niewłaściwym Content-Type lub niezgodnym Accept, kołowrotek nie przepuści. Nawyk patrzenia na nagłówki żądania w takich przypadkach to dorosła, inżynierska oszczędność czasu.
Błąd nr 5: stawianie produces = application/json na endpoincie zwracającym 204 No Content i oczekiwanie „pustego JSONa".
204 oznacza „brak ciała", a nie „ciało jest puste". To subtelna, ale bardzo ważna różnica. Jeśli chcesz zwrócić {} lub jakiś obiekt potwierdzenia – to już będzie 200 OK z body, a nie 204. Nie udawaj, że 204 ma format ciała: formalnie ciała tam być nie powinno.
GO TO FULL VERSION