CodeGym /Cursos /Spring REST & MVC /Tags como endpoint de búsqueda (lookup endpoint)

Tags como endpoint de búsqueda (lookup endpoint)

Spring REST & MVC
Nivel 25, Lección 3
Disponible

1. Lista general de tags para el cliente

Si el backend viviera en el vacío, podríamos decir: «Los tags son simplemente un List<String> dentro de Task, y ya está». Pero la API es un contrato para el cliente, y el cliente normalmente quiere cosas útiles: filtro por tag, autocompletado, una lista desplegable con «qué tags existen en general», para que el usuario no escriba bugfix en un lado y bug-fix en otro. Es decir, los tags se convierten en parte del UX, por lo que necesita una forma predecible de obtenerlos.

Después de los comentarios, se ve otro tipo de extensión de la API. No todo debe convertirse en un campo de Task o en una nueva colección debajo de ella. A veces, el cliente necesita una lista de solo lectura de valores que ayude a filtrar, sugerir opciones y no tener que recopilar el directorio de tareas por su cuenta.

Imagine el frontend: tiene una pantalla «Lista de tareas» con filtros. Uno de los filtros es «Tag». El frontend podría, por supuesto, hacer un truco astuto: solicitar GET /api/v1/tasks con mil elementos, luego recopilar todos los tags, y luego hacer «distinct + sort» por su cuenta. Pero esto trae inmediatamente tres problemas.

El primer problema es tráfico innecesario y lógica adicional en el cliente. Le pedimos tareas al servidor, aunque solo necesitamos los tags. El segundo problema es la imprevisibilidad. Paginación, filtros, permisos de acceso (en nuestro curso no los hay, pero en la vida real los habrá), y de repente el cliente ha recopilado «no todos los tags», sino solo los que aparecieron en la primera página. El tercer problema es que la responsabilidad se dispersa: parte de la «lógica del contrato» vive de repente en el cliente, y tienes dos «fuentes de verdad» diferentes sobre cómo recopilar y ordenar los tags.

Por lo tanto, la lógica es madura y simple: los tags siguen siendo parte de las tareas, pero agregamos un endpoint de búsqueda (lookup endpoint) que devuelve una lista «de referencia» de los tags existentes. Esto cubre la necesidad de UX sin inflar el dominio.

2. Tags como valor: sin CRUD independiente

Cuando un desarrollador ve «valores repetidos», a veces la mano se mueve automáticamente para crear una entidad: Tag con id, repositorio, controlador CRUD y un universo separado de errores. Esto es especialmente típico después de los primeros proyectos con JPA, donde todo parece un conjunto de tablas. Pero aquí es importante detenerse a tiempo y preguntarse: ¿el tag tiene un ciclo de vida independiente en nuestro proyecto? ¿Puede el tag existir independientemente de las tareas? ¿Es necesario editarlo, eliminarlo o «administrarlo» por separado?

En nuestra Task Tracker API la respuesta es honesta: los tags son valores, «etiquetas en la tarea», no objetos de negocio independientes. Aparecen y desaparecen como consecuencia de la creación/actualización de tareas. Si se hace un CRUD completo para tags, inmediatamente se obtendrán acuerdos adicionales innecesarios: ¿qué ocurre si se elimina un tag que ya está asignado a tareas? ¿Lo eliminamos también de las tareas? ¿Prohibimos su eliminación? ¿Y si se renombra un tag, lo renombramos en todas las tareas? No es imposible, pero es otra complejidad que no ayuda a nuestro objetivo del día: mantener la API orientada a recursos y compacta.

Es muy útil comparar los dos enfoques en términos de costo y significado:

Enfoque Lo que recibe el cliente Lo que paga el backend Cuándo está justificado
CRUD de Tag completo (POST/PUT/DELETE /tags) Gestión del directorio de tags como subsistema independiente Nuevas reglas de coherencia, nuevos errores, nuevos escenarios de conflicto, más documentación y pruebas Cuando los tags realmente se gestionan por separado (hay panel de administración, moderación, descripciones, iconos, etc.)
Endpoint de búsqueda GET /tags Lista de tags existentes para la interfaz de usuario y filtros Lógica mínima: recopilar, hacer únicos, ordenar Cuando los tags son solo cadenas dentro de otro recurso

