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:
GETdebe 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
GETy 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
- Abre una rama de trabajo y ejecuta la colección de la sesión 8 antes de editar rutas.
- Reutiliza tu tabla de operaciones. Añade solo la información que falte: método, ruta, cuerpo y respuestas previstas.
- 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:
- Por qué
GET /borrarProyecto/7es inadmisible conceptualmente aunque cambiáramos su nombre (seguridad de lectura, prefetching, proxies de caché). - Qué ocurre si repites exactamente una petición:
GET /tareas/7es seguro y repetible;PUT /tareas/7es idempotente (deja el mismo estado final);POST /tareasno 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
- Abre
TareaController. Mantiene la lista de tareas y su contadorsiguienteId, por lo que alojará ambas formas de creación. - 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();). - Para poder mapear rutas bajo
/proyectos/{proyectoId}/tareasdentro deTareaController, cambia la anotación de clase@RequestMapping("/tareas")por@RequestMappinggenérico y haz las rutas explícitas en cada método (@GetMapping("/tareas"),@GetMapping("/tareas/{id}"), etc.). - 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, respondemos404de 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}:ServletUriComponentsBuildercompone 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:
- Discrepancia intencionada: Crea los proyectos A y B. Lanza un
POST /proyectos/{idA}/tareasenviando{ "titulo": "...", "proyectoId": <idB> }. Comprueba que la respuesta devuelve el código201y que el campoproyectoIdcontiene el valor de A, ignorando B. - Seguimiento de
Location: Captura el valor de la cabeceraLocationy lánzale unGET. Debe devolver200 OKcon los datos de la tarea recién creada. - Aislamiento de la relación: Consulta
GET /proyectos/{idA}/tareasyGET /proyectos/{idB}/tareas. La tarea solo debe aparecer en la colección del proyecto A. - No idempotencia del POST: Repite exactamente la misma petición
POSTal proyecto A. Comprueba que se genera una segunda tarea con unidincrementado. Comprueba también que unPOST /tareasposterior no colisiona con estos identificadores. - Padre inexistente sin efectos secundarios: Elimina el proyecto B (
DELETE /proyectos/{idB}) y envía unPOST /proyectos/{idB}/tareas. Espera un404 Not Found. Realiza unGET /tareasy comprueba que la lista general no ha sufrido ninguna modificación. - Limpia los datos y ejecuta dos veces la secuencia completa para confirmar que es reproducible.
Paso 6 · Revisar el cambio — 20 minutos
- 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, cabeceraLocation).
- Las URLs identifican recursos (
- Verifica que las pruebas anteriores (filtros, PUT, PATCH, DELETE) siguen pasando. El cambio de anotaciones en
TareaControllerno debe romper ninguna ruta existente. - Revisa el diff de Git, prepara tu PR y justifica en la descripción técnica por qué
Locationapunta 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.