11.7 KiB · text History 25c4237
//! Client for the osm-tiles stack's two JSON APIs (see
//! `deployment/argo-01-bos/osm-tiles/README.md`):
//!
//! * `/geocode/` — Photon. Finds places **by name**, and reverse-geocodes.
//! * `/poi/` — tags straight from the render database. Finds places **by what
//!   they are** and carries `cuisine`, `website`, `opening_hours`.
//!
//! Both are called server-side. That sidesteps the referer allowlist (no
//! `Referer` is let through) and lets handlers return htmx fragments rather
//! than shipping JSON to the browser.
//!
//! `/poi/nearby` has a hard 4 s statement timeout upstream and answers 504
//! when it trips, so "what's here?" falls back to Photon's reverse geocoder,
//! which knows names and kinds but not cuisine.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing::warn;

/// After `/poi/` fails, go straight to Photon for this long. A broken POI
/// sidecar times out at 4 s, which is otherwise paid on every map click.
const POI_BACKOFF: Duration = Duration::from_secs(300);

/// The OSM `amenity` values that count as somewhere to eat.
pub const FOOD_AMENITIES: [&str; 7] =
    ["restaurant", "fast_food", "cafe", "food_court", "pub", "bar", "ice_cream"];

/// A place someone might add, normalised from either API.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Place {
    pub osm_ref: Option<String>,
    pub name: String,
    pub lat: f64,
    pub lon: f64,
    pub kind: Option<String>,
    pub cuisine: Option<String>,
    pub address: Option<String>,
    pub website: Option<String>,
    pub distance_m: Option<i64>,
}

#[derive(Clone)]
pub struct OsmClient {
    http: reqwest::Client,
    geocode_url: Option<String>,
    poi_url: Option<String>,
    /// Unix seconds before which `/poi/` is skipped; see [`POI_BACKOFF`].
    poi_skip_until: Arc<AtomicU64>,
}

fn now_secs() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}

