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
@ResponseStatusfür manche Antworten nicht ausreicht ResponseEntityeinsetzen, 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
Locationmit der Adresse des neuen Eintrags - den Statuscode abhängig von der Situation wählen
Location sollBeim 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:
@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(...).
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:
| Aufruf | Ergebnis |
|---|---|
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));
}
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:
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.
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:
| Situation | Dein 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 |
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.
### 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
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
| ID | Beschreibung | Vorbedingung | Testschritte | Erwartetes Ergebnis | Ergebnis |
|---|---|---|---|---|---|
| TF-01 | Location-Header nach dem Anlegen | Anwendung läuft |
| Status 201 und eine Kopfzeile Location, die auf /api/v1/guestbook-entries/{id} zeigt. | |
| TF-02 | Die Location-Adresse funktioniert wirklich | TF-01 wurde ausgeführt |
| Status 200, es kommt genau der eben angelegte Eintrag zurück. | |
| TF-03 | Unbekannte Id beim Lesen | Es gibt keinen Eintrag mit Id 9999 |
| Status 404, im Rumpf ein Feld detail mit der Id. | |
| TF-04 | Löschen liefert keinen Rumpf | Ein Eintrag mit bekannter Id existiert |
| Status 204. Die Antwort hat einen leeren Rumpf — nicht null und nicht {}. | |
| TF-05 | Zweimal löschen | TF-04 wurde ausgeführt |
| Status 404 — nicht erneut 204. | |
| TF-06 | Datum bleibt beim Ändern unverändert | Ein Eintrag existiert; sein date wurde notiert |
| Titel und Kommentar sind neu, date ist unverändert. | |
| TF-07 | Fehlerhaftes JSON | Anwendung läuft |
| Status 400 — nicht 500. |
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
@ResponseStatuslegt nur einen festen Statuscode fest — keine Kopfzeilen, keine Fallunterscheidung.ResponseEntitybü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
- Nenne zwei Dinge, die
ResponseEntitykann und@ResponseStatusnicht. - Wozu dient der
Location-Header, und bei welchem Statuscode gehört er hin? - Warum baut man die Adresse nicht als Zeichenkette zusammen?
- Wann verwendet man
build()stattbody()? - Warum genügt es nicht zu prüfen, dass ein
Location-Header vorhanden ist?
Antworten
- Kopfzeilen setzen und den Statuscode abhängig von der Situation wählen.
- Er nennt die Adresse der neu angelegten Ressource, damit der Client sie nicht selbst zusammensetzen muss. Er gehört zu
201 Created. - 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.
- Bei Antworten ohne Rumpf, typischerweise
204 No Contentund404 Not Found. - Weil er auf eine Adresse zeigen könnte, unter der nichts erreichbar ist. Erst der Aufruf dieser Adresse beweist, dass die Angabe stimmt.