Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado planificar el incremento y sus dependencias. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Retomas el backend persistente del primer trimestre. Hoy harás más útiles sus consultas: un subrecurso expresa una relación en la URL y un filtro reduce los resultados según criterios. Los ejemplos amplían el contrato existente de tu proyecto.
El dilema de la profundidad relacional en REST
En la UD5 aprendiste a modelar relaciones en PostgreSQL y JPA: un proyecto tiene muchas tareas, una tarea pertenece a un proyecto y tiene muchas etiquetas.
Cuando trasladas ese grafo relacional a una API REST surge una pregunta crítica de diseño: ¿cuánta información relacionada debe devolver un endpoint?
Si un cliente solicita GET /proyectos/1, existen dos extremos desastrosos:
- ❌ El extremo del bucle infinito y el volcado masivo
- Si devuelves la entidad directamente o incrustas todo su grafo, el serializador Jackson intentará convertir el
Proyectoa JSON; al leer sus tareas, convertirá cadaTarea; al leer el proyecto de la tarea, volverá a serializar elProyecto, provocando unStackOverflowErrorpor ciclo infinito. Incluso rompiendo el ciclo con anotaciones, una sola petición acabará devolviendo miles de filas de la base de datos (tareas, etiquetas, usuarios). - ❌ El extremo de la pobreza de datos (over-fetching / under-fetching)
- Si devuelves solo los datos planos del proyecto sin ninguna información de sus tareas, obligas al cliente a realizar decenas de peticiones adicionales para saber siquiera si el proyecto tiene trabajo asignado.
La ley del contrato acotado
Un recurso nunca debe exponer el grafo completo de persistencia.
Cada endpoint debe responder únicamente a las necesidades de su caso de uso de cliente, delimitando la frontera de datos mediante DTOs específicos.
Los tres patrones para exponer recursos relacionados
Para diseñar una API REST limpia y predecible, disponemos de tres patrones fundamentales:
- 1. Incrustación resumida (Embedded Summary)
- 2. Subrecurso dedicado (/padre/{id}/hijos)
- 3. Identificador plano o URI de enlace
| Patrón | En qué consiste | Cuándo utilizarlo | Ejemplo en nuestra API |
|---|---|---|---|
| 1 · Incrustación resumida (Embedded Summary) | El DTO padre incluye un resumen compacto del hijo (solo los campos que el cliente necesita para pintar la vista principal). | La relación es 1:1 o 1:N pequeña, y los datos del hijo son inseparables del padre en la interfaz. | En TareaResponse: incluir proyectoId y proyectoNombre. |
| 2 · Subrecurso dedicado (Sub-resource endpoint) | La colección hija no se incrusta en el padre; se expone a través de una ruta jerárquica propia. | La colección hija puede ser numerosa, tiene ciclo de vida propio o requiere paginación y filtros independientes. | GET /proyectos/{id}/tareas para listar las tareas de un proyecto. |
| 3 · Identificador o Enlace | El DTO devuelve únicamente la clave ajena (Long responsableId) o un enlace URI al recurso completo. |
El cliente rara vez necesita los datos del recurso vinculado de forma inmediata. | En TareaResponse: Long responsableId en lugar del usuario completo. |
La trampa de la explosión combinatoria de endpoints
Cuando una API empieza a usarse, los clientes necesitan filtrar información:
- El frontend de proyectos necesita ver las tareas de un proyecto.
- El panel de incidencias necesita ver las tareas de prioridad alta.
- El informe de calidad necesita ver las tareas completadas de un proyecto.
- El buscador necesita encontrar tareas cuyo título contenga una palabra clave.
Un desarrollador principiante suele caer en la explosión combinatoria de rutas:
GET /tareas/proyecto/{id}GET /tareas/prioridad/{prioridad}GET /tareas/completadasGET /tareas/proyecto/{id}/prioridad/{prioridad}GET /tareas/proyecto/{id}/completadas
Con tan solo 4 criterios combinables, ¡necesitarías crear 2⁴ = 16 endpoints distintos en tu controlador!
La convención REST para filtros
Un recurso de colección tiene una única ruta canónica en plural (/tareas).
Todas las variaciones de filtrado, búsqueda y ordenación se transmiten mediante parámetros de consulta en la URL (Query Parameters): GET /tareas?prioridad=alta&completada=false.
Estrategias de filtrado en Spring Data JPA
Para resolver consultas con parámetros opcionales en Spring Data existen tres enfoques principales:
| Enfoque | Cómo funciona | Ventajas y Desventajas |
|---|---|---|
Métodos derivados (findBy...) |
Nombres de método largos como findByProyectoIdAndPrioridadAndCompletada. |
Inviable con filtros opcionales: si un parámetro viene nulo, Spring busca filas con valor NULL en lugar de omitir el filtro. |
| JPQL condicional con comprobación de nulos | Consulta @Query con cláusulas (:param IS NULL OR columna = :param). |
Recomendado para 2-5 filtros comunes. Muy legible, nativo de JPA, sin librerías externas y con excelente rendimiento. |
| Spring Data Specifications (Criteria API) | Objetos Specification<T> que componen el predicado SQL dinámicamente con la API de criterios de JPA. |
Máxima flexibilidad para catálogos con 15+ filtros dinámicos, pero introduce mayor complejidad de código y boiler plate. |
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Ejecuta la colección de la versión anterior y abre los listados, sus DTO y los métodos de repositorio que los alimentan.
- Prepara registros que coincidan solo con un filtro, con varios y con ninguno. Incluye texto con diferencias de mayúsculas o acentos para observar la política elegida.
- Anota las combinaciones de filtros que permitirá tu API y qué respuesta esperas cuando se omitan.
Paso 2 · Subrecursos desacoplados y DTOs específicos
Preparar el mapper usado por los bloques siguientes. Abre TareaMapper, conserva sus métodos anteriores, añade @Component (import org.springframework.stereotype.Component) a la clase y sustituye su constructor privado por uno público sin argumentos. Así Spring puede inyectarlo. Añade estos métodos de instancia e importa TareaResumenResponse, TareaDetalleResponse y Etiqueta de tus paquetes:
public TareaResumenResponse toResumenResponse(Tarea tarea) {
return new TareaResumenResponse(tarea.getId(), tarea.getTitulo(),
tarea.getPrioridad(), tarea.isCompletada());
}
public TareaDetalleResponse toDetalleResponse(Tarea tarea) {
return new TareaDetalleResponse(tarea.getId(), tarea.getTitulo(),
tarea.getPrioridad(), tarea.isCompletada(), tarea.getProyecto().getId(),
tarea.getProyecto().getNombre(),
tarea.getEtiquetas().stream().map(Etiqueta::getNombre).toList());
}
Haz la conversión de detalle dentro de un método transaccional del servicio, porque accede a relaciones LAZY. Los métodos estáticos anteriores pueden conservarse mientras migras sus llamadas; no cambies todos los controladores a la vez.
Cada public record del primer bloque va en un archivo distinto dentro de dto. Antes de cambiar el servicio, conserva o amplía los mappers de la UD3: el nombre toResumen del ejemplo debe corresponder a un método real que construya el DTO con sus componentes. Si tu mapper sigue siendo estático, llámalo por su clase; si decides inyectarlo, conviértelo en componente y ajusta todos sus usos. Modifica el controlador existente y amplía su constructor para recibir los servicios necesarios, sin borrar los otros endpoints.
Creamos dos representaciones distintas según la vista del cliente:
// DTO ligero para listar proyectos sin arrastrar colecciones
public record ProyectoResponse(
Long id,
String nombre,
String descripcion,
boolean activo,
LocalDateTime creadoEn
) {}
// DTO resumen de tarea para subrecursos y listados
public record TareaResumenResponse(
Long id,
String titulo,
String prioridad,
boolean completada
) {}
// DTO detallado de tarea cuando se consulta una tarea individual
public record TareaDetalleResponse(
Long id,
String titulo,
String prioridad,
boolean completada,
Long proyectoId,
String proyectoNombre,
List<String> etiquetas
) {}
Lo que el DTO deja fuera a propósito
Observa cómo TareaDetalleResponse no contiene un objeto Proyecto anidado con todos sus campos ni entidades Etiqueta, sino solo los datos planos que la pantalla necesita (proyectoId, proyectoNombre y nombres de etiquetas). El contrato es completamente inmune a cambios internos del esquema.
Para resolver el subrecurso sin provocar problemas N+1, definimos la consulta filtrada por la clave foránea en TareaRepository:
public interface TareaRepository extends JpaRepository<Tarea, Long> {
@Query("SELECT t FROM Tarea t WHERE t.proyecto.id = :proyectoId ORDER BY t.id ASC")
List<Tarea> findByProyectoId(@Param("proyectoId") Long proyectoId);
}
El servicio valida primero la existencia del proyecto padre antes de buscar sus tareas, garantizando que un identificador erróneo devuelva un código HTTP semántico:
@Service
@Transactional(readOnly = true)
public class TareaService {
private final TareaRepository tareaRepository;
private final ProyectoRepository proyectoRepository;
private final TareaMapper tareaMapper;
public TareaService(TareaRepository tareaRepository,
ProyectoRepository proyectoRepository,
TareaMapper tareaMapper) {
this.tareaRepository = tareaRepository;
this.proyectoRepository = proyectoRepository;
this.tareaMapper = tareaMapper;
}
public List<TareaResumenResponse> listarTareasDeProyecto(Long proyectoId) {
if (!proyectoRepository.existsById(proyectoId)) {
throw new RecursoNoEncontradoException("No existe proyecto con id " + proyectoId);
}
return tareaRepository.findByProyectoId(proyectoId).stream()
.map(tareaMapper::toResumenResponse)
.toList();
}
}
En la arquitectura REST, los subrecursos jerárquicos se definen colgando de la ruta del padre:
@RestController
@RequestMapping("/proyectos")
public class ProyectoController {
private final ProyectoService proyectoService;
private final TareaService tareaService;
public ProyectoController(ProyectoService proyectoService, TareaService tareaService) {
this.proyectoService = proyectoService;
this.tareaService = tareaService;
}
@GetMapping("/{id}")
public ResponseEntity<ProyectoResponse> obtenerPorId(@PathVariable Long id) {
return ResponseEntity.ok(proyectoService.buscarPorId(id));
}
// Subrecurso canónico: GET /proyectos/{id}/tareas
@GetMapping("/{id}/tareas")
public ResponseEntity<List<TareaResumenResponse>> obtenerTareas(@PathVariable Long id) {
return ResponseEntity.ok(tareaService.listarTareasDeProyecto(id));
}
}
Paso 3 · Verificar la frontera de datos en Bruno
Arranca tu aplicación y ejecuta estas dos peticiones en Bruno o Postman:
- Consulta del proyecto:
GET http://localhost:8080/proyectos/1- Código de respuesta:
200 OK. - El cuerpo JSON contiene únicamente los atributos del proyecto (
id,nombre,descripcion, etc.). Cero tareas incrustadas. Payload inferior a 500 bytes.
- Código de respuesta:
- Consulta de las tareas del subrecurso:
GET http://localhost:8080/proyectos/1/tareas- Código de respuesta:
200 OK. - El cuerpo JSON devuelve un array con las tareas correspondientes a ese proyecto, con su id, título, prioridad y estado.
- Código de respuesta:
- Caso límite (proyecto inexistente):
GET http://localhost:8080/proyectos/999/tareas- Código de respuesta:
404 Not Found. - El servicio intercepta la ausencia del padre y emite el error estándar RFC 7807 sin devolver un array vacío engañoso.
- Código de respuesta:
Paso 4 · Subrecurso de etiquetas por tarea
Aplica el mismo principio para exponer la relación Many-to-Many entre tareas y etiquetas mediante un subrecurso dedicado:
- Define el DTO
EtiquetaResponse(Long id, String nombre, String colorHex). - Implementa en
TareaControllerel endpoint de subrecurso:GET /tareas/{id}/etiquetas - En el servicio, valida que la tarea exista (
existsById) y recupera sus etiquetas asociadas. - Asegúrate de que
EtiquetaResponseno incluya la lista de tareas de vuelta (para no generar ciclos de datos). - Comprueba en Bruno que al consultar las etiquetas de una tarea se devuelve la lista limpia con código
200 OK.
Filtros y búsqueda
Paso 5 · Búsqueda textual insensible a mayúsculas y acentos
Para que un buscador sea usable, escribir "bug", "Bug" o "BUG" debe devolver exactamente los mismos resultados.
En JPQL aplicamos la función LOWER() a ambos lados de la comparación y utilizamos el operador LIKE con comodines:
LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%'))
Si el parámetro :q es nulo o viene vacío, la cláusula (:q IS NULL OR ...) desactiva la condición y devuelve todos los registros.
Paso 6 · Consulta multicriterio de tareas
Añade la consulta al repositorio existente, conservando sus métodos CRUD. Mantén una sola operación de listado en el controlador: reemplaza su implementación para pasar los filtros opcionales al servicio. En la consulta agrupa cada filtro entre paréntesis y enlázalos con AND; un parámetro null desactiva solo ese filtro. Prepara registros que permitan distinguir cada combinación y compara los ids obtenidos. El uso de LOWER resuelve mayúsculas, no elimina acentos por sí mismo.
Añadimos el método de búsqueda en TareaRepository asegurándonos de incluir JOIN FETCH sobre el proyecto para evitar el problema N+1:
public interface TareaRepository extends JpaRepository<Tarea, Long> {
@Query("""
SELECT t FROM Tarea t
JOIN FETCH t.proyecto p
WHERE (:proyectoId IS NULL OR p.id = :proyectoId)
AND (:prioridad IS NULL OR t.prioridad = :prioridad)
AND (:completada IS NULL OR t.completada = :completada)
AND (:q IS NULL OR LOWER(t.titulo) LIKE LOWER(CONCAT('%', :q, '%')))
ORDER BY t.id ASC
""")
List<Tarea> buscarConFiltros(
@Param("proyectoId") Long proyectoId,
@Param("prioridad") String prioridad,
@Param("completada") Boolean completada,
@Param("q") String q
);
}
Por qué un filtro nulo no penaliza la consulta
Al escribir (:proyectoId IS NULL OR p.id = :proyectoId), si el cliente no envía el parámetro en la petición, el valor es null. La primera mitad de la condición se evalúa como TRUE y el motor de base de datos descarta ese filtro sin examinar la columna, evaluando únicamente los filtros que sí fueron proporcionados.
En TareaService, limpiamos los espacios en blanco del término de búsqueda y tratamos cadenas vacías como nulos:
@Service
@Transactional(readOnly = true)
public class TareaService {
private final TareaRepository tareaRepository;
private final TareaMapper tareaMapper;
public TareaService(TareaRepository tareaRepository, TareaMapper tareaMapper) {
this.tareaRepository = tareaRepository;
this.tareaMapper = tareaMapper;
}
public List<TareaResumenResponse> buscarTareas(Long proyectoId,
String prioridad,
Boolean completada,
String q) {
// Normalizamos el texto de búsqueda: cadena vacía o espacios se tratan como null
String terminoLimpio = (q != null && !q.isBlank()) ? q.trim() : null;
String prioridadLimpia = (prioridad != null && !prioridad.isBlank()) ? prioridad.toUpperCase().trim() : null;
return tareaRepository.buscarConFiltros(proyectoId, prioridadLimpia, completada, terminoLimpio)
.stream()
.map(tareaMapper::toResumenResponse)
.toList();
}
}
En TareaController, definimos los parámetros como @RequestParam(required = false):
@RestController
@RequestMapping("/tareas")
public class TareaController {
private final TareaService tareaService;
public TareaController(TareaService tareaService) {
this.tareaService = tareaService;
}
@GetMapping
public ResponseEntity<List<TareaResumenResponse>> listar(
@RequestParam(required = false) Long proyectoId,
@RequestParam(required = false) String prioridad,
@RequestParam(required = false) Boolean completada,
@RequestParam(required = false) String q
) {
List<TareaResumenResponse> resultado = tareaService.buscarTareas(proyectoId, prioridad, completada, q);
return ResponseEntity.ok(resultado);
}
}
Paso 7 · Pruebas combinatorias en Bruno
Abre Bruno o Postman y verifica cómo se comporta el mismo endpoint /tareas según los parámetros que envíes:
- Sin parámetros:
GET http://localhost:8080/tareas- Devuelve la lista completa de todas las tareas del sistema.
- Un solo filtro:
GET http://localhost:8080/tareas?prioridad=alta- Devuelve únicamente tareas con prioridad
ALTA.
- Devuelve únicamente tareas con prioridad
- Filtros combinados:
GET http://localhost:8080/tareas?proyectoId=1&completada=false- Devuelve solo las tareas pendientes que pertenecen al proyecto 1.
- Búsqueda textual:
GET http://localhost:8080/tareas?q=auth- Devuelve tareas cuyo título contenga la palabra
"auth","Auth"o"AUTORIZACION".
- Devuelve tareas cuyo título contenga la palabra
- Todos los filtros a la vez:
GET http://localhost:8080/tareas?proyectoId=1&prioridad=alta&completada=false&q=login- Devuelve la intersección exacta de todos los criterios.
- Auditoría de consola SQL:
- Observa la sentencia emitida en la terminal: verás una única consulta SQL con
LEFT/INNER JOINy la cláusulaWHEREevaluada de forma limpia en PostgreSQL.
- Observa la sentencia emitida en la terminal: verás una única consulta SQL con
Paso 8 · Si algo no sale como dice el guion
| Síntoma | Causa casi segura | Qué mirar |
|---|---|---|
| Sin filtros no devuelve nada | El IS NULL de la condición falta |
Un parámetro ausente debe desactivar su filtro, no filtrar por vacío |
?activo=true devuelve todo |
El tipo es boolean y no Boolean |
Un primitivo nunca puede ser null, así que el filtro se aplica siempre con false |
| La búsqueda distingue mayúsculas | Falta normalizar los dos lados | LOWER(p.nombre) LIKE LOWER(:q), y el % se añade en Java, no en el JPQL |
Parameter with that name did not exist |
El @Param no coincide |
El nombre del @Param debe ser idéntico al :nombre de la consulta |
| Filtrar por texto con acentos no encuentra nada | Colación de PostgreSQL | Es comportamiento del motor, no de tu código: anótalo como limitación conocida |
Paso 9 · Filtro de proyectos por estado y nombre
Aplica el patrón de filtrado a la entidad Proyecto:
- Añade en
ProyectoRepositoryuna consultabuscarConFiltrosque recibaBoolean activoyString q. Fíjate en que esBooleancon mayúscula: necesitas poder distinguir «filtra por activos» de «no filtres por este campo», y unbooleanprimitivo no puede expresar esa diferencia. - En
ProyectoService, normaliza el término de búsqueda: recorta espacios, pásalo a minúsculas y trata la cadena vacía como si no hubieran enviado nada. Un?q=vacío no debe vaciar la lista. - Actualiza
GET /proyectospara admitir?activo=true&q=portal. - Comprueba las cuatro combinaciones, no solo la que funciona: sin filtros, solo
activo, soloq, y los dos a la vez. La tabla de verdad completa es lo que demuestra que la consulta condicional está bien escrita. - Prueba los casos incómodos y decide qué hace cada uno:
?q=vacío,?q=%,?q=con 300 caracteres y?activo=quizas. Ese último debe dar400, y lo da solo si el tipo esBoolean: compruébalo. - Añade un test de
@WebMvcTestque verifique que?activo=truellega al servicio comoBoolean.TRUEy que una petición sin parámetros lo recibe comonull. Es la forma de blindar que el filtro opcional sigue siendo opcional dentro de seis meses.
- Cómo saber que lo has terminado
- Las cuatro combinaciones de filtros devuelven lo que deben; una petición sin parámetros devuelve la lista completa y no una vacía; un valor no booleano devuelve
400; y la búsqueda encuentra igual escribiendo en mayúsculas o en minúsculas.
Paso 10 · Comprobar y registrar el resultado del proyecto
- Prueba cada filtro aislado y combinado y contrasta los ids devueltos con los datos preparados.
- Consulta un subrecurso desde dos recursos principales distintos: no debe mezclar registros ajenos ni exponer campos internos de las entidades.
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 · ¿Incrustar o enlazar? El parámetro ?expand frente al subrecurso
En APIs públicas de gran escala (como Stripe o GitHub) a veces se utiliza un parámetro de consulta para permitir al cliente decidir si quiere incrustar un recurso relacionado en una sola llamada:
- Ejemplo:
GET /proyectos/1?expand=tareasfrente aGET /proyectos/1/tareas.
Analiza y responde con criterio de ingeniería:
- ¿Qué ventaja de latencia tiene el parámetro
?expandpara una aplicación móvil conectada con cobertura 4G inestable? - ¿Qué coste arquitectónico introduce soportar
?expanden la capa de servicios y en los mappers de DTOs en comparación con mantener dos endpoints separados? - ¿Cómo resolverías la consulta en JPA si el cliente envía
?expand=tareaspara evitar que Hibernate ejecute consultas adicionales innecesarias?
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.
GET /proyectos/{id}/tareas devolviendo TareaResumenResponse.GET /tareas/{id}/etiquetas implementado con validación de existencia del padre y código 404 semántico.?expand frente a subrecursos justificado con métricas de red y complejidad en JPA.Ver respuestas
1 · Porque Jackson serializa el padre, que contiene a los hijos; al serializar cada hijo, Jackson lee la referencia hacia el padre y vuelve a serializarlo recursivamente sin fin hasta agotar la pila de memoria (call stack).
2 · Embedded Summary incrusta un resumen compacto de los datos del hijo dentro del propio JSON del recurso padre; Sub-resource externaliza la colección a una URL jerárquica independiente (/padre/{id}/hijos).
3 · Porque el recurso padre (el proyecto 999) no existe en el sistema. Devolver un array vacío induciría al cliente a pensar erróneamente que el proyecto existe pero que simplemente no tiene tareas asignadas todavía.
4 · Reduce el tamaño del JSON transmitido por red (payload), evita consultas SQL innecesarias de atributos no utilizados y aísla el contrato de la API de cambios en la estructura interna de la entidad proyecto.
Reto · Caracteres especiales y seguridad en búsquedas LIKE
En las consultas con operador LIKE, los caracteres % (cualquier secuencia) y _ (cualquier carácter único) son comodines del motor SQL.
Analiza qué ocurre si un usuario malicioso o despistado introduce en el buscador la cadena ?q=%:
- ¿Qué consulta SQL acabaría ejecutando PostgreSQL y qué impacto tendría en el uso de CPU si la tabla tiene 2.000.000 de filas?
- ¿Por qué una búsqueda que empieza con comodín a la izquierda (
%texto) anula la capacidad de la base de datos de utilizar un índice B-Tree convencional? - Diseña una función de saneamiento en Java que escape los caracteres comodín (
\%y\_) antes de pasar el parámetro a la consulta JPQL.
GET /tareas con filtros combinables opcionales de proyecto, prioridad y completada.LOWER() y filtro de proyectos implementado.%, _) implementado y análisis de impacto en índices B-Tree justificado.Ver respuestas
1 · Porque evita la explosión combinatoria de rutas (2^N endpoints), respeta la semántica REST de tener una única URI canónica por recurso y permite al cliente combinar cualquier número de filtros opcionales libremente.
2 · Si el parámetro es null (no se envió en la petición HTTP), la primera parte se evalúa como verdadera y desactiva ese filtro para toda la consulta. Si no es null, se evalúa la segunda parte comparando con el valor de la columna.
3 · Porque muchos motores relacionales (incluido PostgreSQL con ciertas configuraciones de intercalación o JPQL estándar) distinguen mayúsculas de minúsculas en LIKE; convertir ambos operandos a minúsculas garantiza coincidencias uniformes.
4 · El motor de base de datos no puede utilizar un índice B-Tree para saltar directamente a los registros porque no conoce el prefijo de inicio, obligando a un escaneo secuencial completo de la tabla (Full Table Scan).
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
El cliente puede consultar relaciones y filtrar sin conocer las tablas internas.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.