CodeGym /Cursos /Spring REST & MVC /Multipart/form-data y file upload

Multipart/form-data y file upload

Spring REST & MVC
Nivel 26, Lección 0
Disponible

1. JSON vs upload: cuándo se necesita multipart

Los comentarios, etiquetas y otros supporting subresources aún vivían tranquilamente en JSON puro. Con los adjuntos este modo se rompe de inmediato: el recurso adquiere contenido de archivo real, y un solo @RequestBody ya no es suficiente.

Cuando escribimos durante mucho tiempo una API JSON-first, el cerebro se vuelve perezoso y piensa así: «La solicitud es JSON, la respuesta es JSON, y todo lo demás son adornos opcionales». Esto es normal, a todos nos pasa, incluso a aquellos que luego en una entrevista dicen con confianza “me encanta la arquitectura limpia” (y luego ponen lógica de negocio en el controlador, pero esa es otra historia). Los archivos nos devuelven a la realidad: el cuerpo de la solicitud es en realidad solo un flujo de bytes, y no todo contenido útil se expresa cómodamente con un solo documento JSON.

En el escenario application/json todo es bonito: hay un solo cuerpo, dentro un único documento estructurado, y Spring MVC puede decir: «Ajá, esto es TaskCreateRequest», deserializarlo, validarlo y pasarlo adelante. Pero en el escenario de upload necesitamos transmitir dos cosas diferentes al mismo tiempo: el contenido binario del archivo (que puede no ser texto en absoluto) y los metadatos (por ejemplo, la descripción del adjunto), que es cómodo mantener en JSON. Y estas dos entidades viven bajo reglas distintas: el archivo tiene nombre, tipo, tamaño; los metadatos tienen estructura y validación.

Teóricamente se puede “meter el archivo en JSON” como una cadena Base64. En la práctica casi siempre es una mala idea: el payload se hincha, el cliente sufre, el servidor sufre, los logs sufren especialmente (porque alguien seguramente registrará el cuerpo de la solicitud “para depuración”, y luego todos recordarán ese día como su primer encuentro con un objeto binario grande). Por eso, para el escenario de archivos existe un mecanismo estándar en HTTP: multipart/form-data.

Para sentir el contraste, veamos un endpoint JSON habitual de creación de tareas: aquí todo encaja en un único DTO a partir de un solo cuerpo:

import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
    // @RequestBody: toma un único JSON del cuerpo y lo mapea al DTO
    // @Valid: ejecuta Bean Validation antes de entrar en la lógica de negocio
    TaskDetailsResponse created = taskService.create(request); // el controlador solo delega
    return ResponseEntity.status(HttpStatus.CREATED).body(created); // 201 + cuerpo de respuesta
}

Aquí el contrato es simple: el cliente envió un único JSON, nosotros recibimos un único objeto Java. Para un archivo, ese formato «único» rápidamente se convierte en un suplicio, por lo que necesitamos otro tipo de cuerpo de solicitud.

2. Multipart: una sola solicitud HTTP

Para decirlo de manera llana, multipart/form-data es una forma de hacer una sola solicitud HTTP que contiene internamente varias partes separadas, cada una con sus propios mini-encabezados y su propio contenido. Imagine una caja con varios paquetes dentro: la caja es una (solicitud HTTP), pero los paquetes son muchos (parts). No son dos solicitudes, ni «primero metadatos, luego archivo», sino una sola operación con un único contrato.

A nivel de HTTP esto se expresa mediante el encabezado Content-Type: multipart/form-data; boundary=.... La palabra boundary es el delimitador que ayuda al servidor a entender dónde termina una parte y comienza la siguiente. No convertiremos la lección en un curso de «parser multipart hecho a mano en Java puro» (de lo contrario sospecharían con razón que siento una atracción oculta por el dolor), pero la idea básica hay que entenderla: el cuerpo de la solicitud en multipart es una “unión” de partes separadas por límites.

Conviene fijar las diferencias entre application/json y multipart/form-data en una pequeña tabla: así es más fácil no confundirse:

Aspecto application/json multipart/form-data
Cuántas partes en el body Una Varias (parts)
Qué hay dentro Un documento JSON Conjunto de partes: cada parte tiene sus headers + contenido
Tipo de contenido Normalmente texto/JSON Mixto: JSON + datos binarios
Para qué es útil CRUD, update, search Subida de archivos y payloads mixtos
Idea principal un DTO a partir de un solo body varias partes con diferentes roles

