AB 03 – Die Schnittstelle vervollständigen
Die Situation
Anlegen und Abrufen funktionieren. Die Personalabteilung meldet zurück: Man braucht außerdem eine Übersicht aller Personen, muss Daten ändern können (Namensänderung nach Heirat) und ausgeschiedene Mitarbeiter löschen.
Lernziele
Nach Bearbeitung dieses Arbeitsblatts kannst du:
- die vier CRUD-Operationen vollständig als REST-Endpunkte umsetzen
- für jede Operation den passenden HTTP-Statuscode wählen und begründen
- den Unterschied zwischen
PUTundPOSTan ihrer Idempotenz erklären - REST-konforme URLs entwerfen und typische Fehler benennen
- deine Schnittstelle mit einer Sammlung von Testfällen systematisch prüfen
Die Zuordnung im Überblick
| Operation | HTTP | Pfad | Erfolgsstatus |
|---|---|---|---|
| Create | POST | /api/v1/persons | 201 Created |
| Read (alle) | GET | /api/v1/persons | 200 OK |
| Read (eine) | GET | /api/v1/persons/{id} | 200 OK |
| Update | PUT | /api/v1/persons/{id} | 200 OK |
| Delete | DELETE | /api/v1/persons/{id} | 204 No Content |
Es gibt nur zwei verschiedene Pfade — /persons und /persons/{id}. Fünf Operationen, zwei Adressen. Den Unterschied macht allein die HTTP-Methode.
Genau das meint die einheitliche Schnittstelle aus dem Infoblatt Das REST-Paradigma.
Aufgaben
Aufgabe 1 – Alle Personen abrufen
@GetMapping
public List<Person> getAllPersons() {
return repository.findAll();
}
Beachte: Kein Pfad in der Annotation. Der Endpunkt liegt genau auf dem Klassenpfad /api/v1/persons.

/persons/all oder /getAllPersons?Weil die Sammlung selbst schon die Adresse ist. /persons bedeutet „die Personen" — alle davon.
/getAllPersons wäre gleich doppelt falsch: ein Verb in der URL, obwohl GET das Verb schon liefert.
Aufgabe 2 – Eine Person ändern
@PutMapping("/{id}")
public Person updatePerson(@PathVariable Long id, @RequestBody Person person) {
Person existing = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Keine Person mit der Id " + id));
existing.setFirstname(person.getFirstname());
existing.setSurname(person.getSurname());
return repository.save(existing);
}
Naheliegend wäre:
person.setId(id);
return repository.save(person); // ❌
Das funktioniert scheinbar — hat aber einen Haken: Der Client bestimmt damit jedes Feld der Ressource, auch solche, die gar nicht ihm gehören. Kommt später ein Anlagedatum dazu, das der Server selbst setzt, würde es beim Speichern stillschweigend geleert.
Der Weg über findById lädt erst den vorhandenen Stand und übernimmt daraus genau die Felder, die der Client ändern darf. Was der Server verwaltet, bleibt unangetastet.
PUT, kein PATCHEin PUT ersetzt die Ressource vollständig — der Client schickt den kompletten neuen Stand, und fehlende Felder gelten als geleert. Genau so verhält sich dein Endpunkt: Wer surname wegläßt, bekommt einen leeren Nachnamen.
Der Unterschied liegt woanders: id und später das Anlagedatum sind keine Felder, die der Client setzen darf. Sie gehören dem Server. Die Auswahl im Code trennt also nicht „geändert / nicht geändert", sondern „gehört dem Client / gehört dem Server".
Was PUT von PATCH unterscheidet, steht im Infoblatt HTTP kompakt.
Aufgabe 3 – Eine Person löschen
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deletePersonById(@PathVariable Long id) {
Person person = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Keine Person mit der Id " + id));
repository.delete(person);
}
Zwei Besonderheiten:
Rückgabetyp void. Es gibt nichts zurückzugeben — die Ressource ist weg.
204 No Content statt 200 OK. Der Code sagt genau das aus: erfolgreich, aber kein Inhalt in der Antwort.
deleteById(id)?Weil deleteById() bei einer unbekannten Id keine Ausnahme wirft, sondern schlicht nichts tut.
Jeder DELETE würde dann mit 204 antworten — auch für Datensätze, die es nie gab. Der Client könnte nicht unterscheiden, ob er etwas gelöscht hat oder ins Leere griff.
Deshalb wird der Datensatz erst geholt. Das ist dasselbe Muster wie bei GET /{id} und PUT /{id}: holen — oder mit 404 abbrechen. Alle drei Endpunkte, die eine Id entgegennehmen, beginnen jetzt mit denselben zwei Zeilen.
Aufgabe 4 – Die Request-Sammlung vervollständigen
### Alle Personen
GET http://localhost:8080/api/v1/persons
### Person ändern
PUT http://localhost:8080/api/v1/persons/1
Content-Type: application/json
{
"firstname": "Anna",
"surname": "Schmidt-Meier"
}
### Person löschen
DELETE http://localhost:8080/api/v1/persons/2
### Löschen, was es nicht gibt
DELETE http://localhost:8080/api/v1/persons/999
requests.http liegt im Projekt. Die Datei wandert mit in die Versionsverwaltung, jeder im Team hat dieselben Aufrufe, und bei einer Änderung der Schnittstelle sieht man im Diff, was sich geändert hat.
Eine Sammlung, die nur lokal in einem Programm liegt, hat niemand außer dir.
Aufgabe 5 – Idempotenz beobachten
Jetzt, wo alle vier Aufrufe in deiner Datei stehen, geht es um eine Eigenschaft, die man dem Code nicht ansieht — nur dem Verhalten.
Was heißt „idempotent"?
Eine Operation ist idempotent, wenn es keinen Unterschied macht, ob man sie einmal oder mehrfach ausführt: Danach sieht die Welt genauso aus.
Das Wort kommt aus dem Lateinischen: idem = dasselbe, potens = wirkend. Also „dieselbe Wirkung", egal wie oft.
Zwei Beispiele aus dem Alltag:
| Handlung | Einmal | Dreimal | |
|---|---|---|---|
| Im Aufzug auf 3 drücken | du bist im 3. Stock | du bist im 3. Stock | ✅ idempotent |
| Ein Stück Kuchen nehmen | eins auf dem Teller | drei auf dem Teller | ❌ nicht idempotent |
Bei Anweisungen an einen Server ist es genau dasselbe. Achte auf das Verb:
- „Setze den Nachnamen auf Meier." → Danach heißt sie Meier. Ob du es einmal oder zehnmal sagst, ändert nichts. Idempotent.
- „Lege eine neue Person an." → Dreimal gesagt, dreimal angelegt. Nicht idempotent.
Idempotent heißt nicht, dass nichts passiert. Beim ersten Mal verändert sich sehr wohl etwas.
Es heißt nur: Wiederholungen verändern nichts zusätzlich.
Jetzt selbst nachprüfen
Statt es zu glauben, probierst du es aus.
| Methode | Nach dreimaligem Ausführen | Idempotent? |
|---|---|---|
POST | drei Datensätze | nein |
PUT | ein Datensatz, unverändert | ja |
DELETE | weg — beim zweiten Mal 404 | ja |
DELETE sieht nach einem Widerspruch ausBeim ersten Mal 204, beim zweiten Mal 404 — zwei verschiedene Antworten. Und trotzdem idempotent?
Ja. Idempotenz ist eine Aussage über den Zustand auf dem Server, nicht über die Antwort. Nach dem zweiten DELETE ist die Welt genau dieselbe wie nach dem ersten: Der Datensatz ist weg. Nur die Auskunft darüber fällt anders aus — beim ersten Mal „habe ich gelöscht", beim zweiten „gibt es nicht".
Prüffrage also nicht „kommt dieselbe Antwort?", sondern „sieht der Server danach gleich aus?"
Ein Client schickt eine Bestellung per POST und bekommt wegen einer Netzstörung keine Antwort. Darf er es erneut versuchen?
Nein — die Bestellung könnte schon angelegt sein. Bei PUT und DELETE wäre ein zweiter Versuch dagegen unbedenklich.
Das ist der Grund für den Hinweis „Bitte nicht zweimal klicken" auf Bezahlseiten.
Eine Übersicht über alle HTTP-Methoden — und dazu den verwandten Begriff sicher — findest du im Infoblatt Das REST-Paradigma.
Aufgabe 6 – URL-Entwurf beurteilen
| # | Aufruf | Dein Urteil |
|---|---|---|
| 1 | GET /api/v1/persons/17 | |
| 2 | POST /api/v1/deletePerson/17 | |
| 3 | GET /api/v1/persons?surname=Schmidt | |
| 4 | GET /api/v1/person/all | |
| 5 | PUT /api/v1/persons/17 | |
| 6 | POST /api/v1/persons/17/setSurname |
Auflösung
- ✅ Korrekt. Substantiv in der Mehrzahl, Id als Pfadbestandteil.
- ❌ Verb in der URL, und
POSTfür ein Löschen. Richtig:DELETE /api/v1/persons/17. - ✅ Korrekt. Ein Filter gehört als Abfrageparameter an die Sammlung — die Ressource bleibt dieselbe.
- ❌ Einzahl und ein überflüssiges
/all. Richtig:GET /api/v1/persons. - ✅ Korrekt.
- ❌ Verb in der URL, und für eine Änderung ist
PUTzuständig. Richtig:PUT /api/v1/persons/17mit dem neuen Stand im Rumpf.
Aufgabe 7 – Vollständige Abnahme
Jeder Testfall baut auf dem Zustand auf, den die vorherigen hinterlassen haben. Deshalb nennt keine Vorbedingung mehr „Datenbank ist leer": Notiere stattdessen vor dem Testfall, wie viele Datensätze es gerade gibt, und prüfe hinterher die Veränderung.
Das ist auch im Betrieb der Normalfall — Testfälle laufen selten auf einer jungfräulichen Datenbank.
| ID | Beschreibung | Vorbedingung | Testschritte | Erwartetes Ergebnis | Ergebnis |
|---|---|---|---|---|---|
| TF-01 | Leere Sammlung abrufen | Anwendung frisch gestartet, keine Daten angelegt |
| Status 200, Rumpf ist [] — nicht 404. | |
| TF-02 | Alle Personen abrufen | Zwei Personen wurden angelegt |
| Status 200, ein JSON-Array mit genau zwei Objekten. | |
| TF-03 | Person ändern | Person mit Id 1 existiert |
| Status 200. Der GET liefert den neuen Nachnamen, die id bleibt 1. | |
| TF-04 | Nicht vorhandene Person ändern | Es gibt keine Person mit Id 999 |
| Status 404. Es wird keine neue Person angelegt. | |
| TF-05 | Person löschen | Person mit Id 2 existiert |
| DELETE liefert 204 ohne Rumpf, der anschließende GET liefert 404. | |
| TF-06 | Zweimal löschen | TF-05 wurde ausgeführt |
| Status 404 — nicht erneut 204. | |
| TF-07 | POST ist nicht idempotent | Anwendung läuft; die Anzahl der Datensätze wurde vorher notiert |
| Es sind drei Datensätze dazugekommen, jeder mit einer eigenen Id. | |
| TF-08 | PUT ist idempotent | Person mit Id 1 existiert; die Anzahl der Datensätze wurde vorher notiert |
| Die Anzahl der Datensätze ist unverändert, und Person 1 sieht nach dem dritten Mal genauso aus wie nach dem ersten. | |
| TF-09 | Fehlerhaftes JSON wird abgewiesen | Anwendung läuft |
| Status 400 (Bad Request) — nicht 500. | |
| TF-10 | POST mit mitgeschickter Id | Es existiert eine Person mit Id 1 |
| Es sollte ein neuer Datensatz mit neuer Id entstehen und Person 1 unberührt bleiben. Prüfe, ob das wirklich passiert. | |
| TF-11 | Falsche Methode auf der Sammlung | Anwendung läuft |
| Status 405 (Method Not Allowed). |
Probier es aus: Person 1 heißt danach Test, und es ist kein neuer Datensatz entstanden. Der Statuscode lautet trotzdem 201 Created.
Der Grund steht in deinem Controller:
public Person createPerson(@RequestBody Person person) {
return repository.save(person);
}
@RequestBody füllt alle Felder aus dem JSON — auch id. Und save() sieht eine gesetzte Id, hält das Objekt für bekannt und macht daraus ein UPDATE statt eines INSERT.
Damit kann jeder Aufrufer fremde Datensätze überschreiben, indem er einfach eine Id mitschickt.
Die Ursache ist grundsätzlicher Art: Deine Entität Person ist gleichzeitig das, was von außen hereinkommt. Der Client bestimmt damit Felder, die ihm nicht gehören. Die saubere Lösung ist ein eigenes Objekt für die Anfrage, in dem id schlicht nicht vorkommt. Solche Objekte heißen DTO — Data Transfer Object, ein Objekt, das nur dem Datentransport über die Grenze der Anwendung dient und nicht dem Speichern. Darauf kommen wir zurück.
Notiere den Testfall als nicht bestanden. Ein Testfall, der einen echten Mangel aufdeckt, hat seine Arbeit getan.
Eine leere Sammlung ist kein Fehler. /persons existiert, sie ist nur gerade leer — also 200 OK mit [].
404 würde bedeuten, dass es die Sammlung gar nicht gibt. Das ist etwas anderes.
Der Unterschied zwischen [] und null ist im Infoblatt JSON genauer beschrieben.
Ausblick
Deine Schnittstelle ist vollständig, aber noch nicht robust. Diese Fragen bleiben offen:
- Was passiert, wenn jemand eine Person ohne Namen anlegt? (→ Validierung)
- Warum verschwinden alle Daten beim Neustart? (→ dauerhafte Speicherung)
- Der Controller redet direkt mit der Datenbank — wohin gehört eigentlich die Fachlogik? (→ Service-Schicht)
- Wie verhindert man, dass ein Client Felder setzt, die ihm nicht gehören — siehe TF-10? (→ DTOs)
- Du hast alle Testfälle von Hand ausgeführt. Beim nächsten Mal wieder? (→ automatisierte Tests)
Das Gästebuch in Tutorial 2 nimmt einige davon auf.
Zusammenfassung
- Alle vier CRUD-Operationen liegen auf zwei Pfaden; unterschieden wird über die HTTP-Methode.
201beim Anlegen,200beim Lesen und Ändern,204beim Löschen,404bei unbekannter Id.- Eine leere Sammlung ist
200mit[], nicht404. POSTist nicht idempotent,PUTundDELETEsind es.- In REST-URLs gehören Substantive; Filter kommen als Abfrageparameter an die Sammlung.
deleteById()wirft bei unbekannter Id keine Ausnahme — deshalb vorher prüfen.
Selbstkontrolle
- Warum liegt der Endpunkt für „alle Personen" auf demselben Pfad wie der zum Anlegen?
- Welchen Statuscode liefert ein erfolgreiches
DELETEund warum nicht200? - Erkläre an einem Beispiel, warum
POSTnicht idempotent ist. - Wie unterscheidet ein Client „Sammlung ist leer" von „Sammlung gibt es nicht"?
- Warum ist
PUT /persons/17besser alsPOST /persons/17/setSurname?
Antworten
- Weil beide auf dieselbe Ressource wirken — die Sammlung aller Personen. Unterschieden wird über die Methode:
GETliest sie,POSTfügt ihr etwas hinzu. 204 No Content. Es gibt nichts zurückzugeben;200würde einen Inhalt ankündigen, der nicht kommt.- Dreimal
POST /personsmit denselben Daten erzeugt drei Datensätze mit drei verschiedenen Ids. Das Ergebnis hängt davon ab, wie oft man die Anfrage schickt — genau das ist nicht idempotent. - Über den Statuscode:
200mit[]heißt „gibt es, ist leer".404heißt „gibt es nicht". - Weil
setSurnameein Verb in der URL ist und eine einzelne Methode nach außen sichtbar macht.PUTdrückt „ersetze diese Ressource" bereits aus; welche Felder sich ändern, steht im Rumpf. Sonst bräuchte man für jedes Feld einen eigenen Endpunkt.
Musterlösung
Hier steht der vollständige Quelltext, so wie er nach allen drei Arbeitsblättern aussieht: die Entität, das Repository, beide Controller, die application.properties und die Datei requests.http.
Eine Lösung zu lesen fühlt sich an wie Verstehen, ist aber keines. Wer sie aufschlägt, bevor er selbst gescheitert ist, nimmt sich genau den Teil weg, an dem man etwas lernt.
Sinnvoll ist sie in zwei Fällen: nach der eigenen Lösung zum Vergleichen — oder wenn du an einer Stelle wirklich nicht weiterkommst. Dann sieh dir nur diese eine Datei an und arbeite danach selbst weiter.