CodeGym /Cursos /Spring REST & MVC /Selección del convertidor en Spring MVC

Selección del convertidor en Spring MVC

Spring REST & MVC
Nivel 8, Lección 1
Disponible

1. Rol de la selección del convertidor en Spring MVC

El hecho de que entre el cuerpo HTTP y su parámetro se encuentre un HttpMessageConverter ya elimina parte de la magia. Pero casi de inmediato surge una pregunta más práctica: ¿por qué una solicitud se lee sin problemas mientras que otra falla con 415 o 406? Porque no basta con tener el convertidor — también hay que seleccionarlo correctamente.

En el mundo web real, el cuerpo de la solicitud puede ser JSON, texto, formulario, datos binarios, y la respuesta también puede ser diferente. Por lo tanto, Spring MVC no puede «simplemente leer el cuerpo» — necesita tomar una decisión sobre cómo leer y escribir los datos. Y esta decisión es como elegir un intérprete en una conferencia internacional: si usted trajo una charla en japonés y el intérprete solo sabe español, la culpa no es del micrófono.

En Spring MVC existe un conjunto de HttpMessageConverter. Cada uno puede trabajar con cierto conjunto de media types (por ejemplo, application/json o text/plain) y con cierto conjunto de tipos Java (por ejemplo, String, byte[], o «cualquier objeto»). Cuando llega una solicitud, Spring debe cotejar varias señales: qué dice Content-Type, qué quiere el cliente a través de Accept, qué ha declarado usted en consumes/produces, y qué tipos Java tiene en sus parámetros y en el return.

Es importante fijar la idea: el convertidor no se elige según el capricho de Spring, sino según reglas, y estas reglas le brindan un beneficio muy práctico. En cuanto usted entienda las reglas, los errores 415/406 dejarán de ser «aleatorios» y se convertirán en situaciones perfectamente diagnosticables: «no coincidieron las expectativas de formato» o «el tipo del valor de retorno no coincide con lo que pidió el cliente».

Para no confundirse más adelante, tenga en mente un mini-mapa de señales — nos será útil más adelante:

Señal Dónde reside Qué afecta Si se equivoca — normalmente será
Content-Type cabecera de la solicitud qué es el cuerpo de la solicitud 415 Unsupported Media Type
consumes @PostMapping/@PutMapping/... qué puede leer el método 415
Accept cabecera de la solicitud qué desea recibir el cliente 406 Not Acceptable
produces @GetMapping/@PostMapping/... qué promete devolver el método 406
Tipo Java del parámetro firma del método en qué convertir el cuerpo de la solicitud error de lectura/incompatibilidad
Tipo Java de la respuesta return cómo escribir el cuerpo de la respuesta 406 o Content-Type extraño

Este esquema ya explica de dónde vienen los 415 y 406. Pero no debe fusionarse en la idea de que «Spring sabe JSON por completo». La selección del converter es una capa. El propio camino JSON dentro del converter seleccionado es otra, y allí es importante ver por separado el papel de Jackson.

2. Entrada: Content-Type, consumes, tipo de parámetro

En la entrada siempre tenemos la misma física: una solicitud HTTP contiene bytes. Pero los bytes por sí solos no tienen sentido hasta que sepamos «cómo leerlos». Y precisamente para eso el cliente debe decirle al servidor: qué hay exactamente en el cuerpo — a través de la cabecera Content-Type. Es similar a la situación en la que alguien le pasa un USB y le dice: «Ahí hay un documento». ¿Qué documento? ¿PDF? ¿Word? ¿Un archivo? ¿Un archivo “final_final_v7(1).docx”? Sin aclaración, al servidor solo le queda adivinar, y Spring, como buen ingeniero, no adivina.

Cómo Spring maneja @RequestBody

Si el método tiene un parámetro con @RequestBody, Spring entiende: «Necesito tomar el cuerpo y obtener un objeto Java de tal tipo». Luego se activa la búsqueda del convertidor: necesitamos un HttpMessageConverter que simultáneamente:

1) sepa leer ese media type (por ejemplo, application/json),
2) sepa leerlo a ese tipo Java (por ejemplo, CreateTaskBody).

Y aquí aparece un vínculo importante con lo que ya escribimos en los controladores: consumes no es un «adorno», sino una promesa pública de su endpoint.

Veamos un mini-ejemplo al estilo de nuestro proyecto. Para simplificar, tendremos estas clases para el cuerpo de la solicitud y la respuesta:

