← Peticiones, respuestas y CRUD en memoria

Sesión 6 · Semana 3

Escrituras y respuestas HTTP

Punto de partida. Tu CRUD conserva datos en memoria y ya permite crear, consultar, sustituir y borrar. Hoy añadirás PATCH y ajustarás los códigos de respuesta. Continúa sobre las dos entidades de tu proyecto; Tarea y Proyecto son los ejemplos para trasladar el procedimiento a tu dominio.

Se explica

25 minutos · explicación y demostración

Efecto de una petición y significado de su respuesta

Una operación segura tiene semántica de consulta: el cliente no solicita cambiar el recurso. GET es seguro, aunque el servidor pueda registrar la petición en un log. Una operación idempotente deja el mismo efecto en el estado del servidor al repetir una petición idéntica. Las respuestas pueden ser distintas: borrar un elemento y volver a borrarlo deja el recurso ausente en ambos casos, aunque el código de estado devuelto al cliente pueda variar (por ejemplo, 204 en el primer intento y 404 en el segundo).

Método Efecto en nuestro CRUD Al repetir la misma petición
GET Consulta No modifica el recurso.
POST Crea un registro con id asignado Crea otro registro; esta implementación no es idempotente.
PUT Sustituye los campos editables Deja la misma representación; es idempotente.
PATCH Modifica los campos indicados Depende de la operación: asignar una prioridad se puede repetir; incrementar un contador cambia de nuevo el resultado.
DELETE Elimina el registro Lo deja ausente en el servidor; es idempotente.

Si se pierde una respuesta, el cliente puede necesitar reintentar. La idempotencia permite razonar sobre ese reintento, pero no significa que todos los clientes repitan automáticamente las peticiones.

PUT representa una sustitución completa de los datos editables acordados. En nuestro código, los campos omitidos quedan con los valores por defecto del objeto recibido. Más adelante rechazaremos una representación incompleta mediante validación. PATCH expresa una modificación parcial: esta primera implementación solo cambiará título y prioridad cuando reciba un valor no nulo.

Estado, cabeceras y cuerpo con ResponseEntity

Hasta ahora varios métodos devuelven un objeto o null, y Spring responde 200 incluso cuando no encuentran el recurso. La clase ResponseEntity (disponible en org.springframework.http.ResponseEntity) permite envolver y controlar con precisión las tres partes de una respuesta HTTP: el código de estado, las cabeceras y el cuerpo. Un contrato HTTP describe qué debe observar el cliente en cada caso.

Construcción Resultado
ResponseEntity.ok(objeto) 200 con cuerpo.
ResponseEntity.notFound().build() 404 sin cuerpo.
ResponseEntity.created(uri).body(objeto) 201 con cuerpo y cabecera Location.
ResponseEntity.noContent().build() 204 sin cuerpo.

Observa dos patrones de su API fluida:

  • Respuestas con cuerpo: terminan aportando los datos con .body(objeto) o mediante el atajo ResponseEntity.ok(objeto). El tipo genérico indica qué viaja dentro, como ResponseEntity<Tarea>.
  • Respuestas sin cuerpo: finalizan con .build(), que cierra la construcción de una respuesta vacía (como 404 o 204). En la cabecera del método se escribe ResponseEntity<Void> con Void en mayúscula: en Java los tipos genéricos no admiten el tipo primitivo en minúscula y requieren la clase envoltorio Void.

Location indica la dirección del recurso recién creado. El cliente puede utilizarla en un GET sin construir esa URL por su cuenta.

Se trabaja

140 minutos · implementación y comprobación del contrato

Los pasos reparten el trabajo en 10, 20, 25, 20, 15, 30 y 20 minutos. Son orientativos. Antes de editar, prepara una issue y una rama desde main actualizada, siguiendo el flujo de Intermodular. Conserva los cambios en esa rama hasta su revisión.

Paso 1 · Preparar los datos y observar el contrato actual · 10 min

  1. Arranca el backend y crea dos registros distintos. Anota sus ids reales: no supongas que son 1 y 2.
  2. Consulta uno de ellos y otro id que hayas comprobado que no existe. Anota estado y cuerpo.
  3. Conserva una petición PUT con todos los campos editables. Incluye los campos añadidos en la sesión 5 si forman parte de tu modelo.

Paso 2 · Diferenciar sustitución y modificación parcial · 20 min