Para ver el aspecto «real», aquí hay un ejemplo muy simplificado (pero honesto en cuanto al significado) de una solicitud HTTP multipart cruda. Es más larga que JSON, y eso es normal: hace más trabajo.

# el límite establece el separador entre las partes del cuerpo multipart
POST /api/v1/tasks/123/attachments HTTP/1.1
Content-Type: multipart/form-data; boundary=---abc

# Parte 1: metadatos JSON (nombre de la parte — metadata)
-----abc
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{"description":"Logs de inicio de la aplicación"}

# Parte 2: archivo (nombre de la parte — file, además filename)
-----abc
Content-Disposition: form-data; name="file"; filename="logs.txt"
Content-Type: text/plain

...bytes del archivo...

# Límite de cierre (nótese el -- al final)
-----abc--

Observe dos ideas clave: cada parte tiene un nombre (name="metadata" y name="file"), y cada parte puede tener su propio Content-Type. De este modelo surge luego el binding de Spring MVC mediante @RequestPart.

3. Part: nombre y encabezados

La palabra part suena muy modesta, casi como «un pedacito de algo». Pero en el contrato de la API es una entidad importante: part es un fragmento con nombre de una solicitud multipart que el servidor debe poder encontrar, leer e interpretar. Si una part falta o tiene un formato inesperado, el servidor debe responder con un error tan honestamente como lo hicimos con un JSON malformado o un parámetro de consulta inválido en módulos anteriores.

Una part suele tener sus propios encabezados. El más notable es Content-Disposition: form-data; name="...". Por él el servidor entiende cómo se llama esa parte. Si es un archivo, se añade filename="...", es decir, el nombre original del archivo que envió el cliente. Además, una part suele tener su propio Content-Type, para que el servidor pueda entender qué contiene: application/json, text/plain, image/png, application/pdf, etc.

Un punto importante para la mentalidad del desarrollador backend: el nombre de la part es parte del contrato público, casi al mismo nivel que la ruta y los nombres de los campos JSON. Si el cliente sabe que debe enviar una part file, y el servidor de repente espera una part upload, eso es exactamente un breaking change, como renombrar el campo title a taskTitle sin anunciar migración. El cliente no es telépata.

Para mayor claridad, podemos representar la solicitud multipart como un pequeño esquema. Nos importa la estructura, no los detalles del parseo:

flowchart TD
    A["Solicitud HTTP Content-Type: multipart/form-data"] --> B["Part: metadata Content-Type: application/json"]
    A --> C["Part: file Content-Type: image/png / application/pdf / ..."]

Y otra idea que vale la pena retener: multipart no es un «caos de campos de formulario». En nuestro curso lo hacemos un contrato tan limpio como los endpoints JSON, solo que la forma del cuerpo es diferente.

4. Partes de la solicitud: file y metadata

Ahora vamos a aterrizar multipart en nuestro dominio, de lo contrario seguirá siendo un “formato para formularios en el navegador” abstracto. En la API de Task Tracker, un adjunto es un supporting subresource de la tarea. El cliente sube un archivo y (opcionalmente) adjunta una descripción. Pero hay un límite importante: el cliente no debe gestionar campos gestionados por el servidor que el servidor puede determinar por sí mismo.

Por ejemplo, atributos como size, contentType, originalFileName, en realidad vienen con el archivo (o se calculan en el servidor a partir del archivo). Si permitimos que el cliente los envíe como «metadatos», abrimos la puerta a rarezas: el cliente dice que el archivo es image/png, pero en realidad envía un PDF; o dice que mide 10 bytes, pero envía 10 megabytes. El servidor igualmente tendrá que verificar la realidad. Por lo tanto, estos campos no deben formar parte del DTO de solicitud.

Por eso, en nuestro contrato conviene dividir los roles así: la part file lleva el contenido del archivo, y la part metadata lleva un JSON con lo que realmente controla el cliente. Por ejemplo, description.

Aquí está el DTO de solicitud canónico para los metadatos de subida, pequeño y comprensible:

import jakarta.validation.constraints.Size;

public record AttachmentUploadMetadataRequest(
        // Limitamos la longitud de la descripción: es un campo controlado por el cliente
        @Size(max = 255) String description
) {
}

Este DTO encaja perfectamente en el modelo que ya conocemos: JSON + Bean Validation. La diferencia es solo que este JSON no está en el cuerpo general de la solicitud, sino en una parte separada de la solicitud multipart.

