maps/backend/src/routes/pois.rs
2026-03-30 09:22:16 +02:00

249 lines
7.3 KiB
Rust

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<String>,
category: Option<String>,
limit: Option<i64>,
offset: Option<i64>,
}
/// 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<PoisQuery>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, AppError> {
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<Vec<String>> = if let Some(ref cat_str) = query.category {
let cats: Vec<String> = 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<PoiRow> = 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<PoiFeature> = 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<PoiPath>,
pool: web::Data<PgPool>,
) -> Result<HttpResponse, AppError> {
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<f64> = bbox
.split(',')
.map(|s| {
s.trim()
.parse::<f64>()
.map_err(|_| AppError::InvalidBbox("bbox contains non-numeric values".into()))
})
.collect::<Result<Vec<_>, _>>()?;
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))
}