RustActix-WebAxumGeolocationTutorial

Rust IP Geolocation: reqwest, Actix-Web and Axum Examples

ApogeoAPI6 min read

Cargo.toml dependencies

[dependencies]
reqwest  = { version = "0.11", features = ["json"] }
serde    = { version = "1", features = ["derive"] }
tokio    = { version = "1", features = ["full"] }
# For caching
dashmap  = "5"
# For Actix-Web integration
actix-web = { version = "4", optional = true }
# For Axum integration
axum = { version = "0.7", optional = true }

Response struct

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GeoData {
    pub ip: String,
    pub country_code: String,
    pub country_name: Option<String>,
    pub city: Option<String>,
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    pub currency_code: Option<String>,
    pub timezone: Option<String>,
}

GeoService with async caching

use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;

#[derive(Clone)]
pub struct GeoService {
    client:  reqwest::Client,
    api_key: String,
    cache:   Arc<DashMap<String, (GeoData, Instant)>>,
    ttl:     Duration,
}

impl GeoService {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            client:  reqwest::Client::new(),
            api_key: api_key.into(),
            cache:   Arc::new(DashMap::new()),
            ttl:     Duration::from_secs(3600),
        }
    }

    /// Cache by /24 subnet to reduce API calls ~250x
    fn subnet(ip: &str) -> String {
        ip.splitn(4, '.').take(3).collect::<Vec<_>>().join(".")
    }

    pub async fn lookup(&self, ip: &str) -> anyhow::Result<GeoData> {
        let key = Self::subnet(ip);

        if let Some(entry) = self.cache.get(&key) {
            if entry.1.elapsed() < self.ttl {
                return Ok(entry.0.clone());
            }
        }

        let data: GeoData = self.client
            .get(format!("https://api.apogeoapi.com/v1/ip/{ip}"))
            .bearer_auth(&self.api_key)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await?;

        self.cache.insert(key, (data.clone(), Instant::now()));
        Ok(data)
    }
}

Actix-Web middleware

use actix_web::{web, HttpRequest, HttpResponse};

pub async fn pricing_handler(
    req: HttpRequest,
    geo: web::Data<GeoService>,
) -> HttpResponse {
    // Extract real IP from reverse-proxy header
    let ip = req
        .headers()
        .get("X-Forwarded-For")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.split(',').next())
        .map(str::trim)
        .unwrap_or("0.0.0.0");

    match geo.lookup(ip).await {
        Ok(data) => {
            let (currency, amount): (&str, u32) = match data.country_code.as_str() {
                "US" => ("USD", 29),
                "GB" => ("GBP", 25),
                "DE" | "FR" | "ES" | "IT" => ("EUR", 27),
                "AU" | "NZ" => ("AUD", 45),
                "BR" => ("BRL", 149),
                _ => ("USD", 29),
            };
            HttpResponse::Ok().json(serde_json::json!({
                "country": data.country_code,
                "currency": currency,
                "amount": amount,
                "city": data.city,
            }))
        }
        Err(_) => HttpResponse::Ok().json(serde_json::json!({
            "country": "US", "currency": "USD", "amount": 29
        })),
    }
}

// Register in main:
// App::new()
//   .app_data(web::Data::new(GeoService::new(api_key)))
//   .route("/pricing", web::get().to(pricing_handler))

Axum extractor

use axum::{extract::{Extension, ConnectInfo}, Json};
use std::net::SocketAddr;

pub async fn geo_handler(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    Extension(geo): Extension<GeoService>,
    headers: axum::http::HeaderMap,
) -> Json<serde_json::Value> {
    let ip = headers
        .get("X-Forwarded-For")
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.split(',').next())
        .map(str::trim)
        .unwrap_or(&addr.ip().to_string())
        .to_string();

    match geo.lookup(&ip).await {
        Ok(data) => Json(serde_json::json!({
            "country": data.country_code,
            "city":    data.city,
            "tz":      data.timezone,
        })),
        Err(_) => Json(serde_json::json!({ "country": "US" })),
    }
}

// Register in main:
// let app = Router::new()
//   .route("/geo", get(geo_handler))
//   .layer(Extension(GeoService::new(api_key)))

Error handling and timeouts

// Add timeout to the reqwest client
let client = reqwest::Client::builder()
    .timeout(Duration::from_secs(3))
    .build()?;

// In the lookup method, map errors to a default instead of propagating:
pub async fn lookup_safe(&self, ip: &str) -> GeoData {
    self.lookup(ip).await.unwrap_or(GeoData {
        ip: ip.to_string(),
        country_code: "US".to_string(),
        ..Default::default()
    })
}

Resources

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key