// DTO para request body (lo que el cliente envía en JSON)
class CreateTaskBody {
    // Título de la tarea
    public String title;

    // Descripción de la tarea
    public String description;
}
// DTO para response body (lo que el servidor devuelve al cliente)
class TaskResponseBody {
    // Identificador de la tarea creada
    public String id;

    // Título de la tarea (como lo ve el cliente)
    public String title;
}

Y ahora el método del controlador, que fija explícitamente que acepta JSON y devuelve JSON:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks") // Ruta base para las tareas
class TaskController {

    @PostMapping(
            consumes = MediaType.APPLICATION_JSON_VALUE, // Decimos explícitamente: solo leemos JSON
            produces = MediaType.APPLICATION_JSON_VALUE  // Decimos explícitamente: solo devolvemos JSON
    )
    TaskResponseBody create(@RequestBody CreateTaskBody body) {
        // En este paso Spring ya ha convertido el request body (JSON) en un objeto Java CreateTaskBody
        TaskResponseBody response = new TaskResponseBody();

        // Para el ejemplo "hardcodeamos" el id: en un proyecto real lo proporcionará la BD/servicio
        response.id = "t-1";
        response.title = body.title;

        // A la salida Spring seleccionará de nuevo el converter: de objeto Java -> JSON
        return response;
    }
}

Aquí consumes = application/json significa: «Este método está dispuesto a leer el cuerpo de la solicitud solo como JSON». Si el cliente envía Content-Type: text/plain, Spring no intentará «adivinar» que en realidad es una cadena JSON. Dirá honestamente: «No entiendo ese formato para este endpoint» y devolverá 415 Unsupported Media Type.

Ejemplo de solicitud correcta (verificación manual)

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Fix API",
  "description": "HttpMessageConverter is not magic"
}

En este caso, todo coincide para Spring: Content-Type dice «es JSON», el método consumes dice «leo JSON», el parámetro @RequestBody CreateTaskBody dice «convierte JSON a CreateTaskBody», y Spring encuentra el convertidor JSON.

Error típico n.º 1: olvidó Content-Type

Uno de los problemas más comunes para principiantes: enviaron JSON pero no establecieron Content-Type: application/json. Entonces para el servidor el cuerpo se convierte en «unos bytes de origen desconocido».

Con alta probabilidad verá 415 Unsupported Media Type (especialmente si el método tiene consumes = application/json). No es que «Spring se haya roto» — el servidor dice: «Necesito entender el formato para seleccionar el convertidor, y usted no me dio una señal formal».

Error típico n.º 2: Content-Type no coincide con el contenido real

También ocurre al revés: Content-Type: application/json, pero en el cuerpo hay algo como title=Fix+API&description=... (formato de formulario), o simplemente un trozo de texto sin estructura JSON. Entonces Spring intentará honestamente leer JSON, pero ya en la fase de lectura del cuerpo se producirá un error «JSON no válido». Importante: el error aparece antes de entrar al método del controlador, porque la conversión es una «puerta de entrada», no parte de la lógica de negocio.

3. Salida: Accept, produces, tipo de respuesta

Ahora cambiemos el enfoque a la salida. Usted escribió return response; — pero el cliente no vive en un mundo de objetos Java. El cliente vive en un mundo de bytes de respuesta HTTP. Por lo tanto, Spring debe seleccionar nuevamente un HttpMessageConverter, pero ahora para escribir la respuesta. Y la selección se hace nuevamente no «por defecto y para siempre», sino según señales. La señal más importante del cliente en la salida es la cabecera Accept.

Si Content-Type responde a la pregunta «qué les envié en el cuerpo de la solicitud», entonces Accept responde a la pregunta «en qué formato quiero recibir la respuesta».

produces no es una «decoración», sino su contrato

Cuando escribe produces = application/json, fija una promesa pública: «La respuesta será en JSON». Esto es especialmente útil en una API JSON-first, porque transforma un «bueno, más o menos devolvemos JSON» en un contrato estricto.

Si el cliente envió Accept: application/xml, y su método está declarado como produces = application/json, Spring no inventará XML «improvisadamente». Responderá 406 Not Acceptable: «No puedo satisfacer su solicitud en cuanto al formato de respuesta».

Para que sea más claro, comparemos dos solicitudes al mismo endpoint.

GET http://localhost:8080/api/v1/tasks/t-1
Accept: application/json
GET http://localhost:8080/api/v1/tasks/t-1
Accept: application/xml

