1. Wiadomość HTTP jako koperta
Jeśli kiedykolwiek wysyłałeś papierowy list (albo chociaż widywałeś je w filmach), pamiętasz: z zewnątrz jest adres, znaczki i służbowe pieczęcie, a w środku — treść. Wiadomość HTTP działa podobnie. Początkujący często zauważają tylko to, co „ładne" — JSON w body — a całą resztę postrzegają jako nudną otoczkę. Ale właśnie ta „nudna otoczka" decyduje o tym, co w ogóle się dzieje: dokąd się zwracamy, o co prosimy, jak serwer ma rozumieć format danych i co klient musi przeczytać w odpowiedzi.
Wiadomości HTTP są dwóch rodzajów: request (żądanie) i response (odpowiedź). Mają różne pierwsze linie, ale dalej logika jest bardzo podobna: linia startowa, nagłówki, pusta linia–separator i opcjonalnie ciało. Ważne, żeby zapamiętać prostą myśl: jeśli dane zostaną nieprawidłowo rozmieszczone w częściach wiadomości, kontrakt API staje się mętny. A mętny kontrakt to taki, gdy klient, serwer i ty za trzy tygodnie patrzą na to samo, a każdy widzi coś innego.
Poniżej — krótka „mapa terenu". Będziemy do niej wracać w trakcie lektury.
| Część | Gdzie się znajduje | Na jakie pytanie odpowiada |
|---|---|---|
| method | pierwsza linia żądania | „Co chcesz zrobić?" |
| path | wewnątrz URL / request-target | „Do czego się zwracasz?" |
| query | po ? | „Jakimi parametrami sprecyzować żądanie?" |
| headers | osobne linie Name: Value | „Jakie są służbowe warunki i umowy wymiany?" |
| body | po pustej linii | „Jakie merytoryczne dane są przekazywane?" |
W pierwszej lekturze patrzyliśmy na HTTP jako na kompletny cykl: żądanie wyszło, odpowiedź przyszła lub nie przyszła. Teraz trzeba zajrzeć do samego żądania i samej odpowiedzi i rozłożyć je na szczegóły. Bez tego method, path, headers i body pozostają ładnymi słowami, a nie roboczym narzędziem do debugowania i projektowania. Gdy struktura stanie się widoczna, pierwsza linia odpowiedzi przestanie wydawać się szumem, a headers — nudną otoczką.
Od razu ustalmy, jak będziemy zapisywać przykłady. W pliku .http lub Postmanie zazwyczaj widzisz pełny URL w stylu http://localhost:8080/api/v1/tasks. W surowym HTTP w pierwszej linii zwykle pozostaje request-target — czyli /api/v1/tasks?status=TODO&page=0. Dalej będę częściej pokazywać właśnie taką formę: bez schematu i hosta, żeby łatwiej analizować anatomię wiadomości.
2. Pierwsza linia żądania
Gdy wysyłasz żądanie HTTP, serwer widzi najpierw nie JSON i nie nagłówki, lecz właśnie pierwszą linię — request line. Określa ona „szkielet" żądania: jaka metoda jest używana, do czego adresowane jest żądanie i jaka wersja HTTP jest stosowana. To jak pierwsze zdanie w korespondencji: jeśli zaczynasz od „Cześć. Daj mi…", dalej można już precyzować szczegóły. Jeśli zaczniesz od niejasnego „eee…", dalej będzie ciężko wszystkim.
Dla HTTP/1.1 ta linia wygląda tak:
METHOD <spacja> REQUEST-TARGET <spacja> HTTP/1.1
W praktyce w przykładach edukacyjnych często używa się właśnie takiej formy, bo jest przejrzysta i czyta się dosłownie wzrokiem. Nawet jeśli w realnym świecie nad HTTP/2 i HTTP/3 wszystko jest binarne, sens pozostaje ten sam: metoda, cel, wersja.
Mini-przykład „w punkt", bez żadnego Spring — żeby po prostu zobaczyć formę:
// Składamy request line ręcznie: metoda + target + wersja protokołu.
String method = "GET";
String target = "/api/v1/tasks?status=TODO&page=0"; // target zawiera zarówno path, jak i query
String requestLine = method + " " + target + " HTTP/1.1";
System.out.println(requestLine); // GET /api/v1/tasks?status=TODO&page=0 HTTP/1.1
Zwróć uwagę na ważny szczegół: target zawiera tu zarówno path, jak i query. Czyli już z pierwszej linii widać, „dokąd i z jakimi uściśleniami" idziemy.
Jeśli chcesz zrobić sobie mały „parser pierwszej linii" (ćwiczeniowo, nie produkcyjnie), można tak:
// Edukacyjny parser request line: w prawdziwym kodzie warto jeszcze sprawdzać liczbę części.
String requestLine = "GET /api/v1/tasks/42 HTTP/1.1";
String[] parts = requestLine.split(" ");
String method = parts[0]; // metoda żądania: GET/POST/...
String target = parts[1]; // request-target: /api/v1/tasks/42 (może zawierać też query)
System.out.println(method); // GET
System.out.println(target); // /api/v1/tasks/42
Z punktu widzenia kontraktu API ważna tu nie jest umiejętność dzielenia stringa, lecz rozumienie: method i target nie „gdzieś tam", zawsze żyją w pierwszej linii. To one wyznaczają podstawowy sens żądania zanim jeszcze zaczniemy omawiać nagłówki i ciało.
3. Path: adres zasobu
Path to adresowa część ścieżki wewnątrz twojego serwera. Jeśli patrzeć na URL jak na „adres domu", to path to, umownie, ulica i numer domu. Bez uściśleń w stylu „klatka 2, piętro 7" — to już query — i bez spraw służbowych w stylu „jaki język preferujesz" — to częściej nagłówki. Path powinien być wystarczająco stabilny i zrozumiały: widząc go, klient powinien mniej więcej rozumieć, co właściwie adresuje.
W żądaniu HTTP path jest zwykle widoczny wewnątrz request-target. Na przykład:
GET /api/v1/tasks HTTP/1.1
GET /api/v1/tasks/42 HTTP/1.1
GET /api/v1/tasks/42/comments HTTP/1.1
W naszym API edukacyjnym ścieżka ma bazowy prefiks /api/v1, który też jest częścią path.
Path składa się z segmentów oddzielonych /. W większości serwerów (i w praktycznym projektowaniu API) jest postrzegany jako hierarchia: najpierw „kolekcja", potem konkretny obiekt, potem podsekcja. Na razie nie chodzi nam o „ideologię REST", lecz o anatomię: path to wszystko, co idzie przed ?.
Żeby zobaczyć różnicę między path a query na poziomie typu Java, wygodnie użyć URI:
import java.net.URI;
// URI potrafi osobno oddawać path i query — wygodne do zrozumienia, że to różne części URL.
URI uri = URI.create("https://example.com/api/v1/tasks/42?status=TODO&page=0");
System.out.println(uri.getPath()); // /api/v1/tasks/42
System.out.println(uri.getQuery()); // status=TODO&page=0
Żadnej magii. To po prostu dobry sposób, żeby „na żywo" zobaczyć, że path i query to różne części adresu, nawet jeśli w pasku przeglądarki wyglądają jak jeden string.
Czasem przydatne jest rozłożenie path na segmenty — czysto ćwiczeniowo:
// Dzielimy path na segmenty (przykład edukacyjny: w prawdziwym świecie są niuanse z //, kodowaniem itp.).
String path = "/api/v1/tasks/42/comments";
// Usuwamy wiodący "/" i dzielimy po "/".
String[] segments = path.substring(1).split("/");
System.out.println(segments[0]); // api
System.out.println(segments[1]); // v1
System.out.println(segments[2]); // tasks
System.out.println(segments[3]); // 42
System.out.println(segments[4]); // comments
Zauważ, że 42 w środku path to już podpowiedź identyfikatora konkretnego zasobu. W prawdziwym API to często UUID, ale na poziomie anatomii ważniejsze jest co innego: path najczęściej odpowiada na pytanie „do czego się zwracamy", a nie „jakimi parametrami filtrujemy" ani „jaką treść przekazujemy".
4. Parametry query
Query string to część adresu po ?. Często jest niedoceniana, dopóki nie pojawi się pierwszy prawdziwy endpoint listy i nie okaże się, że „pokaż mi zadania ze statusem TODO, posortuj po czasie i daj pierwszą stronę" nie mieści się w żadnym porządnym path bez zamieniania URL w powieść Tołstoja. Query jest właśnie po to, żeby precyzować żądanie parametrami.
Forma query jest zazwyczaj taka:
/api/v1/tasks?status=TODO&page=0&size=20
Czyli idą pary key=value, oddzielone &. Sam fakt, że query zaczyna się od ?, to już ważna wskazówka: to uzupełnienie żądania, a nie „nowy zasób". Jeśli usuniesz query, i tak będziesz odnosić się do tego samego path, tylko bez uściśleń.
Bardzo ważne jest pamiętanie o URL-encoding: query żyje wewnątrz URL, a więc niektóre znaki muszą być zakodowane. Najprostszy przykład — spacja w nazwisku:
/api/v1/tasks?assigneeName=John%20Smith
Jeśli „wepchnąć" spację bezpośrednio, zachowanie stanie się nieprzewidywalne: gdzieś zakoduje się automatycznie, gdzieś wszystko się posypie, a gdzieś serwer zobaczy tylko „John". Lepiej przyjąć, że z URL trzeba obchodzić się ostrożnie.
Mały przykład dekodowania wartości:
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
// Dekodujemy percent-encoded wartość z query (np. %20 -> spacja).
String pair = "assigneeName=John%20Smith";
String encodedValue = pair.split("=", 2)[1]; // bierzemy prawą część po "="
String value = URLDecoder.decode(encodedValue, StandardCharsets.UTF_8);
System.out.println(value); // John Smith
A oto przykład „jak poprawnie złożyć query" używając URLEncoder:
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// Kodujemy wartość, żeby bezpiecznie umieścić ją w URL (spacje i znaki specjalne będą zakodowane).
String assignee = URLEncoder.encode("John Smith", StandardCharsets.UTF_8);
String url = "/api/v1/tasks?assigneeName=" + assignee;
System.out.println(url); // /api/v1/tasks?assigneeName=John+Smith
Tak, tu będzie +, a nie %20. To normalna cecha kodowania w stylu application/x-www-form-urlencoded, które często spotyka się w świecie webowym. Na poziomie tej lektury nie chodzi o zapamiętanie wszystkich niuansów, lecz o wyniesienie jednej myśli: parametry query — to miejsce na uściślenia, ale żyją w URL i wymagają ostrożnego obchodzenia się ze stringami.
Czasem chce się po prostu sparsować query do Map. Zróbmy mały, celowo prosty kod (nie pretenduje do „idealnego parsera URL", jest edukacyjny):
import java.util.HashMap;
import java.util.Map;
// Bardzo prosty parser: dzieli po "&" i "=", nie robi URL-decoding i nie obsługuje powtarzających się kluczy.
Map<String, String> parseQuery(String query) {
Map<String, String> result = new HashMap<>();
for (String pair : query.split("&")) { // pary w formie key=value
String[] kv = pair.split("=", 2); // limit 2: wartość może zawierać "="
result.put(kv[0], kv.length == 2 ? kv[1] : ""); // jeśli nie było "=" — wartość pusta
}
return result;
}
W prawdziwym serwisie takie rzeczy zazwyczaj robi infrastruktura (serwer, framework, biblioteka), ale dla zrozumienia anatomii przydatne jest raz ręcznie zobaczyć: query to zestaw par, a nie „magia", która nagle zamienia się w parametry metody.
5. Headers: służbowe umowy
Nagłówki (headers) to linie w formacie Name: Value, które idą bezpośrednio po linii startowej. Nie są danymi domeny (nie wkładaj tam title zadania, proszę), ale stanowią ogromną część kontraktu wymiany: format ciała, oczekiwany format odpowiedzi, długość treści, identyfikator żądania, dane uwierzytelniające (w naszym kursie tego nie używamy, ale świat — tak) i tak dalej.
Najważniejsza rzecz wizualna, którą trzeba zapamiętać: headers są oddzielone od body pustą linią. Pusta linia to jak „skończyliśmy część służbową, teraz zaczyna się treść". Jeśli o tej pustej linii zapomnisz, serwer może próbować czytać ciało jako nagłówki albo na odwrót. I to ten rzadki przypadek, gdy jeden pusty Enter może naprawdę zepsuć wieczór.
Przykład żądania bez body, ale z nagłówkami:
GET /api/v1/tasks HTTP/1.1
Host: localhost:8080
Accept: application/json
User-Agent: IntelliJ HTTP Client
Merytorycznego ciała tu nie ma, ale nagłówki już niosą ważną informację. Nawet Accept — to sygnał, że klient oczekuje określonego formatu odpowiedzi (szczegółowo w to dziś nie wchodzimy, teraz interesuje nas właśnie struktura).
Przykład żądania z body, gdzie nagłówek objaśnia, co to za body:
POST /api/v1/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Accept: application/json
{"title":"Write docs","description":"HTTP lesson"}
Zauważ pustą linię między nagłówkami a JSON. Bez niej wiadomość wygląda niemal tak samo, ale może działać zupełnie inaczej. HTTP nie umie czytać myśli, czyta bajty i separatory.
Warto mieć małą listę „często spotykanych" nagłówków. Nie jako encyklopedię, lecz jako orientację — żebyś nie mylił ich z query i body.
| Nagłówek | Gdzie spotykany | Po co potrzebny (krótko) |
|---|---|---|
| Host | request (HTTP/1.1) | informuje, do jakiego hosta zwraca się klient |
| Accept | request | jaki format odpowiedzi klient preferuje |
| Content-Type | request/response | jaki format ma body |
| Content-Length | request/response | rozmiar body (jeśli jest) |
| Location | response | dokąd „przeprowadził się" utworzony/przekierowany zasób |
| User-Agent | request | kto jest klientem (często do logów/analityki) |
I jeszcze jeden niuans przydatny w praktycznym myśleniu: nazwy nagłówków nie są wrażliwe na wielkość liter. Content-Type i content-type to ten sam nagłówek znaczeniowo. Ale jeśli przechowujesz je w Map, świat Javy nagle staje się wrażliwy na wielkość liter (bo to stringi). Dlatego często normalizuje się klucze.
Mini-przykład „przechowujemy nagłówki w lower-case":
import java.util.Map;
// Przykład idei: sprowadzamy nazwę nagłówka do lower-case przed wyszukiwaniem (żeby nie zależeć od wielkości liter).
String header(Map<String, String> headers, String name) {
return headers.get(name.toLowerCase());
}
Map<String, String> headers = Map.of(
"content-type", "application/json",
"accept", "application/json"
);
System.out.println(header(headers, "Content-Type")); // application/json
Ponownie — nie musisz tak pisać w każdym projekcie. Ale jako model mentalny jest to przydatne — żeby rozumieć, dlaczego w logach może być content-type, a ty szukasz Content-Type, i nic „nie znajduje się".
6. Body: dane żądania
Body (ciało wiadomości) — to miejsce, do którego trafia zwykle „treść" ważna biznesowo: JSON z danymi, tekst, dane binarne. Ale ważna uwaga: body nie jest obowiązkowe. Niektóre żądania (typowy GET) najczęściej idą bez body. Niektóre odpowiedzi też mogą nie mieć body. To normalne i nie oznacza „serwer zapomniał zwrócić JSON".
Główna rola body polega na przekazaniu danych, które nie mieszczą się w części adresowej (path/query) i które znaczeniowo są właśnie payloadem. Na przykład gdy tworzysz zadanie w Task Tracker API, logiczne jest wysłanie jego tytułu i opisu w body, a nie w nagłówkach ani w query.
Oto bardzo zwięzły przykład żądania HTTP z body:
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json
{"title":"Write docs","description":"HTTP lesson"}
Jeszcze raz podkreślam strukturę wizualną: linia startowa, nagłówki, pusta linia, ciało. Jeśli nauczysz się widzieć to wzrokiem, jesteś już w połowie drogi do tego, żeby nie „zawieszać się" na dziwnych błędach w stylu „Server returned 400, a JSON wygląda normalnie".
Jeśli chcesz wyobrazić sobie żądanie jako strukturalny obiekt, a nie jako „jakieś stringi", można zrobić najprostszy model:
import java.util.Map;
// Edukacyjny model żądania HTTP: tylko to, co ważne dla zrozumienia anatomii.
public record HttpRequest(
String method, // GET/POST/...
String target, // request-target: path + query
Map<String, String> headers, // nagłówki
String body // ciało (może być null/puste)
) {
boolean hasBody() {
// Prosta weryfikacja: ciało uważa się za „jest", jeśli nie jest null i nie jest puste.
return body != null && !body.isBlank();
}
}
To oczywiście nie jest prawdziwa implementacja klienta HTTP ani próba zastąpienia Spring. To po prostu edukacyjny „szkielet" dla głowy: w żądaniu jest method, target, headers i body, i body może nie być.
7. Odpowiedź: status line, headers i body
Po tym jak serwer przetworzy żądanie, zwraca HTTP-odpowiedź. Jest ona zbudowana prawie tak samo: pierwsza linia, nagłówki, pusta linia, ciało. Tylko pierwsza linia odpowiedzi jest inna: nie ma tam method, ale jest wersja protokołu, kod statusu i krótka fraza.
Forma pierwszej linii odpowiedzi w HTTP/1.1 wygląda tak:
HTTP/1.1 200 OK
I dalej idą nagłówki i (jeśli trzeba) ciało. Przykład odpowiedzi z listą zadań:
HTTP/1.1 200 OK
Content-Type: application/json
[{"id":"42","title":"Write docs"}]
Nawet jeśli na razie nie wiesz, co oznacza 200 ani jakie są statusy, możesz już zobaczyć strukturę. I to jest ważne: klient czyta nie tylko body. Czyta pierwszą linię (wynik) i nagłówki (warunki i format), a body to już „treść odpowiedzi", jeśli w ogóle jest.
Żeby ostatecznie „przypiąć" strukturę do pamięci, przydatny jest prosty schemat:
flowchart TD
A["Start line
Request: GET /api/v1/tasks HTTP/1.1
Response: HTTP/1.1 200 OK"] --> B["Headers
Name: Value"]
B --> C["Blank line"]
C --> D["Body (optional)
JSON / text / bytes"]
Jeśli zapamiętasz chociażby ten obrazek, będziesz miał znacznie mniej sytuacji „dlaczego Postman pokazuje błąd, a ja patrzę tylko na JSON i nie rozumiem".
8. Task Tracker API: trzy scenariusze
Teraz zbierzmy wszystko razem, żebyś zobaczył: method, path, query, headers i body — to nie osobne „terminy z podręcznika", lecz szczegóły jednego i tego samego obiektu. Wyobraźmy sobie, że ręcznie wywołujemy nasze API edukacyjne.
Scenariusz „pobierz listę zadań ze statusem TODO" (ciało tu nie jest potrzebne, żądanie to „poprosić o dane", a nie „przekazać dane"):
GET /api/v1/tasks?status=TODO&page=0 HTTP/1.1
Host: localhost:8080
Accept: application/json
Tu method — GET, path — /api/v1/tasks, query — status=TODO&page=0, headers — Host i Accept, body nieobecne. I to normalne.
Scenariusz „utwórz zadanie" (tu już potrzebne jest ciało, bo przekazujemy merytoryczne dane):
POST /api/v1/tasks HTTP/1.1
Host: localhost:8080
Content-Type: application/json
Accept: application/json
{"title":"Write docs","description":"HTTP lesson"}
Znaczeniowa różnica polega na tym, że body zawiera payload. I bez Content-Type serwerowi byłoby trudno (albo niemożliwe) poprawnie zrozumieć, co tam leży.
Scenariusz „pobierz jedno konkretne zadanie" (znowu żądanie bez body, ale z bardziej konkretnym path):
GET /api/v1/tasks/42 HTTP/1.1
Host: localhost:8080
Accept: application/json
Nawet bez Spring i bez kontrolerów widzisz już: różnica między „listą" a „jedną sztuką" przejawia się często właśnie w path, a uściślenia — w query.
9. Typowe błędy przy pracy z częściami wiadomości HTTP
Błąd №1: czytać tylko body i ignorować pierwszą linię i nagłówki.
Bardzo częsty nawyk: „jeśli widzę JSON, to wszystko OK". Ale w rzeczywistości status i nagłówki mogą całkowicie zmieniać sens tego, co się dzieje. Czasem serwer zwraca stronę HTML z błędem, a ty próbujesz parsować ją jako JSON i dziwisz się, dlaczego „Jackson się buntuje". Przydatna dyscyplina — zawsze patrzeć na wiadomość w całości: start line, headers, body.
Błąd №2: mylić path z query i zamieniać URL w bałagan.
Gdy filtry i uściślenia zaczynają „pełznąć" do path, pojawiają się brzydkie konstrukcje, które trudno czytać i utrzymywać. I odwrotnie: gdy identyfikator zasobu nagle okazuje się parametrem query, kod kliencki staje się mniej przewidywalny. Dobry nawyk: path — to „do czego się zwracamy", query — to „jak precyzujemy żądanie".
Błąd №3: próbować przekazywać dane biznesowe przez headers „bo tak prościej".
Nagłówki nie są kontenerem na title, description i inne pola domeny. Jeśli zaczniesz tam wkładać treść merytoryczną, stworzysz kontrakt trudny do dokumentowania i jeszcze trudniejszy do utrzymania. Nagłówki powinny pozostawać częścią służbową: format, oczekiwania, korelacja, techniczne warunki wymiany.
Błąd №4: zapominać o pustej linii między headers a body.
To klasyka plików .http i żądań ręcznych. Wizualnie wszystko wygląda „niemal tak samo", ale protokół potrzebuje wyraźnego separatora. Brak pustej linii — ciało może być czytane jako kontynuacja nagłówków. Wynik zazwyczaj objawia się tym, że serwer „nie widzi body", a ty siedzisz i przekonujesz świat, że „przecież tam jest".
Błąd №5: wysyłać ciało bez Content-Type lub z błędnym Content-Type.
Jeśli przekazujesz JSON, serwer musi wiedzieć, że to JSON. Bez właściwego nagłówka może próbować interpretować dane inaczej albo odmówić przetworzenia. Nawet jeśli jakiś klient „zgadnie", inny klient nie zgadnie, a kontrakt stanie się loterią.
GO TO FULL VERSION