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
- ApogeoAPI IP Geolocation — async-friendly, free tier
- reqwest docs
- DashMap — concurrent HashMap for Rust
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key