Y la part file es de «otra naturaleza»: trata sobre bytes. Y tiene sus propias propiedades que Spring podrá proporcionarnos a través de una API conveniente (nos acercaremos a ella con cuidado, sin adelantarnos).

5. Nombres de partes como API pública

Cuando diseñamos una API REST, ya nos hemos acostumbrado a que el contrato no es solo «qué URL invocar». El contrato también incluye los parámetros de consulta, la estructura del JSON, los estados de respuesta, los errores. Multipart añade una capa más de contrato: los nombres de las partes (file, metadata). Y esta capa es igual de importante que los nombres de los campos en el DTO, porque el cliente necesita poder formar la solicitud de manera reproducible.

Imaginemos una situación. Lanzas una API, el cliente se integra, envía la part file y la part metadata. Una semana después decides que la palabra metadata suena demasiado adulta y renombras la part a info. En el servidor son «un par de caracteres». En el cliente, errores repentinos, porque el servidor dice: «¿Dónde está metadata?». El cliente responde: «¡Pero yo envié info!». Ambos tienen razón a su manera, pero la API igualmente está rota.

Por eso, al diseñar el contrato multipart elegimos nombres de partes que sean sustantivos y obvios, y luego los tratamos como estables. En este curso, un buen estándar es file para el archivo y metadata para los metadatos en JSON. Estas palabras no son perfectas (no existen palabras perfectas), pero son lo más claras posible: un archivo, una descripción.

Otro matiz: los nombres de las partes no son un «detalle interno de Spring». Viven a nivel HTTP. Incluso si mañana reescribes la aplicación sin Spring (no lo hagas, pero imaginémoslo por el experimento), el cliente seguirá enviando las mismas partes en el mismo formato. Esto significa que los nombres de las partes son parte de tu contrato de API, no un tecnicismo menor.

6. Content-Type para el endpoint multipart

En módulos anteriores ya usábamos consumes y produces como una forma de hacer el contrato más explícito. Con multipart esto se vuelve aún más importante, porque el servidor debe decir honestamente al cliente: «Este endpoint espera multipart, no JSON». De lo contrario, se producen situaciones extrañas: el cliente envió JSON, el servidor intentó leer multipart, falló en algún lugar de la conversión, y el cliente luego piensa que «Spring hace magia otra vez».

Del lado del cliente, el formato se expresa mediante el encabezado Content-Type. Para multipart será multipart/form-data; boundary=... (boundary lo genera el cliente HTTP). Del lado del servidor en Spring MVC lo fijamos mediante consumes = MediaType.MULTIPART_FORM_DATA_VALUE.

Para contrastar, mostramos nuevamente dos contratos diferentes a nivel de controlador.

Endpoint JSON create (un cuerpo, un DTO):

import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
    // Importante: consumes fija el formato de la solicitud a nivel de contrato (aquí JSON)
    TaskDetailsResponse created = taskService.create(request); // lógica de negocio fuera del controlador
    return ResponseEntity.status(HttpStatus.CREATED).body(created); // devolvemos 201 Created
}

Endpoint multipart upload (una solicitud, pero varias parts). Aquí nos basta con ver la forma misma de la solicitud multipart, por lo que dejamos metadata como cadena:

import org.springframework.http.*;
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)
public ResponseEntity<Void> upload(@PathVariable String taskId,
                                  @RequestPart("file") MultipartFile file,
                                  @RequestPart("metadata") String metadataJson) {
    // @RequestPart("file"): extrae la parte binaria por el nombre "file"
    // @RequestPart("metadata"): extrae la parte JSON por el nombre "metadata"
    // consumes = MULTIPART_FORM_DATA: el servidor dice explícitamente que espera una solicitud multipart
    return ResponseEntity.status(HttpStatus.CREATED).build(); // 201 sin cuerpo
}

Sí, este método se ve inusual porque no tiene @RequestBody. Y esa es precisamente la señal correcta: «la solicitud tiene una forma de cuerpo diferente». El controlador refleja literalmente el contrato.

7. Cómo Spring MVC vincula los parts

Muchas ganas de pensar que multipart es «un universo aparte». En realidad se integra en el mismo modelo de Spring MVC que ya hemos analizado: llega la solicitud, el framework extrae los datos de entrada, los convierte a los tipos Java necesarios y llama al método del controlador. Solo que ahora los datos de entrada no están en un solo cuerpo, sino en varias partes. Más bien es una «extensión de formato» que una nueva paradigma.

Y aquí hay una agradable simetría de ingeniería. Para el archivo, Spring proporciona un envoltorio conveniente del que se pueden obtener propiedades básicas. Incluso sin leer el contenido, podemos entender qué nos enviaron:

import org.springframework.web.multipart.MultipartFile;

// Nombre original del archivo del cliente (puede ser null — depende del cliente y la implementación)
String originalName = file.getOriginalFilename(); // por ejemplo: "report.pdf"

// Tipo MIME que llegó en los encabezados de la parte (puede ser null/inexacto, el servidor igual lo verifica)
String contentType = file.getContentType();       // por ejemplo: "application/pdf"

// Tamaño del cuerpo del archivo en bytes (útil para limitaciones y logging)
long size = file.getSize();                       // por ejemplo: 12345

Y la parte JSON (metadata) sigue siendo en esencia JSON. Es decir, tiene Content-Type: application/json, estructura y reglas que conocemos de @RequestBody. La única diferencia es que ese JSON llega como una part separada. Aquí solo necesitamos una conclusión: multipart trae varias entradas, y Spring MVC sabe distribuirlas entre los argumentos del método, sin necesidad de parsear manualmente cadenas y sin «magia por la magia misma».

Por ahora es importante recordar el modelo mental básico: multipart es una solicitud que «trae varias entradas», y Spring MVC sabe colocarlas en los argumentos del método si las describimos explícita y honestamente.

8. Errores típicos en multipart upload

Antes de empezar a elegir entre MultipartFile, Part y diferentes variantes de @RequestPart, es útil repasar los errores típicos a nivel de pensamiento. No son sobre sintaxis, sino sobre lo fácil que es romper el contrato incluso si el código compila, e incluso si “en mi máquina funciona”.

Error n.º 1: intentar representar el upload como un @RequestBody normal.
Una idea muy frecuente: «¿Y si hacemos AttachmentUploadRequest con un byte[] fileContent y un String description?». Formalmente es posible, pero en la práctica obtienes enormes JSON, problemas de tamaño, carga inútil en la serialización y mala UX. En el contrato de upload es mejor separar la parte binaria y los metadatos mediante multipart.

Error n.º 2: tratar los nombres de las partes como nombres «internos».
Cuando escribes @RequestPart("file"), parece que es solo una cadena en el código que se puede cambiar «en cualquier momento». Pero el cliente forma la solicitud exactamente con ese nombre. Renombrar nombres de partes es un breaking change exactamente como renombrar un campo JSON. En una API estable, los nombres de las partes se eligen deliberadamente y se mantienen constantes.

Error n.º 3: hacer nombres de partes abstractos (data1, data2).
Sí, el código funcionará. Pero el contrato se vuelve ilegible: el cliente abre Swagger/README/.http y ve «data1». ¿Qué es eso? ¿Archivo? ¿Metadatos? ¿JSON? ¿Conjunto de bytes? Nombres claros como file y metadata reducen drásticamente la carga cognitiva tanto para el cliente como para el futuro tú (que dentro de un mes ya habrá olvidado lo que querías decir).

Error n.º 4: llevar los metadatos a los parámetros de consulta «solo porque es más fácil».
A veces parece conveniente hacer POST /attachments?description=... y poner el archivo aparte. Pero los metadatos son parte de la misma operación de subida. Mantenerlos en la parte multipart metadata suele ser más simple para el cliente, más simple para la validación y escala mejor si mañana aparece otro campo en los metadatos.

Error n.º 5: olvidar que multipart también es un contrato con sus propios errores.
Si una parte falta, si la parte no es del tipo esperado, si el Content-Type de la solicitud es incorrecto, el servidor debe responder de manera predecible: con un estado correcto y en tu formato unificado ProblemDetail. Multipart no es un «caos especial» donde se pueda responder de cualquier manera. Al contrario: aquí es donde la disciplina del contrato se nota especialmente.

1
Tarea
Spring REST & MVC, nivel 26, lección 0
Bloqueada
Endpoint multipart de diagnóstico
Endpoint multipart de diagnóstico
1
Tarea
Spring REST & MVC, nivel 26, lección 0
Bloqueada
Dos contratos diferentes para la nota
Dos contratos diferentes para la nota
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION