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

  1. @RestController: qué es exactamente
  2. @RequestMapping y los atajos por verbo
  3. @PathVariable: variables de plantilla
  4. @RequestParam: parámetros de consulta
  5. @RequestBody, @RequestHeader y compañía
  6. Serialización JSON con Jackson
  7. Configuración global de Jackson en YAML
  8. ResponseEntity frente a devolver el objeto
  9. El EstacionController completo
  10. Probar la API: curl y ficheros .http
  11. CORS y @CrossOrigin
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. @RestController: qué es exactamente

Abrimos 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:

  • @Controller es uno de los cinco estereotipos de 02-01: un @Component especializado que además hace que RequestMappingHandlerMapping inspeccione la clase buscando métodos con @RequestMapping.
  • @ResponseBody cambia la interpretación del valor devuelto. Sin ella, un método que devuelve el String "estaciones" se interpreta como el nombre de una vista y Spring busca una plantilla estaciones.html. Con ella, ese String es 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.

  1. @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).

  1. @PathVariable: variables de plantilla

Una 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.

  1. @RequestParam: parámetros de consulta

Los 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 firma

Un 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

  1. @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.

  1. 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: si estacionId es null porque la bicicleta está en uso, la propiedad no aparece en el JSON. Ojo: obliga al cliente a distinguir ausencia de null, un matiz que retomaremos con PATCH en 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: el shape = STRING evita que la fecha se emita como array de números, que es el comportamiento de Jackson sin el módulo JSR-310.
  • @JsonIgnore: codigoAnclajeInterno es 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 @JsonIgnore funciona 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).

  1. 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 0

Las decisiones importantes de esta configuración:

  • write-dates-as-timestamps: false es probablemente la propiedad de Jackson más relevante de un proyecto. Sin ella, un Instant se serializa como 1756645500.000000000: ilegible y frágil. Spring Boot ya la pone a false, pero conviene declararla. Requiere jackson-datatype-jsr310, que spring-boot-starter-web incluye y Spring Boot registra solo.
  • default-property-inclusion: non_null aplica a toda la aplicación lo que @JsonInclude hací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: true sí cambia el valor por defecto. Sin él, {"capacidad": null} se convierte silenciosamente en capacidad = 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.

  1. ResponseEntity frente a devolver el objeto

Un 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 medida

Existe 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.

  1. El EstacionController completo

Reunimos 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 a Long.
  • 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: con concat, el String se construye aunque DEBUG esté desactivado. Y el 404 va a log.info, no a log.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.

  1. Probar la API: curl y ficheros .http

Con 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 estado

La ú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=mucho

La ú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.

  1. CORS y @CrossOrigin

Los 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: 3600

Dos 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:

  1. Que capacidad se llame capacidadTotal en el JSON, sin renombrar el campo Java.
  2. Que se añada un campo coordenadas con el formato "41.3851,2.1734" y que latitud y longitud dejen de aparecer por separado.
  3. 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:

  1. Contamina el dominio con el contrato. Estacion es 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 pide latitud y longitud separadas, no hay forma de satisfacer a ambos con una sola clase.
  2. @JsonIgnore es una lista de exclusiones, y las listas de exclusiones fallan por omisión. El día que se añada un campo codigoAcceso al record y 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.
  3. Rompe el módulo 4. Cuando Estacion sea una entidad JPA con relaciones perezosas, serializarla directamente provocará LazyInitializationException o 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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados