CodeGym /Kursy /Java Server /Endpoint /health: kontrakt

Endpoint /health: kontrakt

Java Server
Poziom 22, Lekcja 3
Dostępny

1. /health: sprawdzamy „serwer żyje"

Gdy dopiero podnosisz serwer, mózg bardzo chce uwierzyć w stringa w logu „Server started…". Ale rzeczywistość backendowa jest sroga: proces może żyć, a endpoint może nie odpowiadać, albo odpowiadać nie tym, albo w ogóle wisieć. /health — to nasz pierwszy uczciwy „ping" przez HTTP, który sprawdza dokładnie to, dla czego podnosiliśmy HttpServer: zdolność przyjąć żądanie i zwrócić poprawną odpowiedź.

Po jednym start() serwer już żyje jako proces, ale z zewnątrz wciąż jest niemy. Potrzebna jest chociaż jedna ścieżka, która udowodni: kontekst jest zarejestrowany, handler się odpala, a odpowiedź wychodzi po HTTP, a nie tylko do logu.

W realnych systemach /health często używa się do automatycznych sprawdzeń (monitoring, load balancery, kontenery i wszystko inne dorosłe). Dzisiaj tam nie wchodzimy, ale ideę bierzemy: potrzebny jest endpoint, który nie zależy od logiki biznesowej, by był stabilnym punktem sprawdzenia. Dla projektu edukacyjnego to też psychologicznie miłe: widzisz, że serwer odpowiada JSON-em, więc kolejny poziom z routingiem i żądaniami przychodzącymi nie będzie już „w próżnię".

By utrzymać porządek w projekcie, warto od razu zdecydować, gdzie będzie żył ten endpoint. Logicznie trzymać go obok startu lokalnego API, a nie wewnątrz feature readinglist, bo /health nie dotyczy listy do czytania, lecz aplikacji jako serwisu.

Mała orientacja po plikach (wariant zgodny z naszą strukturą):

Plik: /src/main/java/com/example/readlater/

# Struktura folderu, w którym żyje lokalny serwer API
# (drzewo ASCII, by oczami łatwiej "ogarnąć" rozmieszczenie plików)
src/main/java/com/example/readlater
├─ app/server
│  ├─ HealthResponse.java
│  └─ LocalApiServer.java
└─ config
   └─ ServerConfig.java

2. Kontrakt /health: status i JSON

Gdy mówią „endpoint odpowiada", początkujący często myślą tylko o body: „no przyszedł tekst, znaczy w porządku". Ale kontrakt HTTP składa się z kilku części, a my już nie jesteśmy na poziomie „byle pokazać coś w przeglądarce". /health powinien odpowiadać przewidywalnie: poprawnym statusem, poprawnym Content-Type i JSON-em o ustalonej formie, by każdy klient (Postman, przeglądarka, twój własny HttpClient) rozumiał, co mu przysłano.

Ustalmy bardzo prosty kontrakt. Będziemy zwracać 200 OK i JSON postaci:

{
  "status": "UP",
  "appName": "readlater-starter"
}

Tutaj status — to pole techniczne, a appName — przydatny marker, że konfiguracja została wczytana i odpowiadamy właśnie tą aplikacją, której oczekujemy. Jeśli kiedyś będą u ciebie równolegle dwa serwisy na różnych portach, szybko zaczniesz cenić takie drobiazgi.

Wygodnie raz zobaczyć kontrakt w tabelce, by nie trzymać wszystkiego w głowie:

Część odpowiedzi Co zwracamy Dlaczego to ważne
Status code 200 Klient nie musi zgadywać: „żywy" = sukces
Header Content-Type application/json; charset=utf-8 Klient rozumie, że body — to JSON i jak go zdekodować
Body JSON z status i appName Dane w przewidywalnej formie, a nie „przypadkowy string"

Zwróć uwagę na charset. Tak, JSON domyślnie zwykle jest UTF-8, ale teraz uczymy się pisać kontrakt jawnie, by nie zależeć od domysłów klienta i od „ustawień domyślnych" na konkretnej maszynie.

3. DTO odpowiedzi: HealthResponse

Bardzo się chce (szczególnie po kilku dniach Java Core) zrobić tak: „a po co, zwrócę po prostu stringa". Albo „a wsadzę do Map parę pól". Zadziała to naprawdę… do pierwszego momentu, gdy dodasz trzecie pole, albo zechcesz wykorzystać odpowiedź ponownie, albo porównać oczekiwany JSON z faktycznym.

