CodeGym /Kurse /Spring REST & MVC /POST /api/v1/tasks: 201 + Location

POST /api/v1/tasks: 201 + Location

Spring REST & MVC
Level 24, Lektion 1
Verfügbar

1. Ressourcenerstellung als Vertrag: Was wir uns verpflichten zu tun

Die Erstellung einer Ressource in REST ist nicht einfach „einen Eintrag hinzufügen“, sondern eine Vereinbarung zwischen Client und Server: wohin der Client die Anfrage sendet, was genau er senden darf, welche Felder der Server selbst vergibt, welcher Status zurückkommt und nach welchen Regeln der Client die neue Ressource später findet. Wenn die Vereinbarung vage ist, schreibt der Client Behelfslösungen und der Server Rechtfertigungen.

Wenn Sie POST /api/v1/tasks machen, entwerfen Sie keine „Controllermethode“, sondern einen Vertrag zur Aufgabenerstellung. Er besteht immer aus mehreren Teilen, und wenn auch nur ein Teil „schwammig“ ist, leiden die Nutzer Ihrer API (einschließlich Ihres zukünftigen Selbst).

Fassen wir den Vertrag zur Aufgabenerstellung in einer Tabelle zusammen. Das ist keine Bürokratie – es ist eine „technische Spezifikation“, die direkt in die API eingebaut ist:

Teil des Vertrags Wie wir es im Task Tracker API machen Warum das wichtig ist
POST /api/v1/tasks Erstellen eines Elements in der Sammlung Die Ressource wird innerhalb der allgemeinen Aufgabensammlung erstellt
POST Operation „erzeuge neues“ Die HTTP-Semantik stimmt mit der Erstellungsoperation überein
TaskCreateRequest Der Client beschreibt „was er erstellen möchte“ Der Vertrag der Eingabedaten wird explizit
id, status, createdAt, updatedAt werden vom Server vergeben Server-managed Felder kommen nicht vom Client Der Client soll sich keine IDs und Daten „selbst ausdenken“
201 Created Dem Client wird explizit mitgeteilt: „neue Ressource erstellt“ Erfolgreiches Erstellen unterscheidet sich von einer normalen Antwort
/api/v1/tasks/{id} Der Client erhält die Adresse der erstellten Ressource Der Location-Header zeigt, wo das Ergebnis zu finden ist
TaskDetailsResponse (optional, aber wir verwenden es) Der Client sieht sofort die endgültige Ressource Praktisch, um sofort eine vollständige Darstellung der erstellten Aufgabe zu erhalten
ProblemDetail (400, manchmal andere) Einheitliches Fehlermodell für die gesamte API Der Client erhält ein vorhersagbares Fehlerformat

Wenn man dies im Kopf behält, wird die Implementierung geradlinig: Der Controller „erfindet“ kein Verhalten, sondern führt einfach den Vertrag aus. Und plötzlich hört man auf, im Team zu diskutieren „ob man 200 oder 201 zurückgeben soll“ – denn das ist keine Geschmackssache, sondern die Semantik der Operation.

2. POST auf die Sammlung /api/v1/tasks

Man möchte (besonders am Anfang) oft so etwas wie POST /api/v1/tasks/{taskId} machen und sagen: „Naja, der Client hat sich die id selbst ausgedacht, das ist einfacher.“ Das erscheint tatsächlich einfacher … bis zu dem Moment, wo man erklären muss, was dann PUT bedeutet, wo die Grenze zwischen Erstellen und Ersetzen liegt und wer überhaupt der Besitzer der IDs ist. In diesem Moment verfliegt die Einfachheit wie das Gehalt nach dem Kauf eines neuen Laptops „für die Arbeit“.

In unserem Kurs halten wir an der grundlegenden REST-Idee fest: Die Sammlung lebt unter einem Pfad im Plural, und die konkrete Ressource befindet sich darin unter id.

Das heißt:

– Aufgabensammlung: /api/v1/tasks
– Konkrete Aufgabe: /api/v1/tasks/{taskId}

POST arbeitet logisch mit der Sammlung: „Füge ein neues Element zur Menge hinzu.“ Der Client sagt: „Hier sind die Daten der neuen Aufgabe“, und der Server antwortet: „Okay, ich habe sie erstellt, hier ist ihre Adresse.“ Das ist die Bedeutung von Location.

