CodeGym /Cursos /Spring REST & MVC /consumes y produces en Spring MVC

consumes y produces en Spring MVC

Spring REST & MVC
Nivel 7, Lección 3
Disponible

1. Fijación explícita del formato en API JSON-first

Al principio parece que consumes/produces son una burocracia del tipo «marque la casilla de que marcó la casilla». Pero en el diseño de API los detalles de las cabeceras son como la firma en un contrato: mientras todo va bien, no se nota, pero cuando todo va mal, de repente resulta que es ella la que resuelve la disputa. Si no fijamos el formato explícitamente, el cliente puede enviar el cuerpo de la solicitud en un formato inesperado (o con un Content-Type inesperado), y luego estaremos mucho tiempo preguntándonos por qué el controlador «como si no se invocara». Del mismo modo, si el cliente pide un formato a través de Accept y el servidor devuelve otro, esto se convierte en un problema «flotante» donde cada uno está seguro de que la culpa no es suya.

En nuestro curso hacemos conscientemente una API JSON-first, es decir, el formato principal es application/json. Esto no significa que podamos dejar de pensar en los media types. Al contrario: ya que conscientemente elegimos un formato, es lógico conscientemente declararlo en el contrato de los endpoints. Entonces cualquier desarrollador, al abrir el controlador, en 10 segundos entiende: «ajá, este endpoint acepta JSON y devuelve JSON», y no «bueno, supongo que por defecto es así». Esto le ahorra tiempo de depuración, tiempo de explicar a los colegas y tiempo de correspondencia con clientes que envían text/plain porque «es más cómodo».

Content-Type y Accept: cabeceras de formato

Para usar correctamente consumes y produces, debemos recordar rápidamente el significado de las dos cabeceras clave. No es «teoría de RFC», sino literalmente el idioma en el que el cliente y el servidor acuerdan cómo leer y cómo escribir el body. Content-Type se refiere al cuerpo de la solicitud: el cliente le dice al servidor «aquí está mi body, está en este formato». Accept se refiere al cuerpo de la respuesta: el cliente le dice al servidor «quiero recibir la respuesta en este formato».