impl OsmClient {
    /// Empty URLs turn that API off.
    pub fn new(geocode_url: &str, poi_url: &str) -> Result<Self> {
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(8))
            .user_agent(concat!("fieri-eternal/", env!("CARGO_PKG_VERSION")))
            .build()?;
        let url = |s: &str| Some(s.trim_end_matches('/').to_string()).filter(|s| !s.is_empty());
        Ok(Self {
            http,
            geocode_url: url(geocode_url),
            poi_url: url(poi_url),
            poi_skip_until: Arc::new(AtomicU64::new(0)),
        })
    }

    /// Search by name, biased toward (but not limited to) `near`. `food_only`
    /// restricts to eateries; off, it finds anything — used for picking an
    /// area's centre ("Austin", "1 Main St").
    pub async fn search(&self, q: &str, near: Option<(f64, f64)>, food_only: bool) -> Result<Vec<Place>> {
        let Some(base) = &self.geocode_url else { return Ok(vec![]) };
        let mut params: Vec<(&str, String)> = vec![("q", q.to_string()), ("limit", "12".into())];
        if let Some((lat, lon)) = near {
            // Photon's default bias is weak enough that "pizza" in Boston
            // lists the next town over first; pure distance at street zoom
            // keeps results walkable.
            params.push(("lat", lat.to_string()));
            params.push(("lon", lon.to_string()));
            params.push(("zoom", "16".into()));
            params.push(("location_bias_scale", "0".into()));
        }
        if food_only {
            params.extend(FOOD_AMENITIES.iter().map(|a| ("osm_tag", format!("amenity:{a}"))));
        }
        let body: PhotonCollection = self
            .http
            .get(format!("{base}/api"))
            .query(&params)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await
            .context("decoding photon search")?;
        Ok(body.features.into_iter().filter_map(PhotonFeature::into_place).collect())
    }

    /// Eateries within `radius_m` of a point, nearest first. Tries `/poi/`
    /// (it has cuisine) and falls back to Photon reverse on any failure.
    pub async fn nearby(&self, lat: f64, lon: f64, radius_m: u32) -> Result<Vec<Place>> {
        if self.poi_url.is_some() && now_secs() >= self.poi_skip_until.load(Ordering::Relaxed) {
            match self.poi_nearby(lat, lon, radius_m).await {
                Ok(places) => return Ok(places),
                Err(e) => {
                    warn!(error = %e, backoff_s = POI_BACKOFF.as_secs(), "poi nearby failed; using photon reverse");
                    self.poi_skip_until.store(now_secs() + POI_BACKOFF.as_secs(), Ordering::Relaxed);
                }
            }
        }
        self.photon_reverse(lat, lon, radius_m).await
    }

    async fn poi_nearby(&self, lat: f64, lon: f64, radius_m: u32) -> Result<Vec<Place>> {
        let base = self.poi_url.as_deref().unwrap_or_default();
        let mut params: Vec<(&str, String)> = vec![
            ("lat", lat.to_string()),
            ("lon", lon.to_string()),
            ("radius", radius_m.to_string()),
            ("limit", "25".into()),
        ];
        params.extend(FOOD_AMENITIES.iter().map(|a| ("tag", format!("amenity:{a}"))));
        let body: PoiNearby = self
            .http
            .get(format!("{base}/nearby"))
            .query(&params)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await
            .context("decoding poi nearby")?;
        Ok(body.places.into_iter().map(PoiPlace::into_place).collect())
    }

    async fn photon_reverse(&self, lat: f64, lon: f64, radius_m: u32) -> Result<Vec<Place>> {
        let Some(base) = &self.geocode_url else { return Ok(vec![]) };
        let mut params: Vec<(&str, String)> = vec![
            ("lat", lat.to_string()),
            ("lon", lon.to_string()),
            ("limit", "15".into()),
            // Photon's radius is in kilometres.
            ("radius", format!("{:.3}", radius_m as f64 / 1000.0)),
        ];
        params.extend(FOOD_AMENITIES.iter().map(|a| ("osm_tag", format!("amenity:{a}"))));
        let body: PhotonCollection = self
            .http
            .get(format!("{base}/reverse"))
            .query(&params)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await
            .context("decoding photon reverse")?;
        let mut places: Vec<Place> = body.features.into_iter().filter_map(PhotonFeature::into_place).collect();
        for p in &mut places {
            p.distance_m = Some(haversine_m(lat, lon, p.lat, p.lon).round() as i64);
        }
        places.sort_by_key(|p| p.distance_m);
        Ok(places)
    }
}

pub fn haversine_m(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
    let (p1, p2) = (lat1.to_radians(), lat2.to_radians());
    let dp = p2 - p1;
    let dl = (lon2 - lon1).to_radians();
    let a = (dp / 2.0).sin().powi(2) + p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2);
    6_371_000.0 * 2.0 * a.sqrt().asin()
}

#[derive(Deserialize)]
struct PoiNearby {
    places: Vec<PoiPlace>,
}

#[derive(Deserialize)]
struct PoiPlace {
    osm_ref: String,
    name: String,
    lat: f64,
    lon: f64,
    distance_m: Option<i64>,
    #[serde(default)]
    tags: HashMap<String, String>,
}

impl PoiPlace {
    fn into_place(self) -> Place {
        let tag = |k: &str| self.tags.get(k).cloned().filter(|v| !v.is_empty());
        let street = [tag("addr:housenumber"), tag("addr:street")].into_iter().flatten().collect::<Vec<_>>().join(" ");
        let address = [Some(street).filter(|s| !s.is_empty()), tag("addr:city")]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join(", ");
        Place {
            kind: tag("amenity"),
            cuisine: tag("cuisine").map(|c| c.replace(';', ", ").replace('_', " ")),
            website: tag("website").or_else(|| tag("contact:website")),
            address: Some(address).filter(|a| !a.is_empty()),
            osm_ref: Some(self.osm_ref),
            name: self.name,
            lat: self.lat,
            lon: self.lon,
            distance_m: self.distance_m,
        }
    }
}