Dlatego robimy dokładnie to samo, co robiliśmy wcześniej dla modeli transportowych: wprowadzamy mały response DTO. W Javie 25 record — to idealny format dla takich obiektów: krótko, zrozumiale i bez zbędnych getterów/setterów. A jeszcze, co przyjemne, ObjectMapper z record dogaduje się normalnie (i wygląda to schludnie).

Zróbmy klasę-nośnik dla odpowiedzi:

package com.example.readlater.app.server;

// DTO odpowiedzi endpointu /health.
// Tu nie ma logiki: to część kontraktu API (struktura JSON).
public record HealthResponse(String status, String appName) {
}

Zwróć uwagę, jak jest „nieważki": dwa pola i tyle. Nie ma tu logiki biznesowej, nie ma walidacji, nie ma metod. To czysty model odpowiedzi, czyli część kontraktu.

Jeśli chce się trochę więcej dyscypliny, można umówić się, że status to zawsze "UP" właśnie stringiem. W bardziej rygorystycznym świecie zrobilibyśmy enum albo stałą, ale teraz ważniejsze jest opracowanie web-mechaniki, a nie spieranie się ze stringami.

4. JSON przez ObjectMapper

Zbudować JSON konkatenacją stringów — to jak naprawiać laptopa młotkiem: czasem rzeczywiście pomaga, ale potem dziwisz się, dlaczego klawisze odbiły się na ścianie. Błędy tam są niemal gwarantowane: cudzysłowy, escapowanie, przecinki, null, kodowania. A jeszcze jest to po prostu niemiłe do czytania.

Dlatego dla /health od razu używamy tego samego narzędzia, które już jest w projekcie: ObjectMapper. Potrafi on serializować nasz HealthResponse do JSON-a. Ważne, że ObjectMapper zwykle tworzy się raz i używa wielokrotnie: jest thread-safe do odczytu/zapisu (jeśli nie zmieniasz ustawień w locie), a jego tworzenie nie jest darmową operacją.

Pokażę najpierw ideę na mini-przykładzie (próżniowo), a potem wbudujemy w handler.

import com.fasterxml.jackson.databind.ObjectMapper;

// Nasze DTO, które chcemy zwrócić klientowi
HealthResponse dto = new HealthResponse("UP", "readlater-starter");

// ObjectMapper zwykle tworzy się raz i używa wielokrotnie,
// ale dla "próżniowego" przykładu utworzymy go tutaj.
ObjectMapper mapper = new ObjectMapper();

// Serializacja DTO do JSON (stringiem)
String json = mapper.writeValueAsString(dto);

// json będzie mniej więcej taki:
// {"status":"UP","appName":"readlater-starter"}

Ale w realnym kodzie serwerowym wygodniej pracować nie ze stringiem, lecz od razu z bajtami. Bo HttpExchange i tak ostatecznie wysyła bajty, a my potrzebujemy poprawnej długości odpowiedzi. ObjectMapper ma przyjemną metodę writeValueAsBytes(...).

5. Odpowiedź przez HttpExchange

HttpExchange — to twoja „koperta pocztowa" na jedno żądanie. Leży w niej żądanie i przez nią też musisz wysłać odpowiedź. Najważniejsze, co tutaj trzeba zapamiętać: odpowiedź ma kolejność składania. Jeśli pomylisz kroki, serwer albo padnie, albo wyśle dziwną odpowiedź, albo klient zawiśnie w oczekiwaniu.

Logika jest taka: przygotowujesz body (w bajtach), ustawiasz nagłówki, wysyłasz status i długość przez sendResponseHeaders(...), a potem piszesz bajty do getResponseBody() i koniecznie zamykasz strumień. Zamknięcie strumienia — to w istocie „kropka", po której klient może uznać odpowiedź za zakończoną. Jak w życiu: dopóki list nie jest zaklejony, adresat dostaje tylko przeciąg.

Zbudujmy działający handler /health. Pokażę go jako metodę handleHealth(...), wywoływaną z context-handlera. To nie nowa równoległa klasa, lecz kolejna wersja tego samego LocalApiServer: teraz nie tylko bind'uje się na adres, ale i potrafi oddać pierwszą techniczną odpowiedź JSON.

Plik: /src/main/java/com/example/readlater/app/server/LocalApiServer.java

import com.example.readlater.config.ServerConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.OutputStream;

public final class LocalApiServer {
    private final ObjectMapper objectMapper;

    public LocalApiServer(ObjectMapper objectMapper) {
        // ObjectMapper przekazujemy z zewnątrz, by nie tworzyć go przy każdym żądaniu
        this.objectMapper = objectMapper;
    }

    private void handleHealth(HttpExchange exchange, ServerConfig config) throws IOException {
        // 1) Przygotowujemy DTO odpowiedzi (część kontraktu)
        HealthResponse response = new HealthResponse("UP", config.appName());

        // 2) Serializujemy do bajtów, by dokładnie znać długość odpowiedzi
        byte[] body = objectMapper.writeValueAsBytes(response);

        // 3) Ustawiamy nagłówki PRZED sendResponseHeaders
        exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");

        // 4) Deklarujemy status i długość odpowiedzi
        exchange.sendResponseHeaders(200, body.length);

        // 5) Piszemy body i obowiązkowo zamykamy strumień (try-with-resources)
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(body);
        }
    }
}

Ten kawałek kodu to prawie cała „sercówka" dzisiejszego tematu. Tutaj wprost rękami widać, co potem schowa framework: status, nagłówki, serializacja, długość, zapis do strumienia.

Przegadajmy ważne miejsca.

objectMapper.writeValueAsBytes(response) daje nam bajty JSON-a (zwykle to UTF-8). Od razu znamy długość body.length, czyli możemy poprawnie wywołać sendResponseHeaders(200, body.length). Jeśli długość nie zgodzi się z tym, co faktycznie wysłałeś, klient może dostać obciętą odpowiedź albo zawisnąć (on czeka na więcej bajtów, a ty już zamknąłeś strumień).

exchange.getResponseHeaders() ustawia nagłówek. Jeśli o nim zapomnieć, Postman i przeglądarka często mimo wszystko pokażą body, ale kod klienta może zacząć traktować odpowiedź jako text/plain i zachowywać się nie tak, jak oczekujesz. Dla edukacyjnego /health to nie jest śmiertelne, ale właśnie wyrabiamy nawyki, a nie tylko „działa u mnie".

try (OutputStream os = exchange.getResponseBody()) — to wprost must-have dla początkującego. Zamykasz strumień gwarantowanie, nawet jeśli w trakcie coś poszło nie tak. Bez zamknięcia czasem „niby wszystko działa", a czasem klient czeka i czeka, jakbyś obiecał mu jeszcze parę kilobajtów sensu życia.

By utrwalić kolejność, oto bardzo prosty schemat:

flowchart TD
    %% Minimalny "rytuał" budowy odpowiedzi w HttpServer
    %% Ważne: nagłówki i sendResponseHeaders idą przed zapisem body
    A["Utwórz DTO HealthResponse"] --> B["Serializuj do JSON bytes"]
    B --> C["Ustaw Content-Type"]
    C --> D["sendResponseHeaders(status, length)"]
    D --> E["Zapisz bytes do responseBody"]
    E --> F["Zamknij OutputStream"]

Można odbierać to jako minimalny „rytuał" dowolnego endpointu na HttpServer: zmieniają się DTO i status, ale kroki prawie zawsze są te same.

Od tego momentu spór z kodem nie ma sensu — trzeba go już sprawdzać zewnętrznym żądaniem. Klient powinien zobaczyć dokładnie te status, headers i body, które przed chwilą zebraliśmy ręcznie.

6. Podłączamy /health

Sam handler — to połowa sprawy. Druga połowa to sprawić, by serwer w ogóle wiedział, że ścieżka /health istnieje i jaki kod powinien ją obsłużyć. W HttpServer robi się to przez createContext(path, handler). Widzieliśmy już tę ideę na poprzednim wykładzie, a teraz dopracowujemy ją do pełnowartościowej odpowiedzi JSON.

Ważne, by nie zsunąć się w chaos, w którym handlery tworzą sobie ObjectMapper wewnątrz, a appName jest zahardkodowany stringiem "my-app" (i potem łapiesz duchy w logach). Dlatego zależności przekazujemy jawnie: LocalApiServer dostaje ObjectMapper w konstruktorze, a ServerConfig przychodzi do start() i jest przekazywany dalej do handleHealth().

Pokażę minimalny fragment start(), gdzie rejestruje się /health:

Plik: /src/main/java/com/example/readlater/app/server/LocalApiServer.java

import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;

public HttpServer start(ServerConfig config) throws IOException {
    // Adres i port bierzemy z konfiguracji (nie hardkodujemy)
    InetSocketAddress address = new InetSocketAddress(config.host(), config.port());
    HttpServer server = HttpServer.create(address, 0);

    // Rejestrujemy endpoint /health i przekazujemy config do handlera
    server.createContext("/health", exchange -> handleHealth(exchange, config));

    // Uruchamiamy serwer
    server.start();
    return server;
}

Tutaj endpoint na razie jest przypisany tylko po ścieżce. Czyli dowolne żądanie na /health trafi do tego handlera; osobne sprawdzanie metody nie jest nam teraz potrzebne. Dla tego kawałka ważne jest co innego: ścieżka jest zarejestrowana, handler jest wywoływany, a poprawny JSON wychodzi na zewnątrz.

Zwróć uwagę: tutaj /health czyta się wprost oczyma. To ważne na starcie. Na razie nie budujemy routera, nie wprowadzamy „tablicy tras" i nie robimy mini-Springa w pojedynkę. Naszym celem jest, by student mógł otworzyć plik i zrozumieć: oto ścieżka, oto handler, oto co robi.

Jeśli chce się zrobić kod jeszcze trochę czytelniejszym, można wynieść rejestrację /health do osobnej metody registerHealthEndpoint(server, config). Ale to już kwestia gustu — najważniejsze, by mechanizm był przejrzysty.

7. Typowe błędy przy pracy z /health

Błąd nr 1: składanie JSON-a ręcznie stringami.
Prawie zawsze zaczyna się od niewinnego "{\"status\":\"UP\"}", a kończy tym, że pół dnia szukasz „zbędnego przecinka" albo nagle łamiesz escapowanie, gdy w appName pojawia się myślnik, spacja albo polska litera. Nawet dla dwóch pól lepiej od razu używać ObjectMapper i nie zamieniać /health w konkurs na najbardziej kruche zaklęcie stringowe.

Błąd nr 2: zapomnieć ustawić Content-Type albo ustawić „prawie JSON".
Jeśli zwrócisz JSON, ale zostawisz Content-Type: text/plain, wielu klientów mimo to pokaże zawartość, ale nie wszyscy będą traktować ją jako dane JSON. Szczególnie jeśli później będziesz sprawdzać kontrakt programowo. application/json; charset=utf-8 — to prosty nawyk, który oszczędza czas na dziwne „dlaczego parser się krzywi".

Błąd nr 3: pomylić kolejność sendResponseHeaders(...) i zapisu body.
HttpExchange ma jasne oczekiwanie: najpierw deklarujesz status i długość, potem piszesz body. Jeśli zaczniesz pisać body wcześniej, możesz dostać wyjątek albo „zepsutą" odpowiedź. To jeden z tych błędów, które szczególnie irytują, bo wyglądają jak „no przecież po prostu napisałem do strumienia".

Błąd nr 4: niepoprawna długość w sendResponseHeaders(...).
Jeśli przekazałeś długość, która nie odpowiada faktycznie wysłanym bajtom, klient może dostać obcięty JSON albo zawisnąć. Dlatego albo zawczasu zdobywaj byte[] i używaj body.length, albo (gdy już pewnie się czujesz) używaj trybu chunked-odpowiedzi. Na bieżącym etapie najbezpieczniejsza i najbardziej zrozumiała droga — body.length.

Błąd nr 5: nie zamknąć exchange.getResponseBody() (albo zapomnieć o try-with-resources).
Czasem kod „niby działa", bo JVM zamyka zasoby przy zakończeniu wątku albo przy końcu żądania. Ale czasem klient czeka na zakończenie odpowiedzi, a ty nie postawiłeś „kropki" — nie zamknąłeś strumienia. try-with-resources nie jest tu po prostu „pięknem", lecz gwarancją, że odpowiedź zostanie poprawnie zakończona.

1
Zadanie
Java Server, poziom 22, lekcja 3
Niedostępne
JSON endpoint `/health` z `appName`
JSON endpoint `/health` z `appName`
1
Zadanie
Java Server, poziom 22, lekcja 3
Niedostępne
JSON endpoint `/health` z danymi aplikacji
JSON endpoint `/health` z danymi aplikacji
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION