Hasta ahora la API de CicloUrbana es de solo lectura: dos endpoints GET y poco más. En esta lección la convertimos en una API completa. Implementaremos el alta, el reemplazo, la modificación parcial y la baja de estaciones y bicicletas, con el código de estado y las cabeceras que corresponden a cada operación. Por el camino aparecen problemas que solo se manifiestan cuando una API empieza a escribir: cómo devolver la URL del recurso recién creado, cómo distinguir "no me mandes este campo" de "pon este campo a nulo", qué hacer cuando dos operarios de Ribalta editan la misma estación a la vez, y cómo modelar operaciones del negocio —iniciar y finalizar un alquiler— que no encajan en el molde del CRUD sin romper el diseño REST.
Contenido
POST: crear recursos@ResponseStatusy la cabeceraLocationPUT: reemplazo total e idempotenciaPATCH: actualización parcial y el problema del campo ausenteDELETE: baja e idempotencia- Tabla resumen: verbo, estado y cuerpo
- El recurso bicicleta y el subrecurso de estación
- Endpoints de acción: iniciar y finalizar un alquiler
HEADyOPTIONS- Concurrencia:
ETag,If-Matchy peticiones condicionales - Errores Comunes y Consejos
- Ejercicios
POST: crear recursos
POST: crear recursosPOST sobre una colección significa "añade un elemento nuevo a esta colección". El servidor asigna el identificador y responde 201 Created con una cabecera Location que apunta al recurso recién creado.
Necesitamos primero una clase para el cuerpo de la petición. No podemos aceptar directamente Estacion, porque el cliente no debe enviar el id: lo asigna el servidor. Usamos un record sencillo —se convertirá en CrearEstacionRequest, con validación, en 03-04 y 03-05:
package com.ciclourbana.estaciones;
public record NuevaEstacion(String nombre, String direccion,
int capacidad, double latitud, double longitud) {}Ampliamos EstacionRepositorio con tres operaciones nuevas —boolean existePorId(Long), boolean eliminarPorId(Long) y boolean existePorNombre(String)— que EstacionRepositorioEnMemoria implementa sobre su ConcurrentHashMap:
@Override
public boolean existePorId(Long id) {
return porId.containsKey(id);
}
@Override
public boolean eliminarPorId(Long id) {
return porId.remove(id) != null; // remove devuelve el valor anterior o null
}
@Override
public boolean existePorNombre(String nombre) {
return porId.values().stream().anyMatch(e -> e.nombre().equalsIgnoreCase(nombre));
}En EstacionService, la creación:
public Estacion crear(NuevaEstacion nueva) {
// Regla de negocio de Ribalta: no hay dos estaciones con el mismo nombre.
// Provisional: en 03-06 será EstacionDuplicadaException -> 409.
if (estacionRepositorio.existePorNombre(nueva.nombre())) {
throw new IllegalStateException("Ya existe una estación llamada " + nueva.nombre());
}
return estacionRepositorio.guardar(new Estacion(null, nueva.nombre(), // id nulo:
nueva.direccion(), nueva.capacidad(), // lo asigna
nueva.latitud(), nueva.longitud())); // el repositorio
}Fíjate en dónde vive la comprobación del nombre duplicado: en el servicio, no en el controlador. Es una regla de negocio de la red y debe aplicarse llame quien llame, no solo por HTTP.
@ResponseStatus y la cabecera Location
@ResponseStatus y la cabecera LocationEl controlador:
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacion> crear(@RequestBody NuevaEstacion nueva) {
Estacion creada = estacionService.crear(nueva);
// URI absoluta del recurso recién creado, a partir de la petición
// actual: http://host/api/v1/estaciones + /{id}
URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(creada.id()).toUri();
return ResponseEntity.created(ubicacion).body(creada);
}ResponseEntity.created(uri) hace dos cosas de una vez: fija el estado en 201 y añade la cabecera Location. La respuesta:
HTTP/1.1 201 Created
Location: http://localhost:8080/api/v1/estaciones/5
Content-Type: application/json
{ "id": 5, "nombre": "Mercado Central", "capacidad": 20, ... }Por qué la cabecera Location importa. Sin ella, el cliente que acaba de crear una estación no sabe dónde está: tendría que leer el id del cuerpo y construir la URL a mano, replicando el esquema de rutas del servidor. Con Location, el cliente guarda esa URL y la usa. Es el único punto en el que CicloUrbana toca el nivel 3 de Richardson, y sale gratis.
Sobre ServletUriComponentsBuilder. Construye la URI a partir de la petición en curso, respetando host, puerto y esquema reales. Sus variantes: fromCurrentRequest() usa la URL completa (lo habitual en un POST sobre la colección), fromCurrentContextPath() solo el host y el contexto, y fromCurrentRequestUri() la URL sin la cadena de consulta. Detrás de un proxy inverso —lo habitual en producción— la petición que ve Tomcat puede ser http://10.0.0.4:8080/... mientras el cliente usó https://api.ciclourbana.es/.... Para que la Location salga correcta hay que activar el tratamiento de las cabeceras X-Forwarded-*:
Es una de esas líneas que nadie recuerda hasta que un cliente recibe una Location con http:// y una IP interna. Es la restricción de sistema por capas de 03-01: el servidor no debe asumir que habla directamente con el cliente.
@ResponseStatus como alternativa. Si no necesitas la cabecera Location, @ResponseStatus(HttpStatus.CREATED) sobre el método fija el 201 y permite devolver el objeto directamente, sin ResponseEntity. Es más legible, pero pierde la Location. La política de CicloUrbana: ResponseEntity.created() para las creaciones —la Location forma parte de un 201 bien hecho— y @ResponseStatus donde el estado sea fijo y no haga falta cabecera.
PUT: reemplazo total e idempotencia
PUT: reemplazo total e idempotenciaPUT /api/v1/estaciones/{id} significa "el estado de este recurso pasa a ser exactamente el que te envío". Es un reemplazo completo, no una fusión: si el cuerpo omite la dirección, la dirección queda vacía.
// En EstacionService
public Optional<Estacion> reemplazar(Long id, NuevaEstacion datos) {
if (!estacionRepositorio.existePorId(id)) {
return Optional.empty();
}
// Objeto nuevo con TODOS los campos del cuerpo: lo que el cliente
// no envía se pierde, y esa es exactamente la semántica de PUT.
return Optional.of(estacionRepositorio.guardar(new Estacion(id, datos.nombre(),
datos.direccion(), datos.capacidad(), datos.latitud(), datos.longitud())));
}
// En EstacionController
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacion> reemplazar(@PathVariable("id") Long id,
@RequestBody NuevaEstacion datos) {
return estacionService.reemplazar(id, datos)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}Por qué PUT es idempotente. Enviar tres veces el mismo cuerpo deja la estación igual que enviarlo una vez, porque describe un estado final absoluto y no una operación relativa. Es la propiedad que permite reintentar sin miedo cuando la red falla.
200 con cuerpo o 204 sin cuerpo. Ambas son válidas: el 200 con el recurso actualizado deja ver al cliente los campos que calcula o normaliza el servidor, a costa de una respuesta más pesada; el 204 minimiza el tráfico pero obliga a un GET posterior. CicloUrbana devuelve 200 con el recurso.
PUT sobre un recurso inexistente. El RFC permite que PUT cree el recurso si el cliente elige el identificador (upsert). CicloUrbana no lo hace: los identificadores los genera el servidor, así que un PUT sobre el id 999 responde 404. Crear con PUT solo tiene sentido cuando el identificador es natural y lo conoce el cliente, como sería un código oficial RIB-001.
PATCH: actualización parcial y el problema del campo ausente
PATCH: actualización parcial y el problema del campo ausentePATCH modifica solo los campos que se envían. El panel de operarios de Ribalta lo necesita para corregir la capacidad de una estación sin reenviar sus coordenadas.
Y aquí aparece el problema más sutil de esta lección. Considera {"capacidad": 28} ("no toques la dirección") frente a {"capacidad": 28, "direccion": null} ("borra la dirección"). Si el cuerpo se deserializa a un record con un campo String direccion, ambas producen exactamente lo mismo: direccion == null. El campo ausente y el campo puesto a nulo son indistinguibles, y sin embargo significan cosas opuestas. Las tres soluciones:
| Estrategia | Cómo distingue | Ventajas | Inconvenientes |
|---|---|---|---|
Map<String, Object> |
Por la presencia de la clave | Sin dependencias | Sin tipado, sin validación, sin OpenAPI |
Campos Optional<T> |
null = ausente, Optional.empty() = a nulo |
Solo Java estándar | Doble envoltorio confuso |
JsonNullable<T> |
isPresent() = enviado |
Tipado, valida, documenta | Dependencia adicional |
Solución con Map, la más directa y la que usa CicloUrbana por ahora:
// En EstacionService. Se parte del estado actual y solo se sustituye
// lo que viene en el mapa.
public Optional<Estacion> actualizarParcial(Long id, Map<String, Object> cambios) {
return estacionRepositorio.buscarPorId(id).map(actual ->
estacionRepositorio.guardar(new Estacion(id,
cambios.containsKey("nombre")
? (String) cambios.get("nombre") : actual.nombre(),
cambios.containsKey("direccion")
? (String) cambios.get("direccion") : actual.direccion(),
cambios.containsKey("capacidad")
? ((Number) cambios.get("capacidad")).intValue() : actual.capacidad(),
actual.latitud(), actual.longitud())));
}containsKey es la clave literal del asunto: distingue "la clave no vino" de "la clave vino con valor null". El Map no está exento de problemas —los casts son frágiles y una clave mal escrita se ignora en silencio—, por lo que conviene validar que todas las claves recibidas son conocidas y rechazar las demás con un 400.
Solución con JsonNullable, la recomendada para APIs públicas. Requiere la dependencia org.openapitools:jackson-databind-nullable y registrar su módulo con el customizer de 03-02: builder.modulesToInstall(new JsonNullableModule()). El DTO queda tipado y expresivo:
public record ParcheEstacion(JsonNullable<String> nombre,
JsonNullable<String> direccion,
JsonNullable<Integer> capacidad) {
public ParcheEstacion { // constructor compacto: nunca campos null
nombre = nombre == null ? JsonNullable.undefined() : nombre;
direccion = direccion == null ? JsonNullable.undefined() : direccion;
capacidad = capacidad == null ? JsonNullable.undefined() : capacidad;
}
/** Aplica el parche sobre el estado actual y devuelve una Estacion nueva. */
public Estacion aplicarSobre(Estacion actual) {
return new Estacion(actual.id(), nombre.orElse(actual.nombre()),
direccion.orElse(actual.direccion()), capacidad.orElse(actual.capacidad()),
actual.latitud(), actual.longitud());
}
}JsonNullable.undefined() significa "el cliente no envió esta clave" y JsonNullable.of(null) significa "la envió con valor nulo". orElse devuelve el valor actual cuando no está definida: exactamente la semántica de PATCH.
El controlador, con la variante del Map:
@PatchMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacion> actualizarParcial(@PathVariable("id") Long id,
@RequestBody Map<String, Object> cambios) {
return estacionService.actualizarParcial(id, cambios)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}Una nota sobre el estándar: existe JSON Patch (RFC 6902), un formato con operaciones explícitas —[{"op":"replace","path":"/capacidad","value":28}]— que resuelve el problema de raíz, pero es riguroso y poco amigable para los clientes. La variante mayoritaria es el JSON Merge Patch (RFC 7386): enviar el objeto parcial, donde null significa "borra este campo". CicloUrbana usa merge patch.
DELETE: baja e idempotencia
DELETE: baja e idempotencia// En EstacionController; el servicio solo delega en eliminarPorId
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> eliminar(@PathVariable("id") Long id) {
return estacionService.eliminar(id)
? ResponseEntity.noContent().build() // 204: borrada
: ResponseEntity.notFound().build(); // 404: no existía
}El debate del 404 en DELETE. ¿Qué debe responder un DELETE sobre una estación ya borrada? Hay dos argumentos: 204 No Content porque el estado final es el deseado —la estación no existe—, que es la lectura estricta de la idempotencia; o 404 Not Found, que informa al cliente de que su modelo mental está desactualizado. Ambas son correctas y hay APIs de primera línea en cada bando. La idempotencia no exige que la respuesta sea idéntica cada vez, solo que el estado del servidor lo sea; un 204 seguido de un 404 es perfectamente idempotente. CicloUrbana elige 404, porque un panel de operarios que borra una estación inexistente probablemente tiene la lista sin refrescar, y silenciarlo oculta el problema. Lo innegociable es que el segundo DELETE no provoque un error del servidor: si un NoSuchElementException se propaga hasta un 500, la operación deja de ser reintentable.
Borrado lógico frente a físico. Borrar una estación con alquileres históricos destruiría datos que el ayuntamiento necesita. Lo habitual en producción es marcarla como inactiva y excluirla de los listados: la API no cambia —sigue siendo DELETE con 204—, solo la implementación. Lo aplicaremos con base de datos, en el módulo 4.
- Tabla resumen: verbo, estado y cuerpo
La referencia completa de la API de CicloUrbana:
| Verbo | Ruta | Cuerpo petición | Éxito | Cuerpo respuesta | Errores |
|---|---|---|---|---|---|
GET |
/estaciones |
— | 200 |
Array | — |
GET |
/estaciones/{id} |
— | 200 |
Objeto | 404 |
POST |
/estaciones |
Objeto sin id |
201 |
Objeto + Location |
400, 409 |
PUT |
/estaciones/{id} |
Objeto completo | 200 |
Objeto actualizado | 400, 404, 412 |
PATCH |
/estaciones/{id} |
Objeto parcial | 200 |
Objeto actualizado | 400, 404, 412 |
DELETE |
/estaciones/{id} |
— | 204 |
Vacío | 404, 409 |
HEAD |
/estaciones/{id} |
— | 200 |
Solo cabeceras | 404 |
OPTIONS |
/estaciones |
— | 200 |
Vacío + Allow |
— |
Y el fichero .http que la ejercita entera:
@base = http://localhost:8080/api/v1
### Crear una estación
POST {{base}}/estaciones
Content-Type: application/json
{ "nombre": "Mercado Central", "direccion": "C/ del Mercado, 8",
"capacidad": 20, "latitud": 41.3902, "longitud": 2.1655 }
### Reemplazo total: OJO, lo que se omita se pierde
PUT {{base}}/estaciones/5
Content-Type: application/json
{ "nombre": "Mercado Central", "direccion": "C/ del Mercado, 8",
"capacidad": 26, "latitud": 41.3902, "longitud": 2.1655 }
### Actualización parcial (merge patch): solo la capacidad
PATCH {{base}}/estaciones/5
Content-Type: application/json
{ "capacidad": 30 }
### Baja, y segunda baja: idempotente, devuelve 404 pero no rompe nada
DELETE {{base}}/estaciones/5
DELETE {{base}}/estaciones/5
- El recurso bicicleta y el subrecurso de estación
Añadimos el paquete com.ciclourbana.bicicletas con su modelo:
package com.ciclourbana.bicicletas;
public enum EstadoBicicleta { DISPONIBLE, EN_USO, MANTENIMIENTO, RETIRADA }
/**
* Bicicleta eléctrica de la red de Ribalta.
* estacionId es null cuando la bicicleta está en uso (fuera de anclaje).
*/
public record Bicicleta(Long id, String matricula, int nivelBateria,
EstadoBicicleta estado, Long estacionId) {}El servicio, con las consultas que necesita la API (omitimos el constructor, que inyecta BicicletaRepositorio, EstacionRepositorio y RedProperties):
@Service
public class BicicletaService {
/** Bicicletas ancladas en una estación. Optional.empty() = la estación no existe. */
public Optional<List<Bicicleta>> buscarPorEstacion(Long estacionId, boolean soloDisponibles) {
if (!estacionRepositorio.existePorId(estacionId)) {
return Optional.empty();
}
List<Bicicleta> bicicletas = bicicletaRepositorio.buscarPorEstacion(estacionId).stream()
.filter(b -> !soloDisponibles || esUtilizable(b))
.toList();
return Optional.of(bicicletas);
}
/** Disponible y con batería suficiente según ciclourbana.red.umbral-bateria. */
private boolean esUtilizable(Bicicleta b) {
return b.estado() == EstadoBicicleta.DISPONIBLE
&& b.nivelBateria() >= redProperties.umbralBateria();
}
}Aquí se ve por qué invertimos el módulo 2 en propiedades tipadas: el umbral de batería no está escrito en el código sino en ciclourbana.red.umbral-bateria, y el ayuntamiento puede subirlo al 30% en invierno sin recompilar.
El subrecurso. /api/v1/estaciones/{id}/bicicletas no es lo mismo que /api/v1/bicicletas?estacionId={id}: el subrecurso expresa pertenencia y responde 404 si la estación no existe, mientras que el filtro sobre la colección global responde 200 con un array vacío. Ambas formas pueden coexistir.
// En EstacionController: el subrecurso cuelga de la estación
@GetMapping("/{id:\\d+}/bicicletas")
public ResponseEntity<List<Bicicleta>> bicicletasDeEstacion(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean soloDisponibles) {
return bicicletaService.buscarPorEstacion(id, soloDisponibles)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build()); // la estación no existe
}BicicletaController replica el patrón de EstacionController para GET /api/v1/bicicletas, GET /api/v1/bicicletas/{id} y POST /api/v1/bicicletas. La creación exige que la matrícula tenga el formato RB-0142 y no esté repetida; ambas comprobaciones se harán declarativas en 03-04 con @MatriculaBicicleta.
- Endpoints de acción: iniciar y finalizar un alquiler
Aquí el CRUD se queda corto. "Iniciar un alquiler" no es solo crear una fila: hay que comprobar que la bicicleta está disponible y tiene batería, marcarla como EN_USO, desanclarla, registrar la hora con el bean Clock y publicar el evento AlquilerIniciado. Y "finalizar" tampoco es una modificación cualquiera: calcula el importe con SelectorTarifa, ancla la bicicleta en el destino y comprueba que quepa. La primera parte sí encaja en REST sin esfuerzo: iniciar un alquiler es crear un recurso alquiler.
package com.ciclourbana.alquileres;
public record IniciarAlquilerRequest(Long usuarioId, Long bicicletaId) {}
public record Alquiler(Long id, Long usuarioId, Long bicicletaId,
Long estacionOrigenId, Long estacionDestinoId,
LocalDateTime inicio, LocalDateTime fin,
BigDecimal importe, EstadoAlquiler estado) {}@RestController
@RequestMapping(path = "/api/v1/alquileres", produces = MediaType.APPLICATION_JSON_VALUE)
public class AlquilerController {
private final AlquilerService alquilerService;
public AlquilerController(AlquilerService alquilerService) {
this.alquilerService = alquilerService;
}
/** POST /api/v1/alquileres — crear un alquiler es CRUD normal. */
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Alquiler> iniciar(@RequestBody IniciarAlquilerRequest peticion) {
Alquiler alquiler = alquilerService.iniciar(peticion.usuarioId(), peticion.bicicletaId());
URI ubicacion = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(alquiler.id()).toUri();
return ResponseEntity.created(ubicacion).body(alquiler);
}
}GET /api/v1/alquileres/{id} sigue el mismo patrón que EstacionController#obtenerPorId.
Finalizar es lo que no encaja. Las opciones sobre la mesa:
| Diseño | Petición | Valoración |
|---|---|---|
PATCH del estado |
PATCH /alquileres/7 con {"estado":"FINALIZADO"} |
Puro, pero el servidor debe adivinar que ese cambio dispara el cobro; la estación de destino no encaja |
PUT del recurso |
PUT /alquileres/7 con todo el objeto |
El cliente enviaría el importe, que solo calcula el servidor |
| Subrecurso de acción | POST /alquileres/7/finalizar |
Explícito, con cuerpo y errores propios |
| Subrecurso de estado | PUT /alquileres/7/estado |
Intermedio; sigue sin encajar el destino |
CicloUrbana elige el subrecurso de acción:
public record FinalizarAlquilerRequest(Long estacionDestinoId) {}
/** POST /api/v1/alquileres/{id}/finalizar — transición de estado de la máquina
* del negocio, no CRUD. Es POST porque no es idempotente: finalizar dos veces
* es un error que debe responder 409, no un no-op. */
@PostMapping(path = "/{id:\\d+}/finalizar", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Alquiler> finalizar(@PathVariable("id") Long id,
@RequestBody FinalizarAlquilerRequest peticion) {
return ResponseEntity.ok(alquilerService.finalizar(id, peticion.estacionDestinoId()));
}Cuándo está justificado un endpoint de acción. No es una licencia para volver al nivel 1 de Richardson. Debe cumplir las tres condiciones: (1) tiene un nombre en el lenguaje del negocio —los operarios dicen "finalizar un alquiler", no "poner el estado a finalizado"—; (2) tiene efectos más allá de cambiar un campo —calcula el importe, ancla la bicicleta, comprueba la capacidad, publica un evento—; y (3) tiene contrato de entrada y errores propios —requiere estacionDestinoId y puede fallar con 409—. Si no cumple las tres, casi seguro que es un PATCH disfrazado: POST /api/v1/estaciones/5/renombrar no cumple ninguna.
El servicio, donde vive la lógica de verdad:
public Alquiler finalizar(Long alquilerId, Long estacionDestinoId) {
Alquiler alquiler = alquilerRepositorio.buscarPorId(alquilerId)
.orElseThrow(() -> new IllegalArgumentException("Alquiler no encontrado"));
if (alquiler.estado() == EstadoAlquiler.FINALIZADO) {
throw new IllegalStateException("El alquiler ya estaba finalizado"); // 409 en 03-06
}
Estacion destino = estacionRepositorio.buscarPorId(estacionDestinoId)
.orElseThrow(() -> new IllegalArgumentException("Estación no encontrada"));
if (bicicletaRepositorio.contarPorEstacion(destino.id()) >= destino.capacidad()) {
throw new IllegalStateException("La estación está llena"); // 409 en 03-06
}
LocalDateTime fin = LocalDateTime.now(clock); // el Clock de ConfiguracionComun
BigDecimal importe = selectorTarifa.para(alquiler.usuarioId())
.calcular(Duration.between(alquiler.inicio(), fin));
bicicletaService.anclarEn(alquiler.bicicletaId(), destino.id());
return alquilerRepositorio.guardar(new Alquiler(alquiler.id(), alquiler.usuarioId(),
alquiler.bicicletaId(), alquiler.estacionOrigenId(), destino.id(),
alquiler.inicio(), fin, importe, EstadoAlquiler.FINALIZADO));
}El Clock inyectado no es un capricho: gracias a él, en el módulo 6 probaremos el cálculo de un alquiler de dos horas sin esperar dos horas.
HEAD y OPTIONS
HEAD y OPTIONSHEAD es idéntico a GET pero sin cuerpo: solo devuelve las cabeceras. Sirve para comprobar si un recurso existe, o para conocer su tamaño o su ETag antes de descargarlo. Spring lo implementa automáticamente para todo @GetMapping: no hay que escribir nada. OPTIONS informa de qué verbos admite una ruta, y también lo genera Spring solo a partir de los mapeos declarados:
curl -I http://localhost:8080/api/v1/estaciones/1
# HTTP/1.1 200 · Content-Type: application/json · Content-Length: 142
curl -i -X OPTIONS http://localhost:8080/api/v1/estaciones/1
# HTTP/1.1 200 · Allow: GET,PUT,PATCH,DELETE,HEAD,OPTIONSEsa segunda es la respuesta que el navegador usa en el preflight de CORS que vimos en 03-02. Para desactivar la respuesta automática —rara vez— existe spring.mvc.dispatch-options-request.
- Concurrencia:
ETag, If-Match y peticiones condicionales
ETag, If-Match y peticiones condicionalesEl escenario, con dos operarios del taller de Ribalta:
sequenceDiagram
participant A as Operario Ana
participant S as CicloUrbana
participant B as Operario Bru
A->>S: GET /estaciones/1 (capacidad 24)
B->>S: GET /estaciones/1 (capacidad 24)
A->>S: PUT /estaciones/1 (capacidad 28)
S-->>A: 200 OK
B->>S: PUT /estaciones/1 (capacidad 24, dirección corregida)
S-->>B: 200 OK
Note over S: El cambio de Ana se ha perdido<br/>sin que nadie se entere
Es el problema de la actualización perdida. La solución de HTTP es el bloqueo optimista con peticiones condicionales: el servidor etiqueta cada versión del recurso con un ETag y el cliente lo devuelve en If-Match al modificar.
GET /api/v1/estaciones/1
→ 200 OK
ETag: "a3f5c9e1"
PUT /api/v1/estaciones/1
If-Match: "a3f5c9e1"
→ 200 OK si el ETag sigue siendo ese
→ 412 Precondition Failed si otro lo cambió mientras tantoBru recibiría un 412 y su panel podría recargar los datos y mostrar el conflicto en lugar de pisar el trabajo de Ana.
La forma más barata de obtener ETag en Spring Boot es el filtro ShallowEtagHeaderFilter, que calcula un hash MD5 del cuerpo ya serializado:
package com.ciclourbana.comun;
@Configuration
public class ConfiguracionEtag {
@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> filtroEtag() {
var registro = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
registro.addUrlPatterns("/api/v1/estaciones/*", "/api/v1/bicicletas/*");
registro.setName("filtroEtag");
return registro;
}
}Con el filtro activo, un GET repetido con If-None-Match: "0a1b2c3d4e..." responde 304 Not Modified sin cuerpo, ahorrando la transferencia.
| Cabecera | Verbo típico | Qué comprueba | Respuesta si falla |
|---|---|---|---|
If-None-Match |
GET |
¿Ha cambiado el recurso? | 304 Not Modified |
If-Match |
PUT, PATCH, DELETE |
¿Sigue siendo la versión que leí? | 412 Precondition Failed |
If-Modified-Since |
GET |
Igual, con fecha | 304 Not Modified |
If-Unmodified-Since |
PUT, PATCH |
Igual, con fecha | 412 Precondition Failed |
Para las escrituras, el filtro superficial no basta: hay que comprobar el If-Match en el controlador.
@PutMapping(path = "/{id:\\d+}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Estacion> reemplazar(
@PathVariable("id") Long id,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch,
@RequestBody NuevaEstacion datos) {
Optional<Estacion> actual = estacionService.buscarPorId(id);
if (actual.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Las comillas del ETag son obligatorias según el RFC 9110
String etagActual = "\"" + estacionService.versionDe(actual.get()) + "\"";
if (ifMatch != null && !ifMatch.equals(etagActual)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
Estacion reemplazada = estacionService.reemplazar(id, datos).orElseThrow();
return ResponseEntity.ok()
.eTag("\"" + estacionService.versionDe(reemplazada) + "\"")
.body(reemplazada);
}Limitaciones del ETag superficial: el servidor genera la respuesta completa y solo entonces calcula el hash, así que ahorra ancho de banda pero no trabajo de servidor; y como el hash depende del JSON exacto, un cambio en el orden de las propiedades produce un ETag distinto aunque los datos sean idénticos. La solución robusta es un campo de versión en la entidad, que es exactamente lo que hace @Version de JPA: llega en el módulo 4 y sustituirá a este filtro.
Errores Comunes y Consejos
Devolver 200 en lugar de 201 al crear, u olvidar la cabecera Location. El 201 con Location es lo que distingue una creación de una consulta para cualquier cliente genérico, y sin la cabecera el cliente debe replicar el esquema de rutas del servidor.
Usar PUT para actualizaciones parciales. PUT reemplaza: si el cliente envía {"capacidad": 28}, la estación se queda sin nombre ni dirección. Fuente clásica de pérdida silenciosa de datos.
No distinguir el campo ausente del nulo en PATCH. Deserializar a un record normal convierte lo no enviado en null y borra campos que el cliente no quería tocar. Usa Map, JsonNullable u Optional.
Hacer que el segundo DELETE reviente. Un NoSuchElementException propagado hasta un 500 rompe la idempotencia y hace peligrosos los reintentos automáticos.
Poner verbos en la URL sin justificación. POST /estaciones/5/renombrar no cumple ninguna de las tres condiciones del apartado 8: es un PATCH. Y devolver un 404 desde el controlador con ResponseEntity funciona, pero esparce la lógica de errores por todos los controladores; desde 03-06 se centraliza.
Consejo: prueba siempre la segunda llamada. Ejecuta cada PUT y cada DELETE dos veces seguidas; si la segunda da un resultado distinto del esperado, la idempotencia está rota. Y la escritura vive en el servicio. El controlador traduce HTTP; las reglas —nombre duplicado, estación llena, batería insuficiente— pertenecen al servicio y deben aplicarse venga la llamada de donde venga.
Ejercicios
Ejercicio 1: Endpoint de acción para poner una bicicleta en mantenimiento
Los operarios de Ribalta necesitan retirar temporalmente una bicicleta del servicio. Diseña e implementa el endpoint, justificando el verbo y la ruta. Debe aceptar un motivo, cambiar el estado a MANTENIMIENTO, y rechazar la operación si la bicicleta está actualmente en uso. Implementa también la operación inversa.
Ejercicio 2: PATCH seguro con validación de claves
La implementación con Map<String, Object> acepta en silencio claves desconocidas: PATCH {"capaciad": 30} (con la errata) responde 200 sin cambiar nada, y el operario cree que ha funcionado. Modifica actualizarParcial para rechazar con 400 cualquier clave no reconocida, y para impedir que se modifique el id.
Ejercicio 3: DELETE con comprobación de integridad y If-Match
Borrar una estación que aún tiene bicicletas ancladas dejaría bicicletas huérfanas. Implementa un DELETE que responda 409 Conflict en ese caso, salvo que se envíe ?forzar=true, en cuyo caso las bicicletas pasan a estado RETIRADA. Añade además soporte de If-Match.
Soluciones
Solución 1.
Diseño. Cumple las tres condiciones del apartado 8: nombre propio en el negocio ("poner en mantenimiento"), efectos más allá de un campo (desanclar, notificar al taller) y contrato y errores propios (el motivo; 409 si está en uso). Por tanto, endpoint de acción con POST: POST /api/v1/bicicletas/{id}/mantenimiento y POST /api/v1/bicicletas/{id}/alta-servicio. Alternativa igualmente defendible: PUT y DELETE sobre /bicicletas/{id}/mantenimiento, modelando el mantenimiento como un subrecurso que existe o no; más elegante y menos legible.
public record MantenimientoRequest(String motivo) {}
// En BicicletaService
public Bicicleta enviarAMantenimiento(Long id, String motivo) {
Bicicleta bicicleta = bicicletaRepositorio.buscarPorId(id)
.orElseThrow(() -> new IllegalArgumentException("Bicicleta no encontrada"));
if (bicicleta.estado() == EstadoBicicleta.EN_USO) {
throw new IllegalStateException("No se puede retirar una bicicleta en uso"); // 409 en 03-06
}
if (bicicleta.estado() == EstadoBicicleta.MANTENIMIENTO) {
return bicicleta; // ya lo está: no es un error, no hacemos nada
}
log.info("Bicicleta {} a mantenimiento. Motivo: {}", bicicleta.matricula(), motivo);
return bicicletaRepositorio.guardar(new Bicicleta(bicicleta.id(), bicicleta.matricula(),
bicicleta.nivelBateria(), EstadoBicicleta.MANTENIMIENTO, bicicleta.estacionId()));
}
// En BicicletaController
@PostMapping(path = "/{id:\\d+}/mantenimiento", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Bicicleta> aMantenimiento(@PathVariable("id") Long id,
@RequestBody MantenimientoRequest peticion) {
return ResponseEntity.ok(bicicletaService.enviarAMantenimiento(id, peticion.motivo()));
}Detalle de diseño. Enviar a mantenimiento una bicicleta que ya está en mantenimiento no es un error: se devuelve el estado actual. Esto hace la operación idempotente en la práctica, aunque el verbo POST no lo garantice, y permite al panel de operarios reintentar sin miedo tras un fallo de red.
Solución 2.
private static final Set<String> CAMPOS_MODIFICABLES =
Set.of("nombre", "direccion", "capacidad", "latitud", "longitud");
public Optional<Estacion> actualizarParcial(Long id, Map<String, Object> cambios) {
// 1. Rechazar claves desconocidas ANTES de tocar nada. En 03-06 esto
// será una excepción propia -> 400 con la lista de campos erróneos.
Set<String> desconocidas = new LinkedHashSet<>(cambios.keySet());
desconocidas.removeAll(CAMPOS_MODIFICABLES);
if (!desconocidas.isEmpty()) {
throw new IllegalArgumentException("Campos no reconocidos: " + desconocidas);
}
// 2. El id no se modifica nunca, ni aunque venga con el valor correcto
if (cambios.containsKey("id")) {
throw new IllegalArgumentException("El campo id no es modificable");
}
return estacionRepositorio.buscarPorId(id).map(actual -> estacionRepositorio.guardar(
new Estacion(
id, // el id manda siempre la ruta
valorODefecto(cambios, "nombre", actual.nombre()),
valorODefecto(cambios, "direccion", actual.direccion()),
cambios.containsKey("capacidad")
? ((Number) cambios.get("capacidad")).intValue() : actual.capacidad(),
cambios.containsKey("latitud")
? ((Number) cambios.get("latitud")).doubleValue() : actual.latitud(),
cambios.containsKey("longitud")
? ((Number) cambios.get("longitud")).doubleValue() : actual.longitud())));
}
@SuppressWarnings("unchecked")
private <T> T valorODefecto(Map<String, Object> cambios, String clave, T actual) {
return cambios.containsKey(clave) ? (T) cambios.get(clave) : actual;
}Por qué el id se rechaza incluso cuando coincide. Aceptarlo en el cuerpo abre la puerta a que una implementación futura lo use para reasignar el recurso. La regla general: el identificador de la ruta es la única fuente de verdad.
Sobre los casts. ((Number) valor).intValue() es necesario porque Jackson deserializa los números JSON a Integer, Long o Double según su magnitud y forma; un (Integer) directo revienta con ClassCastException si el cliente envía 28.0. Esa fragilidad es el mejor argumento para pasarse a JsonNullable con tipos declarados.
Solución 3.
// En EstacionService. El enum permite distinguir tres desenlaces, no dos.
public enum ResultadoBaja { ELIMINADA, NO_EXISTIA, CON_BICICLETAS }
public ResultadoBaja eliminar(Long id, boolean forzar) {
if (!estacionRepositorio.existePorId(id)) {
return ResultadoBaja.NO_EXISTIA;
}
List<Bicicleta> ancladas = bicicletaRepositorio.buscarPorEstacion(id);
if (!ancladas.isEmpty() && !forzar) {
return ResultadoBaja.CON_BICICLETAS;
}
// Con forzar=true, las bicicletas se retiran del servicio antes de borrar
for (Bicicleta b : ancladas) {
bicicletaRepositorio.guardar(new Bicicleta(b.id(), b.matricula(),
b.nivelBateria(), EstadoBicicleta.RETIRADA, null));
log.warn("Bicicleta {} retirada por baja forzada de la estación {}", b.matricula(), id);
}
estacionRepositorio.eliminarPorId(id);
return ResultadoBaja.ELIMINADA;
}
// En EstacionController
@DeleteMapping("/{id:\\d+}")
public ResponseEntity<Void> eliminar(
@PathVariable("id") Long id,
@RequestParam(defaultValue = "false") boolean forzar,
@RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch) {
Optional<Estacion> actual = estacionService.buscarPorId(id);
if (actual.isEmpty()) {
return ResponseEntity.notFound().build();
}
// Comprobación optimista: si el cliente envía If-Match, debe coincidir
if (ifMatch != null
&& !ifMatch.equals("\"" + estacionService.versionDe(actual.get()) + "\"")) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
return switch (estacionService.eliminar(id, forzar)) {
case ELIMINADA -> ResponseEntity.noContent().build(); // 204
case NO_EXISTIA -> ResponseEntity.notFound().build(); // 404
case CON_BICICLETAS -> ResponseEntity.status(HttpStatus.CONFLICT).build(); // 409
};
}Notas de diseño. El switch sobre el enum, exhaustivo gracias a Java 21, garantiza que si mañana se añade un valor a ResultadoBaja el compilador obligue a decidir qué código HTTP le corresponde.
Sobre el If-Match opcional: si el cliente no lo envía, la operación se realiza sin comprobación. Una API con garantías fuertes puede exigirlo siempre y responder 428 Precondition Required cuando falte; para el panel de Ribalta, opcional es suficiente. Y sobre ?forzar=true: es un parámetro de consulta y no una ruta distinta porque modifica cómo se ejecuta la operación, no qué recurso se toca, cumpliendo la regla de diseño de URLs de 03-01. Nótese también el log.warn: una baja forzada retira bicicletas del servicio y eso debe quedar registrado.
Conclusión
La API de CicloUrbana ya escribe. Sabes crear recursos con POST devolviendo 201 Created y una cabecera Location construida con ServletUriComponentsBuilder, incluida la línea server.forward-headers-strategy que hace que esa URL sea correcta detrás de un proxy. Entiendes PUT como reemplazo total, por qué eso lo hace idempotente y por qué CicloUrbana no permite crear con PUT. Has desmontado el problema más sutil de PATCH —distinguir el campo ausente del campo puesto a nulo— y conoces las tres soluciones, con Map implementada y JsonNullable como camino recomendado, además de la diferencia entre JSON Patch y JSON Merge Patch. Sabes implementar DELETE sin romper la idempotencia y conoces el debate del 404 frente al 204 con argumentos de ambos lados. Tienes la tabla completa de verbo, estado y cuerpo de la API.
Además, la red de Ribalta ya está entera: el recurso bicicleta con su estado y su nivel de batería, el subrecurso /estaciones/{id}/bicicletas con su diferencia semántica frente al filtro global, y los alquileres con la creación por POST y la finalización mediante un endpoint de acción, justificado con tres condiciones concretas que evitan que esa excepción se convierta en la puerta de vuelta al nivel 1 de Richardson. Sabes que HEAD y OPTIONS los genera Spring solo. Y has resuelto el problema de las actualizaciones perdidas con ETag, If-Match y ShallowEtagHeaderFilter, conociendo sus limitaciones y sabiendo que la solución definitiva llegará con @Version en el módulo 4.
Pero hay una grieta que se ha ido ensanchando lección a lección. Nada impide crear una estación con capacidad -5, con el nombre vacío, con una latitud de 200 grados o con una matrícula que no se parece a RB-0142. Las comprobaciones que hemos escrito están dispersas por los servicios, mezcladas con las reglas de negocio, y ninguna produce todavía un mensaje útil para el cliente.
La lección 03-04, Validación de Datos de Entrada, cierra esa grieta. Veremos por qué se valida en el borde de la aplicación y qué capas de validación existen; usaremos Jakarta Bean Validation con @NotBlank, @Positive, @Size, @Pattern y el resto del catálogo; aplicaremos @Valid a los cuerpos y @Validated a los parámetros de ruta y de consulta; distinguiremos los grupos de validación del alta y de la modificación; internacionalizaremos los mensajes; y construiremos dos restricciones propias, @MatriculaBicicleta y @CoordenadasValidas, con sus validadores. También fijaremos la política del proyecto sobre cuándo un fallo es un 400 y cuándo un 409 o un 422.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
