This commit is contained in:
2026-02-28 22:29:19 +00:00
commit 53b7fcde22
4 changed files with 2233 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

1896
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "woosmaps_fallback"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenv = "0.15"

323
src/main.rs Normal file
View File

@@ -0,0 +1,323 @@
use axum::{
extract::Query,
http::StatusCode,
response::{IntoResponse, Json},
routing::get,
Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::time::Duration;
use dotenv::dotenv;
use std::env;
#[derive(Debug, Deserialize)]
struct LookupParams {
query: Option<String>,
country: Option<String>,
lat: Option<f64>,
lon: Option<f64>,
}
async fn search_with_google(
client: &reqwest::Client,
query: &str,
api_key: &str,
country: Option<String>,
lat: Option<f64>,
lon: Option<f64>,
) -> Result<Value, String> {
let url = "https://maps.googleapis.com/maps/api/geocode/json";
let mut params = vec![
("address", query.to_string()),
("key", api_key.to_string()),
];
if let Some(c) = country {
params.push(("components", format!("country:{}", c)));
}
// If lat/lon provided, create a bounding box to bias results toward that location
// Using ~0.5 degree radius (~55km at equator) for the bounds
if let (Some(lat), Some(lon)) = (lat, lon) {
let lat_delta = 0.5;
let lon_delta = 0.5;
let bounds = format!(
"{},{}|{},{}",
lat - lat_delta,
lon - lon_delta,
lat + lat_delta,
lon + lon_delta
);
params.push(("bounds", bounds));
}
let response = client
.get(url)
.query(&params)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("Failed reading response: {}", e))?;
println!("Google Geocoding raw response → {}", text);
if !status.is_success() {
return Err(format!("HTTP {}{}", status, text));
}
let json: Value =
serde_json::from_str(&text)
.map_err(|e| format!("JSON parse error: {}", e))?;
if json["status"] != "OK" && json["status"] != "ZERO_RESULTS" {
return Err(format!("Google API error → {}", json));
}
Ok(json)
}
async fn search_with_places(
client: &reqwest::Client,
query: &str,
api_key: &str,
lat: Option<f64>,
lon: Option<f64>,
) -> Result<Value, String> {
let url = "https://maps.googleapis.com/maps/api/place/textsearch/json";
let mut params = vec![
("query", query.to_string()),
("key", api_key.to_string()),
];
// If lat/lon provided, use them to bias results with a 50km radius
if let (Some(lat), Some(lon)) = (lat, lon) {
params.push(("location", format!("{},{}", lat, lon)));
params.push(("radius", "50000".to_string())); // 50km radius
}
let response = client
.get(url)
.query(&params)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("Failed reading response: {}", e))?;
println!("Google Places raw response → {}", text);
if !status.is_success() {
return Err(format!("HTTP {}{}", status, text));
}
let json: Value =
serde_json::from_str(&text)
.map_err(|e| format!("JSON parse error: {}", e))?;
if json["status"] != "OK" && json["status"] != "ZERO_RESULTS" {
return Err(format!("Google Places API error → {}", json));
}
Ok(json)
}
async fn get_place_details(
client: &reqwest::Client,
place_id: &str,
api_key: &str,
) -> Result<Value, String> {
let url = "https://maps.googleapis.com/maps/api/place/details/json";
let params = vec![
("place_id", place_id.to_string()),
("key", api_key.to_string()),
// Request specific fields to get contact information
("fields", "formatted_phone_number,international_phone_number,website,opening_hours,url".to_string()),
];
let response = client
.get(url)
.query(&params)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
let status = response.status();
let text = response
.text()
.await
.map_err(|e| format!("Failed reading response: {}", e))?;
println!("Place Details raw response for {}{}", place_id, text);
if !status.is_success() {
return Err(format!("HTTP {}{}", status, text));
}
let json: Value =
serde_json::from_str(&text)
.map_err(|e| format!("JSON parse error: {}", e))?;
if json["status"] != "OK" {
return Err(format!("Place Details API error → {}", json));
}
Ok(json)
}
async fn lookup(Query(params): Query<LookupParams>) -> impl IntoResponse {
let query = match params.query {
Some(q) => q,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "Missing query parameter"})),
)
.into_response();
}
};
let api_key = env::var("GOOGLE_API_KEY")
.expect("GOOGLE_API_KEY env var not set");
// Force IPv4-only for all outbound requests
let client = reqwest::Client::builder()
.local_address(Some(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)))
.build()
.unwrap();
// Call both Geocoding and Places APIs in parallel
let (geocoding_result, places_result) = tokio::join!(
search_with_google(
&client,
&query,
&api_key,
params.country.clone(),
params.lat,
params.lon,
),
search_with_places(
&client,
&query,
&api_key,
params.lat,
params.lon,
)
);
// Extract geocoding results
let geocoding_data = match geocoding_result {
Ok(data) => {
data.get("results")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default()
}
Err(e) => {
println!("Geocoding error: {}", e);
vec![]
}
};
// Extract places results
let mut places_data = match places_result {
Ok(data) => {
data.get("results")
.and_then(|r| r.as_array())
.cloned()
.unwrap_or_default()
}
Err(e) => {
println!("Places error: {}", e);
vec![]
}
};
// Enrich places with contact details (limit to first 5 to avoid too many API calls)
let places_to_enrich: Vec<_> = places_data
.iter()
.take(5)
.filter_map(|place| {
place.get("place_id")
.and_then(|id| id.as_str())
.map(|id| id.to_string())
})
.collect();
// Fetch place details for each place sequentially (could be parallelized with tokio::spawn)
for (idx, place_id) in places_to_enrich.iter().enumerate() {
if let Ok(detail) = get_place_details(&client, place_id, &api_key).await {
if let Some(result) = detail.get("result") {
// Get the place object to enrich
if let Some(place) = places_data.get_mut(idx) {
if let Some(place_obj) = place.as_object_mut() {
// Add contact details
if let Some(phone) = result.get("formatted_phone_number") {
place_obj.insert("phone".to_string(), phone.clone());
}
if let Some(intl_phone) = result.get("international_phone_number") {
place_obj.insert("international_phone".to_string(), intl_phone.clone());
}
if let Some(website) = result.get("website") {
place_obj.insert("website".to_string(), website.clone());
}
if let Some(url) = result.get("url") {
place_obj.insert("google_maps_url".to_string(), url.clone());
}
if let Some(hours) = result.get("opening_hours") {
place_obj.insert("opening_hours".to_string(), hours.clone());
}
}
}
}
}
}
// Return both results
(StatusCode::OK, Json(json!({
"geocoding": {
"count": geocoding_data.len(),
"results": geocoding_data
},
"places": {
"count": places_data.len(),
"results": places_data
}
}))).into_response()
}
#[tokio::main]
async fn main() {
dotenv().ok();
let app = Router::new().route("/lookup", get(lookup));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("Failed to bind port");
println!("Server running → http://0.0.0.0:3000");
axum::serve(listener, app)
.await
.expect("Server failed");
}