//! 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(¶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
}
/// 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(¶ms)
.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)
}
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: 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 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.
20.1 KiB · text
History
6dd7389