//! 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(¶ms)
.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(¶ms)
.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(¶ms)
.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);
}
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
11.7 KiB · text
History
25c4237