Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado comprobar una dependencia externa y su degradación. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
El backend ya sabe gestionar tareas, guardar adjuntos, consultar el clima y publicar un aviso tras crear una tarea importante. En esta sesión se conectan esas piezas sobre el producto propio. El alta de tarea conserva su endpoint y reglas; la operación integrada añade a esa tarea un adjunto y devuelve también el clima de su proyecto.
Seguir un dato de extremo a extremo. El cliente envía el id de tarea y un archivo. Seguridad comprueba la identidad y el rol antes del controlador. El servicio carga la tarea, consulta el clima, guarda el archivo y registra sus metadatos. La respuesta incluye ids reales y una URL que debe permitir descargar el mismo contenido. En la demostración seguiremos ese recorrido en Red, en los logs, en la tabla adjuntos y en la carpeta de almacenamiento.
Un fallo no siempre tiene el mismo efecto. Si falta permiso o la tarea no existe, rechazamos la operación. Si el proveedor meteorológico falla, podemos seguir guardando el adjunto y devolver un aviso sin inventar una medición. Esta decisión pertenece al caso de uso: el clima es información complementaria.
Qué cubre la transacción. PostgreSQL puede deshacer sus filas, pero no borra automáticamente un fichero escrito en disco ni retira una notificación ya enviada. Por eso la sesión 43 envía eventos después del commit y hoy añadiremos limpieza de archivos en caso de rollback. Explicaremos dónde se registra esa limpieza y comprobaremos que se ejecuta; también reconoceremos su límite ante una caída completa del proceso.
Qué se comprueba. Un 201 aislado no basta: el enlace debe descargar el archivo, la fila debe apuntar a la tarea y una respuesta rechazada no debe dejar efectos inesperados. Separar esas observaciones permite localizar el fallo sin cambiar varias capas a la vez.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre la colección del producto y prepara un usuario, un recurso y los archivos ficticios del recorrido. Identifica todos los servicios que deben estar arrancados.
- Dibuja la secuencia cliente, backend, base de datos, almacenamiento y proveedor. Marca dónde puede fallar y qué resultado debería conservarse.
- Guarda el estado inicial de los datos de prueba para distinguir un alta nueva de restos de ejecuciones anteriores.
Paso 2 · Ensamblado del flujo completo
Vamos a integrar una tarea que ya existe con un adjunto y el clima de su proyecto. En este ejemplo, «incidencia» es la tarea del gestor: no necesitas crear una entidad Incidencia ni otro repositorio. El alta de la tarea sigue usando el CRUD y sus reglas actuales. Este recorrido reutiliza las piezas de las sesiones 41–43 y permite comprobar cada una por separado.
- Crea una tarea de prueba mediante su endpoint habitual, dentro de un proyecto con coordenadas válidas. Guarda su id. Comprueba el GET de esa tarea, el servicio de clima y el POST de adjuntos de la sesión 43 antes de combinarlos.
- Crea
dto/RegistroIntegradoResponse.java. El id principal es el de la tarea;adjuntoIdidentifica la fila que acabamos de guardar. No construyas una URL de descarga con un id todavía null.
package com.ejemplo.gestor.dto;
public record RegistroIntegradoResponse(
Long tareaId,
String titulo,
Long adjuntoId,
String urlDescarga,
ClimaProyectoResponse clima
) {}
- En
AlmacenamientoService.javaañade el método siguiente junto aguardarFicheroycargarComoRecurso. Reutiliza sus imports de Path, Files e IOException. Se usará únicamente con el nombre generado por nuestro servicio si falla la transacción.
public void eliminarFichero(String nombreAlmacenado) {
Path ruta = rutaAlmacenamiento.resolve(nombreAlmacenado).normalize();
if (!ruta.getParent().equals(rutaAlmacenamiento)) {
throw new SecurityException("El archivo debe estar en la carpeta de adjuntos");
}
try {
Files.deleteIfExists(ruta);
} catch (IOException ex) {
throw new IllegalStateException("No se pudo retirar el archivo " + nombreAlmacenado, ex);
}
}
Una transacción de PostgreSQL no deshace una escritura en disco. Por eso registraremos una acción que retira el fichero si PostgreSQL termina con rollback. Este mecanismo cubre el fallo normal de la transacción; no convierte disco y base de datos en un único almacenamiento atómico ante un apagado del equipo.
- Crea
service/RegistroIntegradoService.java. El método carga la tarea, obtiene las coordenadas de su proyecto, consulta el clima con degradación, guarda el archivo y persiste el Adjunto asociado a esa tarea.saveAndFlushfuerza la escritura de la fila antes de construir la respuesta.
package com.ejemplo.gestor.service;
import com.ejemplo.gestor.dto.RegistroIntegradoResponse;
import com.ejemplo.gestor.integration.ClimaService;
import com.ejemplo.gestor.model.Adjunto;
import com.ejemplo.gestor.repository.AdjuntoRepository;
import com.ejemplo.gestor.repository.TareaRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@Service
public class RegistroIntegradoService {
private static final Logger log = LoggerFactory.getLogger(RegistroIntegradoService.class);
private final TareaRepository tareas;
private final AdjuntoRepository adjuntos;
private final AlmacenamientoService almacenamiento;
private final ClimaService climaService;
public RegistroIntegradoService(TareaRepository tareas, AdjuntoRepository adjuntos,
AlmacenamientoService almacenamiento, ClimaService climaService) {
this.tareas = tareas;
this.adjuntos = adjuntos;
this.almacenamiento = almacenamiento;
this.climaService = climaService;
}
@Transactional
public RegistroIntegradoResponse registrar(Long tareaId, MultipartFile archivo) {
var tarea = tareas.findById(tareaId).orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Tarea no encontrada"));
var proyecto = tarea.getProyecto();
if (!proyecto.isActivo()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "El proyecto está inactivo");
}
var clima = climaService.consultarClimaSeguro(proyecto.getLatitud(), proyecto.getLongitud());
String nombre = almacenamiento.guardarFichero(archivo);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
try {
almacenamiento.eliminarFichero(nombre);
} catch (RuntimeException ex) {
log.error("Revisar archivo pendiente de limpieza: {}", nombre, ex);
}
}
}
});
var adjunto = adjuntos.saveAndFlush(new Adjunto(
archivo.getOriginalFilename(), nombre, archivo.getContentType(), archivo.getSize(), tarea));
return new RegistroIntegradoResponse(tarea.getId(), tarea.getTitulo(), adjunto.getId(),
"/api/v1/adjuntos/" + adjunto.getId() + "/descargar", clima);
}
}
La consulta externa tiene los timeouts de la sesión 42. Aquí se realiza antes de escribir el archivo; identifica en los logs el tiempo que añade al caso de uso. Las reglas de creación de tareas siguen en su servicio original, por lo que no se pierde ninguna validación del CRUD al integrar el adjunto.
- Crea
controller/RegistroIntegradoController.java. En el ejemplo pueden registrar adjuntos integrados los jefes de proyecto y administradores; adapta esa regla a la matriz de tu producto e incluye propiedad si corresponde. Mantén el control de descarga de la sesión 43.
package com.ejemplo.gestor.controller;
import com.ejemplo.gestor.dto.RegistroIntegradoResponse;
import com.ejemplo.gestor.service.RegistroIntegradoService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
@RestController
@RequestMapping("/api/v1/tareas")
public class RegistroIntegradoController {
private final RegistroIntegradoService servicio;
public RegistroIntegradoController(RegistroIntegradoService servicio) {
this.servicio = servicio;
}
@PostMapping(value = "/{id}/registro-integrado", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyRole('JEFE_PROYECTO', 'ADMINISTRADOR')")
public ResponseEntity<RegistroIntegradoResponse> registrar(
@PathVariable Long id, @RequestParam("archivo") MultipartFile archivo) {
var respuesta = servicio.registrar(id, archivo);
return ResponseEntity.created(URI.create(respuesta.urlDescarga())).body(respuesta);
}
}
- En Bruno crea
POST /api/v1/tareas/{{tareaId}}/registro-integrado, usa el token de una cuenta permitida y selecciona Multipart. Añadearchivode tipo File. Deja que Bruno construya Content-Type y su boundary. Espera 201, unadjuntoIdreal y la URL en Location. - Descarga desde esa URL usando la misma identidad y compara el archivo recibido. En PostgreSQL comprueba
adjuntos.tarea_id: debe ser el id preparado al principio, nunca null. - Si el alta original de la tarea tenía prioridad alta, comprueba el aviso de la sesión 43. Asociar un archivo no vuelve a publicar «tarea creada»: evita emitir notificaciones duplicadas por dos operaciones distintas.
Paso 3 · Batería de escenarios en Bruno
Prepara una tarea y un archivo pequeño de prueba. Cada llamada correcta crea un adjunto diferente; conserva sus ids para limpiar después.
- Con token de jefe de proyecto o administrador, envía
POST /api/v1/tareas/{{tareaId}}/registro-integrado, Multipart y campoarchivo. Espera 201, descarga desde Location y comprueba la relación de base de datos. - Simula la caída del proveedor con la URL local de prueba de la sesión 42. Reinicia para vaciar la caché. Repite: espera 201 con aviso y temperatura null. Restaura la URL y comprueba la recuperación.
- Repite sin token (401) y con un rol no permitido (403). Comprueba que ninguno crea fila ni archivo.
- Repite con tarea inexistente (404) y proyecto inactivo (409). No deben aparecer adjuntos nuevos.
- Repite con un archivo vacío o un tipo rechazado. El manejador debe traducir la validación conocida a 400; comprueba el directorio y las filas, además del estado HTTP.
Utiliza una copia de prueba y datos ficticios; no necesitas desconectar toda la red para simular un proveedor caído.
Paso 4 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| El adjunto se guarda en disco pero no hay fila en la base de datos | El guardado del fichero está fuera de la transacción | El sistema de archivos no participa en el rollback: guarda primero la fila y el fichero después, o borra el fichero en el catch |
| La incidencia falla entera cuando cae Open-Meteo | El try/catch no envuelve la llamada saliente |
La degradación de la sesión 42 debe aplicarse aquí también: el clima es un extra, no un requisito |
413 Payload Too Large con un fichero de 3 MB |
El límite por defecto de Spring es 1 MB | spring.servlet.multipart.max-file-size y max-request-size en application.properties |
El multipart responde 415 |
El cliente fija mal el Content-Type |
En una petición multiparte, deja que el cliente ponga él el boundary: no lo escribas a mano |
| La descarga baja un fichero con nombre UUID ilegible | Falta la cabecera Content-Disposition |
Devuelve el nombre original en filename=, guardando el UUID solo en disco |
| Todo funciona pero la petición tarda 3 segundos | Estás esperando a Open-Meteo antes de responder | Es correcto y es el coste que decidiste asumir: mídelo y anótalo, o pásalo a asíncrono |
Paso 5 · Cerrar la integración de extremo a extremo
- Añade al cliente un formulario de adjunto sobre una tarea existente. Envía FormData con
archivoy token; no fijes manualmente la cabecera Content-Type. - Muestra el enlace de descarga y el clima. Si la temperatura es null, presenta el aviso de indisponibilidad y no lo interpretes como cero grados ni como condiciones favorables.
- Provoca en el entorno de pruebas un fallo de persistencia después de guardar el archivo. Comprueba el rollback de la fila y la retirada del archivo por afterCompletion. Restaura la condición normal y repite el alta correcta.
- Documenta el endpoint y sus permisos en OpenAPI, usando el patrón multipart de la sesión 43. Ejecútalo también desde Swagger.
- Guarda en el registro de la sesión el recorrido crear tarea → registrar adjunto → descargar, con sus ids y resultados. Anota la latencia con proveedor disponible y caído, distinguiendo timeout y tiempo total de la petición. No prometas que ambos tiempos son exactamente iguales.
Paso 6 · Comprobar y registrar el resultado del proyecto
- Ejecuta el recorrido correcto y comprueba datos persistidos, permisos, adjuntos y respuesta pública.
- Repite con una validación fallida, otro usuario y el proveedor caído. Comprueba tanto lo que se devuelve como los efectos que sí o no deben haberse producido.
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 · Auditoría de integraciones externas
Diseña una tabla de auditoría en PostgreSQL:
CREATE TABLE auditoria_integraciones (
id BIGSERIAL PRIMARY KEY,
servicio_destino VARCHAR(50) NOT NULL,
operacion VARCHAR(50) NOT NULL,
latencia_ms BIGINT NOT NULL,
codigo_http_resultado INT,
estado VARCHAR(20) NOT NULL, -- 'EXITO', 'TIMEOUT', 'ERROR_REMOTO'
fecha_registro TIMESTAMP NOT NULL
);
Implementa un aspecto @Aspect o un interceptor en RestClient (ClientHttpRequestInterceptor) que registre automáticamente cada petición saliente a Open-Meteo o al Webhook en esta tabla.
Formato de entrega
Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.
Ver respuestas
1 · Porque los datos climáticos forman parte de la información que enriquece la incidencia a guardar; el webhook, en cambio, es un efecto secundario de notificación que solo debe emitirse si el registro tuvo éxito.
2 · El archivo quedaría huérfano en disco a menos que se implemente un mecanismo de compensación o limpieza en el bloque catch de la transacción.
3 · Protegiendo el endpoint GET de descarga con @PreAuthorize("isAuthenticated()") o verificando roles específicos en la SecurityFilterChain.
4 · Reduce el número de peticiones de red entre navegador y servidor (round-trips), disminuye la latencia total y simplifica la lógica del cliente frontend.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El caso de uso funciona desde el cliente y sus limitaciones y respuestas ante fallos están documentadas.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.