← APIs REST: recursos, DTO, validación y errores

Sesión 11 · Semana 6

Entradas, salidas y mapeo

Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado cerrar el primer proyecto. En Servidor continúas la implementación del mismo producto.

Se explica

25 minutos · explicación y demostración

Ya has separado la salida con DTO. Hoy separarás también los datos que el cliente puede enviar y reunirás las conversiones repetidas. Un mapper es una clase que transforma un tipo de objeto en otro; no decide reglas de negocio ni atiende peticiones.

La puerta que sigue abierta

Ayer cerraste la salida. La entrada sigue exactamente como estaba:

@PostMapping
public ResponseEntity<TareaResponse> crear(@RequestBody Tarea tarea) {

Ese @RequestBody Tarea significa, literalmente: «cliente, rellena tú mi objeto interno». Cualquier campo que exista en la clase Tarea, el cliente puede intentar enviarlo.

Tu modelo ya tiene datos de uso interno. Campos como creadoPor, fechaDeCreacion o esDePago necesitan reglas explícitas sobre quién puede asignarlos; recibir el modelo completo no expresa esas reglas.

El fallo que esto produce tiene nombre

Se llama mass assignment: el cliente envía un campo que tú no esperabas que enviara, y como el objeto lo tiene, se asigna solo.

El caso clásico: un formulario de registro que manda {"nombre":"Ana","email":"...","rol":"ADMIN"}. Nadie ha puesto rol en el formulario, pero la clase Usuario lo tiene, y Jackson lo asigna encantado. Aparece en listas de vulnerabilidades reales todos los años.

Entrada y salida no son simétricas

Es la idea de la sesión, y se ve mejor con una tabla que con una explicación:

Campo ¿Puede enviarlo el cliente? ¿Se lo devuelves?
titulo Sí Sí
prioridad Sí Sí
id No, lo asigna el servidor Sí
fechaDeCreacion No, la pone el servidor Sí
notaInterna No No
contraseña (en un usuario) Sí, al registrarse Nunca

Mira las dos últimas filas: hay campos que entran y no salen, y campos que salen y no entran. Con una sola clase para las dos cosas, ninguna de las dos columnas se puede respetar.

Tres clases, tres responsabilidades
  1. TareaRequest · lo que acepto
  2. Tarea · lo que manejo
  3. TareaResponse · lo que publico

Tres clases por recurso, ¿no es demasiado?

Es la objeción sensata, y la respuesta honesta es «depende».

Cuándo sobra

Un recurso pequeño, interno, con los mismos campos de entrada y de salida y sin nada que ocultar. Ahí tres clases son ceremonia.

Cuándo salva

En cuanto haya un campo que el cliente no deba tocar, uno que no deba ver, o el modelo pase a ser una entidad de base de datos. Es decir: casi siempre, y siempre en la UD5.

En este proyecto practicamos la separación entre entrada, modelo y salida para poder justificar qué datos admite y publica cada operación.

Cuenta los sitios

Abre tu controlador de tareas y busca todas las líneas que pasan datos de una clase a otra. Vas a encontrar tres zonas:

Dónde está hoy la conversión
  1. En TareaResponse.desde(), un método estático dentro del DTO
  2. En el POST, construyendo un Tarea a mano campo a campo
  3. En el PATCH, con una cadena de if que asigna uno a uno

Tres estilos distintos para el mismo trabajo, en dos archivos. Ninguno responde a una decisión: han ido apareciendo.

La dirección de las dependencias

Esto importa más de lo que parece, y es una pregunta habitual en una defensa:

Quién conoce a quién
  1. DTO · no conoce a nadie
  2. Mapper · conoce a los dos
  3. Modelo · no conoce a nadie

El modelo no sabe que existe una API. Los DTO no saben que existe un modelo. Solo el mapper sabe de ambos, y por eso es el único archivo que hay que tocar cuando cambia la traducción.

Si mañana publicas la misma aplicación por otro canal, el modelo se reutiliza entero. Si mañana cambias de base de datos, los DTO no se enteran.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Continuidad del proyecto. Conserva las rutas de la sesión 9, los DTO de salida completos de la sesión 10 y sus filtros. Los fragmentos siguientes se centran en los campos de entrada: adapta el mapeo a todos los componentes de tus records, sin borrar campos públicos. Aplica la separación a ambos POST y a PUT y PATCH. Si una entrada deja de admitir un campo, actualiza explícitamente su contrato y las peticiones de la colección; mantén las aserciones de salida.

Paso 1 · Retomar el proyecto y preparar la comprobación

  1. Abre los DTO de salida, el modelo y los métodos POST, PUT y PATCH. Localiza qué campos recibe todavía el modelo directamente.
  2. Escribe qué campos puede enviar el cliente al crear y cuáles solo asigna el servidor. Define también qué significa omitir un campo en PATCH.
  3. Busca conversiones repetidas entre modelo y DTO. Esas asignaciones son el punto de partida del mapper; mantén la colección disponible para comprobar la refactorización.

Paso 2 · El DTO de entrada es una lista blanca

Crea TareaRequest.java en dto antes de modificar el POST. Sustituye el tipo del argumento por TareaRequest y construye una Tarea copiando solo los campos admitidos; la identidad la sigue asignando el servidor. El import del modelo permanece porque aún se almacena una Tarea. Si en la sesión 5 activaste el rechazo de campos desconocidos, el intento de enviar id dará 400; si lo desactivaste, se ignorará. En ambos casos el cliente no debe imponer el id.

package com.ejemplo.gestor.dto;

public class TareaRequest {

    private String titulo;
    private String prioridad;
    private Integer proyectoId;

    public TareaRequest() {
    }

    public String getTitulo() {
        return titulo;
    }

    public void setTitulo(String titulo) {
        this.titulo = titulo;
    }

    public String getPrioridad() {
        return prioridad;
    }

    public void setPrioridad(String prioridad) {
        this.prioridad = prioridad;
    }

    public Integer getProyectoId() {
        return proyectoId;
    }

    public void setProyectoId(Integer proyectoId) {
        this.proyectoId = proyectoId;
    }
}

Conviene observar lo que no aparece: no hay id ni completada. En esa ausencia está la decisión de diseño:

Lo que no existe no se puede asignar

El DTO de entrada no declara id. Con el modo estricto que conservaste en la UD2, enviarlo produce 400 por propiedad desconocida. En modo tolerante se ignoraría, pero no cambiarás a ese modo para superar la prueba.

El DTO define los campos admitidos y la configuración de Jackson determina cómo se trata una propiedad desconocida. El servidor sigue asignando el identificador; los valores permitidos de los demás campos se validarán por separado.

Utilizamos una clase con constructor vacío y setters para practicar la asignación de campos y añadir validaciones en la sesión 12. Jackson también admite records y otras formas de construcción; esta es la opción elegida para el ejercicio.

El controlador

@PostMapping
public ResponseEntity<TareaResponse> crear(@RequestBody TareaRequest peticion) {
    Tarea tarea = new Tarea();
    tarea.setId(siguienteId);
    siguienteId = siguienteId + 1;
    tarea.setTitulo(peticion.getTitulo());
    tarea.setPrioridad(peticion.getPrioridad());
    tarea.setCompletada(false);
    if (peticion.getProyectoId() != null) {
        tarea.setProyectoId(peticion.getProyectoId());
    }
    tareas.add(tarea);

    URI ubicacion = ServletUriComponentsBuilder
            .fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(tarea.getId())
            .toUri();

    return ResponseEntity.created(ubicacion).body(TareaResponse.desde(tarea));
}

Lee la línea tarea.setCompletada(false). Es una decisión de negocio que antes estaba en manos del cliente: una tarea recién creada nace sin completar. Antes, si el cliente mandaba "completada": true, nacía terminada. Nadie lo había decidido; simplemente el campo estaba ahí.

POST /tareas
{ "id": 999, "titulo": "Colarme un id", "completada": true }

Con el modo estricto, esta petición responde 400: id y completada no forman parte del DTO de creación. Elimina esos dos campos, incluye los demás datos del contrato y repite. La petición válida responde 201, con id asignado por el servidor y completada en false. Actualiza las altas de la colección para que envíen solo los campos admitidos.

Paso 3 · El DTO que arregla el PATCH

Crea dto/TareaPatchRequest.java con los campos del bloque, un constructor público sin argumentos y getters/setters para cada campo. Para Boolean completada, utiliza getCompletada() y setCompletada(Boolean completada): no lo conviertas a boolean. Sustituye el argumento del PATCH existente por ese DTO y aplica solo valores no nulos. Prueba separadamente un campo omitido y "completada": false para verificar que producen decisiones distintas.

public class TareaPatchRequest {

    private String titulo;
    private String prioridad;
    private Boolean completada;

    // constructor vacío, getters y setters
}

Boolean, con mayúscula. El envoltorio sí puede valer null, así que ahora sí se distingue:

Cuerpo enviado completada vale Significa
{"titulo":"X"} null No me lo han enviado: no lo toques
{"completada":true} true Pónmelo a verdadero
{"completada":false} false Pónmelo a falso
@PatchMapping("/{id}")
public ResponseEntity<TareaResponse> modificar(
        @PathVariable(name = "id") int id,
        @RequestBody TareaPatchRequest cambios) {

    for (Tarea tarea : tareas) {
        if (tarea.getId() == id) {
            if (cambios.getTitulo() != null) {
                tarea.setTitulo(cambios.getTitulo());
            }
            if (cambios.getPrioridad() != null) {
                tarea.setPrioridad(cambios.getPrioridad());
            }
            if (cambios.getCompletada() != null) {
                tarea.setCompletada(cambios.getCompletada());
            }
            return ResponseEntity.ok(TareaResponse.desde(tarea));
        }
    }
    return ResponseEntity.notFound().build();
}

Una limitación se ha resuelto, la otra no

Ya puedes marcar y desmarcar una tarea. Lo que sigue sin poderse es vaciar un campo a propósito: {"prioridad": null} llega como null igual que si no lo hubieras enviado.

Distinguir un campo ausente de un nulo explícito requiere registrar su presencia, por ejemplo mediante un tipo específico o una lectura del árbol JSON. Declarar Optional por sí solo no garantiza esa distinción con cualquier configuración de Jackson. Este PATCH interpreta ambos casos como «sin cambio» y conserva esa limitación documentada.

Paso 4 · La entrada de proyectos

  1. Crea ProyectoRequest con solo lo que un cliente puede enviar al crear un proyecto.
  2. Crea ProyectoPatchRequest con tipos envoltorio.
  3. Cambia el controlador para usarlos en POST, PUT y PATCH.
  4. Comprueba con Postman que un POST con un id y con un campo interno responde 201 e ignora los dos.
  5. Anota en las decisiones técnicas qué campos dejaste fuera de la entrada y por qué.

Del JSON al modelo y del modelo al JSON

Paso 5 · Reproducir una conversión inconsistente entre endpoints

No es un problema estético. Vamos a producirlo a propósito para que lo veas.

Reutiliza proyectoId, que ya existe desde la sesión 8. En una modificación temporal, sustituye únicamente la conversión del listado por una construcción manual de TareaResponse que ponga 0 en el componente proyectoId y copie correctamente todos los demás. Conserva el filtro, la ruta y los ocho componentes de la respuesta del ejemplo; no dupliques el método GET.

Reinicia y crea una tarea vinculada a un proyecto existente. El detalle mostrará el id correcto del proyecto, mientras que el listado mostrará 0. Ambas respuestas pueden tener estado 200, pero sus datos son incoherentes.

Ejecuta las aserciones de relación incorporadas en la sesión 10: deben detectar la diferencia. Si no la detectan, añade una comparación entre el proyectoId esperado y el devuelto por cada elemento del listado. Recupera la conversión correcta antes de continuar.

Este experimento muestra por qué conviene mantener una sola implementación de cada conversión. Un error de datos puede compilar y responder 200; las pruebas de contenido permiten detectarlo.

Paso 6 · Reunir la conversión de objetos en un mapper

Crea mapper/TareaMapper.java con el bloque completo y revisa los imports. Conserva todos los componentes de tu DTO de salida y su orden: el ejemplo de la sesión 10 tiene ocho. Sustituye las conversiones de ambos controladores por llamadas al mapper, incluida la consulta anidada, y elimina TareaResponse.desde cuando ya no tenga usos. Conserva el filtro del listado antes de llamar a aRespuestas.

Mapper

Una clase cuyo único trabajo es convertir entre representaciones y modelo. Ni valida, ni guarda, ni decide: traduce. Constituye además el único punto del proyecto donde se traduce.

Crea el paquete com.ejemplo.gestor.mapper:

package com.ejemplo.gestor.mapper;

import com.ejemplo.gestor.dto.ResponsableResponse;
import com.ejemplo.gestor.dto.TareaPatchRequest;
import com.ejemplo.gestor.dto.TareaRequest;
import com.ejemplo.gestor.dto.TareaResponse;
import com.ejemplo.gestor.model.Tarea;

import java.util.ArrayList;
import java.util.List;

public class TareaMapper {

    private TareaMapper() {
    }

    /** De lo que envía el cliente a un modelo nuevo. El id lo pone quien llame. */
    public static Tarea aModelo(TareaRequest peticion) {
        Tarea tarea = new Tarea();
        tarea.setTitulo(peticion.getTitulo());
        tarea.setPrioridad(peticion.getPrioridad());
        if (peticion.getProyectoId() != null) {
            tarea.setProyectoId(peticion.getProyectoId());
        }
        tarea.setCompletada(false);
        return tarea;
    }

    /** Del modelo a lo que publica la API. */
    public static TareaResponse aRespuesta(Tarea tarea) {
        return new TareaResponse(
                tarea.getId(),
                tarea.getTitulo(),
                tarea.getPrioridad(),
                tarea.isCompletada(),
                tarea.getProyectoId(),
                tarea.getEtiquetas(),
                tarea.getVencimiento(),
                ResponsableResponse.desde(tarea.getResponsable()));
    }

    public static List<TareaResponse> aRespuestas(List<Tarea> tareas) {
        List<TareaResponse> respuesta = new ArrayList<>();
        for (Tarea tarea : tareas) {
            respuesta.add(aRespuesta(tarea));
        }
        return respuesta;
    }

    /** Aplica sobre una tarea existente solo los campos que traiga el cambio. */
    public static void aplicar(TareaPatchRequest cambios, Tarea tarea) {
        if (cambios.getTitulo() != null) {
            tarea.setTitulo(cambios.getTitulo());
        }
        if (cambios.getPrioridad() != null) {
            tarea.setPrioridad(cambios.getPrioridad());
        }
        if (cambios.getCompletada() != null) {
            tarea.setCompletada(cambios.getCompletada());
        }
    }
}
Por qué una clase aparte y no un método en el DTO
Porque entonces el DTO tendría que conocer el modelo. Los DTO describen el contrato con el exterior y deben poder cambiar sin arrastrar nada. El mapper es el único que conoce a los dos, y esa es precisamente su función.
Por qué el constructor privado
Es una clase de utilidad: todos sus métodos son estáticos y no tiene sentido crear una instancia. El constructor privado lo deja claro y evita que alguien escriba new TareaMapper().
Por qué aplicar no devuelve nada
Porque no crea una tarea: modifica una que ya existe. El nombre y la firma dicen lo mismo que hace, y así nadie espera un objeto nuevo.
Por qué estático, de momento
Porque todavía no hemos visto inyección de dependencias. En la UD4 esta clase pasará a ser un componente que Spring construye e inyecta, y entonces entenderás qué se gana. Hoy, estático y funcionando.

Paso 7 · El controlador, después

Sustituye los métodos de listado, detalle y alta existentes por los que se muestran; no crees otra clase Controller. El ejemplo conserva las rutas explícitas de la sesión 9 y el filtro del listado. El método buscar(id) es un auxiliar privado dentro del controlador: recorre su lista, devuelve la tarea si coincide el id y devuelve null al terminar si no encuentra ninguna. Reutilízalo en detalle, PUT y PATCH y conserva sus comprobaciones de ausencia. Ejecuta la colección después de cambiar cada método.

@GetMapping("/tareas")
public List<TareaResponse> lista(
        @RequestParam(name = "completada", required = false) Boolean completada) {
    List<Tarea> seleccion = new ArrayList<>();
    for (Tarea tarea : tareas) {
        if (completada == null || tarea.isCompletada() == completada) {
            seleccion.add(tarea);
        }
    }
    return TareaMapper.aRespuestas(seleccion);
}

@GetMapping("/tareas/{id}")
public ResponseEntity<TareaResponse> detalle(@PathVariable(name = "id") int id) {
    Tarea tarea = buscar(id);
    if (tarea == null) {
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(TareaMapper.aRespuesta(tarea));
}

@PostMapping(value = "/tareas", consumes = "application/json", produces = "application/json")
public ResponseEntity<TareaResponse> crear(@RequestBody TareaRequest peticion) {
    Tarea tarea = TareaMapper.aModelo(peticion);
    tarea.setId(siguienteId);
    siguienteId = siguienteId + 1;
    tareas.add(tarea);

    URI ubicacion = ServletUriComponentsBuilder
            .fromCurrentRequest().path("/{id}")
            .buildAndExpand(tarea.getId()).toUri();

    return ResponseEntity.created(ubicacion).body(TareaMapper.aRespuesta(tarea));
}

Compáralo con el de ayer. Ahora el controlador se lee como el contrato de la API: qué ruta, qué recibe, qué código devuelve. La traducción ha desaparecido de la vista, y con ella la posibilidad de hacerla dos veces distintas.

Ese método buscar(id) privado que aparece ahí es tuyo: sácalo también, porque lo estabas repitiendo en cuatro sitios.

¿No hay librerías que hagan esto solas?

Sí. MapStruct genera el mapper a partir de una interfaz, en tiempo de compilación; ModelMapper lo hace en ejecución por reflexión. En una aplicación grande ahorran mucho código repetitivo.

Aquí no las usamos por dos razones. La primera es que un mapper generado esconde justo la decisión que estás aprendiendo a tomar: qué campo va a dónde y qué se queda fuera. La segunda es que cuando algo falla —y falla—, hay que saber leer el código que se generó.

Sabe que existen. Úsalas cuando escribir mappers a mano te resulte aburrido, que es la señal de que ya entiendes lo que hacen.

Paso 8 · La prueba de ida y vuelta

Un mapper roto no lanza excepciones: pierde datos en silencio. Si te olvidas de una línea, ese campo llega vacío y nadie protesta.

La forma de detectarlo es recorrer el circuito completo y comparar los extremos. Con las herramientas que tienes:

POST /tareas
{ "titulo": "Revisar el login", "prioridad": "alta", "proyectoId": 7 }

Usa la cabecera Location, o {{tareaId}} si ya lo tienes encadenado en la colección.

Añade a esa petición de la colección una comprobación por cada campo enviado:

pm.test("El título sobrevive al viaje", function () {
    pm.expect(pm.response.json().titulo).to.eql("Revisar el login");
});

pm.test("El proyecto sobrevive al viaje", function () {
    pm.expect(pm.response.json().proyectoId).to.eql(7);
});

Qué acabas de construir

Una prueba que recorre JSON → DTO de entrada → modelo → DTO de salida → JSON y comprueba que lo que entró es lo que sale. Si mañana alguien añade un campo al mapper y olvida una de las direcciones, esta prueba falla.

Esta prueba amplía las comprobaciones de contenido de las sesiones anteriores: ahora observa la transformación entre tipos de entrada y salida. En la UD7 escribirás pruebas automatizadas en Java sobre estas mismas decisiones.

Paso 9 · El mapper de proyectos

  1. Crea ProyectoMapper con los cuatro métodos equivalentes.
  2. Vacía el controlador de proyectos de toda conversión.
  3. Extrae también su buscar(id) privado si lo estabas repitiendo.
  4. Añade a la colección la prueba de ida y vuelta para proyectos, con una comprobación por campo.
  5. Comprueba que la colección entera sigue en verde.

Paso 10 · Comprobar y registrar el resultado del proyecto

  1. Intenta enviar un campo que solo pueda asignar el servidor y comprueba que no modifica ese dato interno.
  2. Prueba creación, lectura y modificación parcial: las conversiones deben conservar los datos esperados y el PATCH no debe borrar los campos omitidos.

Ampliación si has completado el trabajo

Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.

Reto · La API de usuarios

Diseña, sin escribir código todavía, las tres clases de un recurso Usuario con estos campos internos:

id, nombre, email, contraseñaCifrada, rol, fechaDeAlta, ultimoAcceso, activo

Requisitos del negocio:

  • Al registrarse, el usuario envía nombre, email y contraseña en claro.
  • El rol lo asigna siempre un administrador, nunca el propio usuario.
  • Nadie, jamás, puede leer la contraseña por la API.
  • El perfil público muestra nombre y fecha de alta.
  • El propio usuario, al consultarse a sí mismo, ve además su email y su último acceso.

Entrega una tabla con una fila por campo y una columna por clase, marcando dónde aparece cada uno:

Campo UsuarioRequest Usuario UsuarioResponse UsuarioPublico

Responde a dos preguntas:

  1. Hay un campo que aparece en la entrada y no existe en el modelo con ese nombre. ¿Cuál, y qué pasa con él entre una clase y otra?
  2. ¿Qué habría ocurrido con el campo rol si hubieras usado el modelo como cuerpo de la petición?
Objetivo mínimoTareaRequest y TareaPatchRequest en uso, con el id del cliente ya ignorado.
Si lo tienesProyectos con sus dos DTO de entrada y el PATCH capaz de marcar y desmarcar.
RetoLas cuatro clases de Usuario repartidas campo a campo, con las dos preguntas respondidas.
Ver respuestas

1 · El DTO delimita las propiedades de entrada. Con la configuración estricta de este proyecto, una propiedad desconocida provoca un 400; con una configuración tolerante se ignoraría.

2 · Porque el primitivo no puede valer null y siempre llega como false, de modo que no se distingue «no me lo han enviado» de «pónmelo a falso». El envoltorio sí admite null.

3 · Entra y no sale: la contraseña. Sale y no entra: el id, o la fecha de creación.

4 · Vaciar un campo a propósito. Para distinguir esa intención de la omisión habría que registrar la presencia del campo; el DTO actual interpreta ambos casos como «sin cambio».

Reto · Encuentra el campo perdido

Haz este ejercicio en pareja, y hazlo en las dos direcciones.

  1. En el proyecto de tu compañero, borra una sola línea de su mapper: una asignación de campo, la que quieras. Compila y arranca.
  2. Que ejecute su colección tal como está.
  3. Si sale verde, su colección tiene un agujero: apunta cuál es el campo y qué prueba le faltaba.
  4. Que la amplíe hasta detectarlo, y que después restaure la línea y compruebe que vuelve a verde.

Responde por escrito:

  • ¿Cuántos campos de tu API están hoy comprobados de verdad, y cuántos solo aparecen en respuestas que nadie verifica?
  • ¿Qué te costaría más: mantener la prueba de ida y vuelta al añadir cada campo, o descubrir el fallo en producción?
  • ¿Habría alguna forma de que el fallo se detectara sin escribir una comprobación por campo?
Objetivo mínimoTareaMapper creado y el controlador sin una sola línea de conversión.
Si lo tienesProyectos con su mapper y la prueba de ida y vuelta comprobando campo a campo.
RetoEl campo perdido del compañero detectado y la colección ampliada hasta cazarlo.
Ver respuestas

1 · Que un cambio se aplique solo en uno de los dos, y el mismo recurso acabe publicándose de dos formas distintas según el endpoint. Las dos respuestas son válidas, así que nada lo detecta.

2 · Porque obligaría al DTO a conocer el modelo. El DTO describe el contrato exterior y debe poder cambiar por su cuenta; el mapper es el único que conoce ambos lados.

3 · Porque modifica una tarea que ya existe en lugar de crear una nueva. La firma dice lo que hace y evita que alguien espere un objeto distinto de vuelta.

4 · Que los datos sobreviven a la transformación. Un 200 solo dice que la petición se atendió; no dice que los campos hayan llegado ni que valgan lo que valían.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión:

Identificadores y valores calculados conservan el control del servidor; los mapeos de ambas entidades son coherentes.

Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.