Zum Hauptinhalt springen

AB 02 – Antworten gestalten

Die Situation

Das Frontend-Team hat begonnen, das Gästebuch anzubinden — und meldet ein Problem zurück:

„Wenn wir einen Eintrag anlegen, bekommen wir zwar 201 zurück, aber wir wissen nicht, unter welcher Adresse der neue Eintrag erreichbar ist. Wir müssen die URL im Frontend selbst zusammenbauen. Und wenn ihr die Pfade mal ändert, geht das kaputt."

Eine berechtigte Beschwerde. Sehen wir uns an, warum das mit deinem bisherigen Code nicht zu lösen ist.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • begründen, warum @ResponseStatus für manche Antworten nicht ausreicht
  • ResponseEntity einsetzen, um Statuscode, Kopfzeilen und Rumpf gemeinsam festzulegen
  • nach dem Anlegen einer Ressource einen korrekten Location-Header liefern
  • den passenden Statuscode für jede Operation wählen und begründen

Das Problem

Was eine gute Fehlerantwort ausmacht und welcher Statuscode wann gilt, steht im Infoblatt Fehlerantworten. Hier baust du sie zum ersten Mal.

Dein POST-Endpunkt sieht so aus:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public GuestbookEntry createEntry(@RequestBody GuestbookEntry entry) {
return repository.save(entry);
}

Die Annotation @ResponseStatus kann genau eine Sache: den Statuscode festlegen. Und zwar fest, für alle Aufrufe gleich.

Was sie nicht kann:

  • eine Kopfzeile setzen — etwa Location mit der Adresse des neuen Eintrags
  • den Statuscode abhängig von der Situation wählen
Was Location soll

Beim Anlegen einer Ressource gehört laut HTTP-Standard die Adresse der neuen Ressource in die Antwort:

HTTP/1.1 201 Created
Location: http://localhost:8080/api/v1/guestbook-entries/1

Der Client muss dann nichts zusammenbauen — er liest die fertige Adresse ab. Ändert sich später der Pfad, funktioniert er trotzdem weiter.

Der Rückgabewert deiner Methode ist ein GuestbookEntry. Damit ist der Rumpf beschrieben — für Status und Kopfzeilen bleibt kein Platz. Genau diese Lücke schließt ResponseEntity.

ResponseEntity: die ganze Antwort in einem Objekt

Eine HTTP-Antwort besteht aus drei Teilen. ResponseEntity fasst alle drei zusammen:

ResponseEntityStatuscode201 Createdwas ist passiert?KopfzeilenLocation: /api/v1/guestbook-entries/1Angaben über die AntwortRumpfder Eintrag als JSONder Inhalt

@ResponseStatus konnte nur den obersten der drei Kästen füllen.

Statt des Objekts gibt die Methode nun eine ResponseEntity um das Objekt zurück:

public GuestbookEntry createEntry(...) // vorher
public ResponseEntity<GuestbookEntry> createEntry(...) // nachher

Der Typ in den spitzen Klammern sagt, was im Rumpf steckt.

Aufgaben

Aufgabe 1 – Den POST-Endpunkt umbauen

@PostMapping
public ResponseEntity<GuestbookEntry> createEntry(@RequestBody GuestbookEntry entry) {
GuestbookEntry saved = repository.save(entry);

URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(saved.getId())
.toUri();

return ResponseEntity.created(location).body(saved);
}

Die Annotation @ResponseStatus entfällt — der Status steckt jetzt in ResponseEntity.created(...).

Warum die Adresse nicht einfach zusammensetzen?

Man könnte schreiben:

URI location = URI.create("http://localhost:8080/api/v1/guestbook-entries/" + saved.getId()); // ❌

Das funktioniert genau so lange, bis die Anwendung auf einem echten Server unter einem anderen Namen läuft. ServletUriComponentsBuilder.fromCurrentRequest() nimmt die Adresse der aktuellen Anfrage als Ausgangspunkt und hängt die Id an. Damit stimmt sie immer.

Du müsstest sehen:

HTTP/1.1 201
Location: http://localhost:8080/api/v1/guestbook-entries/1

Aufgabe 2 – Die übrigen Endpunkte

Jetzt lassen sich alle Antworten einheitlich gestalten. ResponseEntity bietet für die gängigen Fälle fertige Methoden:

AufrufErgebnis
ResponseEntity.ok(objekt)200 OK mit Rumpf
ResponseEntity.created(uri).body(objekt)201 Created mit Location
ResponseEntity.noContent().build()204 No Content, kein Rumpf
ResponseEntity.notFound().build()404 Not Found
build() oder body()?

body(...) hängt einen Rumpf an. build() schließt die Antwort ohne Rumpf ab — das braucht man bei 204, wo es nichts zurückzugeben gibt.

Wenn du nicht weiterkommst – so sieht der Endpunkt aus
@GetMapping
public ResponseEntity<List<GuestbookEntry>> findAllEntries() {
return ResponseEntity.ok(repository.findAll());
}

Der Rückgabetyp ändert sich mit: aus List<GuestbookEntry> wird ResponseEntity<List<GuestbookEntry>> — die Liste steckt jetzt in der Antwort drin, statt selbst die Antwort zu sein.

Eine leere Liste ist übrigens kein Fehlerfall: 200 mit [] ist die richtige Antwort auf „gib mir alle Einträge", wenn es keine gibt. Ein 404 würde behaupten, es gebe die Sammlung nicht.

@GetMapping("/{id}")
public ResponseEntity<GuestbookEntry> findEntryById(@PathVariable Long id) {
return repository.findById(id)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Gästebucheintrag mit der Id " + id));
}
Diese drei Zeilen genauer angesehen

findById() kennst du aus dem ersten Tutorial: Es liefert kein Objekt, sondern ein Optional — einen Behälter, in dem ein Eintrag liegen kann oder eben nicht.

Derselbe Aufruf nimmt deshalb zwei ganz verschiedene Wege:

Eintrag gefundenOptionalder Eintrag.mapOptional200-AntwortorElseThrow200 OKnichts gefundenOptionalleer.mapOptionalimmer noch leerorElseThrow404 Not Founddie Ausnahme wird geworfenwird gar nicht ausgeführt

Der Unterschied steckt allein im Behälter: .map läuft nur, wenn etwas drin ist.

Ausgeschrieben täte der Endpunkt genau dasselbe:

Optional<GuestbookEntry> found = repository.findById(id);

if (found.isPresent()) {
return ResponseEntity.ok(found.get());
}
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Gästebucheintrag mit der Id " + id);

Die kurze Fassung sagt dasselbe — mit drei Schreibweisen, die du so noch nicht benutzt hast.

.map(...) — mach etwas mit dem Inhalt, falls einer da ist. Liegt ein Eintrag im Behälter, wird er umgewandelt und wieder eingepackt. Ist der Behälter leer, bleibt er leer — dann wird gar nichts ausgeführt. Aus dem Eintrag wird so eine fertige 200-Antwort, die weiterhin im Behälter steckt. orElseThrow packt sie am Ende aus.

Der Pfeil -> — eine Funktion ohne Namen. () -> new ResponseStatusException(...) heißt: „nimm nichts entgegen und liefere eine neue Ausnahme". Du übergibst hier keinen fertigen Wert, sondern eine Vorschrift, die nur dann ausgeführt wird, wenn sie gebraucht wird — bei einer unbekannten Id. Diese Schreibweise heißt Lambda-Ausdruck; im ersten Tutorial stand sie schon einmal da, ohne dass sie einen Namen bekommen hätte.

Der Doppel-Doppelpunkt :: — dasselbe noch kürzer. ResponseEntity::ok ist die Kurzform von entry -> ResponseEntity.ok(entry) und heißt: „ruf damit ResponseEntity.ok auf". Das nennt man eine Methodenreferenz.

Beide Fassungen sind richtig, und die ausgeschriebene ist nicht schlechter. Die kurze steht hier, weil sie dir in Spring-Projekten überall begegnen wird.

Denk an die Prüfung vor dem Löschen

deleteById() wirft bei einer unbekannten Id keine Ausnahme, sondern tut nichts. Ohne vorheriges Nachsehen würde jeder Löschversuch mit 204 antworten — auch für Einträge, die es nie gab.

Hol den Eintrag deshalb erst mit findById(...).orElseThrow(...) — genau wie beim Lesen und beim Ändern.

Wenn du nicht weiterkommst – so sehen die beiden Endpunkte aus
@PutMapping("/{id}")
public ResponseEntity<GuestbookEntry> updateEntry(@PathVariable Long id,
@RequestBody GuestbookEntry entry) {
GuestbookEntry existing = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Gästebucheintrag mit der Id " + id));

existing.setTitle(entry.getTitle());
existing.setComment(entry.getComment());
existing.setAuthor(entry.getAuthor());

return ResponseEntity.ok(repository.save(existing));
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteEntryById(@PathVariable Long id) {
GuestbookEntry entry = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Kein Gästebucheintrag mit der Id " + id));

repository.delete(entry);
return ResponseEntity.noContent().build();
}

Zwei Stellen lohnen einen zweiten Blick:

Beim Ändern wird der vorhandene Eintrag geladen und es werden nur drei Felder übernommen. Würdest du stattdessen das hereingereichte entry direkt speichern, käme dessen date aus dem Rumpf der Anfrage — also null. Das Datum bleibt nur erhalten, wenn du das gespeicherte Objekt änderst statt es zu ersetzen.

ResponseEntity<Void> beim Löschen: In den spitzen Klammern steht, was im Rumpf liegt. Bei 204 liegt dort nichts — dafür steht Void.

Aufgabe 3 – Statuscodes systematisch

Deine API antwortet je nach Situation mit einem anderen Statuscode. Ordne zu:

SituationDein Statuscode
Eintrag erfolgreich angelegt
Alle Einträge abgerufen
Eintrag mit unbekannter Id abgerufen
Eintrag erfolgreich gelöscht
Eintrag mit unbekannter Id gelöscht
JSON im Rumpf war fehlerhaft
DELETE auf die Sammlung statt auf einen Eintrag
0 von 7 ausgefüllt

Dafür brauchst du mehr Aufrufe, als bisher in deiner requests.http stehen. Schreib sie nicht selbst ab — kopier den ganzen Block ans Ende der Datei. Er deckt die Tabelle und die Testfälle aus Aufgabe 4 ab.

requests.http
### Einen Eintrag abrufen
GET http://localhost:8080/api/v1/guestbook-entries/1

### Unbekannte Id abrufen - erwartet 404
GET http://localhost:8080/api/v1/guestbook-entries/9999

### Eintrag ändern - das Datum bleibt unberührt
PUT http://localhost:8080/api/v1/guestbook-entries/1
Content-Type: application/json

{
"title": "Toller Kurs",
"comment": "Nachträglich ergänzt.",
"author": "Anna"
}

### Eintrag löschen - erwartet 204 ohne Rumpf
DELETE http://localhost:8080/api/v1/guestbook-entries/1

### Unbekannte Id löschen - erwartet 404
DELETE http://localhost:8080/api/v1/guestbook-entries/9999

### Fehlerhaftes JSON - erwartet 400
POST http://localhost:8080/api/v1/guestbook-entries
Content-Type: application/json

{"title":

### DELETE auf die Sammlung statt auf einen Eintrag - erwartet 405
DELETE http://localhost:8080/api/v1/guestbook-entries
Warum die Aufrufe hier fertig stehen

Anfragen abzutippen lehrt nichts über REST — es kostet nur Zeit und erzeugt Tippfehler, die du dann für einen Fehler in deinem Code hältst. Interessant ist, was zurückkommt, nicht das Tippen.

Die Id 1 musst du gegebenenfalls anpassen: Wenn du inzwischen Einträge gelöscht hast, gibt es sie nicht mehr. Welche Ids es gerade gibt, zeigt dir der Aufruf „Alle Einträge".

Nachschlagen: HTTP kompakt.

Aufgabe 4 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Location-Header nach dem AnlegenAnwendung läuft
  1. POST auf /api/v1/guestbook-entries mit einem gültigen Eintrag
  2. Kopfzeilen der Antwort ansehen
Status 201 und eine Kopfzeile Location, die auf /api/v1/guestbook-entries/{id} zeigt.
TF-02Die Location-Adresse funktioniert wirklichTF-01 wurde ausgeführt
  1. Die Adresse aus dem Location-Header kopieren
  2. Sie als GET aufrufen
Status 200, es kommt genau der eben angelegte Eintrag zurück.
TF-03Unbekannte Id beim LesenEs gibt keinen Eintrag mit Id 9999
  1. GET auf /api/v1/guestbook-entries/9999
Status 404, im Rumpf ein Feld detail mit der Id.
TF-04Löschen liefert keinen RumpfEin Eintrag mit bekannter Id existiert
  1. DELETE auf diesen Eintrag
Status 204. Die Antwort hat einen leeren Rumpf — nicht null und nicht {}.
TF-05Zweimal löschenTF-04 wurde ausgeführt
  1. Denselben DELETE erneut ausführen
Status 404 — nicht erneut 204.
TF-06Datum bleibt beim Ändern unverändertEin Eintrag existiert; sein date wurde notiert
  1. PUT mit geändertem Titel und Kommentar
  2. Antwort mit dem notierten Wert vergleichen
Titel und Kommentar sind neu, date ist unverändert.
TF-07Fehlerhaftes JSONAnwendung läuft
  1. POST mit dem Rumpf {"title":
Status 400 — nicht 500.
TF-02 ist der eigentliche Beweis

Ein Location-Header, der auf eine Adresse zeigt, die es nicht gibt, wäre wertlos. Erst der Aufruf zeigt, dass die Zusage stimmt.

Das ist ein allgemeines Prinzip beim Testen: Prüfe nicht, ob etwas dasteht, sondern ob es funktioniert.

Zusammenfassung

Das hast du gelernt
  • @ResponseStatus legt nur einen festen Statuscode fest — keine Kopfzeilen, keine Fallunterscheidung.
  • ResponseEntity bündelt Statuscode, Kopfzeilen und Rumpf in einem Objekt.
  • Nach dem Anlegen gehört die Adresse der neuen Ressource in den Location-Header.
  • ServletUriComponentsBuilder.fromCurrentRequest() baut diese Adresse aus der laufenden Anfrage — statt sie fest zu verdrahten.
  • ok(), created(), noContent(), notFound() decken die üblichen Fälle ab; build() schließt ohne Rumpf ab.

Selbstkontrolle

  1. Nenne zwei Dinge, die ResponseEntity kann und @ResponseStatus nicht.
  2. Wozu dient der Location-Header, und bei welchem Statuscode gehört er hin?
  3. Warum baut man die Adresse nicht als Zeichenkette zusammen?
  4. Wann verwendet man build() statt body()?
  5. Warum genügt es nicht zu prüfen, dass ein Location-Header vorhanden ist?
Antworten
  1. Kopfzeilen setzen und den Statuscode abhängig von der Situation wählen.
  2. Er nennt die Adresse der neu angelegten Ressource, damit der Client sie nicht selbst zusammensetzen muss. Er gehört zu 201 Created.
  3. Weil eine fest verdrahtete Adresse nur auf dem eigenen Rechner stimmt. Sobald die Anwendung unter einem anderen Namen oder Port läuft, wird sie falsch.
  4. Bei Antworten ohne Rumpf, typischerweise 204 No Content und 404 Not Found.
  5. Weil er auf eine Adresse zeigen könnte, unter der nichts erreichbar ist. Erst der Aufruf dieser Adresse beweist, dass die Angabe stimmt.