En la lección anterior diseñamos el contrato: trece endpoints, sus verbos, sus códigos de estado y las reglas de las URLs. Ahora escribimos el código que lo cumple. Un controlador de Spring es una clase engañosamente sencilla —recibe objetos Java y devuelve objetos Java— pero entre la petición HTTP y ese método hay una maquinaria de resolución de argumentos y conversión de mensajes que conviene dominar, porque es la que explica el 90% de los "no me llega el parámetro" y "el JSON no sale como esperaba". En esta lección desmontamos @RestController, aprendemos a extraer cada pieza de una petición (ruta, cadena de consulta, cabeceras, cuerpo), controlamos cómo Jackson serializa nuestros objetos, decidimos cuándo usar ResponseEntity y terminamos con un EstacionController completo, con filtros y paginación, probado desde la terminal y desde el IDE.
Contenido
@RestController: qué es exactamente@RequestMappingy los atajos por verbo@PathVariable: variables de plantilla@RequestParam: parámetros de consulta@RequestBody,@RequestHeadery compañía- Serialización JSON con Jackson
- Configuración global de Jackson en YAML
ResponseEntityfrente a devolver el objeto- El
EstacionControllercompleto - Probar la API: curl y ficheros
.http - CORS y
@CrossOrigin - Errores Comunes y Consejos
- Ejercicios
@RestController: qué es exactamente
@RestController: qué es exactamenteAbrimos la anotación, como hicimos con @SpringBootApplication en la lección 02-01:
@Controller // <-- es un estereotipo: bean detectado por @ComponentScan
@ResponseBody // <-- el retorno va al cuerpo, no a una vista
public @interface RestController {
@AliasFor(annotation = Controller.class)
String value() default "";
}Dos anotaciones combinadas, nada más:
@Controlleres uno de los cinco estereotipos de 02-01: un@Componentespecializado que además hace queRequestMappingHandlerMappinginspeccione la clase buscando métodos con@RequestMapping.@ResponseBodycambia la interpretación del valor devuelto. Sin ella, un método que devuelve elString"estaciones"se interpreta como el nombre de una vista y Spring busca una plantillaestaciones.html. Con ella, eseStringes el cuerpo de la respuesta.
| Anotación | Retorno del método | Cuándo usarla |
|---|---|---|
@Controller |
Nombre de vista (Thymeleaf, JSP) | Web con HTML generado en servidor |
@Controller + @ResponseBody por método |
Cuerpo de la respuesta | Clases mixtas (raro) |
@RestController |
Cuerpo de la respuesta, siempre | APIs REST: nuestro caso |
Un error frecuentísimo en quien empieza es anotar con @Controller un controlador de API y encontrarse con un error de resolución de vista o un 404 críptico. Si la respuesta debe ser JSON, es @RestController.
@RequestMapping y los atajos por verbo
@RequestMapping y los atajos por verbo@RequestMapping es la anotación base de mapeo. Sus atributos:
| Atributo | Qué hace | Ejemplo |
|---|---|---|
path / value |
Patrón de ruta | "/api/v1/estaciones" |
method |
Verbos aceptados | RequestMethod.GET |
params |
Exige o prohíbe parámetros | "activa", "!borrador" |
headers |
Exige cabeceras | "X-API-Cliente=movil" |
consumes |
Content-Type que acepta |
"application/json" |
produces |
Content-Type que devuelve |
"application/json" |
Desde Spring 4.3 existen atajos que fijan method: @GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping. @GetMapping("/{id}") es idéntico a @RequestMapping(path = "/{id}", method = RequestMethod.GET), y siempre preferible por legibilidad.
El patrón habitual combina @RequestMapping a nivel de clase —que fija el prefijo común— con los atajos a nivel de método:
@RestController
@RequestMapping(path = "/api/v1/estaciones", // prefijo de la clase
produces = MediaType.APPLICATION_JSON_VALUE)
public class EstacionController {
@GetMapping // GET /api/v1/estaciones
public List<Estacion> listar() { ... }
@GetMapping("/{id}") // GET /api/v1/estaciones/1
public Estacion obtener(@PathVariable Long id) { ... }
}Concentrar el prefijo en un solo sitio evita repetirlo en cada método y hace trivial cambiar la versión de la API. Sobre produces y consumes: no son obligatorios —Spring Boot ya negocia JSON por defecto— pero declararlos hace el contrato visible en el código, produce respuestas correctas (415, 406) en lugar de errores confusos, y springdoc los usa para generar la documentación (03-07).
@PathVariable: variables de plantilla
@PathVariable: variables de plantillaUna variable de plantilla es un segmento de la ruta que actúa como parámetro. Se declara entre llaves y se captura con @PathVariable.
@GetMapping("/{id}")
public Estacion obtenerPorId(@PathVariable Long id) {
return estacionService.buscarPorId(id).orElse(null);
}Spring extrae el segmento, lo convierte al tipo declarado usando su ConversionService y lo pasa como argumento. La conversión funciona con Long, int, UUID, LocalDate, enums y cualquier tipo para el que exista un conversor.
El nombre debe coincidir. Si el parámetro Java se llama distinto que la plantilla, hay que decirlo: @GetMapping("/{idEstacion}") con @PathVariable("idEstacion") Long id. Cuando coinciden, el nombre puede omitirse solo si el código se compila con información de parámetros. Los proyectos generados por Spring Initializr lo hacen (el spring-boot-maven-plugin añade -parameters), pero si alguna vez ves un IllegalArgumentException: Name for argument of type [java.lang.Long] not specified, la causa es esa. Escribir el nombre siempre es una costumbre barata y robusta.
Una ruta puede tener varias variables (@GetMapping("/{idEstacion}/bicicletas/{idBicicleta}")), y una variable puede ser opcional declarándola como @PathVariable Optional<Integer> anio y registrando dos patrones en la misma anotación: @GetMapping({"/estadisticas", "/estadisticas/{anio}"}).
Patrones y expresiones regulares. La sintaxis {nombre:regex} restringe qué valores captura el segmento. Es muy útil para desambiguar rutas:
@GetMapping("/{id:\\d+}") // solo dígitos: /estaciones/1
public Estacion porId(@PathVariable Long id) { ... }
@GetMapping("/{codigo:[A-Z]{3}-\\d{3}}") // /estaciones/RIB-001
public Estacion porCodigo(@PathVariable String codigo) { ... }Sin las expresiones regulares, /estaciones/RIB-001 intentaría convertirse a Long y fallaría con un error de tipo. Con ellas, cada ruta va a su método. Los comodines de rutas de Spring:
| Patrón | Coincide con | No coincide con |
|---|---|---|
/estaciones/{id} |
/estaciones/1 |
/estaciones/1/bicicletas |
/estaciones/{id:\\d+} |
/estaciones/1 |
/estaciones/abc |
/estaciones/* |
/estaciones/1 |
/estaciones/1/bicicletas |
/estaciones/** |
/estaciones/1/bicicletas/42 |
— |
/est?cion |
/estacion, /estecion |
/estaacion |
Cuando varios patrones encajan, Spring elige el más específico: un patrón literal gana a uno con variable, y este gana a uno con comodín.
@RequestParam: parámetros de consulta
@RequestParam: parámetros de consultaLos parámetros de la cadena de consulta —lo que va tras el ?— se capturan con @RequestParam.
// GET /api/v1/estaciones?capacidadMinima=20
@GetMapping
public List<Estacion> listar(@RequestParam int capacidadMinima) { ... }Por defecto son obligatorios: si falta, Spring responde 400 Bad Request con MissingServletRequestParameterException. Las tres formas de hacerlo opcional:
@RequestParam(defaultValue = "0") int capacidadMinima // la mejor: nunca hay null
@RequestParam(required = false) Integer capacidadMinima // llega null si no viene
@RequestParam Optional<Integer> capacidadMinima // explícito en la firmaUn error clásico: @RequestParam(required = false) int capacidad con el tipo primitivo. Si el parámetro falta, Spring intenta asignar null a un int y lanza una excepción. Con required = false, el tipo debe ser siempre un envoltorio.
Listas y valores múltiples. @RequestParam List<Long> ids acepta las dos convenciones habituales: ?ids=1,2,3 y ?ids=1&ids=2&ids=3. También puedes recibir todos los parámetros de golpe con @RequestParam Map<String, String> filtros, pero pierdes el tipado, la validación automática y la documentación OpenAPI: úsalo solo si los parámetros son genuinamente dinámicos.
Agrupar parámetros en un objeto. Cuando un endpoint tiene cinco o seis parámetros, la firma se vuelve ilegible. Spring permite enlazarlos a un objeto sin ninguna anotación:
public record FiltroEstaciones(String nombre, Integer capacidadMinima,
int pagina, int tamanio) {}
@GetMapping
public List<Estacion> listar(FiltroEstaciones filtro) { ... }Spring usa el enlace de datos estándar (nombre del parámetro → componente del record). Es más limpio, se valida con @Valid (03-04) y se documenta bien. Lo usaremos en el ejercicio 2.
| Anotación | Origen del dato | Ejemplo de petición |
|---|---|---|
@PathVariable |
Segmento de la ruta | /estaciones/**1** |
@RequestParam |
Cadena de consulta o formulario | /estaciones?**capacidadMinima=20** |
@RequestBody |
Cuerpo de la petición | {"nombre":"Mercado Central"} |
@RequestHeader |
Cabecera HTTP | Accept-Language: ca |
@CookieValue |
Cookie | Cookie: preferencia=mapa |
@MatrixVariable |
Pares dentro de un segmento | /estaciones/1;zona=centro |
@RequestBody, @RequestHeader y compañía
@RequestBody, @RequestHeader y compañía@RequestBody toma el cuerpo de la petición y lo deserializa al tipo declarado usando el HttpMessageConverter adecuado según el Content-Type; para application/json, ese conversor es Jackson. Se escribe public Estacion crear(@RequestBody Estacion estacion). Solo puede haber un @RequestBody por método: el cuerpo es uno. Si falta o el JSON está mal formado, se lanza HttpMessageNotReadableException, que traduciremos a una respuesta decente en 03-06. La implementación completa de la creación es el tema de la lección siguiente.
@RequestHeader captura cabeceras, con la misma semántica de defaultValue y required. También admite recibirlas todas: @RequestHeader HttpHeaders cabeceras.
@GetMapping
public List<Estacion> listar(
@RequestHeader(value = "Accept-Language", defaultValue = "es") String idioma) { ... }En CicloUrbana lo usaremos para el idioma de los mensajes de error (03-04) y para el If-Match del control de concurrencia (03-03).
@MatrixVariable captura pares clave-valor dentro de un segmento de ruta, separados por punto y coma: /api/v1/estaciones/1;zona=centro. Forma parte del RFC 3986 y Spring la soporta, pero está deshabilitada por defecto y hay que activarla configurando UrlPathHelper. Se menciona por completitud: CicloUrbana no la usa, porque esos mismos datos van mejor en la cadena de consulta.
Además de las anotaciones, un método puede declarar tipos que Spring inyecta directamente: HttpServletRequest, Locale, UriComponentsBuilder, Principal (módulo 5). Acoplan el controlador a la API de servlets, así que conviene reservarlos para cuando no haya alternativa.
- Serialización JSON con Jackson
Cuando un método de @RestController devuelve un objeto, MappingJackson2HttpMessageConverter lo convierte a JSON. Con un record de Java el proceso es directo: cada componente del record se convierte en una propiedad JSON con el mismo nombre.
Nuestro record Estacion(Long id, String nombre, String direccion, int capacidad, double latitud, double longitud) se convierte, sin ninguna anotación, en:
{ "id": 1, "nombre": "Plaza Mayor", "direccion": "Plaza Mayor, 1",
"capacidad": 24, "latitud": 41.3851, "longitud": 2.1734 }Jackson soporta record de forma nativa desde la versión 2.12: usa el constructor canónico para deserializar y los accesores para serializar. No hacen falta getters, ni constructor vacío, ni @JsonCreator. Esta es una de las razones por las que el curso usa record para todo lo que viaja por la API.
Las anotaciones de Jackson que usaremos:
| Anotación | Efecto | Ejemplo |
|---|---|---|
@JsonProperty("nombre") |
Renombra la propiedad en el JSON | capacidad → "capacidad_total" |
@JsonIgnore |
Excluye el campo del JSON | Coordenadas internas de mantenimiento |
@JsonInclude(NON_NULL) |
Omite el campo si es null |
No enviar "operario": null |
@JsonFormat |
Controla el formato de fechas y números | "2026-08-31T14:05:00" |
@JsonPropertyOrder |
Fija el orden de las propiedades | {"id", "nombre", ...} |
@JsonAlias |
Acepta varios nombres al deserializar | Compatibilidad con clientes antiguos |
@JsonIgnoreProperties(ignoreUnknown) |
Tolera campos desconocidos al leer | Valor por defecto en Spring Boot |
Un ejemplo completo aplicado a la representación de una bicicleta, que introduciremos formalmente en la próxima lección:
package com.ciclourbana.bicicletas;
@JsonInclude(JsonInclude.Include.NON_NULL) // omite las propiedades nulas
public record Bicicleta(
Long id,
String matricula,
@JsonProperty("bateria") // en el JSON se llama "bateria"
int nivelBateria,
EstadoBicicleta estado,
Long estacionId,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime ultimaRevision,
@JsonIgnore // uso interno: no sale nunca
String codigoAnclajeInterno
) {}Punto por punto:
@JsonInclude(NON_NULL)a nivel de tipo: siestacionIdesnullporque la bicicleta está en uso, la propiedad no aparece en el JSON. Ojo: obliga al cliente a distinguir ausencia denull, un matiz que retomaremos conPATCHen 03-03.@JsonProperty("bateria"): el campo Java sigue el estilo español del proyecto y el JSON expone el nombre acordado con el equipo de la app. Desacoplar ambos nombres permite renombrar en Java sin romper el contrato.@JsonFormat: elshape = STRINGevita que la fecha se emita como array de números, que es el comportamiento de Jackson sin el módulo JSR-310.@JsonIgnore:codigoAnclajeInternoes información operativa de Ribalta que no debe salir al exterior. Aquí asoma un problema serio: el modelo interno contiene datos que la API no debe exponer. Anotar campos con@JsonIgnorefunciona a pequeña escala y se convierte en una fuente de fugas en cuanto el modelo crece. La solución estructural son los DTOs (03-05).
- Configuración global de Jackson en YAML
Anotar campo a campo no escala. Spring Boot expone la configuración global de Jackson bajo spring.jackson.*, y esa es la forma correcta de fijar políticas de todo el proyecto.
# src/main/resources/application.yml
spring:
jackson:
date-format: yyyy-MM-dd'T'HH:mm:ss # afecta a java.util.Date
time-zone: Europe/Madrid
default-property-inclusion: non_null # omitir propiedades nulas en toda la API
serialization:
write-dates-as-timestamps: false # fechas ISO-8601, no números
fail-on-empty-beans: false
indent-output: false # en producción ahorra ancho de banda
deserialization:
fail-on-unknown-properties: false # tolerar campos que no conocemos
fail-on-null-for-primitives: true # rechazar null en un int en vez de poner 0Las decisiones importantes de esta configuración:
write-dates-as-timestamps: falsees probablemente la propiedad de Jackson más relevante de un proyecto. Sin ella, unInstantse serializa como1756645500.000000000: ilegible y frágil. Spring Boot ya la pone afalse, pero conviene declararla. Requierejackson-datatype-jsr310, quespring-boot-starter-webincluye y Spring Boot registra solo.default-property-inclusion: non_nullaplica a toda la aplicación lo que@JsonIncludehacía a una clase, y evita repetir la anotación en cada DTO.fail-on-unknown-properties: false(valor por defecto) es una decisión de compatibilidad: si un cliente antiguo envía un campo retirado, la petición no falla. Es justamente el comportamiento que en 03-01 hacía compatible añadir y retirar campos.fail-on-null-for-primitives: truesí cambia el valor por defecto. Sin él,{"capacidad": null}se convierte silenciosamente encapacidad = 0, y una estación con capacidad cero es un error de datos difícil de rastrear. Mejor un 400 inmediato.
Si necesitas algo que las propiedades no cubren, personaliza con un Jackson2ObjectMapperBuilderCustomizer en com.ciclourbana.comun:
@Configuration
public class ConfiguracionJackson {
/** Se acumula con spring.jackson.* en vez de reemplazarla. */
@Bean
Jackson2ObjectMapperBuilderCustomizer personalizacionCicloUrbana() {
return builder -> builder
.featuresToDisable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
}
}El detalle se deduce de lo aprendido sobre @ConditionalOnMissingBean: si declaras un bean ObjectMapper propio, JacksonAutoConfiguration se aparta y pierdes de golpe todas las propiedades spring.jackson.*, el módulo de fechas y los conversores registrados. Personaliza siempre con el customizer, no reemplazando el ObjectMapper.
ResponseEntity frente a devolver el objeto
ResponseEntity frente a devolver el objetoUn método puede devolver el objeto directamente o envolverlo en un ResponseEntity<T>, que representa la respuesta HTTP completa: estado, cabeceras y cuerpo.
// Forma directa: siempre 200 OK, sin cabeceras propias
public Estacion obtener(@PathVariable Long id) { ... }
// Con ResponseEntity: control total del estado y de las cabeceras
public ResponseEntity<Estacion> obtener(@PathVariable Long id) {
return estacionService.buscarPorId(id)
.map(ResponseEntity::ok) // 200 + cuerpo
.orElseGet(() -> ResponseEntity.notFound().build()); // 404 sin cuerpo
}| Criterio | Devolver el objeto | Devolver ResponseEntity |
|---|---|---|
| Código de estado | Siempre 200 (o el de @ResponseStatus) |
Cualquiera, decidido en tiempo de ejecución |
| Cabeceras propias | No | Sí (Location, ETag, Cache-Control) |
| Legibilidad | Máxima | Algo más de ruido |
| Tipado del cuerpo | Directo | Envuelto en un genérico |
| Documentación OpenAPI | Se infiere sola | A veces necesita @ApiResponse |
| Uso recomendado | GET que siempre tiene éxito |
POST (201 + Location), 204, respuestas condicionales |
La política de CicloUrbana para todo el módulo: devolver el objeto directamente cuando el caso de éxito es único y el estado es 200 (todos los GET de listado); usar ResponseEntity cuando la respuesta necesita una cabecera (Location en los POST, ETag en las condicionales) o cuando el estado varía; y no usar ResponseEntity para devolver errores. Esto último es clave y aún no es evidente: escribir ResponseEntity.notFound() en cada método esparce la lógica de error por todos los controladores. Desde 03-06 lanzaremos RecursoNoEncontradoException y un manejador global la convertirá en un 404 bien formado. En esta lección todavía usamos ResponseEntity para el 404, con un TODO para no olvidarlo.
Formas útiles de construir un ResponseEntity:
ResponseEntity.ok(estacion); // 200 con cuerpo
ResponseEntity.noContent().build(); // 204 sin cuerpo
ResponseEntity.created(uri).body(estacion); // 201 + Location
ResponseEntity.status(HttpStatus.CONFLICT).build(); // cualquier código
ResponseEntity.ok()
.header("X-Total-Elementos", "42")
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(5)).cachePublic())
.body(lista); // cabeceras a medidaExiste además @ResponseStatus sobre el método, que fija el código de éxito sin ResponseEntity; lo aplicaremos a los POST en la lección siguiente.
- El
EstacionController completo
EstacionController completoReunimos todo. Primero ampliamos EstacionService (paquete com.ciclourbana.estaciones) con los métodos que el controlador necesita:
@Service
public class EstacionService {
private final EstacionRepositorio estacionRepositorio;
public EstacionService(EstacionRepositorio estacionRepositorio) {
this.estacionRepositorio = estacionRepositorio;
}
/** Listado filtrado y paginado. Solo variables locales: el bean es
* singleton y lo comparten los hilos de Tomcat (visto en 02-03). */
public List<Estacion> buscar(String nombre, Integer capacidadMinima,
int pagina, int tamanio) {
List<Estacion> filtradas = estacionRepositorio.buscarTodas().stream()
.filter(e -> nombre == null
|| e.nombre().toLowerCase().contains(nombre.toLowerCase()))
.filter(e -> capacidadMinima == null || e.capacidad() >= capacidadMinima)
.sorted(Comparator.comparing(Estacion::nombre))
.toList();
// Paginación en memoria. En el módulo 4, Spring Data la hará en la
// base de datos con Pageable, que es lo correcto en producción.
int desde = pagina * tamanio;
if (desde >= filtradas.size()) {
return List.of();
}
return filtradas.subList(desde, Math.min(desde + tamanio, filtradas.size()));
}
public Optional<Estacion> buscarPorId(Long id) {
return estacionRepositorio.buscarPorId(id);
}
}Y ahora el controlador:
package com.ciclourbana.estaciones;
/**
* API de estaciones de CicloUrbana.
*
* Devuelve todavía la entidad de dominio Estacion; la separación en DTOs
* llega en la lección 03-05, y el manejo centralizado de errores en 03-06.
*/
@RestController
@RequestMapping(path = "/api/v1/estaciones",
produces = MediaType.APPLICATION_JSON_VALUE)
public class EstacionController {
private static final Logger log = LoggerFactory.getLogger(EstacionController.class);
private static final int TAMANIO_PAGINA_MAXIMO = 100;
private final EstacionService estacionService;
// Inyección por constructor: sin @Autowired, como se razonó en 02-02
public EstacionController(EstacionService estacionService) {
this.estacionService = estacionService;
}
/** GET /api/v1/estaciones?nombre=norte&capacidadMinima=20&pagina=0&tamanio=20
* Devuelve la lista directamente: éxito único (200), sin cabeceras a medida. */
@GetMapping
public List<Estacion> listar(
@RequestParam(required = false) String nombre,
@RequestParam(required = false) Integer capacidadMinima,
@RequestParam(defaultValue = "0") int pagina,
@RequestParam(defaultValue = "20") int tamanio) {
// Defensa provisional: sin esto, ?tamanio=1000000 tumba el servicio.
// En 03-04 se sustituye por @Min/@Max declarativos.
int tamanioSeguro = Math.min(Math.max(tamanio, 1), TAMANIO_PAGINA_MAXIMO);
int paginaSegura = Math.max(pagina, 0);
log.debug("Listado de estaciones: nombre={}, capacidadMinima={}, pagina={}",
nombre, capacidadMinima, paginaSegura);
return estacionService.buscar(nombre, capacidadMinima, paginaSegura, tamanioSeguro);
}
/** GET /api/v1/estaciones/1 — ResponseEntity porque el estado varía.
* TODO (03-06): sustituir por lanzar RecursoNoEncontradoException. */
@GetMapping("/{id:\\d+}")
public ResponseEntity<Estacion> obtenerPorId(@PathVariable("id") Long id) {
return estacionService.buscarPorId(id)
.map(ResponseEntity::ok)
.orElseGet(() -> {
log.info("Estación no encontrada: id={}", id);
return ResponseEntity.notFound().build();
});
}
}Detalles que merecen comentario:
@GetMapping("/{id:\\d+}"): la restricción a dígitos evita que/api/v1/estaciones/resumen, si algún día lo añadimos, intente convertirse aLong.- Los límites de paginación en el controlador son provisionales y feos a propósito: 03-04 los sustituye por
@Min(0)y@Max(100), y el contraste hace evidente qué aporta la validación declarativa. - El logging usa el patrón de sustitución de SLF4J (
{}), no concatenación: conconcat, elStringse construye aunqueDEBUGesté desactivado. Y el 404 va alog.info, no alog.error: es información operativa, no un fallo del sistema (se detalla en 03-06).
Arrancamos con ./mvnw spring-boot:run y CargadorEstacionesDemo deja las cuatro estaciones de Ribalta en el repositorio en memoria.
- Probar la API: curl y ficheros
.http
.httpCon curl, la herramienta universal:
curl -s http://localhost:8080/api/v1/estaciones | jq
curl -s "http://localhost:8080/api/v1/estaciones?nombre=norte" | jq
curl -s "http://localhost:8080/api/v1/estaciones?capacidadMinima=24&pagina=0&tamanio=2" | jq
curl -s http://localhost:8080/api/v1/estaciones/1 | jq
curl -i http://localhost:8080/api/v1/estaciones/999 # ver cabeceras y estadoLa última llamada devuelve HTTP/1.1 404 con Content-Length: 0. Y el filtro por capacidad mínima 24:
[ { "id": 2, "nombre": "Estación Norte", "capacidad": 30, "latitud": 41.4012, "longitud": 2.1698 },
{ "id": 1, "nombre": "Plaza Mayor", "capacidad": 24, "latitud": 41.3851, "longitud": 2.1734 } ]Aparecen ordenadas por nombre, como impone el Comparator del servicio; "Estación Norte" (30), "Plaza Mayor" (24) y "Universidad" (36) superan el filtro, pero con tamanio=2 solo llegan las dos primeras.
Para el trabajo diario es más cómodo un fichero .http en src/test/http/estaciones.http, que IntelliJ IDEA y la extensión REST Client de VS Code ejecutan directamente. Se versiona con el código, así que la API queda documentada y probable desde el propio repositorio:
@base = http://localhost:8080/api/v1
### Listar todas las estaciones
GET {{base}}/estaciones
Accept: application/json
### Filtrar por nombre
GET {{base}}/estaciones?nombre=norte
### Filtrar por capacidad mínima y paginar
GET {{base}}/estaciones?capacidadMinima=24&pagina=0&tamanio=2
### Detalle de la Plaza Mayor
GET {{base}}/estaciones/1
### Estación inexistente: debe responder 404
GET {{base}}/estaciones/999
### Parámetro de tipo incorrecto: responde 400
GET {{base}}/estaciones?capacidadMinima=muchoLa última petición es interesante. Spring intenta convertir "mucho" a Integer, falla y lanza MethodArgumentTypeMismatchException, que se traduce en un 400 Bad Request con una respuesta por defecto poco informativa. Guárdala en el fichero: la mejoraremos en 03-06.
- CORS y
@CrossOrigin
@CrossOriginLos navegadores aplican la política del mismo origen: una página servida desde https://panel.ribalta.es no puede llamar por JavaScript a https://api.ciclourbana.es salvo que el servidor lo autorice. Ese permiso es CORS (Cross-Origin Resource Sharing). El mecanismo, en corto: antes de una petición "no simple" (con Content-Type: application/json, o con verbo PUT/DELETE, o con cabeceras propias), el navegador envía un preflight y el servidor debe autorizarlo:
OPTIONS /api/v1/estaciones HTTP/1.1
Origin: https://panel.ribalta.es
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://panel.ribalta.es
Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600Dos aclaraciones que ahorran horas de depuración:
- CORS es cosa del navegador. curl, Postman y las apps móviles nativas lo ignoran por completo. Si tu curl funciona y el frontend no, es CORS.
- CORS no es seguridad del servidor. No protege la API de nadie: solo impide que el navegador entregue la respuesta a un script de otro origen. La protección real es la autenticación (módulo 5).
En Spring, la forma rápida es @CrossOrigin(origins = "https://panel.ribalta.es") sobre la clase o el método. Y la forma correcta para un proyecto, centralizada en com.ciclourbana.comun:
@Configuration
public class ConfiguracionCors implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registro) {
registro.addMapping("/api/**")
.allowedOrigins("https://panel.ribalta.es", "http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("*")
.exposedHeaders("Location", "ETag") // visibles para el JS del cliente
.maxAge(3600);
}
}exposedHeaders merece atención: por defecto, el JavaScript del navegador solo puede leer un puñado de cabeceras de la respuesta. Si el frontend necesita el Location de un POST o el ETag para una petición condicional, hay que exponerlas aquí explícitamente.
Nunca uses allowedOrigins("*") junto con credenciales: la especificación lo prohíbe y Spring lanzará un error de configuración. El endurecimiento de CORS junto con Spring Security se retoma en la lección 05-05.
Errores Comunes y Consejos
Usar @Controller en lugar de @RestController. El método devuelve "Plaza Mayor" y Spring busca una plantilla con ese nombre: error de resolución de vista o un 404 desconcertante.
@RequestParam(required = false) con un tipo primitivo. int no admite null. Usa Integer, Optional<Integer> o, mejor, defaultValue.
Olvidar el nombre en @PathVariable. Si el proyecto se compila sin -parameters, falla en tiempo de ejecución con un mensaje sobre el nombre del argumento. Escríbelo siempre.
Rutas ambiguas. @GetMapping("/{id}") con id de tipo Long y una llamada a /estaciones/abc produce un 400 confuso. La restricción {id:\\d+} lo evita.
Definir un bean ObjectMapper propio. Anula JacksonAutoConfiguration y con ella todas las propiedades spring.jackson.* y el módulo de fechas. Usa Jackson2ObjectMapperBuilderCustomizer. Síntoma relacionado: si ves fechas como [2026,8,31,14,5] o 1756645500.000000000, falta write-dates-as-timestamps: false o el módulo JSR-310.
Poner lógica de negocio en el controlador. El controlador traduce HTTP a llamadas Java y nada más. Si aparece un if sobre reglas del negocio de Ribalta, ese código pertenece a EstacionService. La prueba: cuando llegue un consumidor de mensajes, ¿podría reutilizar la lógica sin pasar por HTTP?
Consejo: un controlador por agregado. EstacionController, BicicletaController, AlquilerController. No un ApiController con veinte métodos.
Consejo: guarda las peticiones en un .http versionado. Cada caso raro que descubras —un tipo incorrecto, un filtro vacío— añádelo. Ese fichero es documentación viva y el borrador de las pruebas de integración del módulo 6.
Ejercicios
Ejercicio 1: Endpoint de búsqueda por proximidad
Añade a EstacionController el endpoint GET /api/v1/estaciones/cercanas?lat=41.38&lon=2.17&radioMetros=800, que devuelve las estaciones dentro del radio indicado, ordenadas por distancia. radioMetros es opcional con valor por defecto 500; lat y lon son obligatorios. Implementa el cálculo en EstacionService y asegúrate de que la ruta no entre en conflicto con /{id}.
Ejercicio 2: Agrupar los filtros en un objeto y devolver metadatos de paginación
La firma de listar ya tiene cuatro parámetros y crecerá. Refactorízala para (a) agrupar los filtros en un record FiltroEstaciones y (b) devolver, además de la lista, el total de elementos y el número de páginas, usando cabeceras HTTP en lugar de envolver el cuerpo.
Ejercicio 3: Controlar la representación JSON
El equipo de la app móvil de Ribalta pide tres cambios en la respuesta de estación:
- Que
capacidadse llamecapacidadTotalen el JSON, sin renombrar el campo Java. - Que se añada un campo
coordenadascon el formato"41.3851,2.1734"y quelatitudylongituddejen de aparecer por separado. - Que las propiedades salgan siempre en el orden
id,nombre,capacidadTotal,coordenadas,direccion.
Resuélvelo solo con anotaciones de Jackson y explica por qué esta solución no escala.
Soluciones
Solución 1.
En EstacionService:
private static final double RADIO_TIERRA_METROS = 6_371_000;
public List<Estacion> buscarCercanas(double latitud, double longitud, int radioMetros) {
return estacionRepositorio.buscarTodas().stream()
.map(e -> Map.entry(e, distanciaMetros(latitud, longitud, e.latitud(), e.longitud())))
.filter(par -> par.getValue() <= radioMetros)
.sorted(Map.Entry.comparingByValue()) // más cercana primero
.map(Map.Entry::getKey)
.toList();
}
/** Fórmula del haversine: distancia sobre la superficie terrestre. */
private double distanciaMetros(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
return RADIO_TIERRA_METROS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}En el controlador:
@GetMapping("/cercanas")
public List<Estacion> cercanas(@RequestParam double lat,
@RequestParam double lon,
@RequestParam(defaultValue = "500") int radioMetros) {
return estacionService.buscarCercanas(lat, lon, radioMetros);
}Sobre el conflicto de rutas. /cercanas es un patrón literal y /{id} uno con variable; Spring da prioridad al literal, así que no hay ambigüedad. Aun así, la restricción {id:\\d+} hace la intención explícita y protege de descuidos futuros.
Nota de diseño. Si faltan lat o lon, Spring responde 400 automáticamente. Pero una latitud de 200 grados pasaría sin problema, y radioMetros=-50 también. Es exactamente el hueco que cubre la lección 03-04 con @DecimalMin/@DecimalMax y @Positive.
Solución 2.
El record de filtros, en com.ciclourbana.estaciones:
public record FiltroEstaciones(String nombre, Integer capacidadMinima,
Integer pagina, Integer tamanio) {
// Constructor compacto: normaliza los valores nulos y acota el tamaño
public FiltroEstaciones {
pagina = (pagina == null || pagina < 0) ? 0 : pagina;
tamanio = (tamanio == null || tamanio < 1) ? 20 : Math.min(tamanio, 100);
}
}El constructor compacto de un record es el sitio ideal para normalizar: se ejecuta siempre, venga el objeto de donde venga, y el resultado es inmutable.
El controlador:
@GetMapping
public ResponseEntity<List<Estacion>> listar(FiltroEstaciones filtro) {
List<Estacion> pagina = estacionService.buscar(filtro.nombre(),
filtro.capacidadMinima(), filtro.pagina(), filtro.tamanio());
long total = estacionService.contarConFiltro(filtro.nombre(), filtro.capacidadMinima());
int totalPaginas = (int) Math.ceil((double) total / filtro.tamanio());
return ResponseEntity.ok()
.header("X-Total-Elementos", String.valueOf(total))
.header("X-Total-Paginas", String.valueOf(totalPaginas))
.header("X-Pagina-Actual", String.valueOf(filtro.pagina()))
.body(pagina);
}Spring enlaza los parámetros de consulta al record sin ninguna anotación, por coincidencia de nombres: la petición sigue siendo ?nombre=norte&pagina=0&tamanio=10.
Por qué cabeceras y no un cuerpo envolvente. Ambas son legítimas. Las cabeceras mantienen el cuerpo como un array puro de estaciones, que es lo que el recurso "colección" representa; es la elección de GitHub. La alternativa —{"contenido": [...], "totalElementos": 42}— es la que produce Spring Data con Page<T> y la que adoptaremos en el módulo 4, porque llega gratis. Advertencia: si eliges cabeceras, decláralas en exposedHeaders de la configuración CORS, o el JavaScript del navegador no podrá leerlas.
Solución 3.
@JsonPropertyOrder({"id", "nombre", "capacidadTotal", "coordenadas", "direccion"})
public record Estacion(
Long id,
String nombre,
String direccion,
@JsonProperty("capacidadTotal")
int capacidad,
@JsonIgnore double latitud,
@JsonIgnore double longitud
) {
/**
* Propiedad calculada: Jackson serializa cualquier método sin argumentos
* anotado con @JsonProperty, aunque no sea un componente del record.
*/
@JsonProperty("coordenadas")
public String coordenadas() {
return latitud + "," + longitud;
}
}Resultado:
{ "id": 1, "nombre": "Plaza Mayor", "capacidadTotal": 24,
"coordenadas": "41.3851,2.1734", "direccion": "Plaza Mayor, 1" }Por qué esta solución no escala, que es el fondo del ejercicio:
- Contamina el dominio con el contrato.
Estaciones el modelo interno y ahora carga con las preferencias de formato de la app móvil. Si mañana el portal de datos abiertos del ayuntamiento pidelatitudylongitudseparadas, no hay forma de satisfacer a ambos con una sola clase. @JsonIgnorees una lista de exclusiones, y las listas de exclusiones fallan por omisión. El día que se añada un campocodigoAccesoalrecordy nadie recuerde anotarlo, se publica sin querer. Una lista de inclusiones —un DTO que enumera lo que sí sale— falla al revés: como mucho olvidas exponer algo, y eso se detecta al instante.- Rompe el módulo 4. Cuando
Estacionsea una entidad JPA con relaciones perezosas, serializarla directamente provocaráLazyInitializationExceptiono consultas en cascada inesperadas.
Todo esto se resuelve con un EstacionResponse distinto de Estacion: el contenido de la lección 03-05.
Conclusión
Ya sabes escribir controladores REST de verdad. Has visto que @RestController no es más que @Controller + @ResponseBody, y por qué confundirlos produce errores de vista incomprensibles. Dominas el mapeo de rutas con @RequestMapping a nivel de clase y los atajos por verbo, incluidas las restricciones con expresiones regulares que evitan rutas ambiguas. Sabes extraer cada parte de una petición —@PathVariable, @RequestParam con obligatoriedad, valores por defecto, listas y objetos de filtro, @RequestBody, @RequestHeader— y conoces las trampas de cada uno, empezando por el required = false sobre un primitivo. Controlas cómo Jackson convierte un record en JSON, campo a campo con @JsonProperty, @JsonIgnore, @JsonFormat y @JsonInclude, y globalmente desde spring.jackson.*, con la regla de oro de personalizar siempre con un Jackson2ObjectMapperBuilderCustomizer en lugar de reemplazar el ObjectMapper. Tienes criterio para decidir entre devolver el objeto y envolverlo en un ResponseEntity. Y entiendes qué es CORS, por qué no es un mecanismo de seguridad y cómo configurarlo de forma centralizada.
Sobre todo, CicloUrbana ya tiene un EstacionController que se parece a uno real: listado con filtro por nombre y por capacidad, paginación, consulta por identificador con su 404, logging con niveles apropiados, y un fichero .http versionado con los casos de prueba, incluidos los que todavía responden mal. El ejercicio 3 ha dejado señalada, con nombre y apellidos, la deuda técnica que arrastramos: estamos exponiendo la clase de dominio directamente.
La lección 03-03, Manejo de Métodos HTTP, completa el CRUD. Implementaremos POST con su 201 Created y la cabecera Location construida con ServletUriComponentsBuilder, PUT como reemplazo total, PATCH con el problema —más sutil de lo que parece— de distinguir un campo ausente de un campo puesto a nulo, y DELETE con su idempotencia. Añadiremos el recurso bicicleta y el subrecurso /estaciones/{id}/bicicletas, modelaremos las acciones que no encajan en el CRUD puro (POST /api/v1/alquileres/{id}/finalizar) sin romper el diseño REST, y resolveremos el problema de las actualizaciones perdidas con ETag e If-Match. La API de Ribalta deja de ser de solo lectura.
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
