Tester les envois de fichiers en multipart/form-data
Envoyez un fichier en curl, JavaScript, Python et C#, voyez exactement ce que le serveur a reçu et évitez l’erreur du boundary.
Exemples exécutés pour la dernière fois contre l’API le
Pour tester un envoi de fichier, il faut voir ce que le serveur a réellement reçu : quels champs, quels fichiers, sous quels noms, avec quel type de contenu et quelle taille. POST /api/echo sur https://api.testingapis.com analyse une requête multipart/form-data et vous la renvoie en JSON. Gratuit, sans clé, sans rien conserver, et ouvert à toute origine, il fonctionne donc aussi depuis un navigateur. Ci-dessous : un envoi qui marche en curl, JavaScript, Python et C#, les erreurs qui cassent les envois dans chacun, et un test xUnit.
Un envoi multipart avec curl
-F fait envoyer à curl du multipart/form-data. name=value est un champ texte, name=@path joint un fichier, et ;type= fixe son type de contenu. Sans ;type=, curl envoie application/octet-stream. La réponse contient d’autres détails de la requête ; ici, jq ne garde que les deux parties utiles :
$ 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 avec fetch
Construisez un FormData, ajoutez-y des champs et des fichiers, et passez-le comme corps. Le même code tourne dans le navigateur (avec un fichier issu d’un <input type="file"> au lieu de readFile) et dans Node 18 ou plus récent.
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
Les champs texte vont dans data, les fichiers dans files. Donnez à chaque fichier un tuple avec le nom du fichier, l’objet fichier et le type de contenu. Avec un objet fichier seul, le type de contenu est omis.
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
Ajoutez un StringContent par champ texte et un StreamContent (ou ByteArrayContent) par fichier. C’est l’argument du nom de fichier qui fait d’une partie un fichier : sans lui, les octets du fichier arrivent comme un simple champ texte.
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}]Les erreurs qui cassent les envois
Chaque ligne a été envoyée à /api/echo ; la dernière colonne indique ce que le serveur a analysé.
| Ce que le code a fait | Bibliothèques | Ce que le serveur a reçu |
|---|---|---|
Définir à la main l’en-tête Content-Type à multipart/form-data | fetch, requests, HttpClient (en remplaçant le Content-Type du formulaire) | Rien : Missing content-type boundary. Le boundary qui sépare les parties a disparu |
| Omettre le nom de fichier | HttpClient (form.Add(content, name)) | Le contenu du fichier comme champ texte, et aucun fichier |
| Omettre le nom de fichier | fetch (Blob sans nom) | Un fichier nommé blob |
| Omettre le type de contenu | curl, requests (objet fichier seul), HttpClient | curl : application/octet-stream ; requests et HttpClient : un type de contenu vide |
Utiliser data= sans files= | requests | Pas de multipart du tout : application/x-www-form-urlencoded |
L’erreur du boundary est la plus courante. Voici ce que voit le serveur :
{
"form": { "parseError": "Missing content-type boundary." },
"files": null,
"contentType": "multipart/form-data"
}Un test xUnit pour un envoi
Il vérifie ce qui est arrivé au serveur, pas seulement que la requête a renvoyé 200. Il passe avec xUnit 2.9 sur .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());
}
}Sommes de contrôle et un point d’accès d’envoi réaliste
Deux autres points d’accès sont réservés aux membres connectés. POST /api/bodies/formconnectez-vous pour voir indique les mêmes champs et fichiers, plus le SHA-256 de chaque fichier, pour qu’un test prouve que les octets sont arrivés intacts, et pas seulement la taille. POST /api/formdata/uploadconnectez-vous pour voir se comporte comme une vraie API d’envoi : il stocke le fichier et répond 201 Created avec un 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"}]}Ce qu’il faut vérifier dans votre code d’envoi
- L’en-tête
Content-Typeest laissé à la bibliothèque, pour qu’il porte le boundary. - Chaque partie fichier a un nom de fichier et un type de contenu.
- Les noms des champs correspondent à ce qu’attend le serveur (
file,filesouupload). - Les champs texte et les fichiers vont dans le même corps multipart, pas dans une requête JSON séparée.
- Un test vérifie ce que le serveur a reçu : noms, types et tailles, pas seulement le code de statut.
Pour le reste d’une requête, en-têtes et query string compris, la référence de l’API echo liste chaque champ qu’elle renvoie.