1. HttpRequest jako „paszport” żądania
Kiedy początkujący pisze klienta HTTP, często myśli tak: „Znam już URL, więc żądanie prawie gotowe”. Ale w rzeczywistości URL to tylko adres. Aby żądanie stało się żądaniem, musimy ustalić jeszcze metodę, nagłówki i, jeśli to nie GET, to także body. W Javie obiekt HttpRequest pełni właśnie rolę „paszportu” wywołania: jest w nim wprost zapisane, o co prosimy serwer i w jakiej formie.
Patrząc na to „z lotu ptaka”, HttpRequest to coś, co najpierw starannie składasz, a potem oddajesz do client.send(...). Dlatego im czytelniej wygląda budowa żądania, tym mniej masz zagadek poziomu „dlaczego serwer mnie nie rozumie”.
Mały schemat (bez prób stania się podręcznikiem RFC):
flowchart TD
%% Ważne: sieć pojawia się dopiero na etapie send, do tego momentu po prostu "składamy paszport" żądania
A["URI (dokąd idziemy)"] --> B["HttpRequest.Builder (jak dokładnie idziemy)"]
B --> C["HttpRequest (ustalona konfiguracja)"]
C --> D["HttpClient.send(...) (próba pójścia do sieci)"]
D --> E["HttpResponse (status + headers + body)"]
Zwróć uwagę: sieć pojawia się dopiero na etapie send. Wszystko, co przed tym — to nasze decyzje, i wygodnie widzieć je wprost w kodzie.
Minimalny „szkielet” żądania wygląda tak:
import java.net.URI;
import java.net.http.HttpRequest;
// Dokąd idziemy: to tylko adres, nie "gotowe żądanie"
URI uri = URI.create("https://catalog.example/search?q=java");
// Składamy "paszport" żądania: metoda + (potem w razie potrzeby) nagłówki i body
HttpRequest request = HttpRequest.newBuilder(uri)
.GET() // Jawnie ustalamy metodę
.build(); // Po build() żądanie staje się niemodyfikowalne
Tak, to już jest żądanie. Ale na razie zbyt „nieme”: nie ma w nim umowy co do formatu odpowiedzi, a w realnym projekcie takie żądania szybko zamieniają się w „działa… dopóki działa”.
2. HttpRequest.Builder: budowanie żądania
W API HttpClient bardzo udanie zrobiono coś, co początkującym zazwyczaj się podoba: żądanie składa się przez builder, a to oznacza, że można je czytać z góry na dół jak mały scenariusz. W każdej linii odpowiadasz na pytanie: dokąd idziemy, czego oczekujemy w odpowiedzi, czym (jeśli trzeba) obciążamy żądanie, jaką metodę wybieramy. To szczególnie przydatne w projekcie edukacyjnym, gdzie celowo nie chowamy mechaniki za frameworkami.
Ważny moment: HttpRequest po build() staje się niemodyfikowalny. To znaczy, że żądanie jest „zamrożone” i przypadkiem go nie zepsujesz. A HttpRequest.Builder to rzecz tymczasowa: złożyliśmy, zbudowaliśmy, zapomnieliśmy. Nie próbuj „reużywać buildera na wszystkie okazje życia”, bo dość szybko złapiesz nieoczywisty bałagan: gdzieś dodałeś nagłówek, a on niespodziewanie „przykleił się” do innego żądania.
Typowa czytelna budowa żądania GET w naszym stylu:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=clean+code");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Umawiamy się co do formatu odpowiedzi
.GET() // Ustalamy metodę
.build(); // "Zamrażamy" konfigurację żądania
Tu już widać trzy rzeczy: dokąd idziemy, co chcemy dostać, jaką metodą idziemy. To w zasadzie nasz mały „kontrakt” klienta z serwerem, tylko od strony klienta.
3. GET: nagłówek Accept i „uprzejme” oczekiwanie odpowiedzi JSON
Do tej pory często pracowaliśmy z odpowiedzią jak z surowym stringiem, i to normalne na start. Ale nawet gdy czytamy body jako string, nadal ważne, by umówić się z serwerem, jakiego formatu odpowiedzi się spodziewamy. W HTTP służy do tego nagłówek Accept. Oznacza on: „Serwerze, jeśli potrafisz, odpowiedz mi w takim formacie”.
W praktyce niektóre API zawsze odpowiadają JSON-em i ignorują Accept. Ale w projekcie edukacyjnym ważny jest nawyk: Accept to część normalnego kontraktu HTTP. Plus bywają API, które potrafią odpowiadać i JSON-em, i czymś jeszcze (np. HTML albo XML), i tam Accept realnie decyduje o tym, co dostaniesz.
Kontynuujmy nasz scenariusz wyszukiwania książek w katalogu. Załóżmy, że już zbudowaliśmy URI (z wykładu 1) i teraz robimy żądanie:
import java.net.URI;
import java.net.http.HttpRequest;
// W URI są już zaszyte parametry query (to część adresu)
URI uri = URI.create("https://catalog.example/search?q=clean+code&limit=5");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Prosimy o JSON (jeśli serwer potrafi)
.GET() // GET bez body — normalny scenariusz
.build();
Zwróć uwagę: nie ustawiamy Content-Type dla GET. Dlaczego? Bo Content-Type opisuje ciało żądania, a w GET ciała zwykle nie ma. Tak, bywają egzotyczne przypadki „GET z body”, ale jeśli nie chcesz cierpieć — nie trzeba. Budujemy przewidywalnego klienta, a nie rebus.
Czasem warto dodać jeszcze User-Agent. Niektóre zewnętrzne serwisy traktują go kapryśnie, a czasem po nim łatwiej zdiagnozować, że żądanie przyszło właśnie od naszej aplikacji. Ale nie zamieniaj nagłówków w kolekcję znaczków: dodajemy tylko to, co rozumiemy.
Na przykład tak:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=java");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Co chcemy dostać
.header("User-Agent", "readlater-starter/1.0") // Kim jesteśmy (do diagnostyki i kapryśnych API)
.GET()
.build();
4. Accept i Content-Type: różnice
Mylenie Accept i Content-Type to prawie obowiązkowy „rytuał inicjacji” w HTTP. Nic strasznego: te nagłówki rzeczywiście mają podobny wygląd, ale ich sens jest różny. By przestać mylić, wygodnie trzymać w głowie prostą myśl: jeden nagłówek mówi o tym, co chcesz dostać, a drugi — o tym, co wysyłasz.
Można nawet wyobrazić sobie dialog:
- Accept: „Umiem przyjąć JSON, proszę odpowiedz JSON-em”.
- Content-Type: „Wysyłam ci JSON. Nie myśl, że to plain text albo coś innego”.
I to działa zarówno dla żądań, jak i dla odpowiedzi. W odpowiedzi serwera Content-Type oznacza: „Oto w jakim formacie leży body odpowiedzi”.
Zbierzmy różnice w krótką tabelkę (realnie oszczędza czas na starcie):
| Nagłówek | Gdzie występuje | O czym jest | Przykład |
|---|---|---|---|
| Accept | w żądaniu klienta | jaki format odpowiedzi klient preferuje/potrafi | Accept: application/json |
| Content-Type | w żądaniu klienta | w jakim formacie client wysyła body | Content-Type: application/json |
| Content-Type | w odpowiedzi serwera | w jakim formacie server zwraca body | Content-Type: application/json; charset=utf-8 |
Jeśli chcesz całkiem życiowej analogii, to Accept to jak fraza „poproszę kawę bez mleka”, a Content-Type to naklejka na pojemniku „w środku zupa”. Niby oba o jedzeniu, ale jeden o oczekiwaniu, drugi o faktycznej zawartości.
5. POST z body JSON: BodyPublishers.ofString
POST dziś używamy w celach edukacyjnych, bo w realnych katalogach książek wyszukiwanie często robione jest GET-em z parametrami query. Ale wysyłanie body JSON to na tyle częsta historia w integracjach backendowych, że nie da się przejść obok niej obojętnie. Nawet jeśli dziś nie będziesz realnie wysyłać POST do naszego zewnętrznego katalogu, mechanika musi stać się znajoma: metoda + headers + body.
W HttpRequest body zadaje się przez BodyPublisher. Najprostsza opcja to wysłać string, czyli BodyPublishers.ofString(...). I tu pojawiają się dwa praktyczne niuanse: string JSON trzeba zbudować bez błędów, i trzeba ustawić Content-Type: application/json.
W Javie 25 bardzo pomagają nam text blocks. Oszczędzają nerwy, bo nie trzeba escape'ować każdego cudzysłowu w JSON. Zamiast „cudzysłowowego piekła” piszemy prawie normalny JSON.
Przykład „edukacyjnego żądania”, które wysyła JSON na umowny endpoint /preview:
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.nio.charset.StandardCharsets;
String baseUrl = "https://catalog.example";
// Ciało żądania: JSON wygodnie trzymać jako text block, żeby nie escape'ować cudzysłowów
String json = """
{
"query": "clean code",
"limit": 5
}
""";
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/preview"))
.header("Accept", "application/json") // Chcemy dostać odpowiedź JSON
.header("Content-Type", "application/json") // Informujemy, że wysyłamy JSON
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // Ustalamy method + body + kodowanie
.build();
Tu ważne, by zobaczyć „szkielet” żądania POST na własne oczy:
1) POST(...) robi żądanie właśnie POST-em i jednocześnie ustawia body.
2) Content-Type mówi serwerowi, że wewnątrz body jest JSON.
3) Accept mówi serwerowi, że chcemy dostać JSON w odpowiedzi.
Jeśli zapomnisz Content-Type, niektóre serwery będą próbowały interpretować body jako „nie wiadomo co” i dostaniesz błąd, który wygląda jak „serwer jest zepsuty”. Tak naprawdę po prostu nie zrozumiał, co mu wysłałeś.
Jeszcze drobiazg: wiele API oczekuje Content-Type: application/json; charset=utf-8. We współczesnym świecie JSON prawie zawsze jest w UTF‑8, i wiele serwerów rozumie to domyślnie. Ale jeśli widzisz dziwne problemy z cyrylicą lub znakami specjalnymi, warto wskazać charset jawnie. W naszych przykładach zostawimy application/json dla prostoty, a kodowanie zapewnimy przez ofString(...).
6. Powtarzające się nagłówki: mały helper
Kiedy robisz dwa-trzy żądania, wszystko wydaje się proste. Ale gdy tylko pojawia się „wyszukiwanie” i „szczegóły”, a potem jeszcze parę wariantów, nagle odkrywasz, że w każdym miejscu kopiujesz to samo: Accept, czasem User-Agent, czasem Content-Type. Kopiuj-wklej w kodzie transportowym jest niebezpieczny nie filozoficznie, a bardzo praktycznie: jedna linia gdzieś uciekła — i „z jakiegoś powodu jedno żądanie działa, a inne nie”.
Przy tym nie chcemy budować „uniwersalnego frameworka żądań”. Potrzebujemy małego, uczciwego helpera, by budowa żądania stała się krótsza, ale nie straciła czytelności.
Najprostszy wariant — metoda, która zwraca skonfigurowany HttpRequest.Builder dla odpowiedzi JSON:
import java.net.URI;
import java.net.http.HttpRequest;
private static HttpRequest.Builder jsonRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Bazowa umowa: chcemy JSON
.header("User-Agent", "readlater-starter/1.0");// Bazowa identyfikacja klienta
}
Wtedy GET staje się całkiem czytelny:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=java");
HttpRequest request = jsonRequest(uri) // Bierzemy bazowe nagłówki (Accept + User-Agent)
.GET() // Dodajemy metodę dla konkretnego żądania
.build();
A POST — też:
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.nio.charset.StandardCharsets;
// Krótki JSON też można trzymać jako text block, by nie kombinować z escape'owaniem
String json = """
{
"query": "java",
"limit": 3
}
""";
HttpRequest request = jsonRequest(URI.create("https://catalog.example/preview"))
.header("Content-Type", "application/json") // Dla POST z body to ważne: co dokładnie wysyłamy
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // Body + kodowanie
.build();
Zauważ, że nie próbujemy ukrywać „POST z body” gdzieś w głębinach. Po prostu usunęliśmy powtarzające się kawałki, a sens żądania nadal czyta się w 5–6 linijkach.
7. Typowe błędy przy budowie HttpRequest
Błąd nr 1: mylić Accept i Content-Type.
Najczęstsza historia wygląda tak: robisz POST, ustawiasz Accept: application/json, myślisz „no powiedziałem o JSON” i dziwisz się, dlaczego serwer narzeka na body. Serwerowi jest wszystko jedno, co chcesz dostać w odpowiedzi; jemu trzeba zrozumieć, co wysłałeś. Po to istnieje Content-Type.
Błąd nr 2: wysłać POST bez body (albo z pustym body) przypadkiem.
Czasem chce się napisać po prostu .POST(...), ale „co tam podać” nie wiadomo, i ręka sięga do czegoś typu BodyPublishers.noBody(). Formalnie to możliwe, ale w scenariuszach JSON zwykle bez sensu: serwer czekał na JSON, a ty wysłałeś „nic”. Jeśli scenariusz naprawdę o JSON, body musi być jawne.
Błąd nr 3: złożyć JSON stringiem z cudzysłowami i złamać go bez powodu.
String typu "{\"query\":\"clean code\"}" wygląda jak kara za grzechy z poprzedniego życia. Łatwo w nim zapomnieć o backslashu, zamknąć cudzysłów nie tam i dostać „nieprawidłowy JSON”. W projekcie edukacyjnym lepiej albo używać Java text block (""" ... """), albo trzymać JSON krótki i sprawdzalny.
Błąd nr 4: ustawić Content-Type na GET „na wszelki wypadek”.
Content-Type opisuje ciało żądania. W GET ciała nie ma, i nagłówek staje się dziwnym szumem. Czasem serwery są cierpliwe, ale czasem trafisz na API, które robi ścisłą walidację i zaczyna się zachowywać nieprzewidywalnie. „Na wszelki wypadek” w HTTP to częsta droga do niespodzianek.
Błąd nr 5: próbować reużyć jeden HttpRequest.Builder dla różnych żądań.
Builder wygodnie wygląda jak „obiekt-konfigurator” i pojawia się myśl: „stworzę jeden, potem będę zmieniać URI”. W praktyce szybko zapominasz, jakie nagłówki już dodałeś, i dostajesz losowy zestaw headers w różnych żądaniach. Twórz builder od nowa dla każdego żądania, a wspólne minimum wynoś do małego helpera (jak zrobiliśmy wyżej).
GO TO FULL VERSION