Sobre un registro de prueba, envía un PUT solo con titulo. Consulta después el registro: otros campos han vuelto a sus valores por defecto. Restáuralo con el PUT completo antes de probar PATCH.

Añade este método dentro de TareaController e importa org.springframework.web.bind.annotation.PatchMapping. Las demás anotaciones ya se utilizan en el controlador:

@PatchMapping("/{id}")
public Tarea modificar(
        @PathVariable(name = "id") int id,
        @RequestBody Tarea 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());
            }
            return tarea;
        }
    }
    return null;
}

Guarda, reinicia, recrea los datos y envía {"titulo":"Título corregido"} con PATCH a un id existente. El GET posterior debe conservar la prioridad anterior. Comprueba también {"prioridad":"baja"}.

Límites del PATCH inicial

Este método modifica solo los campos de texto mostrados. Un campo omitido y uno enviado como null se interpretan igual: no se cambian. Tampoco permite modificar completada, porque el tipo primitivo boolean no distingue omisión de false. En la sesión 11 un DTO con Boolean resolverá esa segunda limitación; distinguir un nulo explícito requerirá además registrar la presencia del campo.

Paso 3 · Devolver 200 o 404 según la existencia · 25 min

Importa org.springframework.http.ResponseEntity. Sustituye el método de detalle por este, manteniendo una única ruta GET de detalle:

@GetMapping("/{id}")
public ResponseEntity<Tarea> detalle(@PathVariable(name = "id") int id) {
    for (Tarea tarea : tareas) {
        if (tarea.getId() == id) {
            return ResponseEntity.ok(tarea);
        }
    }
    return ResponseEntity.notFound().build();
}

Comprueba un id existente y uno ausente. Después adapta todas las salidas del PUT y del PATCH: cambia su retorno a ResponseEntity<Tarea>, envuelve el resultado encontrado con ResponseEntity.ok(...) y sustituye el return null final por ResponseEntity.notFound().build().

En PUT conserva datos.setId(id) antes de sustituir el registro. En PATCH conserva las condiciones que dejan intactos los campos no enviados. No cambies el GET de listado: una lista vacía sigue respondiendo 200 con [].

Comprobar cómo quedan PUT y PATCH adaptados

En actualizar (PUT), sustituye el retorno por ResponseEntity<Tarea>, envuelve datos en ResponseEntity.ok(datos) y cierra con notFound().build():

@PutMapping("/{id}")
public ResponseEntity<Tarea> actualizar(
        @PathVariable(name = "id") int id,
        @RequestBody Tarea datos) {

    for (int i = 0; i < tareas.size(); i++) {
        if (tareas.get(i).getId() == id) {
            datos.setId(id);
            tareas.set(i, datos);
            return ResponseEntity.ok(datos);
        }
    }
    return ResponseEntity.notFound().build();
}

En modificar (PATCH), aplica el mismo patrón conservando la comprobación de campos no nulos:

@PatchMapping("/{id}")
public ResponseEntity<Tarea> modificar(
        @PathVariable(name = "id") int id,
        @RequestBody Tarea 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());
            }
            return ResponseEntity.ok(tarea);
        }
    }
    return ResponseEntity.notFound().build();
}

Comprueba: GET de detalle, PUT y PATCH responden 404 para un id ausente y no crean registros por accidente.

Paso 4 · Crear con 201 y Location · 20 min

Sustituye el POST existente por esta versión. Conserva una sola lista y un solo contador en la clase. Añade los imports java.net.URI y org.springframework.web.servlet.support.ServletUriComponentsBuilder:

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

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

La llamada a ServletUriComponentsBuilder monta la URL absoluta del recurso recién creado a partir de la petición en curso:

  • .fromCurrentRequest() toma la URL base recibida en la petición (por ejemplo, http://localhost:8080/tareas).
  • .path("/{id}") añade a esa ruta el segmento con la variable {id}.
  • .buildAndExpand(tarea.getId()) reemplaza {id} por el número real asignado al registro.
  • .toUri() genera el objeto URI que ResponseEntity.created(...) inserta en la cabecera Location.

Conservamos consumes y produces de la sesión 5. Tras guardar y reiniciar, crea un registro: debe responder 201 y devolver un id asignado. Abre Headers de la respuesta, copia Location y úsala en un GET. Debes recuperar el mismo registro.

Paso 5 · Borrar con una respuesta sin cuerpo · 15 min

Sustituye el DELETE anterior e importa DeleteMapping si falta:

@DeleteMapping("/{id}")
public ResponseEntity<Void> eliminar(@PathVariable(name = "id") int id) {
    tareas.removeIf(tarea -> tarea.getId() == id);
    return ResponseEntity.noContent().build();
}

Usamos el método removeIf de Java con una lambda (tarea -> tarea.getId() == id) porque busca y elimina de la colección en un solo paso seguro. Si intentásemos borrar elementos con un bucle for tradicional mientras lo recorremos, Java lanzaría una excepción ConcurrentModificationException.

El contrato del ejemplo devuelve 204 también si el recurso ya estaba ausente: la operación es idempotente porque el estado final del servidor no cambia (el recurso sigue sin existir tras cualquier repetición). Ejecuta DELETE dos veces y un GET después de cada borrado: los DELETE dan 204 sin cuerpo y los GET dan 404. Otra API puede elegir 404 en el segundo DELETE sin dejar de ser idempotente en su estado; para nuestras pruebas mantendremos el contrato del ejemplo.

Comparar con una respuesta fija mediante anotación

@ResponseStatus(HttpStatus.NO_CONTENT), con los imports de ResponseStatus y HttpStatus, puede fijar 204 en un método DELETE que devuelva void. ResponseEntity resulta útil cuando el método decide entre resultados o incluye cabeceras. Conserva una única implementación de cada ruta; no añadas otra ruta para probar esta alternativa.

Paso 6 · Aplicar el contrato a la segunda entidad · 30 min

Trabaja sobre la segunda entidad de tu dominio, sin empezar otro proyecto. Adapta sus operaciones una a una y prueba cada cambio antes de continuar:

Operación Resultado que debes observar
Listado, con o sin filtro 200 y un array, también cuando está vacío.
Detalle existente / ausente 200 con objeto / 404 sin cuerpo.
Creación 201 con objeto, id asignado y Location utilizable.
PUT existente / ausente 200 con representación sustituida / 404.
PATCH existente / ausente 200 con los campos admitidos modificados / 404.
DELETE, incluido el repetido 204 sin cuerpo.

Si su CRUD estaba incompleto, termina primero el método pendiente con el patrón de la sesión 4. Aplica PATCH a campos de referencia que puedan omitirse; declara qué campos siguen sin admitirse en esta versión.

Paso 7 · Comprobar repeticiones y revisar la propuesta · 20 min

  1. Crea un registro de prueba y conserva su id. Repite el mismo PUT completo dos veces y consulta después de cada envío: el resultado debe ser equivalente.
  2. Repite un PATCH con la misma asignación de texto. Comprueba que no modifica los otros campos.
  3. Envía dos POST con el mismo contenido. Consulta ambos ids: son dos registros diferentes en esta implementación.
  4. Borra los registros creados. Repite un DELETE y comprueba estado, cuerpo y ausencia con GET.
  5. Publica la rama y abre su PR. Tu pareja ejecuta las peticiones sobre esa rama; integra tras atender la revisión. Los estados correctos no sustituyen la comprobación del contenido.

Ampliación si has completado el trabajo

Diagnóstico de una regresión. En una rama local de prueba, cambia el borrado para que no elimine nada pero siga devolviendo 204. Escribe qué petición detecta el fallo y qué comprobaría una prueba que solo mirase el estado. Restituye el método y verifica la recuperación. No integres la avería.

Diseño de una operación propia. Elige una acción real de tu proyecto, como reasignar una reserva o archivar un elemento. Define método, ruta, cuerpo y efecto de repetirla. Implementa una asignación simple con los recursos actuales y compruébala; no añadas autenticación ni nuevas entidades para este ejercicio.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar, las dos entidades responden según la tabla del contrato. Puedes mostrar un alta con 201 y Location, una ausencia con 404 y un borrado con 204 sin cuerpo. PUT y PATCH tienen efectos distintos comprobados mediante un GET posterior. La limitación de PATCH está identificada y el cambio está revisado en el repositorio.

¿Qué diferencia hay entre repetir el efecto de una operación y repetir su código de respuesta?
¿Qué comprobarías para demostrar que un 204 corresponde a un borrado real?
¿Qué permite hacer Location al cliente y qué campos no admite todavía tu PATCH?