En una API JSON-first normalmente se ve así: el cliente envía Content-Type: application/json y también (a menudo) añade Accept: application/json. Si no se especifica Accept, muchos clientes por defecto se comportan como si fuera */* (es decir, «me vale cualquier formato»), y el servidor devuelve lo que sabe. Pero en la vida real, tener un Accept explícito ayuda mucho: convierte la «esperanza» en un contrato.

Una pequeña tabla para que todo quede claro en la mente (y para que no lo confunda automáticamente):

Entidad Dónde vive Quién la define De qué habla Ejemplo
Content-Type cabecera HTTP de la solicitud cliente «¿Qué formato tiene mi request body?» Content-Type: application/json
Accept cabecera HTTP de la solicitud cliente «¿En qué formato quiero el response body?» Accept: application/json
consumes anotación de mapping en Spring MVC servidor (nosotros) «Qué Content-Type estoy dispuesto a aceptar en el request body» consumes = "application/json"
produces anotación de mapping en Spring MVC servidor (nosotros) «Qué formatos estoy dispuesto a devolver en el response body» produces = "application/json"

Observen una pequeña «trampa para principiantes»: Content-Type y Accept son cabeceras en la solicitud, mientras que consumes/produces son «reglas» del lado del servidor según las cuales Spring seleccionará su método de controlador o dirá «no, no habíamos quedado así». Y es este comportamiento el que necesitamos hoy.

2. consumes y produces como filtro del contrato

Si lo explicamos de la forma más humana posible, consumes y produces son parte de las reglas de matching del endpoint. Es decir, Spring no solo mira la ruta y el método HTTP (POST /api/v1/tasks), sino que también mira: «¿esta solicitud encaja en el formato?». Y aquí consumes/produces imponen disciplina: si la solicitud llega con un Content-Type que usted no acepta, Spring puede devolver 415 Unsupported Media Type. Si la solicitud llega con un Accept que usted no puede satisfacer, Spring puede devolver 406 Not Acceptable.

Es importante sentir el momento: en tales casos su código dentro del método del controlador puede no ejecutarse en absoluto. Y esto es normal. No es que «Spring se haya roto». Significa que la solicitud no cumple el contrato a nivel del marco HTTP. Su lógica de negocio no tiene nada que ver aquí: simplemente no entiende cómo leer el cuerpo o no puede devolver la respuesta como el cliente pide.

Se puede imaginar como un torniquete en el metro. Usted tiene billete — pasa; no tiene billete — no pasa. Y el torniquete no está obligado a discutir con usted cuestiones filosóficas del «por qué es así», su tarea es filtrar el flujo de forma rápida y clara. consumes y produces son un torniquete similar, pero para el formato del body.

El esquema a nivel «suficiente para un Junior» puede ser así:

flowchart TD
    A[Solicitud HTTP] --> B[Spring MVC: búsqueda del handler method adecuado]
    B --> C{¿Coinciden la ruta y el método?}
    C -->|no| X["404 / no handler"]
    C -->|sí| D{¿coincide consumes con Content-Type?}
    D -->|no| E[415 Unsupported Media Type]
    D -->|sí| F{¿coincide produces con Accept?}
    F -->|no| G[406 Not Acceptable]
    F -->|sí| H[Invocación del método del controlador]

Por ahora no analizamos la mecánica interna de «quién lee exactamente el body» ni «qué componente se encarga del JSON». Pero ya es útil tener en mente: consumes/produces son parte del filtrado de la solicitud antes de su lógica de aplicación, y esto se relaciona directamente con el contrato de la API.

3. Fijamos el media type en el controlador y los métodos

produces a nivel de TaskController

Ahora vamos a hacer algo práctico en nuestra API Task Tracker: a nivel del controlador fijaremos que devuelve JSON. ¿Por qué a nivel del controlador? Porque así no tendremos que escribir lo mismo en cada método, y el contrato se volverá legible «desde fuera»: abre la clase e inmediatamente entiendes que aquí tenemos application/json.

Imagine que el controlador es una «carpeta» con endpoints. Si en esa carpeta casi todas las respuestas son JSON, es lógico escribirlo una vez en la carpeta y no en cada archivo dentro. Para un controlador JSON-first esto es conveniente también porque otro produces solo se necesita donde la respuesta en sí tiene un formato diferente. Pero hoy estamos en el mundo JSON-first, y nos importa hacerlo explícito.

Estructura mínima del controlador con produces:

package com.example.tasktracker.api.controller;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // Le decimos a Spring: esto es un controlador REST, las respuestas se irán al body
@RequestMapping(
        path = "/api/v1/tasks", // Ruta base para todos los endpoints de tareas
        produces = MediaType.APPLICATION_JSON_VALUE // Por defecto devolvemos JSON
)
public class TaskController {

    // Aquí estarán los handler-methods (GET/POST/PUT/DELETE) para trabajar con tareas
}

Aquí hacemos dos cosas importantes. Primero, fijamos la ruta base /api/v1/tasks para que los métodos internos sean más compactos. Segundo, le decimos a Spring MVC: «todo lo que salga de este controlador como response body se devolverá como JSON». Y esto no es solo «para que el cliente lo vea bonito». Es para que nuestra API sea predecible: incluso si devuelve un objeto directamente, Spring establecerá el Content-Type correcto en la respuesta.

consumes para @RequestBody

El siguiente paso lógico es fijar el formato de entrada para aquellas operaciones donde leemos el request body. En nuestro proyecto esto es la creación y actualización de tareas. Cuando en la firma del método aparece @RequestBody, en esencia decimos: «el cliente envía un cuerpo, nosotros lo leemos». Por lo tanto, es útil decir de inmediato: «solo leemos JSON». Y esto se expresa precisamente a través de consumes = application/json.

¿Por qué no es solo una «bonitez»? Porque de lo contrario el cliente podría enviar un body sin Content-Type (o con text/plain), y usted se sorprenderá de que Spring no quiera convertirlo en su objeto Java. Con consumes todo se vuelve honesto: tenemos un contrato — JSON, y si el cliente trae algo que no es JSON, la solicitud no pasa.

Ejemplo de POST de creación de tarea:

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import java.net.URI;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) // Aceptamos solo JSON en el request body
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
    // request proviene del cuerpo JSON de la solicitud y ya ha sido parseado por Spring

    Task created = taskService.create(request.getTitle(), request.getDescription());

    // Para 201 Created es una buena práctica devolver Location con la URL del recurso creado
    URI location = URI.create("/api/v1/tasks/" + created.getId());

    return ResponseEntity
            .created(location) // HTTP 201 + cabecera Location
            .body(created);    // Cuerpo de la respuesta (JSON, porque produces está definido en el controlador)
}

Observe el detalle: aquí no escribimos produces porque ya lo fijamos a nivel del controlador. Si prefiere leerlo así, puede escribirlo también en el método, pero entonces empezará a repetir lo mismo. En un proyecto de aprendizaje es mejor mantener la repetición bajo control para que el código no parezca un «empapelado de anotaciones».

Para PUT y PATCH el enfoque es el mismo: si hay @RequestBody, entonces el método tiene un formato de entrada y podemos definirlo explícitamente:

import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@PutMapping(path = "/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) // Leemos JSON del body
public ResponseEntity<Task> replace(@PathVariable String taskId,
                                    @RequestBody TaskUpdateRequest request) {
    // taskId proviene de la URL, request del cuerpo JSON
    Task updated = taskService.replace(taskId, request.getTitle(), request.getDescription());

    return ResponseEntity.ok(updated); // 200 OK + cuerpo JSON (produces en el controlador)
}

En cambio, DELETE generalmente no requiere consumes: la eliminación normalmente no tiene cuerpo de solicitud. Con produces también hay que tener cuidado, porque 204 No Content es un éxito sin body. Puede tener un produces general en el controlador y funcionará, pero a nivel de significado es útil recordar: 204 significa «sin cuerpo», por lo tanto el formato del cuerpo ya no es tan importante como el estado.

4. Solicitudes del cliente y 415/406

Ahora viene un momento útil que ahorra muchas horas de depuración. Cuando ha agregado consumes/produces, parte de las solicitudes pueden empezar a devolver 415/406, y un desarrollador novato a menudo lo percibe como «se me ha roto algo en el controlador». Pero no: simplemente ha agregado disciplina contractual, y ahora las solicitudes deben cumplirla.

Ejemplo de una solicitud correcta de creación de tarea en estilo .http:

# Solicitud correcta: tanto Content-Type como Accept están alineados con el contrato del controlador
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Fix login bug",
  "description": "Users cannot sign in after last deploy"
}

Si accidentalmente envía el body sin Content-Type o con un tipo incorrecto, por ejemplo así:

# Content-Type incorrecto: el servidor espera JSON, pero llegó text/plain
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json

{"title":"Fix login bug"}

entonces Spring puede devolver 415 Unsupported Media Type. Y es importante: no es un error de «su lógica de negocio». Es un error a nivel de «no nos pusimos de acuerdo en cómo leer el body». Su taskService.create(...) en este escenario no tiene la culpa y, muy probablemente, ni siquiera fue invocado.

Y ahora un ejemplo con Accept. Supongamos que el cliente dice: «quiero texto»:

# Accept incorrecto: el cliente pide text/plain, pero el servidor por contrato devuelve JSON
GET http://localhost:8080/api/v1/tasks
Accept: text/plain

Si el controlador tiene produces = application/json, entonces Spring puede devolver 406 Not Acceptable. Es una situación en la que «el servidor solo puede devolver JSON, pero el cliente quiere text/plain». En la vida real los navegadores normalmente no harán esto, pero algunas herramientas e integraciones sí pueden.

Y aquí aparece un hábito de ingeniería muy útil: cuando ve 415 o 406, lo primero que mira no es el servicio ni el repositorio, sino las cabeceras de la solicitud. Es el lugar donde más a menudo está el problema.

5. Cómo evitar un «bosque de anotaciones»

Ahora, sobre la disciplina en el código. Es muy fácil caer en dos extremos. El primer extremo es no fijar nada y esperar que «por defecto ya es JSON». El segundo extremo es escribir consumes y produces en todas partes, además con cadenas, a veces con errores tipográficos, y luego depurar heroicamente por qué la mitad de los endpoints no hacen match.

El camino saludable para un proyecto de aprendizaje suele ser este: produces lo fijamos a nivel del controlador (o del @RequestMapping general), para que todo el conjunto de endpoints tenga un mismo formato. Y consumes lo colocamos solo donde efectivamente leemos el body a través de @RequestBody. Esto hace explícito el contrato, pero no convierte el código en una cortina de anotaciones.

Al mismo tiempo es importante evitar cadenas mágicas. Escribir "application/json" a mano en todas partes es como escribir a mano «localhost» en cada línea de código: un error tipográfico y todo «por alguna razón no funciona». Por lo tanto, use las constantes de Spring:

import org.springframework.http.MediaType;

// Lugar único donde fijamos los media types de nuestra API
public final class ApiMediaTypes {

    // JSON — formato base de nuestra API JSON-first
    public static final String JSON = MediaType.APPLICATION_JSON_VALUE;

    private ApiMediaTypes() {
        // Prohibimos crear instancias: es una clase utilitaria con constantes
    }
}

Y entonces en el controlador podemos escribir de forma compacta y sin errores tipográficos:

import static com.example.tasktracker.api.ApiMediaTypes.JSON;

import org.springframework.web.bind.annotation.PostMapping;

@PostMapping(consumes = JSON)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
    // Resto del código del método (creación de tarea, formación de respuesta, etc.)
}

Esta no es una técnica obligatoria, pero muestra bien la idea general: el formato es parte del contrato, y el contrato queremos mantenerlo ordenado y uniforme, no mediante copia-pega.

6. Errores típicos al trabajar con consumes y produces

Error nº1: confundir «quién responde de qué» entre Content-Type, Accept, consumes, produces.
Situación muy frecuente: el desarrollador pone produces pensando que es «nosotros aceptamos esto», o pone consumes pensando que es «nosotros devolvemos esto». Como resultado, el endpoint de repente empieza a devolver 415/406, y la persona busca el problema en el servicio. La comprobación útil es simple: consumes se refiere a lo que el servidor lee (cómo digerir el request body), y produces a lo que el servidor escribe (cómo formar el response body).

Error nº2: poner mecánicamente consumes en un GET que no tiene cuerpo de solicitud.
A nivel de significado es simplemente ruido: fija el formato de algo que no existe. Y a nivel de comportamiento a veces se pueden obtener efectos inesperados, especialmente si algún cliente envía de repente un Content-Type donde usted no lo espera. En un proyecto de aprendizaje es mejor seguir una regla simple: consumes aparece donde hay @RequestBody.

Error nº3: escribir "application/json" a mano en veinte lugares y equivocarse una vez.
Un error tipográfico como "applicaton/json" parece gracioso justo hasta que haya perdido 40 minutos depurando y se dé cuenta de que el endpoint «no se encuentra» porque consumes no coincide. Use MediaType.APPLICATION_JSON_VALUE o su propia constante, y eliminará toda una clase de errores de su vida.

Error nº4: pensar que 415/406 son «error de mi código en el método del controlador».
En realidad, a menudo significa que ni siquiera ha llegado al método. El servicio puede ser perfecto, el repositorio genial, y @RequestBody hermoso, pero si la solicitud llegó con un Content-Type incorrecto o con un Accept incompatible, el torniquete no deja pasar. Acostumbrarse a mirar las cabeceras de la solicitud en tales casos es un ahorro de tiempo de ingeniería muy adulto.

Error nº5: poner produces = application/json en un endpoint que devuelve 204 No Content y esperar un «JSON vacío».
204 significa «sin cuerpo», no «cuerpo vacío». Es una diferencia sutil pero muy importante. Si quiere devolver {} o algún objeto de confirmación, eso ya será un 200 OK con body, no 204. No finja que 204 tiene un formato de cuerpo: formalmente, no debe haber cuerpo allí.

1
Tarea
Spring REST & MVC, nivel 7, lección 3
Bloqueada
Endpoint solo JSON con `consumes` y `produces`
Endpoint solo JSON con `consumes` y `produces`
1
Tarea
Spring REST & MVC, nivel 7, lección 3
Bloqueada
JSON-only `GET` y respuesta `406 Not Acceptable`
JSON-only `GET` y respuesta `406 Not Acceptable`
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION