From 4cd12038642ed3402e75a7a3d5bda6831dfbbb16 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 15:56:28 +0000 Subject: [PATCH] feat(cafes): add nearby cafe search via Nominatim API - Add OSM infrastructure module with Nominatim forward search, viewbox location bias, and haversine distance calculation - Add reqwest::Client to AppState for server-side HTTP requests - Add GET /api/v1/nearby-cafes endpoint (authenticated) accepting lat, lng, and text query parameters - Check website, contact:website, url, contact:url, and brand:website tags to maximise website coverage from OSM data --- src/application/routes/cafes.rs | 30 ++++ src/application/routes/mod.rs | 1 + src/application/server.rs | 4 + src/infrastructure/mod.rs | 1 + src/infrastructure/osm.rs | 304 ++++++++++++++++++++++++++++++++ tests/server/helpers.rs | 1 + 6 files changed, 341 insertions(+) create mode 100644 src/infrastructure/osm.rs diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index 8f7eabc..11860e8 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -2,6 +2,7 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Redirect, Response}; +use serde::Deserialize; use super::macros::{define_delete_handler, define_get_handler}; use crate::application::auth::AuthenticatedUser; @@ -14,6 +15,7 @@ use crate::application::server::AppState; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::ids::CafeId; use crate::domain::listing::{ListRequest, SortDirection}; +use crate::infrastructure::osm::{self, NearbyCafe}; use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate}; use crate::presentation::web::views::{CafeView, ListNavigator, Paginated}; @@ -191,3 +193,31 @@ async fn render_cafe_list_fragment( crate::application::routes::support::render_fragment(template, "#cafe-list") } + +#[derive(Debug, Deserialize)] +pub struct NearbyQuery { + lat: f64, + lng: f64, + q: String, +} + +#[tracing::instrument(skip(state, _auth_user))] +pub(crate) async fn nearby_cafes( + State(state): State, + _auth_user: AuthenticatedUser, + Query(query): Query, +) -> Result>, ApiError> { + if !(-90.0..=90.0).contains(&query.lat) || !(-180.0..=180.0).contains(&query.lng) { + return Err(AppError::validation("lat must be -90..90, lng must be -180..180").into()); + } + + let q = query.q.trim(); + if q.is_empty() || q.len() < 2 { + return Err(AppError::validation("q must be at least 2 characters").into()); + } + + let cafes = osm::search_nearby(&state.http_client, query.lat, query.lng, q) + .await + .map_err(ApiError::from)?; + Ok(Json(cafes)) +} diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs index b1623aa..977825c 100644 --- a/src/application/routes/mod.rs +++ b/src/application/routes/mod.rs @@ -73,6 +73,7 @@ pub fn app_router(state: AppState) -> axum::Router { .put(cafes::update_cafe) .delete(cafes::delete_cafe), ) + .route("/nearby-cafes", get(cafes::nearby_cafes)) .route( "/tokens", post(tokens::create_token).get(tokens::list_tokens), diff --git a/src/application/server.rs b/src/application/server.rs index 50a84f0..c4dc51f 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -45,6 +45,7 @@ pub struct AppState { pub user_repo: Arc, pub token_repo: Arc, pub session_repo: Arc, + pub http_client: reqwest::Client, } impl AppState { @@ -60,6 +61,7 @@ impl AppState { user_repo: Arc, token_repo: Arc, session_repo: Arc, + http_client: reqwest::Client, ) -> Self { Self { roaster_repo, @@ -72,6 +74,7 @@ impl AppState { user_repo, token_repo, session_repo, + http_client, } } } @@ -110,6 +113,7 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { user_repo, token_repo, session_repo, + reqwest::Client::new(), ); let listener = TcpListener::bind(config.bind_address) diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 6dfba9b..3e8d185 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -2,4 +2,5 @@ pub mod auth; pub mod backup; pub mod client; pub mod database; +pub mod osm; pub mod repositories; diff --git a/src/infrastructure/osm.rs b/src/infrastructure/osm.rs new file mode 100644 index 0000000..6246c63 --- /dev/null +++ b/src/infrastructure/osm.rs @@ -0,0 +1,304 @@ +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::application::errors::AppError; + +const NOMINATIM_SEARCH_URL: &str = "https://nominatim.openstreetmap.org/search"; +const USER_AGENT: &str = "Brewlog/1.0"; +const MAX_RESULTS: &str = "8"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +/// Viewbox half-size in degrees (~11 km at equator, tighter at higher latitudes). +const VIEWBOX_DELTA: f64 = 0.1; + +#[derive(Debug, Clone, Serialize)] +pub struct NearbyCafe { + pub name: String, + pub latitude: f64, + pub longitude: f64, + pub city: String, + pub country: String, + pub website: Option, + pub distance_meters: u32, +} + +/// Searches for places matching `query` near the given coordinates via Nominatim. +/// Results are biased towards (but not restricted to) the user's location. +pub async fn search_nearby( + client: &reqwest::Client, + lat: f64, + lng: f64, + query: &str, +) -> Result, AppError> { + let viewbox = format!( + "{},{},{},{}", + lng - VIEWBOX_DELTA, + lat + VIEWBOX_DELTA, + lng + VIEWBOX_DELTA, + lat - VIEWBOX_DELTA, + ); + + let response = client + .get(NOMINATIM_SEARCH_URL) + .header("User-Agent", USER_AGENT) + .timeout(REQUEST_TIMEOUT) + .query(&[ + ("q", query), + ("format", "json"), + ("limit", MAX_RESULTS), + ("addressdetails", "1"), + ("namedetails", "1"), + ("extratags", "1"), + ("viewbox", &viewbox), + ("bounded", "0"), + ]) + .send() + .await + .map_err(|e| AppError::unexpected(format!("Nominatim search failed: {e}")))?; + + if !response.status().is_success() { + return Err(AppError::unexpected(format!( + "Nominatim returned status {}", + response.status() + ))); + } + + let results: Vec = response + .json() + .await + .map_err(|e| AppError::unexpected(format!("Failed to parse Nominatim response: {e}")))?; + + let cafes = results + .into_iter() + .filter_map(|r| { + let result_lat: f64 = r.lat.parse().ok()?; + let result_lng: f64 = r.lon.parse().ok()?; + + let name = r.namedetails.and_then(|nd| nd.name).unwrap_or_else(|| { + // Fall back to text before first comma in display_name + r.display_name + .split(',') + .next() + .unwrap_or(&r.display_name) + .trim() + .to_string() + }); + + if name.is_empty() { + return None; + } + + let address = r.address.unwrap_or_default(); + + let city = address + .city + .or(address.town) + .or(address.village) + .unwrap_or_default(); + + let website = r + .extratags + .and_then(|tags| { + tags.website + .or(tags.contact_website) + .or(tags.url) + .or(tags.contact_url) + .or(tags.brand_website) + }) + .filter(|w| !w.trim().is_empty()); + + let distance = haversine_distance(lat, lng, result_lat, result_lng); + + Some(NearbyCafe { + name, + latitude: result_lat, + longitude: result_lng, + city, + country: address.country.unwrap_or_default(), + website, + distance_meters: distance as u32, + }) + }) + .collect(); + + Ok(cafes) +} + +/// Haversine distance in meters between two lat/lng points. +fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 { + const R: f64 = 6_371_000.0; // Earth radius in meters + + let d_lat = (lat2 - lat1).to_radians(); + let d_lng = (lng2 - lng1).to_radians(); + + let a = (d_lat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2); + + let c = 2.0 * a.sqrt().asin(); + R * c +} + +// --- Nominatim types --- + +#[derive(Debug, Deserialize)] +struct NominatimSearchResult { + lat: String, + lon: String, + display_name: String, + #[serde(default)] + namedetails: Option, + #[serde(default)] + address: Option, + #[serde(default)] + extratags: Option, +} + +#[derive(Debug, Deserialize)] +struct NominatimNameDetails { + name: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct NominatimAddress { + city: Option, + town: Option, + village: Option, + country: Option, +} + +#[derive(Debug, Deserialize)] +struct NominatimExtraTags { + website: Option, + #[serde(rename = "contact:website")] + contact_website: Option, + url: Option, + #[serde(rename = "contact:url")] + contact_url: Option, + #[serde(rename = "brand:website")] + brand_website: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn haversine_london_to_paris() { + // London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 344 km + let dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522); + let km = dist / 1000.0; + assert!((km - 344.0).abs() < 5.0, "Expected ~344 km, got {km:.1} km"); + } + + #[test] + fn haversine_same_point_is_zero() { + let dist = haversine_distance(51.5, -0.1, 51.5, -0.1); + assert!(dist.abs() < 0.01, "Expected 0, got {dist}"); + } + + #[test] + fn parse_nominatim_search_response() { + let json = r#"[ + { + "place_id": 123, + "lat": "51.5246", + "lon": "-0.1098", + "display_name": "Prufrock Coffee, Leather Lane, London, England, United Kingdom", + "namedetails": { "name": "Prufrock Coffee" }, + "address": { + "cafe": "Prufrock Coffee", + "road": "Leather Lane", + "city": "London", + "state": "England", + "country": "United Kingdom", + "country_code": "gb" + }, + "extratags": { + "website": "https://www.prufrockcoffee.com" + } + }, + { + "place_id": 456, + "lat": "51.4543", + "lon": "-2.5930", + "display_name": "Full Court Press, Broad Street, Bristol, England, United Kingdom", + "namedetails": { "name": "Full Court Press" }, + "address": { + "town": "Bristol", + "country": "United Kingdom" + } + } + ]"#; + + let results: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(results.len(), 2); + + let first = &results[0]; + assert_eq!(first.lat, "51.5246"); + assert_eq!( + first.namedetails.as_ref().unwrap().name.as_deref(), + Some("Prufrock Coffee") + ); + assert_eq!( + first.address.as_ref().unwrap().city.as_deref(), + Some("London") + ); + assert_eq!( + first.extratags.as_ref().unwrap().website.as_deref(), + Some("https://www.prufrockcoffee.com") + ); + + // Second result uses town fallback, no extratags + let second = &results[1]; + assert!(second.address.as_ref().unwrap().city.is_none()); + assert_eq!( + second.address.as_ref().unwrap().town.as_deref(), + Some("Bristol") + ); + assert!(second.extratags.is_none()); + } + + #[test] + fn parse_nominatim_result_without_namedetails() { + let json = r#"[{ + "place_id": 789, + "lat": "48.8566", + "lon": "2.3522", + "display_name": "Café de Flore, Boulevard Saint-Germain, Paris, France", + "address": { + "city": "Paris", + "country": "France" + } + }]"#; + + let results: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(results.len(), 1); + + // Without namedetails, should fall back to display_name prefix + assert!(results[0].namedetails.is_none()); + assert_eq!( + results[0].display_name.split(',').next().unwrap().trim(), + "Café de Flore" + ); + } + + #[test] + fn website_fallback_chain() { + // url tag should be used when website and contact:website are absent + let json = r#"[{ + "place_id": 101, + "lat": "51.5", + "lon": "-0.1", + "display_name": "Test Cafe, London, UK", + "namedetails": { "name": "Test Cafe" }, + "address": { "city": "London", "country": "United Kingdom" }, + "extratags": { "url": "https://testcafe.example.com" } + }]"#; + + let results: Vec = serde_json::from_str(json).unwrap(); + let tags = results[0].extratags.as_ref().unwrap(); + assert!(tags.website.is_none()); + assert!(tags.contact_website.is_none()); + assert_eq!(tags.url.as_deref(), Some("https://testcafe.example.com")); + } +} diff --git a/tests/server/helpers.rs b/tests/server/helpers.rs index 0668757..02d3017 100644 --- a/tests/server/helpers.rs +++ b/tests/server/helpers.rs @@ -84,6 +84,7 @@ pub async fn spawn_app() -> TestApp { user_repo.clone(), token_repo.clone(), session_repo, + reqwest::Client::new(), ); // Create router