Wenn Sie eine Analogie aus dem echten Leben brauchen: POST /tasks ist wie zum Bürgeramt zu gehen (in der Rolle des Servers) und zu sagen: „Bitte registrieren Sie mir dieses Dokument.“ Sie müssen keine „offizielle Dokumentennummer“ mitbringen – Sie bringen die Daten, und die Nummer und das Registrierungsdatum werden vom System vergeben. Sie sagen ja auch nicht: „Hier ist mein Pass, die Nummer habe ich mir selbst ausgedacht, so fühle ich es.“

Ein wichtiger praktischer Punkt: POST ist in der Regel nicht idempotent. Wenn der Client die Anfrage zweimal sendet (z.B. weil das Netz geflackert hat und er denkt „sicherheitshalber wiederhole ich“), kann der Server zwei identische Aufgaben erstellen. Das ist kein Fehler von Spring, sondern die normale Semantik von POST. Wir führen heute keine „Idempotency Keys“ ein (das ist eine andere Komplexitätsstufe), aber das Verständnis, dass das erneute Senden eines POST Duplikate erzeugen kann, ist eine sehr nützliche Gewohnheit.

3. TaskCreateRequest: Was der Client senden kann

Wenn jemand „Aufgabe erstellen“ sieht, ist der erste Gedanke oft: „Na, der Client soll halt ein JSON mit allen Feldern schicken, die die Aufgabe hat.“ Und das ist der klassische Weg zum Problem des Over-Postings / Mass Assignment (das haben wir bereits im Modul über DTOs besprochen): Der Client bekommt plötzlich die Möglichkeit, Dinge zu steuern, die er nicht steuern sollte.

Für die Create-Operation brauchen wir ein DTO, das einen einfachen Gedanken ausdrückt: „Hier sind die Daten, die ausreichen, damit der Server eine Aufgabe erstellt.“ Alles, was der Server selbst vergeben muss, gehört nicht in das Create-DTO.

Bei uns sind das:

– id – nur Server
– status – nur Server (bei Erstellung z.B. TODO)
– createdAt/updatedAt – nur Server
– alle „internen“ technischen Felder – erst recht nur Server

Unten sehen Sie einen guten, „lehrbuchmäßig ehrlichen“ TaskCreateRequest. Er zeigt sowohl die Idee der server-managed Felder als auch grundlegende Einschränkungen und dass einige Felder optional sein können.

// DTO zum Erstellen einer Aufgabe: nur das, was der Client senden darf.
// Wichtig: Hier gibt es KEIN id/status/createdAt/updatedAt – diese werden vom Server vergeben.
public record TaskCreateRequest(
        // Erforderlicher menschenlesbarer Name der Aufgabe
        @NotBlank @Size(min = 3, max = 120) String title,

        // Beschreibung ist nicht erforderlich, aber in der Länge begrenzt
        @Size(max = 2000) String description,

        // Priorität ist erforderlich: wir möchten nicht stillschweigend eine Aufgabe „ohne Priorität“ erstellen
        @NotNull TaskPriority priority,

        // Fälligkeitsdatum ist optional
        LocalDate dueDate,

        // Bearbeiter kann nicht angegeben sein
        @Size(max = 80) String assigneeName,

        // Beispiel für verschachtelte Validierung: Begrenzung der Anzahl der Tags und der Länge jedes Tags
        @Size(max = 10) List<@Size(min = 1, max = 30) String> tags
) {}

Hier lohnt es sich, innezuhalten und einige knifflige, aber sehr lebensnahe Punkte zu besprechen.

Erstens: title ist ein Pflichtfeld, und das spiegelt sich direkt an der Vertragsgrenze wider. Wir machen kein „leeres Title, und dann irgendwo im Service ein if“. Wenn ein Feld für die Erstellung der Ressource erforderlich ist, sollte das im DTO sichtbar sein und automatisch geprüft werden.

Zweitens: priority ist bei uns erforderlich (@NotNull), weil das Projekt einfacher lebt, wenn die Priorität definiert ist. Wenn wir einen Default wollten (z.B. MEDIUM), wäre das ebenfalls eine Vertragsentscheidung: Entweder machen wir priority optional und vergeben den Wert auf dem Server, oder wir machen es erforderlich. Wichtig ist, dass die Entscheidung explizit sein muss.

Drittens: tags ist ein gutes Beispiel dafür, wie ein Teil der Regeln in der Bean Validation lebt (maximal 10 Tags, Länge eines Tags), und ein Teil bereits fachliche/domänenspezifische Disziplin ist (Groß-/Kleinschreibung ignorierende Eindeutigkeit, Normalisierung von Leerzeichen usw.). Wir müssen heute nicht die Validierungsschicht neu erfinden – wir haben sie bereits –, aber es ist nützlich, sich der Grenze bewusst zu sein: „Eingabestruktur“ und „fachliche Bedeutung“ sind nicht dasselbe.

