249 lines
7.3 KiB
Rust
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))
|
|
}
|