1. Upload-Vertrag: manuell zusammenstellen
Jetzt, wo MultipartFile, Part und @RequestPart nicht mehr wie Schamanentum aussehen, ist es an der Zeit, den kanonischen Upload-Vertrag des Projekts festzulegen. Das Multipart-Modell haben wir bereits: eine Anfrage, separate Teile file und metadata, Bindung über die Namen der Teile. Hier ist der nächste Schritt wichtig: diese Mechanik in einen konkreten Endpoint Task Tracker API zu verwandeln, bei dem URI, Anfrageaufbau und Antwort klar sind.
Pfad und Methode: /tasks/{taskId}/attachments + POST
Zuerst scheint es, dass der URI für eine Datei etwas Besonderes und Einzigartiges ist. Aber im REST-Denken ist eine Datei einfach ein Teil der Fachdomäne, also eine Ressource. In unserem Projekt existiert eine Datei nicht „im luftleeren Raum“: Sie wird immer an eine bestimmte Aufgabe angehängt. Daher ist dies eine Unterressource (supporting subresource) der Aufgabe, keine unabhängige Entität der obersten Ebene.
Deshalb bleibt der Pfad ressourcenorientiert und lesbar: Wir „gehen“ über taskId in die konkrete Aufgabe und arbeiten dann mit der Sammlung ihrer Anhänge. Die Sammlung ist /attachments. Das Hinzufügen eines neuen Elements zu einer Sammlung wird in REST normalerweise als POST auf diese Sammlung ausgedrückt. Das heißt: „einen neuen Anhang bei einer Aufgabe erstellen“ = POST /tasks/{taskId}/attachments.
Und das ist nicht nur Ästhetik. Es gibt dem Client ein klares Modell: „Wenn ich mit den Anhängen einer bestimmten Aufgabe arbeiten möchte, beginne ich immer mit /tasks/{taskId}“. Ein solcher Vertrag ist einfacher zu dokumentieren, einfacher zu testen und schlichtweg einfacher zu erklären (auch für das frühere Ich, das am Freitagabend Code geschrieben hat).
Ein kleines Diagramm, um das Ressourcenmodell „mit den Augen zu sehen“:
flowchart LR
A["Task (taskId)"] --> B["Attachments collection"]
B --> C["Attachment metadata (attachmentId)"]
A ---|URI| AURI["/api/v1/tasks/{taskId}"]
B ---|URI| BURI["/api/v1/tasks/{taskId}/attachments"]
C ---|URI| CURI["/api/v1/tasks/{taskId}/attachments/{attachmentId}"]
Heute implementieren wir genau die Erstellung: POST auf BURI.
2. Multipart-Teile: file und metadata
Für diesen Endpoint behalten wir zwei Teile bei, die uns bereits vertraut sind. file trägt den binären Inhalt der Datei. metadata trägt das JSON mit dem, was der Client tatsächlich steuert. Die grundlegende Multipart-Logik haben wir bereits festgelegt; hier ist es wichtig, sie an die konkrete Operation „Anhang für Aufgabe erstellen“ zu binden.
Ein solches Paar von Teilen ist praktisch, weil ihre Rollen unterschiedlich sind. file hat Größe, Namen und Content-Type; metadata hat Struktur und Validierung. Daher sollte metadata als JSON-Teil mit Content-Type: application/json kommen, damit Spring es direkt mit dem DTO verknüpft, anstatt uns zu zwingen, Strings manuell zu parsen.
Aus Sicht des Clients sieht die Anfrage aus wie „ein Umschlag, darin zwei Anhänge“:
- Anhang Nr. 1: metadata (JSON),
- Anhang Nr. 2: file (binärer Inhalt + Attribute).
Und diese beiden Anhänge bilden zusammen eine einzige Operation: „Anhang erstellen“. Die Namen file und metadata sind hier kein Beispiel, sondern ein festgelegter Teil der öffentlichen API.
3. DTO metadata: AttachmentUploadMetadataRequest
Im Upload-Szenario ist es besonders leicht, versehentlich die DTO-Disziplin zu verletzen. Zum Beispiel möchte man sagen: „Nun, da wir sowieso Metadaten erstellen, legen wir doch originalFileName, size, contentType hinein …“. Und genau hier sollten wir innehalten und uns an die Grundlage des Kurses erinnern: Ein Request-DTO sollte nur das enthalten, was der Client tatsächlich steuert.
originalFileName, size, contentType sind nicht „das, was der Client eingibt“. Es sind Eigenschaften der Datei, die der Server aus dem file-Teil ermitteln kann. Wenn wir dem Client erlauben, sie in metadata zu senden, schaffen wir eine falsche Freiheit: Der Client könnte angeben, dass die Datei 1 Byte groß ist, aber tatsächlich 20 MB senden. Ist das gut? Spoiler: nein.
Daher wird unser Request-DTO minimal sein. Gemäß den Anforderungen des Projekts haben wir ein Feld description mit einer Längenbeschränkung.
import jakarta.validation.constraints.Size;
public record AttachmentUploadMetadataRequest(
// Beschreibung des Anhangs, vom Benutzer eingegeben (kann null sein)
@Size(max = 255) String description
) {
}
Beachten Sie zwei Kleinigkeiten, die zu einem professionellen Ergebnis führen. Erstens: description kann null sein, weil die Beschreibung optional ist. Zweitens setzen wir sofort eine Einschränkung – das ist Teil des Vertrags, und Spring kann den JSON-Teil im Multipart über @Valid validieren (das werden wir jetzt im Controller anbinden).
Es ist hilfreich, eine kleine „Feldgrenze“ für Upload im Kopf zu behalten:
| Feld | Herkunft | Warum so |
|---|---|---|
| description | aus metadata (JSON) | Client steuert dies tatsächlich |
| originalFileName | aus file (MultipartFile) | Attribut der hochgeladenen Datei |
| contentType | aus file (MultipartFile) | technische Tatsache über den Anfrageteil |
| size | aus file (MultipartFile) | Server sieht die Größe selbst |
| uploadedAt | auf dem Server | server-managed Zeit |
| id | auf dem Server | server-managed Identifikator |
Wenn Sie jemals einen Bug „Client hat id manipuliert“ erlebt haben, wissen Sie, dass diese Tabelle nicht pedantisch ist, sondern eine Absicherung gegen Abenteuer.
4. Upload-Befehl: AttachmentUploadCommand
Für den externen Vertrag dieses Endpoints reicht ein interner Gedanke: Der Controller sollte MultipartFile nicht roh in den Service durchreichen. Es ist bequemer, ein normales Java-Objekt mit den Operationsdaten zu sammeln und weiterzuleiten.
Hier brauchen wir keine vollständige Architektur-Aufstellung. Es genügt, die Eingabeform zu sehen, mit der der Service bequem arbeiten kann:
public record AttachmentUploadCommand(
// ID der Aufgabe, an die die Datei angehängt wird
String taskId,
// Dateiattribute (aus MultipartFile)
String originalFileName,
String contentType,
long size,
// Dateiinhalt (im Lehrprojekt vollständig in den Speicher gelesen)
byte[] content,
// Beschreibung aus dem metadata-Teil
String description
) {
}
Das reicht bereits, um HTTP-Details nicht mit der eigentlichen Upload-Operation zu vermischen.
5. Controller: @RequestPart + consumes=multipart/form-data
Die Controller-Methode für Upload sollte maximal lesbar sein, denn sie ist Teil des öffentlichen Vertrags. Wenn die Methode wie ein Zauberspruch in einer alten Sprache aussieht, wird es dem Client nicht leichter fallen (und Ihnen schwerer). Daher machen wir die Methode kurz und vorhersagbar: Pfad, taskId, zwei Teile file und metadata, Delegation an den Service.
Beginnen wir mit der Basis: der Endpoint selbst und seine Signatur.
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(
path = "/api/v1/tasks/{taskId}/attachments",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public void upload(
@PathVariable String taskId,
@RequestPart("file") MultipartFile file,
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) {
// Später sammeln wir den Befehl und delegieren
}
Man sieht sofort alles Wichtige: wo taskId liegt, wie die Anfrageteile heißen, welcher Teil als DTO validiert wird. Beachten Sie: Wir verwenden @RequestPart, nicht @RequestBody. Denn @RequestBody arbeitet mit einem einzigen Body, aber wir haben mehrere Teile.
Jetzt fügen wir die Delegation an den Service und die korrekte Antwort 201 Created hinzu. Damit dies für den Client nützlich ist, ist es üblich, die Metadaten des erstellten Anhangs zurückzugeben und einen Location-Header hinzuzufügen. Dazu brauchen wir ein Response-DTO (ohne binären Inhalt).
6. Response-DTO: nur Metadaten
Im Upload-Szenario kann man leicht eine seltsame Antwort erzeugen: zum Beispiel die Datei in der Antwort zurückgeben, weil „nun, sie ist ja da“. Aber das ist normalerweise nicht nötig und macht die API schnell schwerfällig und unbequem. Der Client möchte meistens erfahren, „was erstellt wurde“ – also die Metadaten und die ID des Anhangs.
Daher führen wir AttachmentResponse als Response-DTO ein. Es enthält Informationen über den erstellten Anhang.
import java.time.Instant;
public record AttachmentResponse(
String id,
String taskId,
String originalFileName,
String contentType,
long size,
String description,
Instant uploadedAt
) {
}
Dieses Modell fügt sich gut in den gesunden Menschenverstand ein. Der Client kennt sofort die attachmentId und kann weiter mit dem Anhang arbeiten (z.B. ihn in der UI als Anhangsliste anzeigen). Der binäre Inhalt der Datei ist eine andere Geschichte und ein anderer Endpoint, den wir heute nicht anfassen.
Um die Schichten nicht zu vermischen, wird der Service ein internes Modell zurückgeben (z.B. AttachmentMetadata), und der Controller wird es in AttachmentResponse mappen.
7. Vollständiger Ablauf: command → service → 201 Created
Von außen sieht ein erfolgreicher Upload sehr einfach aus: Wir erstellen einen neuen Anhang zu einer konkreten Aufgabe. Der Client sollte also 201 Created sehen, Location auf die erstellte Ressource und eine Metadaten-Antwort, mit der er in der UI und bei weiteren Aufrufen weiterarbeiten kann. Binärer Inhalt in der Antwort wird hier nicht benötigt.
Intern reicht ein minimales Schema: Der Controller sammelt AttachmentUploadCommand, der Service führt die Operation aus und gibt ein internes Metadaten-Modell zurück, und der Controller mappt es in AttachmentResponse. Unter AttachmentMetadata verstehen wir hier ein internes Metadaten-Modell des Anhangs – kein öffentliches Response-DTO.
import java.io.IOException;
import java.net.URI;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(
path = "/api/v1/tasks/{taskId}/attachments",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<AttachmentResponse> upload(
@PathVariable String taskId,
@RequestPart("file") MultipartFile file,
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) throws IOException {
AttachmentUploadCommand cmd = new AttachmentUploadCommand(
taskId,
file.getOriginalFilename(),
file.getContentType(),
file.getSize(),
file.getBytes(),
metadata.description()
);
// Der Service gibt ein internes Metadaten-Modell des erstellten Anhangs zurück
AttachmentMetadata created = attachmentService.upload(cmd);
AttachmentResponse response = attachmentMapper.toResponse(created);
URI location = URI.create("/api/v1/tasks/" + taskId + "/attachments/" + created.id());
return ResponseEntity.created(location).body(response);
}
Hier ist vor allem die Semantik der Antwort wichtig: ResponseEntity.created(location) setzt 201 Created und Location, während der Antwortkörper als JSON-Metadaten des erstellten Anhangs bleibt.
8. Beispiel einer Multipart-Anfrage für .http
Wenn der Endpoint fertig ist, lautet die nächste Studentenfrage meist: „Okay, wie sendet man das überhaupt?“. Und das ist eine ehrliche Frage. Multipart ist nicht so visuell einfach wie JSON. Daher ist es nützlich, ein Beispiel für eine manuelle Anfrage zu haben, das Sie in requests.http ablegen können.
Hier ist ein Beispiel im Stil des IntelliJ HTTP Client (mit expliziten Teilen metadata und file). Ja, es sieht „wortreich“ aus, aber das ist auch der Sinn: Sie sehen den Vertrag mit eigenen Augen.
POST http://localhost:8080/api/v1/tasks/11111111-1111-1111-1111-111111111111/attachments
Content-Type: multipart/form-data; boundary=BOUNDARY
--BOUNDARY
Content-Disposition: form-data; name="metadata"
Content-Type: application/json
{
"description": "Logs (ja, ich lese auch nicht gerne Logs, aber was soll's)"
}
--BOUNDARY
Content-Disposition: form-data; name="file"; filename="notes.txt"
Content-Type: text/plain
< ./sample-files/notes.txt
--BOUNDARY--
Hier gibt es zwei Dinge, an denen man am häufigsten „stolpert“. Erstens der Name des Teils: er muss mit @RequestPart("metadata") und @RequestPart("file") übereinstimmen. Zweitens Content-Type für den Teil metadata. Wenn Sie die Metadaten als text/plain senden, kann Spring sie nicht automatisch mit dem DTO verknüpfen, und Sie erhalten einen Binding-/Conversion-Fehler.
9. Typische Fehler beim Hochladen von Anhängen
Fehler Nr. 1: Pfad verbal gestalten, z.B. /api/v1/uploadAttachmentToTask.
Das sieht aus wie ein „Befehl“, nicht wie das Arbeiten mit einer Ressource. Ja, es „funktioniert“, aber es zerstört die Lesbarkeit und das REST-Modell. In unserem Projekt sind Anhänge eine Unterressource der Aufgabe, daher ist der korrekte und nachhaltige Pfad /api/v1/tasks/{taskId}/attachments, und die Erstellungsoperation ist ein normaler POST auf die Sammlung.
Fehler Nr. 2: In AttachmentUploadMetadataRequest die Felder size, contentType, originalFileName aufnehmen.
Das ist ein häufiger Versuch, „das DTO reicher zu machen“, aber es schwächt den Vertrag. Der Client könnte etwas anderes senden, und die Datei könnte anders sein. Diese Felder muss der Server aus dem file-Teil ermitteln, sonst organisieren Sie sich selbst Inkonsistenzen in den Daten und müssen diese heldenhaft reparieren.
Fehler Nr. 3: JSON-Metadaten als String entgegennehmen und manuell über ObjectMapper direkt im Controller parsen.
Manchmal wird das gemacht, „weil es nicht anders ging“, aber meistens ist es einfach Unkenntnis darüber, dass @RequestPart("metadata") in der Lage ist, einen JSON-Teil über Jackson mit einem DTO zu verknüpfen. Manuelles Parsen wächst schnell, erschwert die Fehlerbehandlung und verwandelt den Controller in ein Mini-Framework.
Fehler Nr. 4: MultipartFile in die Service-Schicht durchreichen.
Ein Service, der MultipartFile entgegennimmt, wird von Spring MVC abhängig. Dadurch wird die Geschäftslogik schlechter testbar und schlechter wiederverwendbar. Viel gesünder ist es, wenn der Controller die Multipart-Eingabe in ein normales Java-Objekt (einen Befehl) umwandelt und der Service damit als anwendungsbezogene Daten arbeitet.
Fehler Nr. 5: 200 OK zurückgeben, nur weil „ich es immer so mache“.
Das Erstellen eines neuen Anhangs ist das Erstellen einer neuen Ressource, daher ist die grundlegende korrekte Antwort 201 Created. Und wenn Sie zusätzlich Location zurückgeben, wird das Leben des Clients einfacher: Er versteht sofort, wo der erstellte Anhang in der API liegt.
GO TO FULL VERSION