4. Serverseite: id, status, Zeit

Hier ist nur eines wichtig: Die Create-Logik sollte nicht im Controller verteilt sein. Der Controller nimmt TaskCreateRequest entgegen, und der Service entscheidet, welche Felder als server-managed gelten und welche Werte sie bei der Erstellung erhalten. In unserem Projekt sind das mindestens id, status, createdAt, updatedAt, und eine neue Aufgabe startet als TODO.

Aus Sicht der Schichten des Projekts ist es praktisch, diese Reihenfolge beizubehalten:

  1. Der Controller empfängt TaskCreateRequest und validiert es;
  2. Der Service erstellt das Domänenmodell Task und vergibt die server-managed Felder;
  3. Das Repository speichert im In-Memory-Speicher;
  4. Ein Mapper wandelt das Domänenmodell in TaskDetailsResponse um.

Ein kurzer Entwurf der Write-Service-Schnittstelle, um eine Vorstellung zu bekommen:

// Write-Service: verantwortlich für Zustandsänderungen des Systems (Erstellen/Aktualisieren/Löschen).
public interface TaskWriteService {
    // Gibt das DTO der bereits erstellten Aufgabe zurück (einschließlich server-managed Felder).
    TaskDetailsResponse create(TaskCreateRequest request);
}

Nun das „Herzstück“ der Erstellung. Beachten Sie: Dies ist keine „vollständige Implementierung aller Felder“, sondern ein konzentriertes Beispiel, das zeigt, wo genau die server-managed Werte entstehen.

import java.time.Instant;
import java.util.UUID;

public TaskDetailsResponse create(TaskCreateRequest request) {
    // Die ID wird vom Server generiert, nicht vom Client.
    String id = UUID.randomUUID().toString();

    // Die Zeit nehmen wir ebenfalls auf dem Server, um „Ich habe die Aufgabe 1998 erstellt“ zu vermeiden.
    Instant now = Instant.now();

    // Erstellen des Domänenmodells mit Defaults (z.B. status=TODO) und Zeitstempeln.
    Task task = Task.createNew(id, request, now); // status=TODO, createdAt=now

    // Speichern (hier im In-Memory, aber der Vertrag hängt nicht von der Speicherimplementierung ab).
    Task saved = taskRepository.save(task);

    // Abbilden des tatsächlich gespeicherten Modells auf das Antwort-DTO.
    return taskMapper.toDetailsResponse(saved);
}

Der Clou liegt nicht darin, dass wir die Logik in createNew versteckt haben (das geht auch ohne), sondern in der Disziplin: UUID und Zeit werden auf dem Server genommen, nicht aus dem JSON. Der Client sollte uns kein „Erstellungsdatum“ schicken, so wie er auch nicht sagen sollte „Mir ist es recht, dass ich die Aufgabe gestern im Jahr 1998 erstellt habe.“

Wenn Sie sehen möchten, was in Task.createNew(...) leben könnte, hier eine sehr kurze Illustration: Wir nehmen die Daten aus der Anfrage und fügen server-managed Felder hinzu.

public static Task createNew(String id, TaskCreateRequest req, Instant now) {
    // Übertragen der „Client“-Felder aus der Anfrage in das Domänenmodell.
    Task task = new Task(id, req.title());

    // Standardstatus bei der Erstellung wird auf dem Server gesetzt.
    task.setStatus(TaskStatus.TODO);

    // Zeitstempel – ebenfalls server-managed.
    task.setCreatedAt(now);
    task.setUpdatedAt(now);

    return task;
}

Ja, das sieht „zu einfach“ aus. Und das ist gut. Die Erstellung einer Ressource sollte einfach und transparent sein. Komplexität halten wir unter Kontrolle und fügen sie nur dann hinzu, wenn sie wirklich benötigt wird.

5. Controller: 201, Location und Antwortkörper

Im Create-Szenario reicht es dem Controller nicht, einfach den Service aufzurufen: Er muss auch eine ehrliche HTTP-Antwort zusammenstellen. Hier sind für uns 201 Created, Location und, falls erforderlich, der Körper mit der erstellten Aufgabe wichtig, daher ist ResponseEntity die natürliche Wahl.

Hier ist die kanonische Controller-Methode (kurz und ohne überflüssige Spielereien):

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) Geschäftsoperation im Service ausführen, nicht im Controller.
    TaskDetailsResponse created = taskWriteService.create(request);

    // 2) Location auf die erstellte Ressource basierend auf der tatsächlichen ID aus der Service-Antwort bilden.
    URI location = URI.create("/api/v1/tasks/" + created.id());

    // 3) 201 Created + Location + Körper zurückgeben (bequem für den Client).
    return ResponseEntity.created(location).body(created);
}

Hier stecken drei wichtige Ideen.

Erstens: @Valid steht genau auf dem request DTO. Das bedeutet: Wenn der Client ein ungültiges JSON sendet (z. B. leeren title), erreicht die Anfrage den Service gar nicht erst. Der Service kann also „fachlicher“ denken und sich nicht mit endlosen Prüfungen der Eingabeform befassen.

Zweitens: Location wird aus der id gebildet, die der Service zurückgegeben hat. Wir nehmen die id nicht „irgendwoher“, machen keine „Gedächtnisleistung“ und verwenden keine Werte aus dem Request-Body (weil es diese dort gar nicht geben sollte).

Drittens: Wir geben 201 Created und den Körper zurück. Dies ist eine der bequemsten Varianten für Clients: Nach der Erstellung müssen sie keinen separaten GET ausführen, um zu erfahren, „welche id dabei herausgekommen ist“ und „welche Felder der Server automatisch vergeben hat“.

Nun ein wichtiger Punkt, der später oft auftaucht: URI.create("/api/v1/tasks/...") erzeugt einen relativen URI. Das ist in einem Lehrprojekt oft akzeptabel, aber in einer realen Umgebung können Sie hinter einem Reverse Proxy, mit Anwendungskontext, HTTPS usw. sein. Daher ist eine „erwachsenere“ Methode, den Location aus der aktuellen Anfrage zu bauen.

In Spring MVC macht man das so:

import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

// Location aus der aktuellen Anfrage bauen (berücksichtigt Host/Schema/Kontextpfad hinter Proxy).
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
        // Pfad bis zur konkreten Ressource innerhalb der Sammlung ergänzen.
        .path("/{taskId}")
        // Tatsächliche ID der erstellten Aufgabe einsetzen.
        .buildAndExpand(created.id())
        .toUri();

Und dann wird Ihr Location absolut, in der Form http://localhost:8080/api/v1/tasks/{id} (oder https://... in anderen Umgebungen). Im Rahmen des Kurses reicht es, das Prinzip zu verstehen: Location muss auf die erstellte Ressource verweisen, und die Art und Weise, wie der URI zusammengebaut wird, ist ein technisches Detail, das ohne Änderung des Vertrags verbessert werden kann.

6. HTTP-Antwort: Wie man sie manuell prüft

Eine gute REST-API prüft man nicht nur mit den Augen im Code, sondern auch manuell über einen HTTP-Client. Und bei Write-Operationen ist das besonders wichtig, weil es viele „unsichtbare“ Teile gibt: Status, Header, Inhaltstyp. Wenn Sie nur prüfen, „ob im Antwort-JSON etwas steht“ – dann übersehen Sie die Hälfte des Vertrags.

So sollte eine erfolgreiche Antwort auf das Erstellen einer Aufgabe aussehen, wenn wir 201 Created, Location und JSON-Körper verwenden:

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": "Katzenfutter kaufen",
  "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"
}

Vergleichen Sie dies mit einer „typisch schlechten Antwort“, die manchmal vorkommt:

– Status 200 OK (obwohl eine neue Ressource erstellt wurde);
– Location fehlt;
– Der Körper ist unverständlich: mal „Erfolg“, mal „id“, mal nur der String "OK".

Bei uns liest sich der Vertrag buchstäblich an den Headern ab: „erstellt, hier ist die Adresse, hier ist das endgültige Modell“.

Lassen Sie uns das nun mit einer .http-Datei festhalten (oder Postman – egal, aber .http ist praktisch, um es neben dem Code zu speichern). Beispielanfrage:

### Aufgabe erstellen
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Katzenfutter kaufen",
  "description": "Und vergiss nicht das Nassfutter",
  "priority": "MEDIUM",
  "dueDate": "2026-03-25",
  "assigneeName": "Anna",
  "tags": ["home", "cat"]
}

Die Erwartungen an diese Anfrage sind einfach und prüfbar: Der Status sollte 201 sein, der Location-Header sollte auf /api/v1/tasks/{id} verweisen, und im Körper sollten die id und die anderen server-managed Felder (mindestens status, createdAt, updatedAt) enthalten sein.

Und es ist sofort nützlich, ein negatives Szenario zu prüfen – nicht weil wir heute Fehler behandeln (das haben wir bereits getan), sondern weil der Create-Vertrag auch beinhalten muss, „was passiert, wenn der Client Mist sendet“. Zum Beispiel:

### Aufgabe erstellen – ungültig
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/problem+json

{
  "title": "  ",
  "priority": "MEDIUM"
}

Hier sollten Sie 400 Bad Request und application/problem+json sehen. Und darin Ihr ProblemDetail mit code=INVALID_INPUT und Details zu den Feld-Fehlern. Der Sinn dieses Schrittes ist sehr praktisch: Der Client versteht, was er falsch gemacht hat, und kann das Feld in der UI automatisch hervorheben, anstatt nur „Serverfehler, versuchen Sie es später“ anzuzeigen.

7. Typische Fehler beim Erstellen von Aufgaben

Fehler Nr. 1: Location wird aus einem „fest codierten String“ zusammengebaut, der nicht mit dem tatsächlichen Basispfad übereinstimmt.
Manchmal ändert sich im Projekt das Präfix (/api/v1), es kommt ein Kontextpfad hinzu oder die API lebt hinter einem Proxy, und Location: /api/v1/tasks/... wird plötzlich „fast richtig“. Wenn Sie sehen, dass sich die Umgebungen unterscheiden, verwenden Sie den Aufbau des URI aus der aktuellen Anfrage über ServletUriComponentsBuilder, damit Location ehrlich bleibt.

Fehler Nr. 2: Es wird 201 Created zurückgegeben, aber der Antwortkörper widerspricht dem, was tatsächlich gespeichert wurde.
Zum Beispiel setzt der Server „in der Antwort“ status=TODO, während im gespeicherten Modell aus irgendeinem Grund IN_PROGRESS steht (oder umgekehrt). Meistens ist das die Folge davon, dass ein Teil der Logik im Mapper, ein Teil im Service und ein Teil im Controller lebt. Das wird durch Disziplin geheilt: Server-managed Felder werden an einer Stelle vergeben (Service/Domain), und die DTOs spiegeln das tatsächliche Modell wider.

Fehler Nr. 3: Die Create-Methode des Service gibt ein DTO zurück, aber der Controller baut den Location nach etwas anderem.
Wenn der Location nicht aus der id gebaut wird, die als „Wahrheit“ zurückkam (z.B. aus irgendwelchen lokalen Variablen genommen oder erneut generiert wird), erhalten Sie eine Antwort, die sich selbst widerspricht: Der Client geht zum Location und erhält 404. Ja, das kommt vor. Und ja, es sieht aus wie „Magie“. In Wirklichkeit sind es einfach zwei verschiedene „Wahrheitsquellen“.

Fehler Nr. 4: Der Create-Endpoint wird nicht manuell getestet, weil „die Tests kommen ja später“.
Selbst mit Tests ist es nützlich, einmal mit eigenen Augen zu sehen: 201, Location, Content-Type, JSON. Das dauert eine Minute, spart aber Stunden, wenn Sie plötzlich feststellen, dass dem Client ein Header fehlt oder dass der Standardstatus nicht dort gesetzt wird, wo er sollte. Ein manueller Request ist ein schneller Sanity-Check des Vertrags.

Fehler Nr. 5: „Schwimmende“ Semantik optionaler Felder.
Zum Beispiel ist tags mal null, mal [], mal fehlt es ganz, und das ändert sich von Anfrage zu Anfrage. Auf Client-Seite wird daraus „jedes Mal erraten, was angekommen ist“. Die Lösung ist nicht, alles zu verbieten, sondern zu stabilisieren: Man einigt sich darauf, wie sich das Feld in der Antwort genau verhält, und hält sich konsequent an diese Regel.

1
Aufgabe
Spring REST & MVC, Level 24, Lektion 1
Gesperrt
Aufgabe erstellen mit `201 Created` und `Location`
Aufgabe erstellen mit `201 Created` und `Location`
1
Aufgabe
Spring REST & MVC, Level 24, Lektion 1
Gesperrt
Server-verwaltete Felder beim Erstellen einer Erinnerung
Server-verwaltete Felder beim Erstellen einer Erinnerung
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION