1. HTTP jako kontrakt: żądanie → odpowiedź
Kiedy ludzie po raz pierwszy słyszą „HTTP”, często myślą: „no tak, to przecież o stronach internetowych”. I tak, strony też. Ale dla programisty HTTP to przede wszystkim bardzo ścisły sposób porozumienia między dwoma programami: jeden wysyła żądanie (client), drugi odpowiada (server). Ta umowa nazywa się kontraktem i obejmuje nie tylko „jaki URL wywołać”, ale też „jaka metoda”, „jakie kody sukcesu/błędów”, „jaki format ciała”, „co uważa się za poprawne dane wejściowe”.
W Go jest to szczególnie przyjemne: Go historycznie jest bardzo mocnym językiem do serwerów i kodu sieciowego. Nawet w materiałach o ewolucji Go podkreśla się, że Go projektowano do pisania serwerów.
Mentalny model „koperty”
Wyobraź sobie, że HTTP to poczta.
- List = żądanie (request).
- Koperta = metadane (metoda, ścieżka, nagłówki).
- Treść listu = ciało żądania (na przykład JSON).
- List zwrotny = odpowiedź (response).
- Znaczek na odpowiedzi = kod statusu (200, 404, 500…), który krótko i jednoznacznie mówi „co się wydarzyło”.
Brzmi trochę staroświecko, ale jako model mentalny działa idealnie: serwer nie „zgaduje po nastroju”, tylko odpowiada formalnie.
Schemat żądania i odpowiedzi
sequenceDiagram
participant C as Client
participant S as Server
C->>S: HTTP Request (method + path + headers + body)
S->>S: validate + compute
S-->>C: HTTP Response (status + headers + body)
Najważniejsza myśl z tego schematu: na wyjściu zawsze jest kod statusu, a ciało odpowiedzi może być, ale nie musi. I klient powinien umieć żyć według kodu statusu, a nie próbować „parsować tekst błędu wzrokiem”.
Endpoint: metoda + ścieżka
Żeby przestać mówić o HTTP jak o magii, wygodnie jest wprowadzić mały termin: endpoint — to konkretne „miejsce wejścia” do API. I składa się on z dwóch części: metody HTTP (co robimy) i ścieżki (z czym pracujemy).
Na przykład, „pobierz listę zadań” często wygląda jak GET /api/v1/tasks.
Teraz nie uruchamiamy serwera i nie piszemy handlerów. Robimy to, co robią dojrzali inżynierowie na początku projektu: ustalamy kontrakt. I do tego można nawet napisać parę struktur w Go — nie dlatego, że „trzeba kodu”, ale po to, żeby myśli stały się precyzyjniejsze.
Mini-typ do opisu endpointu
package main
type Endpoint struct {
Method string
Path string
}
var listTasks = Endpoint{Method: "GET", Path: "/api/v1/tasks"}
var createTask = Endpoint{Method: "POST", Path: "/api/v1/tasks"}
Tu ważna jest nie „ładność kodu”, lecz dyscyplina myślenia: endpoint to dokładnie para (method, path). Jeśli zmieniasz metodę, zmieniasz sens. Jeśli zmieniasz ścieżkę, zmieniasz zasób.
2. Metody HTTP: sens działania i obietnice dla klienta
O metodach wygodnie myśleć jak o czasownikach z bardzo ścisłymi zasadami. Jeśli używasz metod „jak popadnie”, klient musi zgadywać zachowanie, a API zaczyna przypominać quest „zgadnij przycisk”. Jeśli natomiast trzymasz się standardowej semantyki, klient (i inni programiści) rozumieją zachowanie bez czytania źródeł serwera. Oszczędza to godziny, nerwy i liczbę wiadomości „a dlaczego tu jest 200, skoro to błąd?”.
Poniżej — praktyczna tabela, którą naprawdę trzyma się w głowie podczas projektowania API.
Tabela podstawowej semantyki metod
| Metoda | Pomysł (po ludzku) | Zwykle o czym | Idempotentność (idea) |
|---|---|---|---|
|
„Daj dane” | odczyt | tak |
|
„Utwórz / wykonaj działanie” | tworzenie/komenda | zwykle nie |
|
„Zastąp całość” | pełna podmiana zasobu | tak |
|
„Zmień częściowo” | częściowa zmiana | zwykle tak (jeśli zaprojektowane ostrożnie) |
|
„Usuń” | usuwanie | tak |
Kilka komentarzy, żeby nie wyglądało to jak sucha teoria.
GET prawie zawsze jest bezpieczny w tym sensie, że nie powinien zmieniać stanu na serwerze. Jeśli GET nagle tworzy zadanie, pobiera pieniądze i wysyła list do mamy, to nie jest GET, tylko scenariusz do horroru.
POST często używa się do tworzenia: „utwórz zadanie”, „utwórz użytkownika”. Ale czasem POST to „komenda”, która nie mieści się w „zastąp stan”. Na przykład: „uruchom przeliczenie raportu”. W naszym szkolnym domenie zadań będziemy starać się traktować POST jako „utwórz zadanie”, bo tak jest prościej.
PUT i PATCH: jeśli nie jesteś pewien, nie śpiesz się z ich użyciem. Ale rozumieć sens trzeba: PUT to „oto nowa wersja zasobu w całości”, PATCH to „oto mała zmiana”.
DELETE — usuń. I tu zaczyna się filozofia: co znaczy „usunąć”, jeśli zasób już został usunięty? Zwykle umowę robi się tak, aby powtórzenie DELETE nie psuło systemu.
3. Kody statusu: język odpowiedzi serwera bez zgadywania z tekstu
Jeśli metoda to „czego chcemy”, to kod statusu to „co się udało”. I to nie jest drugorzędny szczegół, tylko część kontraktu: klient pisze swoją logikę, kierując się kodami. W dobrym API klient nie musi „czytać ciągu błędu” i zgadywać, co miałeś na myśli. Widzi 404 i rozumie „nie znaleziono”. Widzi 400 i rozumie „coś wysłałem nie tak”.
Jest tu bardzo wygodny model mentalny: klasy kodów.
- 2xx — sukces,
- 4xx — problem w żądaniu (wina klienta albo przynajmniej klient może to naprawić),
- 5xx — problem po stronie serwera (klient nie jest winny).
Mała funkcja „klasa kodu”
package main
func statusClass(code int) int {
return code / 100
}
// 200 -> 2
// 404 -> 4
// 500 -> 5
Tak, to szkolna matematyka. Ale pomaga: widzisz statusClass == 4 — to znaczy „błąd żądania”. Widzisz statusClass == 5 — „coś się u nas zepsuło”.
Minimalny zestaw kodów, który trzeba rozumieć pewnie
W ramach naszego przyszłego API zadań wystarczy nam mała „podstawowa piątka” plus jeden:
| Kod | Nazwa | Kiedy zwykle się używa | Czy jest ciało odpowiedzi |
|---|---|---|---|
|
OK | udany odczyt/zmiana | zwykle tak |
|
Created | zasób utworzony | zwykle tak (często zwracany jest utworzony obiekt) |
|
No Content | sukces bez ciała | nie (i to jest ważne!) |
|
Bad Request | nieprawidłowe dane wejściowe/format/walidacja | zwykle tak (błąd w JSON) |
|
Not Found | zasób nie został znaleziony | zwykle tak |
|
Internal Server Error | wewnętrzny błąd serwera | zwykle tak (ale komunikat jest stabilny i bezpieczny) |
Kluczowy niuans dotyczący 204: to nie jest „200, ale bez nastroju”. To jasny sygnał: „sukces, ciała nie ma”. Jeśli zwrócisz 204 i jednocześnie wyślesz JSON, niektórzy klienci po prostu go zignorują, a niektórzy zaczną zachowywać się nieprzewidywalnie. Czyli sam sobie zrobisz błąd, a potem będziesz go bohatersko naprawiać.
Szybki helper „czy to sukces?”
package main
func is2xx(code int) bool {
return code >= 200 && code <= 299
}
4. Idempotentność: powtórzyłeś żądanie — świat nie stał się gorszy
Słowo „idempotentność” brzmi tak, jakby wymyślono je po to, żeby straszyć studentów na kolokwiach. W praktyce idea jest bardzo prosta: jeśli powtórzysz żądanie, efekt końcowy nie powinien się zmienić. To nie znaczy, że „nic się nie zmienia”. To znaczy: „powtórzenie nie doda zbędnego efektu”.
Przykład z życia: przycisk „usuń zadanie”. Jeśli użytkownik kliknął go dwa razy (albo sieć się zacięła i klient powtórzył żądanie), zadanie powinno pozostać usunięte, a nie „usunąć się dwa razy” (cokolwiek by to miało znaczyć).
Idempotentność jest ważna przez realny świat: sieć jest niestabilna, połączenia się zrywają, klient może nie dostać odpowiedzi i uznać, że trzeba powtórzyć żądanie. Jeśli projektujesz API tak, by powtórzenie było bezpieczne, zmniejszasz liczbę dziwnych duplikatów i „widmowych” stanów.
Surowa reguła jako pierwsze przybliżenie
Często przyjmuje się tak: uznaje się, że GET/PUT/PATCH/DELETE powinny być idempotentne, a POST — nie (bo „utworzyć” dwa razy = dwa zasoby). To nie jest absolutna prawda, ale dobra reguła startowa.
package main
func isIdempotent(method string) bool {
switch method {
case "GET", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
Mała demonstracja, żeby to „poczuć”
package main
import "fmt"
func main() {
fmt.Println(isIdempotent("GET")) // true
fmt.Println(isIdempotent("POST")) // false
fmt.Println(isIdempotent("DELETE")) // true
}
To oczywiście nie jest „prawdziwe bezpieczeństwo API”. Ale to dobry sposób, żeby przestać mylić idempotentność z „nic nie zmienia”.
5. Wiążemy to z domeną: szkic kontraktu HTTP dla zadań
Teraz zrobimy ważny krok: przełożymy abstrakcyjne słowa HTTP na konkretny słownik naszej aplikacji. Od dawna żyjemy w domenie zadań (tasks): dodajemy zadanie, oglądamy listę, oznaczamy jako wykonane. W stylu HTTP chcemy, aby ścieżka opisywała byt, a metoda opisywała działanie.
Uzgodnijmy, że API wersjonujemy przez ścieżkę. Wygląda to tak: /api/v1/.... Wersja w ścieżce to sposób powiedzenia: „kontrakt został ustalony, a jeśli go złamiemy, zrobimy to w innej wersji, a nie nagle w piątek wieczorem”.
Tabela endpointów jako szkic kontraktu
| Znaczenie operacji | Metoda | Ścieżka | Sukces |
|---|---|---|---|
| Lista zadań | |
|
|
| Utwórz zadanie | |
|
|
| Pobierz zadanie | |
|
|
| Oznacz jako „done” | |
|
200 (albo 204, jeśli zdecydujemy się bez ciała) |
| Usuń zadanie | |
|
|
Zwróć uwagę: na razie nie omawiamy formatów JSON ani jednego formatu błędów (to też jest część kontraktu, ale to osobny temat). Tutaj ćwiczymy „szkielet”: metoda, ścieżka, kod sukcesu — żeby nie było u nas POST /getTask i GET /deleteTask. Piszemy API tak, żeby dało się je czytać jak angielski, no może jak bardzo dziwny angielski.
Trochę kodu, żeby utrwalić metody i nie pisać ciągów ręcznie
Ręczne ciągi "GET" i "POST" działają, ale łatwo o pomyłkę. Dlatego nawet na wczesnym etapie warto wprowadzić stałe.
package main
type Method string
const (
MethodGet Method = "GET"
MethodPost Method = "POST"
MethodPut Method = "PUT"
MethodPatch Method = "PATCH"
MethodDelete Method = "DELETE"
)
A teraz endpointy można już opisywać „typowo”:
package main
type Endpoint struct {
Method Method
Path string
}
var listTasks = Endpoint{Method: MethodGet, Path: "/api/v1/tasks"}
var deleteTask = Endpoint{Method: MethodDelete, Path: "/api/v1/tasks/{id}"}
6. Typowe błędy przy projektowaniu HTTP API
Błąd nr 1: zmienianie stanu przez GET.
Zwykle dzieje się to „dla wygody”: „no bo ja tylko otworzę przez link...”. Ale GET to odczyt. Jeśli GET zmienia stan, psujesz cache, psujesz powtarzane żądania i zaskakujesz wszystkich klientów. W pewnym momencie trafisz na błąd „u nas zadanie tworzy się samo” — i nie będzie to magia, tylko GET.
Błąd nr 2: zawsze odpowiadanie 200 i ukrywanie błędów w tekście.
Czasem robi się tak: status 200, a wewnątrz JSON pole "error": "...". Dla klienta to koszmar: musi parsować ciało, żeby zrozumieć, czy to sukces, czy nie. Lepiej, żeby sukces był 2xx, błąd żądania był 4xx, a wewnętrzny błąd serwera był 5xx. Ciało błędu może być strukturalne, ale kod jest najważniejszy.
Błąd nr 3: mylenie 400 i 404.
Jeśli klient wysłał id = "abc" i nie możesz odczytać go jako liczby — to nie jest „nie znaleziono”, tylko „nieprawidłowe żądanie”, czyli 400. A 404 to sytuacja, gdy id jest poprawne, ale takiego zasobu nie ma. To rozróżnienie bardzo ułatwia życie klientom i testom.
Błąd nr 4: zwracanie ciała odpowiedzi przy 204.
204 oznacza „sukces, ale bez ciała”. Jeśli mimo to wyślesz JSON, część klientów go zignoruje. Powstanie API, które „czasem działa”. A „czasem działa” to szczególny rodzaj bólu, bo nie ma czego debugować: przecież ono „czasem”.
Błąd nr 5: brak myślenia o idempotentności i powtórzeniach.
W realnym świecie żądania się powtarzają. Jeśli projektujesz operacje tak, że powtórzenie DELETE nagle zamienia się w 500, albo powtórzenie PATCH tworzy duplikujące się zmiany, klienci zaczną bać się retry, a ty będziesz łapać „dziwne” stany. Idempotentność to nie ozdoba, tylko zabezpieczenie przed sieciową rzeczywistością.
GO TO FULL VERSION