Testar uploads de arquivo multipart/form-data

Envie um arquivo em curl, JavaScript, Python e C#, veja exatamente o que o servidor recebeu e evite o erro do boundary.

Exemplos executados pela última vez contra a API em

Para testar um upload de arquivo, você precisa ver o que o servidor recebeu de fato: quais campos, quais arquivos, com quais nomes, tipo de conteúdo e tamanho. POST /api/echo em https://api.testingapis.com lê uma requisição multipart/form-data e devolve isso como JSON. É gratuito, não precisa de chave, não guarda nada e aceita requisições de qualquer origem, então também funciona no navegador. A seguir: um upload que funciona em curl, JavaScript, Python e C#, os erros que quebram uploads em cada um e um teste xUnit.

Um upload multipart com curl

-F faz o curl enviar multipart/form-data. name=value é um campo de texto, name=@path anexa um arquivo e ;type= define o tipo de conteúdo dele. Sem ;type=, o curl envia application/octet-stream. A resposta traz outros detalhes da requisição; aqui o jq fica só com as duas partes que importam:

Terminal
$ 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
    }
  ]
}
POST/api/echo
title=Quarterly report

JavaScript: FormData com fetch

Monte um FormData, adicione campos e arquivos e passe-o como corpo. O mesmo código roda no navegador (com um arquivo vindo de um <input type="file"> no lugar de readFile) e no Node 18 ou mais recente.

upload.mjs
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);
Saída (Node 24)
{ title: 'Quarterly report' } [
  {
    field: 'file',
    fileName: 'report.csv',
    contentType: 'text/csv',
    size: 22
  }
]

Python: requests

Campos de texto vão em data, arquivos em files. Dê a cada arquivo uma tupla com o nome do arquivo, o objeto do arquivo e o tipo de conteúdo. Se você passar só o objeto do arquivo, o tipo de conteúdo fica de fora.

upload.py
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"])
Saída
{'title': 'Quarterly report'} [{'field': 'file', 'fileName': 'report.csv', 'contentType': 'text/csv', 'size': 22}]

C#: MultipartFormDataContent

Adicione um StringContent por campo de texto e um StreamContent (ou ByteArrayContent) por arquivo. O argumento do nome do arquivo é o que torna uma parte um arquivo: sem ele, os bytes do arquivo chegam como um campo de texto comum.

Program.cs
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"));
Saída (.NET 10)
{"title":"Quarterly report"}
[{"field":"file","fileName":"report.csv","contentType":"text/csv","size":22}]

Os erros que quebram uploads

Cada linha foi enviada para /api/echo; a última coluna é o que o servidor leu.

O que o código fezBibliotecasO que o servidor recebeu
Definiu à mão o cabeçalho Content-Type como multipart/form-datafetch, requests, HttpClient (substituindo o Content-Type do formulário)Nada: Missing content-type boundary. O boundary que separa as partes sumiu
Deixou de fora o nome do arquivoHttpClient (form.Add(content, name))O conteúdo do arquivo como campo de texto, e nenhum arquivo
Deixou de fora o nome do arquivofetch (Blob sem nome)Um arquivo chamado blob
Deixou de fora o tipo de conteúdocurl, requests (objeto de arquivo sozinho), HttpClientcurl: application/octet-stream; requests e HttpClient: um tipo de conteúdo vazio
Usou data= sem files=requestsNem é multipart: application/x-www-form-urlencoded

O erro do boundary é o mais comum. Isto é o que o servidor vê:

JSON
{
  "form": { "parseError": "Missing content-type boundary." },
  "files": null,
  "contentType": "multipart/form-data"
}

Um teste xUnit para um upload

Ele verifica o que chegou ao servidor, não só que a requisição devolveu 200. Passa com xUnit 2.9 no .NET 10.

UploadTests.cs
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());
    }
}

Checksums e um endpoint de upload realista

Mais dois endpoints são para membros conectados. POST /api/bodies/formentre para ver informa os mesmos campos e arquivos, mais o SHA-256 de cada arquivo, para que um teste prove que os bytes chegaram intactos, e não só o tamanho. POST /api/formdata/uploadentre para ver se comporta como uma API de upload de verdade: guarda o arquivo e responde 201 Created com um id.

Terminal
$ 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"}]}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

O que verificar no seu código de upload

  • O cabeçalho Content-Type fica a cargo da biblioteca, para levar o boundary.
  • Toda parte de arquivo tem um nome de arquivo e um tipo de conteúdo.
  • Os nomes dos campos batem com o que o servidor espera (file, files ou upload).
  • Campos de texto e arquivos vão no mesmo corpo multipart, não numa requisição JSON separada.
  • Um teste verifica o que o servidor recebeu: nomes, tipos e tamanhos, não só o código de status.

Para o resto de uma requisição, incluindo cabeçalhos e query strings, a referência da API echo lista todos os campos que ela devolve.