Es decir, el lookup no es un «CRUD reducido». Es otro tipo de endpoint: un pequeño recurso de apoyo que ayuda al cliente a navegar entre los valores sin crear una entidad independiente donde no es necesaria.

3. Contrato GET /api/v1/tags

Ahora fijaremos el contrato para que no sea «más o menos como salga», sino claramente entendible y repetible. Necesitamos un endpoint GET /api/v1/tags que devuelva una lista de tags que aparecen en las tareas. Es importante que sea un endpoint de solo lectura, no crea ni modifica datos — un clásico GET seguro. Esto tiene un buen efecto tanto en la percepción como en la posibilidad de almacenar en caché el resultado (en nuestro curso no hacemos almacenamiento en caché, pero la idea es correcta).

Las decisiones clave del contrato son las siguientes. En primer lugar, el estado de la respuesta exitosa es 200 OK, incluso si aún no hay tags. Una lista vacía es un resultado normal, no un error: la ausencia de tags no significa que el endpoint «no se encuentre». En segundo lugar, la lista debe ser determinista: un conjunto de datos idéntico debe dar el mismo orden de valores. Para ello, ordenamos los tags. En tercer lugar, la lista debe ser única: sin repeticiones, de lo contrario la interfaz de usuario se convierte en una comedia («bug», «bug», «bug»…).

Con los comentarios pudimos permitirnos un array sin envoltorio: es una pequeña colección dentro de una tarea específica, y normalmente no se esperan metadatos adicionales allí. El endpoint /tags tiene otro rol: es un recurso de búsqueda global de apoyo para la interfaz de usuario y los filtros, por lo que aquí un envelope parece más fiable.

En cuanto a la forma de la respuesta, hay dos caminos: devolver un array JSON «desnudo» ["bug","java"] o devolver un pequeño envelope, por ejemplo { "items": [...] }. En el módulo sobre el contrato JSON estable, ya discutimos por qué el envelope a menudo gana: si desea agregar metadatos (por ejemplo, total), puede hacerlo sin romper a los clientes. Por lo tanto, a continuación mostraré la opción con envelope. Es un poco más larga en 3 caracteres, pero más agradable como contrato.

Ejemplo de respuesta:

{
  "items": ["backend", "bug", "java", "urgent"]
}

Y otra regla importante: la fuente de verdad para los tags son las tareas, no un «directorio por el directorio mismo». Por lo tanto, el endpoint GET /tags recopilará los tags desde TaskRepository, sin crear un TagRepository separado.

4. Implementación: TagController → TagService → TaskRepository

Ahora pasemos al código para que se integre naturalmente en nuestra arquitectura: el controlador es delgado, la lógica de negocio (aunque sea simple) está en el servicio, y los datos se obtienen a través del repositorio. Incluso si parece que «solo hay una línea de stream», es mejor mantener la disciplina de las capas. De lo contrario, en una semana tendrá un controlador de 200 líneas con flatMap, ordenamientos y «bueno, aquí había una cosita pequeña».

El esquema de llamada queda así:

flowchart TD
  A["HTTP GET /api/v1/tags"] --> B["TagController"]
  B --> C["TagService"]
  C --> D["TaskRepository"]
  D --> C
  C --> B
  B --> E["200 OK + TagLookupResponse"]

DTO de respuesta: TagLookupResponse

Comencemos con el modelo de respuesta. Lo lógico es ubicarlo en com.example.tasktracker.api.dto.response.

import java.util.List;

/**
 * DTO para la respuesta de búsqueda de tags.
 * Importante: es una representación de solo lectura para el cliente, no una entidad de dominio.
 */
public record TagLookupResponse(List<String> items) {
}

Este es un DTO intencionalmente mínimo: una lista de cadenas. Si se desea, se pueden agregar metadatos como total fácilmente, pero ahora no son necesarios.

Controlador: TagController

