GLP Cerca (3): el backend en Rust — Axum, SeaORM y búsqueda por cercanía

Publicado el 03/07/2026

El backend es la pieza que carga con todo el peso para que la app pueda ser tonta y rápida. Ingiere (lo vimos en el artículo 2), persiste, calcula y expone una API REST. Aquí cuento el porqué del stack y el detalle del que más orgulloso estoy: resolver "estaciones a menos de X km" sin una base de datos geoespacial.

Por qué Rust

Podría haber hecho esto en cualquier lenguaje. Elegí Rust y lo defiendo:

  • Es un servicio que corre solo, sin nadie mirando. Ingesta periódica, endpoints públicos. Quiero que no se caiga por un null inesperado a las 3 de la mañana. El sistema de tipos y el Option/Result de Rust convierten una clase entera de bugs en errores de compilación.
  • Un solo binario, despliegue trivial. Con OpenSSL vendorizado (lo conté en el artículo 2) el contenedor de producción es prácticamente "copia el binario y arranca". Sin runtime, sin intérprete, sin gestión de dependencias en el servidor.
  • Footprint mínimo. Esto va a vivir en un VPS modesto. Un backend Rust ocioso consume una fracción de lo que consumiría una alternativa con runtime pesado.
  • El parseo del feed es justo donde Rust brilla: transformar datos sucios en estructuras tipadas con manejo explícito de lo que falta.

Opinión: Rust tiene fama de "lento de escribir". Para un CRUD efímero, puede. Para un servicio que quiero olvidar que existe porque simplemente funciona, el tiempo extra de tipar bien al principio se devuelve con creces en madrugadas sin alertas.

El stack, en una pantalla

axum = "0.8"                 # servidor web
tokio = { features = ["full"] }
sea-orm = "1.1"              # ORM async sobre sqlx-postgres
askama = "0.13"              # plantillas HTML (SSR) compiladas
tower-sessions + argon2      # sesión del backoffice
utoipa = "5"                 # genera el spec OpenAPI
reqwest = "0.12"             # cliente del feed (native-tls)
clap = "4.5"                 # CLI (ingestar, crear-admin, …)
tracing                      # observabilidad estructurada

Decisiones que merecen comentario:

  • Axum + SeaORM porque ambos son async nativos sobre Tokio y encajan sin fricción. SeaORM me da entidades tipadas pero, cuando necesito SQL crudo (la búsqueda normalizada, el sembrado de provincias), me deja bajar a ras de suelo sin pelear con el ORM.
  • Askama para el HTML del servidor. El backend no solo sirve JSON: también tiene una web pública (buscador, mapa, fichas con gráficas, informes) y un pequeño backoffice. Askama compila las plantillas a código Rust, así que un error en una plantilla es un error de compilación, no una sorpresa en runtime.
  • utoipa solo genera el spec; el Swagger UI lo sirvo por CDN para no acoplar la documentación a la versión de Axum. Pequeña decisión de desacoplo que evita dolores en futuras actualizaciones.
  • Un binario, varios subcomandos (clap): el mismo ejecutable sirve la web, fuerza una ingesta (ingestar), crea un admin (crear-admin) o resetea su contraseña. Operación sin scripts sueltos.

Una capa de consulta compartida

Un detalle de arquitectura del que estoy contento: la lógica de filtrado, orden, búsqueda y cercanía vive en un módulo único (consulta.rs) que usan tanto la API REST como las vistas HTML del servidor. No hay dos implementaciones de "buscar estaciones baratas en Madrid" que puedan divergir. La API y la web responden con la misma lógica porque es la misma lógica.

Búsqueda insensible a acentos y mayúsculas

Que "almeria" encuentre "ALMERÍA" no es opcional en España. Lo resuelvo normalizando en la propia base de datos con una función glp_norm (creada por migración) y columnas normalizadas precalculadas (rotulo_busqueda, etc.), con índices GIN pg_trgm encima para que el LIKE no sea un full scan:

cond = cond.add(
    Condition::any()
        .add(Expr::cust_with_values("rotulo_busqueda LIKE glp_norm($1)", [p.clone()]))
        .add(Expr::cust_with_values("localidad_busqueda LIKE glp_norm($1)", [p.clone()]))
        .add(Expr::cust_with_values("direccion_busqueda LIKE glp_norm($1)", [p])),
);

El patrón también escapa los comodines de LIKE para que un % que escriba el usuario sea texto literal y no un comodín:

pub fn patron(q: &str) -> String {
    let escapado = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
    format!("%{escapado}%")
}

Querystrings que no revientan con un campo vacío

Un formulario web manda precio_max= (vacío) cuando el usuario no rellena el campo. Si intento parsear eso como f64, falla. En vez de validar a mano en cada endpoint, un deserializador genérico trata "vacío" como None:

pub fn empty_as_none<'de, D, T>(d: D) -> Result<Option<T>, D::Error> { /* … */ }
// uso: #[serde(default, deserialize_with = "empty_as_none")]

Pequeño, reutilizable, y elimina una categoría entera de errores 400 tontos.

La joya: cercanía sin PostGIS

El endpoint estrella es este:

GET /api/v1/estaciones/cercanas?lat=…&lon=…&radio_km=…&orden=precio_asc

Es el que llama la app al abrirse. Tiene que responder "dame las estaciones a menos de N km de este punto, ordenadas por precio", rápido y sobre miles de estaciones.

La solución "de libro" sería PostGIS. Decidí no usarlo. ¿Por qué?

Opinión: PostGIS es magnífico y, para este problema concreto, exagerado. Añade una extensión a mantener, complica el despliegue y la migración, y resuelve una clase de problemas geométricos (polígonos, intersecciones, proyecciones) que yo no tengo. Mi problema es "puntos dentro de un radio". Eso se resuelve con aritmética de instituto y un índice B-tree normal.

La técnica es dos fases: bounding-box + haversine.

Fase 1 — Bounding-box (descarta barato, usa índice)

Calculo una caja rectangular de lat/lon alrededor del punto y dejo que PostgreSQL filtre con el índice (latitud, longitud). Esto descarta la inmensa mayoría de estaciones del país con una comparación de rangos baratísima:

/// Caja envolvente alrededor de un punto para un radio en km.
/// 1° de latitud ≈ 111 km; la longitud se corrige por el coseno de la latitud.
/// Se aplica un margen de seguridad del 10 %.
pub fn bounding_box(lat: f64, lon: f64, radio_km: f64) -> (f64, f64, f64, f64) {
    let dlat = (radio_km / 111.0) * 1.1;
    let coslat = lat.to_radians().cos().abs().max(0.01);
    let dlon = (radio_km / (111.0 * coslat)) * 1.1;
    (lat - dlat, lat + dlat, lon - dlon, lon + dlon)
}

Dos sutilezas que aprendí metiéndome en el barro:

  • La longitud se corrige por el coseno de la latitud. Un grado de longitud son ~111 km en el ecuador pero se encoge según subes hacia el polo. En España la diferencia es notable; ignorarla daría cajas torcidas.
  • El .max(0.01) evita una división por cero absurda cerca de los polos. No hay estaciones de GLP en el polo, pero un guardarraíl barato es un guardarraíl que no te explota nunca.
  • El margen del 10 % ensancha la caja a propósito: prefiero que la caja deje pasar alguna estación de más (que luego la fase 2 descarta) a que recorte una que sí estaba dentro del radio por un error de redondeo del rectángulo.

Fase 2 — Haversine (distancia exacta sobre los pocos que quedaron)

Sobre el puñado de candidatos que sobrevivió a la caja, calculo la distancia real sobre la esfera con la fórmula del haversine y descarto los que se pasen del radio:

/// Distancia haversine en km entre dos puntos (lat/lon en grados).
pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
    const R: f64 = 6371.0; // radio medio de la Tierra (km)
    let (p1, p2) = (lat1.to_radians(), lat2.to_radians());
    let dlat = (lat2 - lat1).to_radians();
    let dlon = (lon2 - lon1).to_radians();
    let a = (dlat / 2.0).sin().powi(2)
        + p1.cos() * p2.cos() * (dlon / 2.0).sin().powi(2);
    2.0 * R * a.sqrt().asin()
}

El reparto de trabajo es lo elegante: la base de datos hace el filtrado masivo y barato (con índice), y Rust hace el cálculo trigonométrico fino sobre un conjunto ya pequeño. Cada herramienta en lo que es buena. Y la distancia_km exacta viaja en la respuesta, así la app la puede mostrar sin recalcular.

Los parámetros tienen topes sanos (radio_km ≤ 200, limite ≤ 200) para que nadie pueda pedir "todas las estaciones de Europa" y tumbar el servicio.

El resto de la API, en breve

  • GET /api/v1/estaciones — listado paginado con filtros (q, provincia, precio_max, orden…). Mismo consulta.rs.
  • GET /api/v1/estaciones/{id}/tendencia — min/max/media, variación y la serie del histórico barato. Aquí cobra sentido el modelado del artículo anterior.
  • GET /api/v1/estadisticas — resumen global y ranking por provincia.
  • GET /health — estado y última ingesta, con la marca stale.

Lo que me llevo de esta capa

  • Elige el aburrimiento cuando puedas. No usar PostGIS fue elegir menos potencia a cambio de menos cosas que mantener. Para este problema fue la decisión correcta. La sofisticación que no necesitas es deuda.
  • Una sola fuente de verdad para la lógica de negocio. Que API y web compartan consulta.rs significa que no pueden contradecirse.
  • Empuja el filtrado barato a la base de datos y reserva el CPU para lo fino. Bounding-box + haversine es ese principio hecho código.

En el siguiente artículo salto al cliente: la app móvil. Por qué Tauri, el código de color relativo, y cómo conseguí el flujo de "una sola pulsación hasta la ruta".