#[derive(Deserialize)]
struct PhotonCollection {
    features: Vec<PhotonFeature>,
}

#[derive(Deserialize)]
struct PhotonFeature {
    properties: PhotonProps,
    geometry: PhotonGeometry,
}

#[derive(Deserialize)]
struct PhotonGeometry {
    coordinates: (f64, f64),
}

#[derive(Deserialize)]
struct PhotonProps {
    osm_type: Option<String>,
    osm_id: Option<i64>,
    osm_key: Option<String>,
    osm_value: Option<String>,
    name: Option<String>,
    housenumber: Option<String>,
    street: Option<String>,
    city: Option<String>,
    state: Option<String>,
}

impl PhotonFeature {
    fn into_place(self) -> Option<Place> {
        let p = self.properties;
        let street = [p.housenumber, p.street].into_iter().flatten().collect::<Vec<_>>().join(" ");
        let address = [Some(street).filter(|s| !s.is_empty()), p.city.clone()]
            .into_iter()
            .flatten()
            .collect::<Vec<_>>()
            .join(", ");
        // Unnamed results are useless here, except a city or address hit,
        // which names itself by its locality.
        let name = p.name.or_else(|| Some(address.clone()).filter(|a| !a.is_empty()))?;
        let osm_ref = match (p.osm_type.as_deref(), p.osm_id) {
            (Some(t @ ("N" | "W" | "R")), Some(id)) => Some(format!("{t}{id}")),
            _ => None,
        };
        let (lon, lat) = self.geometry.coordinates;
        let kind = match (p.osm_key.as_deref(), p.osm_value) {
            (Some("amenity"), v) => v,
            (Some("place"), Some(v)) => Some(format!("{v}{}", p.state.map(|s| format!(", {s}")).unwrap_or_default())),
            (_, v) => v,
        };
        Some(Place {
            osm_ref,
            name,
            lat,
            lon,
            kind,
            cuisine: None,
            address: Some(address).filter(|a| !a.is_empty()),
            website: None,
            distance_m: None,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn photon_feature_normalises() {
        let json = r#"{"features":[{"properties":{"osm_type":"W","osm_id":29560269,"osm_key":"amenity",
            "osm_value":"restaurant","name":"Pizza Hut","housenumber":"7","street":"Sycamore Street",
            "city":"Everett"},"geometry":{"type":"Point","coordinates":[-71.0467525,42.4144179]}}]}"#;
        let c: PhotonCollection = serde_json::from_str(json).unwrap();
        let p = c.features.into_iter().next().unwrap().into_place().unwrap();
        assert_eq!(p.osm_ref.as_deref(), Some("W29560269"));
        assert_eq!(p.address.as_deref(), Some("7 Sycamore Street, Everett"));
        assert_eq!((p.lat, p.lon), (42.4144179, -71.0467525));
        assert_eq!(p.kind.as_deref(), Some("restaurant"));
    }

    #[test]
    fn poi_place_normalises() {
        let json = r#"{"places":[{"osm_ref":"N1","name":"Tandoor","lat":1.0,"lon":2.0,"distance_m":40,
            "tags":{"amenity":"restaurant","cuisine":"indian;nepalese","addr:street":"Main St",
            "addr:housenumber":"12","website":"https://example.com"}}]}"#;
        let n: PoiNearby = serde_json::from_str(json).unwrap();
        let p = n.places.into_iter().next().unwrap().into_place();
        assert_eq!(p.cuisine.as_deref(), Some("indian, nepalese"));
        assert_eq!(p.address.as_deref(), Some("12 Main St"));
        assert_eq!(p.website.as_deref(), Some("https://example.com"));
    }

    #[test]
    fn haversine_is_sane() {
        // ~111 km per degree of latitude.
        assert!((haversine_m(0.0, 0.0, 1.0, 0.0) - 111_195.0).abs() < 100.0);
    }
}