El controlador es lo más delgado posible: recibe la solicitud, llama al servicio, devuelve el DTO. Nada de streams ni «distinct() directamente en el controlador».

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/tags") // recurso de apoyo a nivel global
public class TagController {

    private final TagService tagService;

    public TagController(TagService tagService) {
        // Inyectamos el servicio a través del constructor: el controlador no debe «saber» cómo recopilar los tags
        this.tagService = tagService;
    }

    @GetMapping
    public TagLookupResponse getAll() {
        // Devolvemos una búsqueda de solo lectura: lista de tags existentes de las tareas
        return tagService.getAllTags();
    }
}

Tenga en cuenta varios puntos. La ruta comienza directamente con /api/v1/tags, para que el cliente no tenga que recordar «¿esto está bajo /tasks o por separado?». Es realmente un recurso de apoyo a nivel global: ayuda para los filtros de tareas, pero no es un subrecurso de una tarea específica.

Servicio: TagService

En TagService tomamos todas las tareas y recopilamos los tags de ellas. Sí, en el mundo en memoria «tomar todas las tareas» suena normal. En un proyecto real con base de datos, no haría «todo en memoria», pero ahora nuestro objetivo es el contrato de la API y la lógica legible, no la optimización para millones de registros.

import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TagService {

    private final TaskRepository taskRepository;

    public TagService(TaskRepository taskRepository) {
        // El servicio depende del repositorio: el servicio no debe almacenar colecciones de tareas por sí mismo
        this.taskRepository = taskRepository;
    }

    public TagLookupResponse getAllTags() {
        // El repositorio «obtiene los datos» (tags), el servicio «da formato a la respuesta» según el contrato de la API
        List<String> tags = taskRepository.findAllTags();
        return new TagLookupResponse(tags);
    }
}

Aquí hice un movimiento interesante: TagService llama a taskRepository.findAllTags(). No es la única opción. Podría haber llamado a findAll() y recopilar los tags en el servicio. Pero findAllTags() hace que nuestro código sea más claro: el repositorio se encarga de «cómo obtener los datos», el servicio se encarga de «cómo formatear el resultado para la API». Y la recopilación de tags desde el almacenamiento está más cerca de «obtener datos» que de «lógica de negocio».

Si actualmente su repositorio no tiene ese método, es normal. Podemos agregarlo, y la implementación en memoria lo soportará fácilmente.

Repositorio: método findAllTags() (en memoria)

Mostraré la idea a nivel de interfaz:

import java.util.List;

public interface TaskRepository {

    List<Task> findAll();

    /**
     * Devuelve todos los tags que aparecen en las tareas.
     * Importante: el contrato del método debe corresponder al contrato de la API (la unicidad/orden se resuelven a continuación).
     */
    List<String> findAllTags();
}

Y ahora un ejemplo de implementación para el repositorio en memoria (súper simplificado para ver la mecánica). Supongamos que tenemos una colección tasks.

import java.util.List;

public class InMemoryTaskRepository implements TaskRepository {

    private final List<Task> tasks;

    public InMemoryTaskRepository(List<Task> tasks) {
        // Almacenamos las tareas en memoria (para la versión de aprendizaje)
        this.tasks = tasks;
    }

    @Override
    public List<String> findAllTags() {
        // Versión «cruda»: simplemente desplegamos la lista de tags de todas las tareas.
        // La unicidad y el orden se agregarán en el siguiente paso
        return tasks.stream()
                .flatMap(t -> t.getTags().stream())
                .toList();
    }
}

Esta versión es «cruda» por ahora: no hace distinct() ni ordena. Dejamos esto intencionalmente para la siguiente sección, porque es allí donde viven los errores típicos que hacen que el endpoint de búsqueda se comporte repentinamente como un generador de números aleatorios.

5. Unicidad y orden de los tags

Sobre el papel, «devolver una lista de tags» parece una línea. En la práctica, es precisamente en estos lugares donde la API comienza a acumular pequeños problemas. A veces el orden de las respuestas «salta», a veces aparecen ["Java","java"], a veces los tags contienen espacios inesperadamente, y en la interfaz de usuario surge una obra maestra: " urgent" y "urgent" como si fueran valores diferentes. Por lo tanto, ahora haremos la parte que convierte la selección cruda en un contrato normal.

Normalización: trim() y toLowerCase()

Empecemos por el hecho de que un tag es una entrada de usuario. Y la entrada de usuario, como se sabe, puede incluir espacios inesperados. Por lo tanto, casi siempre es útil normalizar las cadenas. El mínimo más claro es trim() y toLowerCase().

private String normalize(String tag) {
    // Eliminamos espacios en los extremos y convertimos a minúsculas para una semántica que no distingue mayúsculas de minúsculas
    return tag.trim().toLowerCase();
}

¿Por qué toLowerCase()? Porque en los requisitos del proyecto tenemos una regla: los tags deben ser únicos dentro de una tarea sin distinguir mayúsculas y minúsculas. Ya que vivimos en esa semántica, es lógico hacer lo mismo con el lookup. De lo contrario, una persona verá dos variantes del mismo tag y se hará una pregunta filosófica: «¿cuál de ellos es el verdadero?».

Recopilamos: flatMap() → normalize → distinct() → sorted()

Ahora juntemos todo en un pipeline claro. Mostraré la implementación en InMemoryTaskRepository#findAllTags() (o, si lo prefiere, puede mantenerse en el servicio; la lógica será la misma).

import java.util.List;

@Override
public List<String> findAllTags() {
    return tasks.stream()
            .flatMap(t -> t.getTags().stream()) // 1) extraemos los tags de todas las tareas en un solo flujo
            .map(this::normalize)               // 2) normalizamos la entrada del usuario
            .distinct()                         // 3) eliminamos duplicados después de la normalización
            .sorted()                           // 4) hacemos que el orden sea determinista para el cliente
            .toList();
}

Aquí es importante entender el orden de las operaciones. Primero extraemos todos los tags (flatMap()), luego normalizamos (map()), luego hacemos que sean únicos (distinct()), y luego ordenamos (sorted()). Si se invierte el orden, se obtendrá algo extraño. Por ejemplo, si distinct() se hace antes de la normalización, entonces "Java" y "java" no se considerarán iguales, y aún así obtendrá duplicados.

La ordenación como parte del contrato

La ordenación hace que la respuesta sea determinista. Sin ordenación, el orden de los tags dependerá del orden interno de las tareas, de los datos de prueba, de cómo agregó los elementos a la lista, y a veces también del tipo de colección que utilizó (por ejemplo, HashSet puede cambiar el orden entre ejecuciones). Para el cliente, esto es un problema: la interfaz de usuario «parpadeará»: los tags en la lista desplegable hoy en un orden, mañana en otro.

Con la ordenación, el contrato se vuelve predecible: «orden alfabético, siempre igual». Y esto mejora inmediatamente la experiencia de usuario: el usuario encuentra más rápido el tag deseado, y las pruebas automáticas del frontend dejan de fallar debido al orden aleatorio.

Si desea una ordenación un poco más amigable (sin sorpresas con mayúsculas/minúsculas), puede ordenar de forma que no distinga mayúsculas:

import java.util.Comparator;

// Mostramos explícitamente la ordenación con un comparador (en nuestro caso, después de convertir a minúsculas ya no es necesario)
.sorted(Comparator.naturalOrder())

Pero en nuestro caso ya normalizamos a minúsculas, así que el sorted() normal funciona perfectamente.

Un resultado vacío es un éxito

Reforcemos la semántica una vez más: si no hay tareas o ninguna tarea tiene tags, GET /api/v1/tags debe devolver:

{
  "items": []
}

y el estado 200 OK.

Esto es importante psicológicamente. 404 dice: «el recurso no existe». Pero en nuestro caso, el recurso existe, simplemente ahora está vacío. Es normal.

6. Verificación del contrato mediante .http

Después de cualquier nuevo endpoint, es muy útil hacer una rápida «prueba de humo manual». No porque no nos gusten las pruebas (las amaremos más tarde), sino porque es necesario que una persona vea que el contrato realmente se ve como se planeó. Por lo general, son 30 segundos que ahorran 30 minutos de «¿por qué el frontend se queja?».

Ejemplo de solicitud en un archivo .http:

### Tags lookup
# Verificamos el endpoint de apoyo que devuelve la lista de referencia de tags
GET http://localhost:8080/api/v1/tags
Accept: application/json

Respuesta esperada (ejemplo):

{
  "items": ["backend", "bug", "java", "urgent"]
}

Y ahora, un vínculo práctico importante. El cliente recibió la lista de tags, mostró un menú desplegable al usuario, el usuario seleccionó, digamos, bug. Luego, el cliente va a nuestro endpoint de lista de tareas, que ya admite filtro por tag:

### Tasks filtered by tag
# Usamos el tag seleccionado como parámetro de filtro en la lista de tareas
GET http://localhost:8080/api/v1/tasks?tag=bug&page=0&size=20&sort=updatedAt,desc
Accept: application/json

Y este par de endpoints se ve para el cliente como un ecosistema normal: hay un directorio de valores y hay filtrado por ellos. Al mismo tiempo, el modelo de dominio del proyecto no se ha inflado, porque Tag no se ha convertido en una entidad separada.

7. Errores típicos en la búsqueda (lookup) de tags

Cuando se hace un endpoint de búsqueda (lookup), es fácil caer en la trampa de «bueno, es algo pequeño». Pero son precisamente las pequeñas cosas las que crean la sensación de que la API «a veces es normal, a veces es extraña». Los errores que se enumeran a continuación son los más comunes, y casi todos se curan con una cosa: conciencia del contrato y un poco de disciplina en el código.

Error n.º 1: convertir los tags en un CRUD completo solo porque “es más familiar”.
Es muy tentador hacer POST /tags, DELETE /tags/{id}, PUT /tags/{id} — parece que así «completas el dominio». En realidad, has agregado una nueva entidad con un ciclo de vida independiente y un montón de escenarios conflictivos que no se pidieron. Si los tags son valores dentro de una tarea, entonces el endpoint de búsqueda cubre la necesidad del cliente de manera mucho más económica.

Error n.º 2: devolver un orden no determinista (unas veces así, otras veces asá).
Si recopila los tags en un HashSet y luego hace toList(), el orden puede ser diferente entre ejecuciones. Si los recopila «tal como salen» de las tareas, el orden dependerá de los datos de prueba y de cómo agregó las tareas. Esto es desagradable para el cliente, y para el desarrollador es aún más desagradable depurar un comportamiento «flotante». Una ordenación estable hace que el contrato sea predecible.

Error n.º 3: hacer distinct() antes de la normalización y obtener duplicados como "Java" y "java".
A nivel de Java son dos String diferentes, y distinct() no los combinará. Si en el proyecto la semántica de los tags no distingue mayúsculas y minúsculas (y en nuestro caso es así), entonces la normalización debe ocurrir antes de la unicidad. El orden correcto es: normalize → distinct → sort.

Error n.º 4: devolver 404 Not Found cuando no hay tags.
404 significa «el recurso no existe». Pero /api/v1/tags como recurso existe siempre: solo que a veces devuelve una lista vacía. Una lista vacía es un resultado exitoso y esperado, especialmente al inicio de la vida de la aplicación o en un entorno «limpio».

Error n.º 5: hardcodear la lista de tags en el controlador.
A veces uno quiere «hacerlo rápido»: return List.of("bug", "urgent"). Esto rompe la idea de la fuente de verdad y convierte los tags en un directorio separado que vive por sí mismo y diverge de los datos reales de las tareas. El endpoint de búsqueda es valioso precisamente porque refleja las tareas existentes, no las fantasías del controlador sobre qué tags «deberían existir».

1
Tarea
Spring REST & MVC, nivel 25, lección 3
Bloqueada
Endpoint de consulta (lookup) con lista de etiquetas
Endpoint de consulta (lookup) con lista de etiquetas
1
Tarea
Spring REST & MVC, nivel 25, lección 3
Bloqueada
Normalización de etiquetas antes de la respuesta de búsqueda (lookup)
Normalización de etiquetas antes de la respuesta de búsqueda (lookup)
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION