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

Sesión 9 · Semana 5

Recursos y contrato REST del dominio

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

Se explica

25 minutos · explicación y demostración

Hasta ahora tenías endpoints que funcionaban y pasaban pruebas. Hoy el objetivo es que además tengan sentido como API REST: que puedas justificar por qué cada ruta y cada método están diseñados así.

Imagina este caso en la pizarra antes de tocar una sola línea de código:

Proyecto 7
├── Tarea 15
├── Tarea 16
└── ¿cómo creamos aquí una nueva tarea?

De esa única pregunta se deriva toda la sesión. Para responderla con rigor, dividimos el diseño en cuatro ideas fundamentales.

1. Un recurso no es un endpoint

En una API HTTP es imprescindible distinguir tres conceptos que a menudo se confunden:

  • Recurso: una entidad o concepto del dominio sobre el que opera el sistema (Proyecto, Tarea, Usuario).
  • Representación: el formato con el que el cliente consulta o envía ese recurso (por ejemplo, el JSON que devuelve el servidor).
  • Endpoint o ruta: la dirección o URI mediante la cual se accede a dicho recurso (/tareas/7).
GET /tareas/7  ──>  { "id": 7, "titulo": "Preparar memoria", "completada": false }
  [Endpoint]                            [Representación JSON]

La tarea es el recurso; el JSON es una representación del estado de esa tarea en ese instante. REST organiza la API alrededor de sustantivos (cosas del dominio), no alrededor de funciones ni procedimientos remotos.

2. La URL dice «qué» y el método HTTP dice «qué haces»

Esta es la regla central del diseño REST. Un diseño procedimental o RPC (Remote Procedure Call) traslada las acciones a la URL:

POST /crearProyecto
GET  /obtenerProyecto?id=7
GET  /borrarProyecto/7
POST /proyecto/7/editar

En una API REST la URL identifica qué recurso se manipula y el método HTTP indica qué operación se solicita:

Petición Significado REST
POST /proyectos Crea un nuevo proyecto
GET /proyectos/7 Obtiene el estado del proyecto 7
PUT /proyectos/7 Sustituye o actualiza por completo el proyecto 7
PATCH /proyectos/7 Modifica parcialmente el proyecto 7
DELETE /proyectos/7 Elimina el proyecto 7

¿Por qué GET /borrarProyecto/7 es un grave error conceptual? No es una simple cuestión de estilo:

  • Semántica HTTP: GET debe ser una operación segura de solo lectura; nunca debe alterar el estado del servidor.
  • Riesgo crítico en producción: un navegador que precalienta enlaces (prefetching), un bot de búsqueda (crawler) o un proxy de caché intermedio que intente acelerar peticiones podría recorrer tus rutas GET y borrar accidentalmente datos de la base de datos sin confirmación.

3. Recursos relacionados: lugar de creación vs. dirección permanente

En la sesión 8 creaste GET /proyectos/7/tareas («dame las tareas que pertenecen al proyecto 7»). Hoy incorporamos:

POST /proyectos/7/tareas  ──>  Crea una tarea asociada al proyecto 7

Cuando llega esta petición con el JSON { "titulo": "Preparar memoria" }, el servidor sigue este flujo estricto:

1. ¿Existe el proyecto 7?
   ├─ NO  ──> Responde 404 Not Found de inmediato (y NO guarda nada)
   └─ SÍ  ──> Continúa
2. Genera el identificador único de la tarea (p. ej. id = 23).
3. Fuerza la relación del dominio: proyectoId = 7.
4. Guarda la nueva tarea en la colección.
5. Responde 201 Created con cabecera Location: /tareas/23.

La regla de oro: El lugar donde creo un recurso no es necesariamente su dirección permanente.

Aunque la tarea nazca a través de la relación /proyectos/7/tareas, una vez creada tiene identidad propia en el dominio. Su dirección canónica y definitiva de detalle es /tareas/23. Por eso la cabecera Location devuelve /tareas/23 y no obligamos a consumir una ruta redundante como /proyectos/7/tareas/23.

4. Conflicto de fuentes: la URL manda sobre el cuerpo

¿Qué ocurre si el cliente hace POST /proyectos/7/tareas pero envía { "titulo": "Tarea X", "proyectoId": 12 }?

La regla de diseño es inequívoca: la URL define el contexto de la operación y manda sobre el cuerpo. El servidor sobrescribe cualquier valor contradictorio:

nueva.setProyectoId(proyectoId); // El 7 de la URL prevalece siempre sobre el 12 del JSON

Esta discrepancia anticipa directamente la necesidad de los DTO de la sesión 10: permitir que el cliente envíe campos como id o proyectoId que realmente controla el servidor obliga a ignorarlos o sobrescribirlos en código. En la siguiente sesión veremos cómo recibir únicamente { "titulo": "Tarea X" }.

Los niveles de Richardson en perspectiva

El modelo de madurez de Richardson es una referencia comparativa para evaluar el grado de aprovechamiento de los estándares web:

  • Nivel 0 (The Swamp of POX): Una única URL para todo el sistema que recibe órdenes en el cuerpo (POST /api).
  • Nivel 1 (Recursos): Se dividen las URLs en recursos individuales (/tareas, /proyectos).
  • Nivel 2 (Verbos y códigos HTTP): Se aprovechan los métodos estándar (GET, POST, PUT, DELETE) y códigos de estado semánticos (200, 201, 404).
  • Nivel 3 (Hipermedia / HATEOAS): Las respuestas contienen enlaces autodescriptivos a las siguientes operaciones válidas.

Tu objetivo formativo es dominar un Nivel 2 sólido y coherente. No dedicaremos la sesión a hipermedia (Nivel 3), pero tampoco nos quedaremos en endpoints desconectados de los estándares HTTP.

Se trabaja

140 minutos · implementación guiada sobre el proyecto propio

Paso 1 · Registrar el contrato que ya funciona — 15 minutos

  1. Abre una rama de trabajo y ejecuta la colección de la sesión 8 antes de editar rutas.
  2. Reutiliza tu tabla de operaciones. Añade solo la información que falte: método, ruta, cuerpo y respuestas previstas.
  3. Aplica estos criterios y conserva el resultado para compararlo en la sesión 14. Un criterio puede quedar pendiente o no aplicarse; explica el motivo.
# Criterio Evidencia que puedes buscar
1 Las rutas identifican recursos Nombres y significado
2 Cada recurso tiene una dirección estable Consulta de detalle
3 Las colecciones se nombran de forma consistente Inventario de rutas
4 El método expresa la operación GET, POST, PUT, PATCH y DELETE
5 GET no solicita cambios en los datos del dominio Datos antes y después de consultar
6 Los resultados previstos tienen estados adecuados Aserciones de la colección
7 Las relaciones se pueden consultar de forma coherente Padre con datos, vacío e inexistente
8 Un mismo dato mantiene nombre y tipo entre respuestas JSON de listado, detalle y escrituras
9 Los campos internos quedan fuera del JSON Separación pendiente de la sesión 10
10 Las respuestas ofrecen enlaces de navegación Hipermedia fuera del alcance de esta unidad

No busques tres defectos por obligación: si una ruta ya cumple su contrato, justifica por qué la mantienes.

Paso 2 · Comparar decisiones antes de programar — 15 minutos

Analiza las siguientes operaciones. Justifica el método, la ruta y el impacto de la repetición:

Operación Propuesta inicial que debes revisar Alternativa REST defendible
Crear proyecto POST /crearProyecto POST /proyectos
Consultar proyecto 7 GET /obtenerProyecto?id=7 GET /proyectos/7
Borrar proyecto 7 GET /borrarProyecto/7 DELETE /proyectos/7
Cambiar el nombre de un proyecto POST /proyecto/7/editar PATCH /proyectos/7 o PUT /proyectos/7
Listar tareas pendientes GET /tareas/pendientes GET /tareas?completada=false
Asignar una tarea a un proyecto Modificar la referencia interna PUT /tareas/7 o PATCH /tareas/7
Archivar todos los proyectos cerrados Acción masiva por lotes Diseñar recurso de proceso o actualización

Justifica en tu cuaderno o en la PR:

  1. Por qué GET /borrarProyecto/7 es inadmisible conceptualmente aunque cambiáramos su nombre (seguridad de lectura, prefetching, proxies de caché).
  2. Qué ocurre si repites exactamente una petición: GET /tareas/7 es seguro y repetible; PUT /tareas/7 es idempotente (deja el mismo estado final); POST /tareas no es idempotente (crea un recurso nuevo cada vez).

Paso 3 · Diseñar una creación dentro de la relación — 15 minutos

Ya disponías de GET /proyectos/{id}/tareas. Ahora diseñamos POST /proyectos/{id}/tareas. Define el contrato exacto antes de codificar:

Caso Entrada Comportamiento del servidor Respuesta HTTP
El proyecto existe JSON tarea válido Asigna ID de tarea, vincula a proyectoId, guarda 201 Created con cabecera Location: /tareas/{id} y JSON creado
El proyecto no existe ID inexistente Aborta inmediatamente sin registrar nada en memoria 404 Not Found sin cuerpo
Cuerpo con otro proyectoId JSON trae proyectoId: 12 Manda la ruta: se sobrescribe con el ID de la URL 201 Created con el ID de la URL
Cuerpo con un id JSON trae id: 999 El servidor lo ignora y asigna su propio contador 201 Created con ID nuevo autogenerado
Petición POST repetida Mismo JSON enviado 2 veces Crea dos tareas distintas con identificadores diferentes 201 Created (dos recursos distintos)

Ambas vías (POST /tareas y POST /proyectos/{id}/tareas) son puertas de entrada para el mismo recurso: deben compartir el mismo contador y la misma lista interna. Una tarea nueva tiene una única dirección de detalle permanente: /tareas/{id}.

Paso 4 · Implementar sin duplicar el contador — 40 minutos

  1. Abre TareaController. Mantiene la lista de tareas y su contador siguienteId, por lo que alojará ambas formas de creación.
  2. Inyecta la lista de proyectos compartida: añade private final List<Proyecto> proyectos; y asígnala en el constructor desde el almacén de memoria (this.proyectos = memoria.getProyectos();).
  3. Para poder mapear rutas bajo /proyectos/{proyectoId}/tareas dentro de TareaController, cambia la anotación de clase @RequestMapping("/tareas") por @RequestMapping genérico y haz las rutas explícitas en cada método (@GetMapping("/tareas"), @GetMapping("/tareas/{id}"), etc.).
  4. Añade el método de creación anidada:
@PostMapping(value = "/proyectos/{proyectoId}/tareas",
        consumes = "application/json", produces = "application/json")
public ResponseEntity<Tarea> crearEnProyecto(
        @PathVariable(name = "proyectoId") int proyectoId,
        @RequestBody Tarea nueva) {

    // 1. Validar existencia del recurso padre antes de cualquier efecto colateral
    boolean existe = false;
    for (Proyecto proyecto : proyectos) {
        if (proyecto.getId() == proyectoId) {
            existe = true;
            break;
        }
    }
    if (!existe) {
        return ResponseEntity.notFound().build();
    }

    // 2. El servidor controla la identidad y la pertenencia
    nueva.setId(siguienteId++);
    nueva.setProyectoId(proyectoId);
    tareas.add(nueva);

    // 3. Dirección permanente del recurso recién creado
    URI ubicacion = ServletUriComponentsBuilder.fromCurrentContextPath()
            .path("/tareas/{id}").buildAndExpand(nueva.getId()).toUri();

    return ResponseEntity.created(ubicacion).body(nueva);
}

Analiza el propósito de cada bloque:

  • if (!existe) return notFound(): Si el proyecto no existe, respondemos 404 de inmediato. Es crítico que no se guarde la tarea ni se incremente el contador.
  • nueva.setId(siguienteId++): La identidad la genera y garantiza el servidor.
  • nueva.setProyectoId(proyectoId): La URL manda sobre cualquier valor que viniera en el JSON.
  • Location: /tareas/{id}: ServletUriComponentsBuilder compone la URL canónica absoluta (p. ej. http://localhost:8080/tareas/23).

¿Por qué es crucial no duplicar el contador? Si declararas un contador siguienteId en TareaController y otro en ProyectoController, una llamada a POST /tareas crearía la tarea 10, y otra a POST /proyectos/4/tareas crearía otra tarea con id 10. Habrías generado dos recursos distintos con el mismo identificador. Por eso ambas vías de creación deben operar sobre la misma lista y el mismo contador.

Paso 5 · Comprobar la operación con casos distintos — 35 minutos

Amplía tu colección de Postman/Bruno utilizando identificadores dinámicos capturados:

  1. Discrepancia intencionada: Crea los proyectos A y B. Lanza un POST /proyectos/{idA}/tareas enviando { "titulo": "...", "proyectoId": <idB> }. Comprueba que la respuesta devuelve el código 201 y que el campo proyectoId contiene el valor de A, ignorando B.
  2. Seguimiento de Location: Captura el valor de la cabecera Location y lánzale un GET. Debe devolver 200 OK con los datos de la tarea recién creada.
  3. Aislamiento de la relación: Consulta GET /proyectos/{idA}/tareas y GET /proyectos/{idB}/tareas. La tarea solo debe aparecer en la colección del proyecto A.
  4. No idempotencia del POST: Repite exactamente la misma petición POST al proyecto A. Comprueba que se genera una segunda tarea con un id incrementado. Comprueba también que un POST /tareas posterior no colisiona con estos identificadores.
  5. Padre inexistente sin efectos secundarios: Elimina el proyecto B (DELETE /proyectos/{idB}) y envía un POST /proyectos/{idB}/tareas. Espera un 404 Not Found. Realiza un GET /tareas y comprueba que la lista general no ha sufrido ninguna modificación.
  6. Limpia los datos y ejecuta dos veces la secuencia completa para confirmar que es reproducible.

Paso 6 · Revisar el cambio — 20 minutos

  1. Revisa tu contrato y confirma que cumple las cinco reglas de la sesión:
    • Las URLs identifican recursos (/proyectos, /tareas), no acciones.
    • Los verbos HTTP representan operaciones (GET, POST, PUT, PATCH, DELETE).
    • Las relaciones del dominio se expresan de forma anidada (/proyectos/7/tareas).
    • El servidor controla la identidad y la integridad de las relaciones (la URL prevalece).
    • Las respuestas HTTP completan el contrato (201 Created, 404 Not Found, cabecera Location).
  2. Verifica que las pruebas anteriores (filtros, PUT, PATCH, DELETE) siguen pasando. El cambio de anotaciones en TareaController no debe romper ninguna ruta existente.
  3. Revisa el diff de Git, prepara tu PR y justifica en la descripción técnica por qué Location apunta a /tareas/{id} y cómo demostraste que un padre inexistente no crea datos huérfanos.

Ampliación si has completado el trabajo

Elige una acción real de tu producto que todavía no esté cubierta: archivar, reservar o cambiar una prioridad, por ejemplo. Describe su efecto y decide si corresponde a una actualización o a un recurso nuevo. Implementa una sola operación con datos ya disponibles y comprueba su repetición y el caso de recurso inexistente. Mantén las reglas de validación y autenticación que aún no has implementado como pendientes explícitos.

Cierre

15 minutos · resultado comprobable y explicación individual

Al terminar la sesión: el contrato conserva las rutas que ya eran adecuadas e incorpora una creación anidada comprobada. La ruta determina la relación, los ids no se duplican entre formas de creación y Location conduce al detalle canónico. Puedes justificar por qué la URL manda sobre el cuerpo, por qué GET nunca debe alterar datos y qué pruebas demuestran que un error 404 no deja registros huérfanos.