23.6 KiB · text History 3d2ee7c
//! 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.
//!
//! Either way, [`OsmClient::enrich`] then fills each candidate's cuisine,
//! website, address and opening hours from its OSM tags: `/poi/tags` (a lookup near a known
//! position, fast even when `/poi/nearby` isn't), then the public OSM API for
//! whatever the import doesn't have — places added since, typically.
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 opening_hours: Option<String>,
    pub distance_m: Option<i64>,
    /// Already built from the element's tags, so [`OsmClient::enrich`] has
    /// nothing to add.
    #[serde(skip)]
    pub tagged: bool,
}

pub type Tags = HashMap<String, String>;

/// An OSM element and roughly where it is (`/poi/tags` needs the position).
#[derive(Debug, Clone)]
pub struct ElementRef {
    pub osm_ref: String,
    pub lat: f64,
    pub lon: f64,
}

/// What we take from an OSM element's tags.
#[derive(Debug, Default, PartialEq)]
pub struct Details {
    pub kind: Option<String>,
    pub cuisine: Option<String>,
    pub website: Option<String>,
    pub address: Option<String>,
    pub opening_hours: Option<String>,
}

pub fn details(tags: &Tags) -> Details {
    let tag = |k: &str| 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(", ");
    Details {
        kind: tag("amenity"),
        // "indian;nepalese" / "fish_and_chips" → "indian, nepalese" / "fish and chips"
        cuisine: tag("cuisine").map(|c| c.replace(';', ", ").replace('_', " ")),
        website: tag("website").or_else(|| tag("contact:website")),
        address: Some(address).filter(|a| !a.is_empty()),
        opening_hours: tag("opening_hours"),
    }
}

impl Place {
    /// Take what the tags know, keeping anything already set.
    fn fill(&mut self, d: Details) {
        self.kind = self.kind.take().or(d.kind);
        self.cuisine = self.cuisine.take().or(d.cuisine);
        self.website = self.website.take().or(d.website);
        self.address = self.address.take().or(d.address);
        self.opening_hours = self.opening_hours.take().or(d.opening_hours);
        self.tagged = true;
    }
}

/// `N123` → (`node`, 123), as the OSM API names element types.
fn split_ref(osm_ref: &str) -> Option<(&'static str, i64)> {
    let kind = match osm_ref.get(..1)? {
        "N" => "node",
        "W" => "way",
        "R" => "relation",
        _ => return None,
    };
    Some((kind, osm_ref[1..].parse().ok()?))
}

#[derive(Clone)]
pub struct OsmClient {
    http: reqwest::Client,
    geocode_url: Option<String>,
    poi_url: Option<String>,
    osm_api_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, osm_api_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),
            osm_api_url: url(osm_api_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, None).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
    }

    /// Fill cuisine, website, address and hours from OSM tags for every place
    /// that has an OSM ref and wasn't built from tags already. Best effort: a
    /// failed lookup leaves places as they were.
    pub async fn enrich(&self, places: &mut [Place]) {
        let wanted = |p: &Place| !p.tagged && p.osm_ref.is_some();
        let refs: Vec<ElementRef> = places
            .iter()
            .filter(|p| wanted(p))
            .filter_map(|p| Some(ElementRef { osm_ref: p.osm_ref.clone()?, lat: p.lat, lon: p.lon }))
            .collect();
        if refs.is_empty() {
            return;
        }
        let mut found = self.tags_for(&refs).await;
        for p in places.iter_mut().filter(|p| wanted(p)) {
            if let Some(tags) = p.osm_ref.as_ref().and_then(|r| found.remove(r)) {
                p.fill(details(&tags));
            }
        }
    }

    /// Current tags for each element, keyed by ref: `/poi/tags` first, then
    /// the OSM API for refs the tile server's import doesn't have. Refs
    /// neither knows are left out; failures are logged and skipped.
    pub async fn tags_for(&self, refs: &[ElementRef]) -> HashMap<String, Tags> {
        let mut found = HashMap::new();
        if self.poi_url.is_some() {
            for chunk in refs.chunks(50) {
                match self.poi_tags(chunk).await {
                    Ok(tags) => found.extend(tags),
                    Err(e) => {
                        warn!(error = %e, "poi tags failed");
                        break;
                    }
                }
            }
        }
        let missing: Vec<&ElementRef> = refs.iter().filter(|r| !found.contains_key(&r.osm_ref)).collect();
        if self.osm_api_url.is_some() && !missing.is_empty() {
            for chunk in missing.chunks(100) {
                match self.osm_api_tags(chunk.iter().copied()).await {
                    Ok(tags) => found.extend(tags),
                    Err(e) => {
                        warn!(error = %e, "osm api tags failed");
                        break;
                    }
                }
            }
        }
        found
    }

    /// `/poi/tags` looks elements up near a position (the render database has
    /// no index on osm_id), which every element we track has.
    async fn poi_tags(&self, refs: &[ElementRef]) -> Result<HashMap<String, Tags>> {
        let base = self.poi_url.as_deref().unwrap_or_default();
        let params: Vec<(&str, String)> =
            refs.iter().map(|r| ("ref", format!("{}@{},{}", r.osm_ref, r.lat, r.lon))).collect();
        let body: PoiTags = self
            .http
            .get(format!("{base}/tags"))
            .query(&params)
            .send()
            .await?
            .error_for_status()?
            .json()
            .await
            .context("decoding poi tags")?;
        Ok(body.tags)
    }

    /// The live OSM API: one multi-fetch per element type. Interactive and a
    /// few dozen elements at most, so well inside its usage policy.
    async fn osm_api_tags<'a>(
        &self,
        refs: impl Iterator<Item = &'a ElementRef>,
    ) -> Result<HashMap<String, Tags>> {
        let base = self.osm_api_url.as_deref().unwrap_or_default();
        let mut ids: HashMap<&str, Vec<String>> = HashMap::new();
        for (kind, id) in refs.filter_map(|r| split_ref(&r.osm_ref)) {
            ids.entry(kind).or_default().push(id.to_string());
        }
        let mut found = HashMap::new();
        for (kind, ids) in ids {
            let body: OsmElements = self
                .http
                .get(format!("{base}/api/0.6/{kind}s.json"))
                .query(&[(format!("{kind}s"), ids.join(","))])
                .timeout(Duration::from_secs(4))
                .send()
                .await?
                .error_for_status()?
                .json()
                .await
                .context("decoding osm api elements")?;
            for e in body.elements {
                let prefix = match e.kind.as_str() {
                    "node" => "N",
                    "way" => "W",
                    "relation" => "R",
                    _ => continue,
                };
                found.insert(format!("{prefix}{}", e.id), e.tags);
            }
        }
        Ok(found)
    }

    /// Places with this cuisine tag within `radius_m` of a point, nearest
    /// first, with their tags. `/poi/nearby` filters by cuisine directly;
    /// when it's unavailable, a Photon search for the cuisine word near the
    /// point stands in — it only finds places with the word in their name,
    /// so their tags are then checked to keep just the ones really tagged.
    pub async fn similar(&self, lat: f64, lon: f64, radius_m: u32, cuisines: &[String]) -> Vec<Place> {
        let mut found: Vec<Place> = vec![];
        for cuisine in cuisines.iter().take(3) {
            let mut from_poi = None;
            if self.poi_url.is_some() && now_secs() >= self.poi_skip_until.load(Ordering::Relaxed) {
                match self.poi_nearby(lat, lon, radius_m, Some(cuisine)).await {
                    Ok(p) => from_poi = Some(p),
                    Err(e) => {
                        warn!(error = %e, backoff_s = POI_BACKOFF.as_secs(), "poi similar failed; using photon");
                        self.poi_skip_until.store(now_secs() + POI_BACKOFF.as_secs(), Ordering::Relaxed);
                    }
                }
            }
            let places = match from_poi {
                Some(p) => p,
                None => {
                    let mut p = self.search(cuisine, Some((lat, lon)), true).await.unwrap_or_else(|e| {
                        warn!(error = %e, "photon similar search failed");
                        vec![]
                    });
                    self.enrich(&mut p).await;
                    p.retain(|p| {
                        crate::entities::restaurants::cuisine_tags(p.cuisine.as_deref().unwrap_or_default())
                            .contains(cuisine)
                    });
                    p
                }
            };
            found.extend(places);
        }
        let mut seen = std::collections::HashSet::new();
        found.retain(|p| p.osm_ref.as_ref().is_none_or(|r| seen.insert(r.clone())));
        by_distance(&mut found, lat, lon, Some(radius_m as f64));
        found
    }

    async fn poi_nearby(&self, lat: f64, lon: f64, radius_m: u32, cuisine: Option<&str>) -> 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()),
        ];
        if let Some(c) = cuisine {
            // The tag as stored in OSM: "fish and chips" → "fish_and_chips".
            params.push(("cuisine", c.replace(' ', "_")));
        }
        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();
        by_distance(&mut places, lat, lon, None);
        Ok(places)
    }
}

/// Fill in each place's distance from a point, drop any beyond `max_m`, and
/// sort nearest first.
pub fn by_distance(places: &mut Vec<Place>, lat: f64, lon: f64, max_m: Option<f64>) {
    for p in places.iter_mut() {
        p.distance_m = Some(haversine_m(lat, lon, p.lat, p.lon).round() as i64);
    }
    if let Some(max) = max_m {
        places.retain(|p| p.distance_m.is_some_and(|d| d as f64 <= max));
    }
    places.sort_by_key(|p| p.distance_m);
}

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: Tags,
}

impl PoiPlace {
    fn into_place(self) -> Place {
        let d = details(&self.tags);
        Place {
            kind: d.kind,
            cuisine: d.cuisine,
            website: d.website,
            address: d.address,
            opening_hours: d.opening_hours,
            osm_ref: Some(self.osm_ref),
            name: self.name,
            lat: self.lat,
            lon: self.lon,
            distance_m: self.distance_m,
            tagged: true,
        }
    }
}

#[derive(Deserialize)]
struct PoiTags {
    tags: HashMap<String, Tags>,
}

#[derive(Deserialize)]
struct OsmElements {
    elements: Vec<OsmElement>,
}

#[derive(Deserialize)]
struct OsmElement {
    #[serde(rename = "type")]
    kind: String,
    id: i64,
    #[serde(default)]
    tags: Tags,
}

#[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,
            opening_hours: None,
            distance_m: None,
            tagged: false,
        })
    }
}

#[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 fill_keeps_what_is_set_and_takes_the_rest() {
        let mut p = Place {
            osm_ref: Some("N1".into()),
            name: "x".into(),
            lat: 0.0,
            lon: 0.0,
            kind: Some("restaurant".into()),
            cuisine: None,
            address: Some("from photon".into()),
            website: None,
            opening_hours: None,
            distance_m: None,
            tagged: false,
        };
        let tags: HashMap<String, String> = [
            ("cuisine", "fish_and_chips"),
            ("contact:website", "https://example.com"),
            ("addr:street", "Main St"),
            ("opening_hours", "Mo-Fr 11:00-15:00"),
        ]
        .into_iter()
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect();
        p.fill(details(&tags));
        assert_eq!(p.cuisine.as_deref(), Some("fish and chips"));
        assert_eq!(p.website.as_deref(), Some("https://example.com"));
        assert_eq!(p.address.as_deref(), Some("from photon"));
        assert_eq!(p.opening_hours.as_deref(), Some("Mo-Fr 11:00-15:00"));
        assert!(p.tagged);
    }

    #[test]
    fn osm_api_elements_parse() {
        let json = r#"{"version":"0.6","elements":[{"type":"node","id":12020644552,"lat":42.3,"lon":-71.0,
            "tags":{"amenity":"restaurant","website":"https://www.cocobeet.com/"}},{"type":"way","id":7}]}"#;
        let e: OsmElements = serde_json::from_str(json).unwrap();
        assert_eq!(e.elements[0].tags["website"], "https://www.cocobeet.com/");
        assert!(e.elements[1].tags.is_empty());
        assert_eq!(split_ref("W29560269"), Some(("way", 29560269)));
        assert_eq!(split_ref("X1"), None);
    }

    #[test]
    fn by_distance_sorts_and_drops_the_far_side_of_the_world() {
        let at = |name: &str, lat: f64, lon: f64| Place {
            osm_ref: None,
            name: name.into(),
            lat,
            lon,
            kind: None,
            cuisine: None,
            address: None,
            website: None,
            opening_hours: None,
            distance_m: None,
            tagged: false,
        };
        // From Boston City Hall.
        let mut places = vec![
            at("everett", 42.4144, -71.0467),
            at("london", 51.5072, -0.1276),
            at("downtown", 42.3594, -71.0590),
        ];
        by_distance(&mut places, 42.3601, -71.0589, Some(50_000.0));
        let names: Vec<&str> = places.iter().map(|p| p.name.as_str()).collect();
        assert_eq!(names, ["downtown", "everett"]);
        assert!(places[0].distance_m.unwrap() < 100);
    }

    #[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);
    }
}