Si su método usa produces = application/json, la segunda solicitud con alta probabilidad terminará en 406. Y esto es una buena noticia: el cliente no recibirá «algo extraño», recibirá una señal honesta de que los formatos no coinciden.

Si no se envió Accept

Para los principiantes es un punto importante: Accept a menudo es implícito. Por ejemplo, muchos clientes HTTP envían Accept: */* (es decir, «acepto cualquier cosa») o simplemente no envían nada. En tales casos, Spring elige lo que sabe, guiándose por produces (si existe), y si produces no está especificado, por sus valores predeterminados razonables y los convertidores disponibles.

En un proyecto educativo JSON-first, una buena disciplina es fijar explícitamente produces = application/json en los endpoints JSON, porque reduce las sorpresas y hace que el contrato sea legible incluso para alguien que solo mira el código del controlador.

4. Cómo el tipo Java afecta la selección: String, byte[], Void, objeto

Si las cabeceras y consumes/produces son «señales de formato», entonces los tipos Java en la firma son «señales de intenciones». Spring no sabe leer su mente, pero sabe leer su método. Y a veces su método mismo le dice a Spring: «no necesito un objeto JSON, dame solo una cadena», o al revés: «necesito un objeto, analízalo por favor».

Entrada: @RequestBody String y @RequestBody CreateTaskBody — mundos diferentes

Desde el punto de vista de los HttpMessageConverter son solicitudes fundamentalmente diferentes.

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

public void create(@RequestBody String rawBody) {
    // Spring seleccionará un convertidor que pueda leer el cuerpo como texto
    // En rawBody estará el cuerpo "crudo", por ejemplo: {"title":"Fix API"}
    // Importante: NO es un objeto parseado, es solo una cadena
}

Entonces Spring puede elegir el convertidor de texto y simplemente darle el texto bruto del cuerpo. Si el cliente envió JSON, obtendrá una cadena como {"title":"Fix API"}. Esto a veces es útil para diagnóstico, pero para una API normal casi siempre significa que se está complicando la vida: está haciendo manualmente lo que debería hacer el mecanismo de conversión.

public void create(@RequestBody CreateTaskBody body) {
    // Spring seleccionará el convertidor JSON y llenará los campos de body desde el JSON
    // Aquí body.title ya está extraído del JSON
}

Entonces le dice a Spring: «necesito un objeto». En ese caso debe seleccionarse el convertidor JSON (siempre que Content-Type sea JSON).

Salida: String casi siempre es text/plain (y está bien)

Con las respuestas ocurre lo mismo. Tomemos el endpoint más simple «verificar que el servidor está vivo»:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;

@GetMapping(path = "/api/v1/ping", produces = MediaType.TEXT_PLAIN_VALUE)
public String ping() {
    // Devolvemos String => Spring escribe la respuesta como texto plano (text/plain)
    return "ok";
}

Aquí Spring ve que return es un String y selecciona el convertidor que escribe texto. Se obtiene un honesto text/plain y todo está bien.

Pero ahora imaginemos que intenta «hacer JSON» devolviendo una cadena y estableciendo produces = application/json:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;

@GetMapping(path = "/api/v1/ping-json", produces = MediaType.APPLICATION_JSON_VALUE)
public String pingJson() {
    // El contrato dice "devuelvo JSON", pero en realidad devolvemos solo una cadena
    // "ok" sin comillas no es JSON válido (un literal JSON válido sería "ok")
    return "ok";
}

Con alta probabilidad obtendrá una respuesta con Content-Type: application/json, pero el cuerpo será simplemente ok. Y ok es JSON no válido (un literal de cadena JSON válido sería "ok"). Para el cliente puede parecer que «el servidor prometió JSON pero envió algo extraño».

En la práctica, la conclusión es simple: si el endpoint está declarado como JSON, devuelva un objeto, no una cadena. Incluso si el objeto es primitivo, como { "status": "ok" }. Sí, es un poco más de código, pero gana en claridad contractual.

Caso importante aparte: ResponseEntity<Void> y 204 No Content

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;

@DeleteMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<Void> delete() {
    // 204 No Content: el cuerpo de la respuesta está ausente => no se necesita convertidor para escribir el body
    return ResponseEntity.noContent().build();
}

Aquí no hay cuerpo de respuesta. Por lo tanto, no se necesita convertidor para escribir la respuesta porque no hay nada que escribir. Y este ejemplo es útil precisamente para entender la mecánica: la presencia de una respuesta HTTP y la presencia de un cuerpo de respuesta son cosas diferentes. En la mente debe hacer clic: «si es 204, entonces la segunda mitad de la cadena de conversión (escritura del cuerpo) simplemente no se ejecuta».

5. API de Task Tracker: 3 escenarios

La teoría se vuelve clara cuando puede repetir el escenario manualmente. Ahora lo haremos de la manera más «humana»: el mismo proyecto, el mismo endpoint, y tres solicitudes — una correcta, dos problemáticas. Y verá que los problemas están directamente relacionados con la selección del convertidor.

Escenario A: todo correcto — JSON en entrada, JSON en salida

Supongamos que en TaskController tenemos la creación de tareas:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks") // Ruta base para las tareas
class TaskController {

    @PostMapping(
            consumes = MediaType.APPLICATION_JSON_VALUE, // Endpoint acepta JSON
            produces = MediaType.APPLICATION_JSON_VALUE  // Endpoint devuelve JSON
    )
    TaskResponseBody create(@RequestBody CreateTaskBody body) {
        // body ya está llenado por Spring desde JSON (si Content-Type es correcto)
        TaskResponseBody response = new TaskResponseBody();
        response.id = "t-1";          // Para el ejemplo — id estático
        response.title = body.title;  // Transferimos el campo de la solicitud a la respuesta
        return response;              // Spring serializa el objeto a JSON
    }
}
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Fix API",
  "description": "Make converters predictable"
}

Lo que sucede internamente (a nivel de nuestra lección): Spring ve Content-Type: application/json, selecciona el convertidor que puede leer JSON y construir CreateTaskBody, invoca el método, luego ve Accept: application/json y produces = application/json, selecciona el convertidor que puede escribir JSON desde TaskResponseBody, y envía la respuesta.

Escenario B: olvidó Content-Type — cómo leer el cuerpo de la solicitud

La solicitud se ve casi igual, pero sin Content-Type:

POST http://localhost:8080/api/v1/tasks
Accept: application/json

{
  "title": "Fix API"
}

Ahora Spring no tiene una señal clara de cómo interpretar el cuerpo. En combinación con consumes = application/json esto suele terminar en 415 Unsupported Media Type. Y un detalle importante: puede poner un punto de interrupción en el método create y sorprenderse de que no se active. Es normal: simplemente no se llegó al método porque el convertidor no fue seleccionado, el cuerpo no se leyó, por lo tanto la entrada al método es imposible.

Escenario C: el cliente pidió un formato de respuesta incorrecto (Accept)

Supongamos que el cliente envió:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: text/plain

{
  "title": "Fix API"
}

Y su método está declarado como produces = application/json. El cliente dice: «Quiero texto», el servidor dice: «Devuelvo JSON». En tal situación, Spring honestamente puede devolver 406 Not Acceptable.

Este es un buen mecanismo de contrato: los clientes no necesitan «adivinar» el formato, y el servidor no necesita «cambiar silenciosamente» el contrato de respuesta. En producción, esto ahorra muchas horas de investigación del tipo «¿por qué a veces tenemos JSON y a veces texto?».

6. Mini-algoritmo de selección del convertidor

Cuando se enfrenta por primera vez a errores de formato, es muy tentador «simplemente agregar otra anotación» y esperar que todo funcione. Pero es mucho más efectivo tener un pequeño algoritmo en mente — casi como una lista de verificación mental, solo que sin pánico. Este algoritmo no requiere conocimiento de las clases internas de Spring, solo requiere atención al contrato: qué llegó, qué se espera, qué está declarado en el código.

En el diagnóstico práctico, es conveniente dividir el problema en dos preguntas independientes: «no pudimos leer el cuerpo de la solicitud» y «no pudimos escribir el cuerpo de la respuesta». Son independientes porque la solicitud puede no tener cuerpo (GET), y la respuesta puede no tener cuerpo (204), y viceversa.

A continuación se presenta un esquema que suele ayudar a los principiantes a dejar de «disparar a ciegas»:

flowchart TD
    A[Solicitud recibida] --> B{¿Tiene cuerpo la solicitud?}
    B -->|no| C[Leer path/query sin HttpMessageConverter]
    B -->|sí| D[Mirar Content-Type]
    D --> E{¿Coincide con consumes?}
    E -->|no| F[415 Unsupported Media Type]
    E -->|sí| G["Buscar converter: canRead(mediaType, paramType)"]
    G --> H{¿Encontrado?}
    H -->|no| F
    H -->|sí| I[Leer body -> objeto Java]
    I --> J[Ejecutar método del controlador]
    J --> K{¿Tiene cuerpo la respuesta?}
    K -->|no: 204/void| L[Devolver respuesta sin cuerpo]
    K -->|sí| M[Mirar Accept + produces]
    M --> N{¿Hay coincidencia?}
    N -->|no| O[406 Not Acceptable]
    N -->|sí| P["Buscar converter: canWrite(mediaType, returnType)"]
    P --> Q{¿Encontrado?}
    Q -->|no| O
    Q -->|sí| R[Escribir response body]

Si tiene este esquema en mente, el diagnóstico comienza a parecer casi aburrido (y ese es el mejor cumplido para un diagnóstico). Deja de discutir con Spring y comienza a hablar su mismo idioma: «Veo Content-Type, veo consumes, entiendo por qué 415» o «Veo Accept, veo produces, entiendo por qué 406».

Otro truco muy práctico: si está seguro de que el método debería ejecutarse, pero no se ejecuta, y no ve la lógica del servicio, casi siempre significa que el problema ocurrió antes de entrar al método. Por lo tanto, sus sospechosos número uno son Content-Type, consumes, JSON malformado o falta de coincidencia con el tipo Java del parámetro. Es como con la puerta del edificio: si la llave no encaja, ni siquiera llegó al ascensor, y es demasiado pronto para discutir «por qué no funciona el ascensor».

7. Errores típicos al seleccionar el convertidor

Error n.º 1: confundir Content-Type y Accept, pensando que son «lo mismo».
Es uno de los errores más comunes de principiantes: la persona establece Accept: application/json y cree que con eso «le dijo al servidor que está enviando JSON». En realidad, dijo «quiero recibir JSON en la respuesta». En cambio, «estoy enviando JSON» es Content-Type. Si mantiene estas cabeceras en diferentes estantes del cerebro, la mitad de los problemas con @RequestBody desaparecen por sí solos.

Error n.º 2: percibir consumes y produces como etiquetas decorativas que se pueden «dejar para después».
Sin consumes/produces su endpoint se vuelve menos explícito: en proyectos grandes, esto lleva a la dilución del contrato y sorpresas en el formato. En un proyecto educativo duele particularmente porque intenta aprender la mecánica, pero esta «flota». consumes/produces es una forma corta y honesta de decirle tanto a Spring como al lector humano del código: «acordamos JSON».

Error n.º 3: esperar que String signifique automáticamente «cadena JSON».
String no es un objeto JSON, es simplemente una cadena. Si construye una API JSON-first y quiere devolver JSON, devuelva un objeto y entréguelo a través de la conversión JSON. De lo contrario, puede obtener fácilmente una respuesta con Content-Type: application/json que contenga JSON no válido, y el cliente fallará «sin motivo aparente». Este es el caso en que «todo funcionaba en el navegador» y «todo se rompió en el cliente» — un escenario completamente típico.

Error n.º 4: intentar arreglar el código del controlador cuando la solicitud ni siquiera llegó al método.
Cuando llega un 415 o un error de lectura del cuerpo, el método del controlador puede no invocarse en absoluto. Si empieza a reescribir servicios, mapeos, repositorios, y luego resulta que el problema era un Content-Type olvidado, está siguiendo el camino clásico del desarrollador: «arreglé todo excepto lo que estaba roto». El hábito de verificar primero las cabeceras y los media types ahorra nervios.

Error n.º 5: concluir que «el convertidor no funciona» sin tener en cuenta el tipo Java del parámetro/respuesta.
A veces el convertidor funciona perfectamente, simplemente usted le pidió que hiciera otra cosa. Por ejemplo, @RequestBody String raw es una solicitud «dame el texto crudo», no «analízame el JSON en un objeto». Del mismo modo, return String es una solicitud «la respuesta será texto». Spring aquí no discute ni presume, simplemente ejecuta obedientemente el contrato que usted definió con la firma del método. Y en eso, francamente, está su fortaleza: es predecible si usted formula las expectativas de manera predecible.

1
Tarea
Spring REST & MVC, nivel 8, lección 1
Bloqueada
JSON endpoint con `consumes` y `produces` explícitos
JSON endpoint con `consumes` y `produces` explícitos
1
Tarea
Spring REST & MVC, nivel 8, lección 1
Bloqueada
Respuesta `String` y cabecera `Accept`
Respuesta `String` y cabecera `Accept`
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION