Probar subidas de archivos multipart/form-data

Sube un archivo en curl, JavaScript, Python y C#, mira exactamente lo que recibió el servidor y evita el error del boundary.

Ejemplos ejecutados por última vez contra la API el

Para probar una subida de archivos necesitas ver qué recibió de verdad el servidor: qué campos, qué archivos, con qué nombres, con qué tipo de contenido y tamaño. POST /api/echo en https://api.testingapis.com analiza una petición multipart/form-data y te la devuelve como JSON. Es gratuito, no necesita clave, no guarda nada y acepta peticiones desde cualquier origen, así que también funciona desde un navegador. Abajo: una subida que funciona en curl, JavaScript, Python y C#, los errores que rompen las subidas en cada uno y un test de xUnit.

Una subida multipart con curl

-F hace que curl envíe multipart/form-data. name=value es un campo de texto, name=@path adjunta un archivo y ;type= fija su tipo de contenido. Sin ;type=, curl envía application/octet-stream. La respuesta trae también otros detalles de la petición; aquí jq se queda con las dos partes que importan:

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 con fetch

Crea un FormData, añade campos y archivos y pásalo como cuerpo. El mismo código funciona en el navegador (con un archivo de un <input type="file"> en lugar de readFile) y en Node 18 o posterior.

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

Python: requests

Los campos de texto van en data y los archivos en files. Da a cada archivo una tupla con el nombre del archivo, el objeto del archivo y el tipo de contenido. Si pasas solo el objeto del archivo, el tipo de contenido se queda fuera.

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

C#: MultipartFormDataContent

Añade un StringContent por campo de texto y un StreamContent (o ByteArrayContent) por archivo. El argumento del nombre de archivo es lo que convierte una parte en archivo: si lo omites, los bytes del archivo llegan como un campo de texto normal.

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

Los errores que rompen las subidas

Cada fila se envió a /api/echo; la última columna es lo que analizó el servidor.

Lo que hizo el códigoLibreríasLo que recibió el servidor
Poner a mano la cabecera Content-Type a multipart/form-datafetch, requests, HttpClient (sustituyendo el Content-Type del formulario)Nada: Missing content-type boundary. El boundary que separa las partes ha desaparecido
Omitir el nombre de archivoHttpClient (form.Add(content, name))El contenido del archivo como campo de texto, y ningún archivo
Omitir el nombre de archivofetch (Blob sin nombre)Un archivo llamado blob
Omitir el tipo de contenidocurl, requests (objeto de archivo solo), HttpClientcurl: application/octet-stream; requests y HttpClient: un tipo de contenido vacío
Usar data= sin files=requestsNada de multipart: application/x-www-form-urlencoded

El error del boundary es el más común. Esto es lo que ve el servidor:

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

Un test de xUnit para una subida

Comprueba lo que llegó al servidor, no solo que la petición devolviera 200. Pasa con xUnit 2.9 en .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 y un endpoint de subida realista

Hay dos endpoints más para miembros con sesión iniciada. POST /api/bodies/forminicia sesión para verlo informa de los mismos campos y archivos más el SHA-256 de cada archivo, para que un test pueda demostrar que los bytes llegaron intactos, no solo el tamaño. POST /api/formdata/uploadinicia sesión para verlo se comporta como una API de subida real: guarda el archivo y responde 201 Created con un 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 ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Qué comprobar en tu código de subida

  • La cabecera Content-Type se deja a la librería, así lleva el boundary.
  • Cada parte de archivo tiene un nombre de archivo y un tipo de contenido.
  • Los nombres de los campos coinciden con lo que espera el servidor (file, files o upload).
  • Los campos de texto y los archivos van en el mismo cuerpo multipart, no en una petición JSON aparte.
  • Un test comprueba lo que recibió el servidor: nombres, tipos y tamaños, no solo el código de estado.

Para el resto de una petición, cabeceras y query string incluidas, la referencia de la API echo lista todos los campos que devuelve.