Datei-Uploads mit multipart/form-data testen
Lade eine Datei mit curl, JavaScript, Python und C# hoch, sieh genau, was der Server empfangen hat, und vermeide den Boundary-Fehler.
Beispiele zuletzt am gegen die API ausgeführt
Um einen Datei-Upload zu testen, musst du sehen, was der Server tatsächlich empfangen hat: welche Felder, welche Dateien, unter welchen Namen, mit welchem Content-Type und welcher Größe. POST /api/echo auf https://api.testingapis.com parst einen multipart/form-data-Request und schickt das als JSON zurück. Kostenlos, ohne Schlüssel, speichert nichts und erlaubt Requests von jedem Origin, funktioniert also auch aus dem Browser. Unten: ein funktionierender Upload in curl, JavaScript, Python und C#, die Fehler, die Uploads in jeder Sprache kaputtmachen, und ein xUnit-Test.
Ein Multipart-Upload mit curl
-F lässt curl multipart/form-data senden. name=value ist ein Textfeld, name=@path hängt eine Datei an, und ;type= setzt ihren Content-Type. Ohne ;type= sendet curl application/octet-stream. Die Antwort enthält noch weitere Request-Details; hier behält jq die zwei Teile, auf die es ankommt:
$ printf 'id,name\n1,Alice\n2,Bob\n' > report.csv
$ curl -s https://api.testingapis.com/api/echo \
-F "title=Quarterly report" \
-F "file=@report.csv;type=text/csv" | jq '{form, files}'
{
"form": {
"title": "Quarterly report"
},
"files": [
{
"field": "file",
"fileName": "report.csv",
"contentType": "text/csv",
"size": 22
}
]
}/api/echotitle=Quarterly report
JavaScript: FormData mit fetch
Bau ein FormData, hänge Felder und Dateien an und übergib es als Body. Derselbe Code läuft im Browser (mit einer Datei aus einem <input type="file"> statt readFile) und in Node 18 oder neuer.
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("title", "Quarterly report");
form.append("file", new Blob([await readFile("report.csv")], { type: "text/csv" }), "report.csv");
// No Content-Type header: fetch adds "multipart/form-data; boundary=..." itself.
const res = await fetch("https://api.testingapis.com/api/echo", { method: "POST", body: form });
const { form: fields, files } = await res.json();
console.log(fields, files);{ title: 'Quarterly report' } [
{
field: 'file',
fileName: 'report.csv',
contentType: 'text/csv',
size: 22
}
]Python: requests
Textfelder gehören in data, Dateien in files. Gib jeder Datei ein Tupel aus Dateiname, Dateiobjekt und Content-Type. Übergibst du nur das Dateiobjekt, fehlt der Content-Type.
import requests
with open("report.csv", "rb") as f:
res = requests.post(
"https://api.testingapis.com/api/echo",
data={"title": "Quarterly report"},
files={"file": ("report.csv", f, "text/csv")}, # (file name, file, content type)
)
body = res.json()
print(body["form"], body["files"]){'title': 'Quarterly report'} [{'field': 'file', 'fileName': 'report.csv', 'contentType': 'text/csv', 'size': 22}]C#: MultipartFormDataContent
Füge pro Textfeld ein StringContent hinzu und pro Datei ein StreamContent (oder ByteArrayContent). Erst das Argument mit dem Dateinamen macht einen Teil zur Datei: Lässt du es weg, kommen die Bytes der Datei als normales Textfeld an.
using System.Net.Http.Headers;
using System.Text.Json;
using var http = new HttpClient();
using var form = new MultipartFormDataContent();
form.Add(new StringContent("Quarterly report"), "title");
var file = new StreamContent(File.OpenRead("report.csv"));
file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
form.Add(file, "file", "report.csv"); // the third argument, the file name, makes it a file
using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Console.WriteLine(echo.GetProperty("form"));
Console.WriteLine(echo.GetProperty("files"));{"title":"Quarterly report"}
[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22}]Die Fehler, die Uploads kaputtmachen
Jede Zeile wurde an /api/echo geschickt; die letzte Spalte zeigt, was der Server geparst hat.
| Was der Code getan hat | Bibliotheken | Was der Server bekam |
|---|---|---|
Den Header Content-Type von Hand auf multipart/form-data gesetzt | fetch, requests, HttpClient (Content-Type des Formulars ersetzt) | Nichts: Missing content-type boundary. Die Boundary, die die Teile trennt, ist weg |
| Den Dateinamen weggelassen | HttpClient (form.Add(content, name)) | Den Dateiinhalt als Textfeld, aber keine Datei |
| Den Dateinamen weggelassen | fetch (Blob ohne Namen) | Eine Datei namens blob |
| Den Content-Type weggelassen | curl, requests (nur das Dateiobjekt), HttpClient | curl: application/octet-stream; requests und HttpClient: einen leeren Content-Type |
data= ohne files= genutzt | requests | Gar kein Multipart: application/x-www-form-urlencoded |
Der Boundary-Fehler ist der häufigste. Das sieht der Server:
{
"form": { "parseError": "Missing content-type boundary." },
"files": null,
"contentType": "multipart/form-data"
}Ein xUnit-Test für einen Upload
Er prüft, was beim Server angekommen ist, nicht nur, dass der Request 200 zurückgab. Er besteht mit xUnit 2.9 auf .NET 10.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Xunit;
public class UploadTests
{
[Fact]
public async Task The_file_arrives_with_its_name_type_and_size()
{
var bytes = Encoding.UTF8.GetBytes("id,name\n1,Alice\n2,Bob\n");
using var http = new HttpClient();
using var form = new MultipartFormDataContent();
form.Add(new StringContent("Quarterly report"), "title");
var file = new ByteArrayContent(bytes);
file.Headers.ContentType = new MediaTypeHeaderValue("text/csv");
form.Add(file, "file", "report.csv");
using var response = await http.PostAsync("https://api.testingapis.com/api/echo", form);
response.EnsureSuccessStatusCode();
var echo = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal("Quarterly report", echo.GetProperty("form").GetProperty("title").GetString());
var sent = Assert.Single(echo.GetProperty("files").EnumerateArray());
Assert.Equal("file", sent.GetProperty("field").GetString());
Assert.Equal("report.csv", sent.GetProperty("fileName").GetString());
Assert.Equal("text/csv", sent.GetProperty("contentType").GetString());
Assert.Equal(bytes.Length, sent.GetProperty("size").GetInt32());
}
}Prüfsummen und ein realistischer Upload-Endpunkt
Zwei weitere Endpunkte sind für angemeldete Mitglieder. POST /api/bodies/formanmelden zum Anzeigen meldet dieselben Felder und Dateien plus den SHA-256 jeder Datei, ein Test kann also beweisen, dass die Bytes unversehrt angekommen sind, nicht nur die Größe. POST /api/formdata/uploadanmelden zum Anzeigen verhält sich wie eine echte Upload-API: Er speichert die Datei und antwortet mit 201 Created und einer ID.
$ curl -s https://api.testingapis.com/api/bodies/form -F "file=@report.csv;type=text/csv"
{"contentType":"multipart/form-data; boundary=------------------------8iZt5Fmg2P4QL6fBoNn1Ms","encoding":"multipart","fields":{},"files":[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22,"sha256":"2e9f445b06e5ca0fb9638798078f6a239ea1fcace10e1dcfb70ceb3a3322ad44"}]}Was du in deinem Upload-Code prüfen solltest
- Der Header
Content-Typebleibt der Bibliothek überlassen, damit er die Boundary trägt. - Jeder Dateiteil hat einen Dateinamen und einen Content-Type.
- Die Feldnamen passen zu dem, was der Server erwartet (
file,filesoderupload). - Textfelder und Dateien stehen im selben Multipart-Body, nicht in einem separaten JSON-Request.
- Ein Test prüft, was der Server empfangen hat: Namen, Typen und Größen, nicht nur den Statuscode.
Für den Rest eines Requests, Header und Query-Strings eingeschlossen, listet die Referenz der Echo-API jedes Feld, das sie zurückgibt.