use actix_web::{web, HttpResponse}; use sqlx::PgPool; use crate::errors::AppError; use crate::models::poi::*; /// Query parameters for listing POIs. #[derive(serde::Deserialize)] pub struct PoisQuery { bbox: Option, category: Option, limit: Option, offset: Option, } /// Path parameters for a single POI. #[derive(serde::Deserialize)] pub struct PoiPath { osm_type: String, osm_id: i64, } /// GET /api/pois /// /// Query PostGIS for POIs within a bounding box, optionally filtered by category. pub async fn list_pois( query: web::Query, pool: web::Data, ) -> Result { let bbox_str = query .bbox .as_ref() .ok_or_else(|| AppError::MissingParameter("bbox parameter is required".into()))?; let (min_lon, min_lat, max_lon, max_lat) = parse_bbox(bbox_str)?; // Validate area: max 0.25 square degrees let area = (max_lon - min_lon) * (max_lat - min_lat); if area > 0.25 { return Err(AppError::InvalidBbox( "Bounding box area exceeds maximum of 0.25 square degrees".into(), )); } let limit = query.limit.unwrap_or(100); if !(1..=500).contains(&limit) { return Err(AppError::InvalidParameter( "limit must be between 1 and 500".into(), )); } let offset = query.offset.unwrap_or(0); if offset < 0 { return Err(AppError::InvalidParameter( "offset must be >= 0".into(), )); } // Validate and parse categories let categories: Option> = if let Some(ref cat_str) = query.category { let cats: Vec = cat_str.split(',').map(|s| s.trim().to_string()).collect(); for cat in &cats { if !VALID_CATEGORIES.contains(&cat.as_str()) { return Err(AppError::InvalidParameter(format!( "Unknown category: {cat}. Valid categories: {}", VALID_CATEGORIES.join(", ") ))); } } Some(cats) } else { None }; // Build and execute the count query let total = if let Some(ref cats) = categories { sqlx::query_as::<_, CountRow>( "SELECT COUNT(*) as count FROM pois \ WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ AND category = ANY($5)", ) .bind(min_lon) .bind(min_lat) .bind(max_lon) .bind(max_lat) .bind(cats) .fetch_one(pool.get_ref()) .await? .count .unwrap_or(0) } else { sqlx::query_as::<_, CountRow>( "SELECT COUNT(*) as count FROM pois \ WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326)", ) .bind(min_lon) .bind(min_lat) .bind(max_lon) .bind(max_lat) .fetch_one(pool.get_ref()) .await? .count .unwrap_or(0) }; // Build and execute the data query let rows: Vec = if let Some(ref cats) = categories { sqlx::query_as::<_, PoiRow>( "SELECT osm_id, osm_type, name, category, \ ST_AsGeoJSON(geometry)::json AS geometry, \ address, tags, opening_hours, phone, website, wheelchair \ FROM pois \ WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ AND category = ANY($5) \ ORDER BY name \ LIMIT $6 OFFSET $7", ) .bind(min_lon) .bind(min_lat) .bind(max_lon) .bind(max_lat) .bind(cats) .bind(limit) .bind(offset) .fetch_all(pool.get_ref()) .await? } else { sqlx::query_as::<_, PoiRow>( "SELECT osm_id, osm_type, name, category, \ ST_AsGeoJSON(geometry)::json AS geometry, \ address, tags, opening_hours, phone, website, wheelchair \ FROM pois \ WHERE geometry && ST_MakeEnvelope($1, $2, $3, $4, 4326) \ ORDER BY name \ LIMIT $5 OFFSET $6", ) .bind(min_lon) .bind(min_lat) .bind(max_lon) .bind(max_lat) .bind(limit) .bind(offset) .fetch_all(pool.get_ref()) .await? }; let features: Vec = rows.into_iter().map(|r| r.into_feature()).collect(); let response = PoiFeatureCollection { r#type: "FeatureCollection".into(), features, metadata: PaginationMetadata { total, limit, offset, }, }; Ok(HttpResponse::Ok() .content_type("application/json; charset=utf-8") .insert_header(("Cache-Control", "public, max-age=300")) .json(response)) } /// GET /api/pois/{osm_type}/{osm_id} /// /// Fetch a single POI from PostGIS by OSM type and ID. pub async fn get_poi( path: web::Path, pool: web::Data, ) -> Result { let PoiPath { osm_type, osm_id } = path.into_inner(); // Validate osm_type if !["N", "W", "R"].contains(&osm_type.as_str()) { return Err(AppError::InvalidParameter( "osm_type must be one of: N, W, R".into(), )); } if osm_id <= 0 { return Err(AppError::InvalidParameter( "osm_id must be a positive integer".into(), )); } let row = sqlx::query_as::<_, PoiRow>( "SELECT osm_id, osm_type, name, category, \ ST_AsGeoJSON(geometry)::json AS geometry, \ address, tags, opening_hours, phone, website, wheelchair \ FROM pois \ WHERE osm_type = $1 AND osm_id = $2", ) .bind(&osm_type) .bind(osm_id) .fetch_optional(pool.get_ref()) .await?; match row { Some(r) => { let feature = r.into_feature(); Ok(HttpResponse::Ok() .content_type("application/json; charset=utf-8") .insert_header(("Cache-Control", "public, max-age=3600")) .json(feature)) } None => Err(AppError::NotFound(format!( "No POI found with type {osm_type} and id {osm_id}" ))), } } /// Parse a bounding box string "minLon,minLat,maxLon,maxLat". fn parse_bbox(bbox: &str) -> Result<(f64, f64, f64, f64), AppError> { let parts: Vec = bbox .split(',') .map(|s| { s.trim() .parse::() .map_err(|_| AppError::InvalidBbox("bbox contains non-numeric values".into())) }) .collect::, _>>()?; if parts.len() != 4 { return Err(AppError::InvalidBbox( "bbox must have exactly 4 comma-separated values: minLon,minLat,maxLon,maxLat".into(), )); } let (min_lon, min_lat, max_lon, max_lat) = (parts[0], parts[1], parts[2], parts[3]); if !(-180.0..=180.0).contains(&min_lon) || !(-180.0..=180.0).contains(&max_lon) || !(-90.0..=90.0).contains(&min_lat) || !(-90.0..=90.0).contains(&max_lat) { return Err(AppError::InvalidBbox( "bbox coordinates out of valid range".into(), )); } if min_lon >= max_lon || min_lat >= max_lat { return Err(AppError::InvalidBbox( "bbox min values must be less than max values".into(), )); } Ok((min_lon, min_lat, max_lon, max_lat)) }