1. Tworzenie zasobu jako kontrakt: co się zobowiązujemy zrobić
Tworzenie zasobu w REST to nie tylko „dodaj rekord", ale umowa między klientem a serwerem: dokąd klient wysyła żądanie, co dokładnie może przysłać, jakie pola serwer przypisze sam, jaki status zostanie zwrócony i według jakich reguł klient potem znajdzie nowy zasób. Jeśli umowa jest mętna, klient pisze protezy, a serwer – usprawiedliwienia.
Gdy robisz POST /api/v1/tasks, nie projektujesz „metody kontrolera", a kontrakt tworzenia zadania. Ma zawsze kilka części, i jeśli choć jedna część jest „płynna", użytkownicy twojego API (łącznie z tobą w przyszłości) zaczynają cierpieć.
Ujmijmy kontrakt tworzenia zadania w jedną tabelę. To nie biurokracja – to „specyfikacja techniczna" wbudowana bezpośrednio w API:
| Część kontraktu | Jak to robimy w Task Tracker API | Dlaczego to ważne |
|---|---|---|
| POST /api/v1/tasks | tworzymy element w kolekcji | zasób jest tworzony wewnątrz wspólnego zestawu zadań |
| POST | operacja „utwórz nowy" | semantyka HTTP pokrywa się z operacją tworzenia |
| TaskCreateRequest | klient opisuje „co chce stworzyć" | kontrakt danych wejściowych staje się jawny |
| id, status, createdAt, updatedAt przypisuje serwer | pola zarządzane przez serwer nie przychodzą od klienta | klient nie powinien „rysować sobie" identyfikatorów i dat |
| 201 Created | klientowi jawnie mówi się: „stworzyliśmy nowy zasób" | pomyślne tworzenie różni się od zwykłej odpowiedzi |
| /api/v1/tasks/{id} | klient otrzymuje adres utworzonego zasobu | nagłówek Location wskazuje, gdzie znaleźć wynik |
| TaskDetailsResponse (opcjonalne, ale używamy) | klient od razu widzi gotowy zasób | wygodne jest od razu otrzymać pełną reprezentację stworzonego zadania |
| ProblemDetail (400, czasem inne) | jednolity model błędów dla całego API | klient otrzymuje przewidywalny format błędu |
Jeśli mieć to w głowie, implementacja staje się prosta: kontroler nie „wymyśla" zachowania, a po prostu realizuje kontrakt. I nagle przestajesz spierać się w zespole „zwracać 200 czy 201" – bo to nie kwestia gustu, a semantyka operacji.
2. POST po kolekcji /api/v1/tasks
Bardzo chce się (szczególnie na początku) zrobić coś w stylu POST /api/v1/tasks/{taskId} i powiedzieć: „no klient sam wymyślił id, tak prościej". To naprawdę wydaje się prostsze… dokładnie do momentu, gdy próbujesz wyjaśnić, co wtedy oznacza PUT, gdzie granica między tworzeniem a zamianą i kto w ogóle jest właścicielem identyfikatorów. W tym momencie prostota ulatuje jak wypłata po zakupie nowego laptopa „do pracy".
W naszym kursie trzymamy podstawową ideę REST: kolekcja żyje po ścieżce w liczbie mnogiej, a konkretny zasób – wewnątrz niej po id.
To znaczy:
— kolekcja zadań: /api/v1/tasks
— konkretne zadanie: /api/v1/tasks/{taskId}
POST logicznie działa właśnie z kolekcją: „dodaj nowy element do zestawu". Klient mówi: „oto dane nowego zadania", a serwer odpowiada: „ok, stworzyłem je, oto jego adres". I to jest właśnie sens Location.
Jeśli potrzebujesz analogii z życia, POST /tasks to jak przyjść do urzędu (który w roli serwera) i powiedzieć „proszę zarejestrować taki oto dokument". Nie musisz przynosić „oficjalnego numeru dokumentu" – przynosisz dane, a numer i data rejestracji są przypisywane przez system. Nie mówisz: „oto mój paszport, numer sam wymyśliłem, tak czuję".
Ważna praktyczna uwaga: POST zwykle nie jest idempotentny. Jeśli klient wyśle żądanie dwa razy (np. sieć mignęła i postanowił „na wszelki wypadek powtórzyć"), serwer może stworzyć dwa identyczne zadania. To nie jest bug Springa, to normalna semantyka POST. Nie będziemy dziś wprowadzać „idempotency keys" (to już inny poziom złożoności), ale rozumienie, że powtarzający się POST może tworzyć duplikaty – to bardzo przydatny nawyk.
3. TaskCreateRequest: co klient może przysłać
Gdy człowiek widzi „utwórz zadanie", pierwsza myśl często jest taka: „no niech klient przyśle JSON ze wszystkimi polami, które zadanie ma". I to klasyczna droga do problemu over-posting / mass assignment (omawialiśmy to już w module o DTO): klient nagle dostaje możliwość zarządzania tym, czym nie powinien.
Do operacji create potrzebujemy DTO, który wyraża prostą myśl: „oto dane wystarczające, żeby serwer stworzył zadanie". Wszystko, czym serwer musi zarządzać sam, w create DTO nie wchodzi.
U nas to:
— id – tylko serwer
— status – tylko serwer (przy tworzeniu np. TODO)
— createdAt/updatedAt – tylko serwer
— wszelkie „wewnętrzne" techniczne pola – tym bardziej tylko serwer
Poniżej – dobry, „edukacyjnie uczciwy" TaskCreateRequest. Pokazuje zarówno ideę pól zarządzanych przez serwer, jak i podstawowe ograniczenia, oraz to, że niektóre pola mogą być opcjonalne.
// DTO do tworzenia zadania: tylko to, co klient ma prawo przysłać.
// Ważne: tutaj NIE MA id/status/createdAt/updatedAt – przypisuje je serwer.
public record TaskCreateRequest(
// Obowiązkowa czytelna dla człowieka nazwa zadania
@NotBlank @Size(min = 3, max = 120) String title,
// Opis nieobowiązkowy, ale ograniczony co do długości
@Size(max = 2000) String description,
// Priorytet obowiązkowy: nie chcemy cicho tworzyć „bez priorytetu" domyślnie
@NotNull TaskPriority priority,
// Termin opcjonalny
LocalDate dueDate,
// Wykonawca może być nieokreślony
@Size(max = 80) String assigneeName,
// Przykład zagnieżdżonej walidacji: ograniczenie na liczbę tagów i długość każdego tagu
@Size(max = 10) List<@Size(min = 1, max = 30) String> tags
) {}
Tutaj warto się zatrzymać i omówić kilka subtelnych, ale bardzo życiowych momentów.
Po pierwsze, title to pole obowiązkowe, i jest to odzwierciedlone bezpośrednio na granicy kontraktu. Nie robimy „pusta nazwa, a potem gdzieś w serwisie if". Jeśli pole jest obowiązkowe do tworzenia zasobu, widać to w DTO i jest weryfikowane automatycznie.
Po drugie, priority jest u nas obowiązkowe (@NotNull), bo projektowi łatwiej żyć, gdy priorytet jest określony. Gdybyśmy chcieli wartości domyślnej (np. MEDIUM), to też byłoby decyzją kontraktu: albo robimy priority opcjonalne i przypisujemy wartość na serwerze, albo robimy obowiązkowe. Ważne jest właśnie to, że decyzja powinna być jawna.
Po trzecie, tags to dobry przykład tego, jak część reguł żyje w Bean Validation (maksymalnie 10 tagów, długość tagu), a część – to już przedmiotowa / domenowa dyscyplina (unikalność bez uwzględnienia wielkości liter, normalizacja spacji itd.). Nie musisz dziś wymyślać warstwy walidacji od nowa – już ją mamy, ale pamiętanie o granicy jest przydatne: „struktura wejścia" i „sens przedmiotowy" to nie to samo.
4. Część serwerowa: id, status, czas
Tutaj ważna jest tylko jedna rzecz: logika create nie powinna rozlewać się po kontrolerze. Kontroler przyjmuje TaskCreateRequest, a serwis decyduje, które pola są zarządzane przez serwer i jakie wartości otrzymują przy tworzeniu. W naszym projekcie to przynajmniej id, status, createdAt, updatedAt, a nowe zadanie startuje jako TODO.
Z punktu widzenia warstw projektu wygodnie trzymać taki porządek:
- kontroler przyjmuje TaskCreateRequest i waliduje go;
- serwis tworzy model domenowy Task i przypisuje pola zarządzane przez serwer;
- repozytorium zapisuje do in-memory storage;
- mapper zamienia model domenowy w TaskDetailsResponse.
Mini-szkic interfejsu write-serwisu, żeby mieć w głowie kształt:
// Write-serwis: odpowiada za zmianę stanu systemu (tworzenie/aktualizacja/usuwanie).
public interface TaskWriteService {
// Zwracamy DTO już stworzonego zadania (łącznie z polami zarządzanymi przez serwer).
TaskDetailsResponse create(TaskCreateRequest request);
}
Teraz samo „serce" tworzenia. Zwróć uwagę: to nie „pełna implementacja wszystkich pól", a skoncentrowany przykład pokazujący, gdzie dokładnie rodzą się wartości zarządzane przez serwer.
import java.time.Instant;
import java.util.UUID;
public TaskDetailsResponse create(TaskCreateRequest request) {
// Identyfikator generuje serwer, nie klient.
String id = UUID.randomUUID().toString();
// Czas też bierzemy na serwerze, żeby uniknąć „stworzyłem zadanie w 1998 r.".
Instant now = Instant.now();
// Tworzymy model domenowy z domyślnymi wartościami (np. status=TODO) i timestamps.
Task task = Task.createNew(id, request, now); // status=TODO, createdAt=now
// Zapisujemy (tutaj to in-memory, ale kontrakt nie zależy od implementacji przechowywania).
Task saved = taskRepository.save(task);
// Mapujemy faktycznie zapisany model do DTO odpowiedzi.
return taskMapper.toDetailsResponse(saved);
}
Sztuczka tutaj nie polega na tym, że schowaliśmy logikę w createNew (można i bez niej), ale w dyscyplinie: UUID i czas bierze się na serwerze, a nie z JSON. Klient nie powinien „przysyłać nam daty tworzenia", jak nie powinien przysyłać „wygodne mi, że stworzyłem zadanie wczoraj w 1998 r.".
Jeśli chcesz zobaczyć, co mogłoby żyć wewnątrz Task.createNew(...), oto bardzo krótka ilustracja: bierzemy dane z żądania i dodajemy pola zarządzane przez serwer.
public static Task createNew(String id, TaskCreateRequest req, Instant now) {
// Przenosimy pola „klienckie" z żądania do modelu domenowego.
Task task = new Task(id, req.title());
// Domyślny status przy tworzeniu ustawiamy na serwerze.
task.setStatus(TaskStatus.TODO);
// Timestamps – też zarządzane przez serwer.
task.setCreatedAt(now);
task.setUpdatedAt(now);
return task;
}
Tak, wygląda to „zbyt prosto". I to dobrze. Tworzenie zasobu powinno być proste i przejrzyste. Złożoność trzymamy pod kontrolą i dodajemy tylko wtedy, gdy naprawdę jest potrzebna.
5. Kontroler: 201, Location i ciało odpowiedzi
W scenariuszu create kontroler musi nie tylko wywołać serwis: musi jeszcze złożyć uczciwy response HTTP. Tu ważne są 201 Created, Location i ewentualnie ciało z stworzonym zadaniem, więc ResponseEntity to naturalny wybór.
Oto kanoniczny method kontrolera (krótki i bez zbędnych trików):
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import java.net.URI;
@PostMapping
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// 1) Robimy operację biznesową w serwisie, a nie w kontrolerze.
TaskDetailsResponse created = taskWriteService.create(request);
// 2) Budujemy Location na stworzony zasób po faktycznym id z odpowiedzi serwisu.
URI location = URI.create("/api/v1/tasks/" + created.id());
// 3) Zwracamy 201 Created + Location + ciało (wygodne dla klienta).
return ResponseEntity.created(location).body(created);
}
Są tu trzy ważne idee.
Pierwsza: @Valid stoi właśnie na request DTO. Oznacza to, że jeśli klient przysłał nieprawidłowy JSON (np. pusty title), żądanie nawet nie dotrze do serwisu. A więc serwis może myśleć bardziej „przedmiotowo" i nie zajmować się nieskończoną weryfikacją kształtu wejścia.
Druga: Location jest budowany z id, który zwrócił serwis. Nie bierzemy id „skądinąd", nie robimy „z pamięci", nie używamy wartości z request body (bo ich tam nie powinno być).
Trzecia: zwracamy 201 Created i ciało. To jeden z najwygodniejszych dla klientów wariantów: po stworzeniu nie muszą robić oddzielnego GET, żeby dowiedzieć się „jaki id wyszedł" i „jakie pola serwer przypisał automatycznie".
Teraz ważny niuans, który często pojawia się później: URI.create("/api/v1/tasks/...") tworzy względny URI. To często akceptowalne w projekcie edukacyjnym, ale w prawdziwym środowisku możesz okazać się za reverse proxy, z context path, z HTTPS itd. Dlatego bardziej „dorosłym" sposobem jest budowanie Location od bieżącego żądania.
W Spring MVC robi się to tak:
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
// Budujemy Location od bieżącego żądania (uwzględnia host/scheme/context path za proxy).
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
// Dobudowujemy ścieżkę do konkretnego zasobu wewnątrz kolekcji.
.path("/{taskId}")
// Podstawiamy faktyczny id stworzonego zadania.
.buildAndExpand(created.id())
.toUri();
Wtedy twój Location staje się absolutny, w postaci http://localhost:8080/api/v1/tasks/{id} (lub https://... w innym środowisku). W ramach kursu wystarczy rozumieć zasadę: Location powinien prowadzić do stworzonego zasobu, a sposób budowania URI to szczegół inżynierski, który można ulepszać bez zmiany kontraktu.
6. Odpowiedź HTTP: jak sprawdzić ręcznie
Dobry REST API sprawdza się nie tylko oczami w kodzie, ale i ręcznie przez klienta HTTP. I dla operacji write jest to szczególnie ważne, bo jest tam wiele „niewidocznych" części: status, nagłówki, typ treści. Jeśli sprawdzasz tylko „w odpowiedzi przyszedł JSON" – pomijasz połowę kontraktu.
Oto jak powinna wyglądać pomyślna odpowiedź na tworzenie zadania, jeśli używamy 201 Created, Location i ciała JSON:
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://localhost:8080/api/v1/tasks/6c5b9d4e-9f7c-4f7c-9e87-0b4f5aa1b2d3
{
"id": "6c5b9d4e-9f7c-4f7c-9e87-0b4f5aa1b2d3",
"title": "Kupić karmę kotu",
"status": "TODO",
"priority": "MEDIUM",
"dueDate": "2026-03-25",
"assigneeName": "Anna",
"tags": ["home", "cat"],
"createdAt": "2026-03-21T10:15:30Z",
"updatedAt": "2026-03-21T10:15:30Z"
}
Porównajmy to z „typową złą odpowiedzią", która czasem się pojawia:
— status 200 OK (choć stworzono nowy zasób);
— brak Location;
— ciało nie wiadomo co: to „sukces", to „id", to wręcz ciąg "OK".
U nas kontrakt czyta się dosłownie po nagłówkach: „stworzyliśmy, oto adres, oto gotowy model".
Teraz utrwalmy przez plik .http (lub Postman – bez różnicy, ale .http wygodnie trzymać obok kodu). Przykładowe żądanie:
### Create task
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Kupić karmę kotu",
"description": "I nie zapomnieć o mokrej",
"priority": "MEDIUM",
"dueDate": "2026-03-25",
"assigneeName": "Anna",
"tags": ["home", "cat"]
}
Oczekiwania co do tego żądania są proste i weryfikowalne: status powinien być 201, nagłówek Location powinien wskazywać na /api/v1/tasks/{id}, a w ciele powinny być id i pozostałe pola zarządzane przez serwer (przynajmniej status, createdAt, updatedAt).
I od razu warto sprawdzić jeden scenariusz negatywny – nie dlatego, że dziś uczymy się błędów (już to przerobiliśmy), ale dlatego że kontrakt create musi obejmować „co będzie, jeśli klient przysłał bzdury". Na przykład:
### Create task - invalid
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/problem+json
{
"title": " ",
"priority": "MEDIUM"
}
Tu powinieneś zobaczyć 400 Bad Request i application/problem+json. A wewnątrz – twój ProblemDetail z code=INVALID_INPUT i szczegółami field errors. Sens tego kroku jest bardzo praktyczny: klient rozumie, co zrobił nie tak, i może automatycznie podświetlić pole w UI, a nie po prostu wyświetlić „błąd serwera, spróbuj ponownie".
7. Typowe błędy przy tworzeniu zadań
Błąd nr 1: Location jest budowany z „zakodowanego ciągu", który nie pokrywa się z rzeczywistą ścieżką bazową.
Czasem w projekcie zmienia się prefix (/api/v1), dodaje się context path lub API zaczyna żyć za proxy, i Location: /api/v1/tasks/... nagle staje się „prawie poprawne". Jeśli widzisz, że środowiska się różnią, użyj budowania URI od bieżącego żądania przez ServletUriComponentsBuilder, żeby Location pozostawał uczciwy.
Błąd nr 2: zwracane jest 201 Created, ale ciało odpowiedzi przeczy temu, co faktycznie zostało zapisane.
Na przykład serwer „w odpowiedzi" ustawia status=TODO, a w zapisanym modelu z jakiegoś powodu IN_PROGRESS (lub odwrotnie). Najczęściej wynika to z tego, że część logiki żyje w mapperze, część w serwisie, część w kontrolerze. Leczy się to dyscypliną: pola zarządzane przez serwer są przypisywane w jednym miejscu (serwis / domena), a DTO odzwierciedlają faktyczny model.
Błąd nr 3: w metodzie create serwis zwraca DTO, ale kontroler buduje Location na podstawie czegoś innego.
Jeśli Location jest budowany nie z tego id, który wrócił „jako prawda" (np. bierzony jest z jakichś lokalnych zmiennych lub generowany ponownie), otrzymujesz odpowiedź, która sama sobie przeczy: klient idzie do Location, a tam 404. Tak, zdarza się. I tak, wygląda jak „magia". W rzeczywistości – po prostu dwa różne „źródła prawdy".
Błąd nr 4: create-endpointu nie sprawdza się ręcznie, bo „no testy będą potem".
Nawet przy testach warto raz zobaczyć na własne oczy: 201, Location, Content-Type, JSON. To zajmuje minutę, ale oszczędza godziny, gdy nagle okazuje się, że klientowi brakuje nagłówka, albo że status jest domyślnie ustawiany nie tam. Ręczne żądanie to szybki sanity check kontraktu.
Błąd nr 5: „płynna" semantyka opcjonalnych pól.
Na przykład tags raz jest null, raz [], raz w ogóle nieobecny, i zmienia się z żądania na żądanie. Na poziomie klienta zamienia się to w „za każdym razem zgadnij, co przyszło". Rozwiązanie nie polega na zakazaniu wszystkiego, a na ustabilizowaniu: uzgodnij, jak dokładnie pole zachowuje się w odpowiedzi, i trzymaj się tej reguły konsekwentnie.
GO TO FULL VERSION