use axum::{
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Instant;
use tracing::{debug, info, warn};
use crate::error::RossbyError;
use crate::logging::{generate_request_id, log_request_error};
use crate::state::AppState;
#[derive(Debug, Deserialize, Clone)]
pub struct PointQuery {
#[serde(default)]
pub lon: Option<f64>,
#[serde(default)]
pub lat: Option<f64>,
#[serde(default)]
pub time: Option<f64>,
#[serde(rename = "_longitude", default)]
pub _longitude: Option<f64>,
#[serde(rename = "_latitude", default)]
pub _latitude: Option<f64>,
#[serde(rename = "_time", default)]
pub _time: Option<f64>,
#[serde(rename = "__longitude_index", default)]
pub __longitude_index: Option<usize>,
#[serde(rename = "__latitude_index", default)]
pub __latitude_index: Option<usize>,
#[serde(rename = "__time_index", default)]
pub __time_index: Option<usize>,
#[serde(default)]
pub time_index: Option<usize>,
pub vars: String,
pub interpolation: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct PointResponse {
#[serde(flatten)]
pub values: serde_json::Map<String, serde_json::Value>,
}
pub async fn point_handler(
State(state): State<Arc<AppState>>,
Query(params): Query<PointQuery>,
) -> Response {
let request_id = generate_request_id();
let start_time = Instant::now();
debug!(
endpoint = "/point",
request_id = %request_id,
lon = ?params.lon,
lat = ?params.lat,
time = ?params.time,
time_index = ?params.time_index,
vars = %params.vars,
interpolation = ?params.interpolation,
"Processing point query"
);
match process_point_query(state, params.clone()) {
Ok(response) => {
let duration = start_time.elapsed();
info!(
endpoint = "/point",
request_id = %request_id,
duration_us = duration.as_micros() as u64,
"Point query successful"
);
Json(response).into_response()
}
Err(error) => {
log_request_error(
&error,
"/point",
&request_id,
Some(&format!("vars={}", params.vars)),
);
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": error.to_string(),
"request_id": request_id
})),
)
.into_response()
}
}
}
fn process_point_query(
state: Arc<AppState>,
params: PointQuery,
) -> Result<PointResponse, RossbyError> {
#[allow(unused_assignments)]
let mut longitude_idx: Option<usize> = None;
#[allow(unused_assignments)]
let mut latitude_idx: Option<usize> = None;
#[allow(unused_assignments)]
let mut time_idx: Option<usize> = None;
#[allow(unused_assignments)]
let mut lon_value = None;
if let Some(idx) = params.__longitude_index {
let lon_coords = state
.get_coordinate_checked("lon")
.or_else(|_| state.get_coordinate_checked("_longitude"))
.or_else(|_| state.get_coordinate_checked("longitude"))?;
if idx >= lon_coords.len() {
return Err(RossbyError::IndexOutOfBounds {
param: "__longitude_index".to_string(),
value: idx.to_string(),
max: lon_coords.len() - 1,
});
}
longitude_idx = Some(idx);
lon_value = Some(lon_coords[idx]);
} else {
let lon = match (params.lon, params._longitude) {
(Some(value), _) => value,
(None, Some(value)) => value,
(None, None) => {
return Err(RossbyError::InvalidParameter {
param: "longitude".to_string(),
message: "Missing longitude coordinate. Provide either file-specific name (e.g., 'lon'), canonical name with underscore prefix (e.g., '_longitude'), or raw index with double-underscore prefix (e.g., '__longitude_index')".to_string(),
})
}
};
lon_value = Some(lon);
}
#[allow(unused_assignments)]
let mut lat_value = None;
if let Some(idx) = params.__latitude_index {
let lat_coords = state
.get_coordinate_checked("lat")
.or_else(|_| state.get_coordinate_checked("_latitude"))
.or_else(|_| state.get_coordinate_checked("latitude"))?;
if idx >= lat_coords.len() {
return Err(RossbyError::IndexOutOfBounds {
param: "__latitude_index".to_string(),
value: idx.to_string(),
max: lat_coords.len() - 1,
});
}
latitude_idx = Some(idx);
lat_value = Some(lat_coords[idx]);
} else {
let lat = match (params.lat, params._latitude) {
(Some(value), _) => value,
(None, Some(value)) => value,
(None, None) => {
return Err(RossbyError::InvalidParameter {
param: "latitude".to_string(),
message: "Missing latitude coordinate. Provide either file-specific name (e.g., 'lat'), canonical name with underscore prefix (e.g., '_latitude'), or raw index with double-underscore prefix (e.g., '__latitude_index')".to_string(),
})
}
};
lat_value = Some(lat);
}
if let Some(idx) = params.__time_index {
if idx >= state.time_dim_size() {
return Err(RossbyError::IndexOutOfBounds {
param: "__time_index".to_string(),
value: idx.to_string(),
max: state.time_dim_size() - 1,
});
}
time_idx = Some(idx);
} else if let Some(idx) = params.time_index {
warn!(
param = "time_index",
deprecated_since = "0.1.0",
replacement = "__time_index",
"The 'time_index' parameter is deprecated. Please use '__time_index' instead."
);
if idx >= state.time_dim_size() {
return Err(RossbyError::IndexOutOfBounds {
param: "time_index".to_string(),
value: idx.to_string(),
max: state.time_dim_size() - 1,
});
}
time_idx = Some(idx);
} else if let Some(time_val) = params.time.or(params._time) {
let _time_coords = state
.get_coordinate_checked("time")
.or_else(|_| state.get_coordinate_checked("_time"))?;
match state.find_coordinate_index_exact("time", time_val) {
Ok(idx) => time_idx = Some(idx),
Err(e) => return Err(e),
}
} else {
time_idx = Some(0);
}
let time_index = time_idx.unwrap_or(0);
let variables: Vec<String> = params
.vars
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if variables.is_empty() {
return Err(RossbyError::InvalidParameter {
param: "vars".to_string(),
message: "No variables specified".to_string(),
});
}
let interpolation_method = params.interpolation.as_deref().unwrap_or("bilinear");
let interpolator = crate::interpolation::get_interpolator(interpolation_method)?;
let mut values = serde_json::Map::new();
for var_name in variables {
if !state.has_variable(&var_name) {
return Err(RossbyError::VariableNotFound { name: var_name });
}
let dimensions = state.get_variable_dimensions(&var_name)?;
let mut lat_dim_idx = None;
let mut lon_dim_idx = None;
let mut time_dim_idx = None;
for (i, dim) in dimensions.iter().enumerate() {
let canonical = state.get_canonical_dimension_name(dim).unwrap_or(dim);
if dim == "lat" || canonical == "latitude" {
lat_dim_idx = Some(i);
} else if dim == "lon" || canonical == "longitude" {
lon_dim_idx = Some(i);
} else if dim == "time" || canonical == "time" {
time_dim_idx = Some(i);
}
}
let lat_dim_idx = lat_dim_idx.ok_or_else(|| RossbyError::DataNotFound {
message: format!("Variable {} does not have a lat dimension", var_name),
})?;
let lon_dim_idx = lon_dim_idx.ok_or_else(|| RossbyError::DataNotFound {
message: format!("Variable {} does not have a lon dimension", var_name),
})?;
let data = state.get_variable_checked(&var_name)?;
let lon_coords = state
.get_coordinate_checked("lon")
.or_else(|_| state.get_coordinate_checked("_longitude"))
.or_else(|_| state.get_coordinate_checked("longitude"))?;
let lat_coords = state
.get_coordinate_checked("lat")
.or_else(|_| state.get_coordinate_checked("_latitude"))
.or_else(|_| state.get_coordinate_checked("latitude"))?;
let lon_idx = if let Some(idx) = longitude_idx {
idx as f64
} else {
let lon = lon_value.unwrap();
if lon < *lon_coords.first().unwrap() || lon > *lon_coords.last().unwrap() {
return Err(RossbyError::InvalidCoordinates {
message: format!(
"Longitude {} is outside the range [{}, {}]",
lon,
lon_coords.first().unwrap(),
lon_coords.last().unwrap()
),
});
}
crate::interpolation::common::coord_to_index(lon, lon_coords)?
};
let lat_idx = if let Some(idx) = latitude_idx {
idx as f64
} else {
let lat = lat_value.unwrap();
if lat < *lat_coords.first().unwrap() || lat > *lat_coords.last().unwrap() {
return Err(RossbyError::InvalidCoordinates {
message: format!(
"Latitude {} is outside the range [{}, {}]",
lat,
lat_coords.first().unwrap(),
lat_coords.last().unwrap()
),
});
}
crate::interpolation::common::coord_to_index(lat, lat_coords)?
};
let mut indices = vec![0.0; data.ndim()];
indices[lon_dim_idx] = lon_idx;
indices[lat_dim_idx] = lat_idx;
if let Some(idx) = time_dim_idx {
indices[idx] = time_index as f64;
}
let data_slice = data.as_slice().ok_or_else(|| RossbyError::DataNotFound {
message: format!(
"Cannot access data for variable {} as contiguous slice",
var_name
),
})?;
let value = interpolator.interpolate(data_slice, data.shape(), &indices)?;
values.insert(
var_name,
serde_json::Value::Number(serde_json::Number::from_f64(value as f64).unwrap()),
);
}
Ok(PointResponse { values })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::state::{AttributeValue, Dimension, Metadata, Variable};
use ndarray::{Array, IxDyn};
use std::collections::HashMap;
fn create_test_state() -> Arc<AppState> {
let data_array =
Array::from_shape_vec(IxDyn(&[2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
let mut dimensions = HashMap::new();
dimensions.insert(
"lat".to_string(),
Dimension {
name: "lat".to_string(),
size: 2,
is_unlimited: false,
},
);
dimensions.insert(
"lon".to_string(),
Dimension {
name: "lon".to_string(),
size: 3,
is_unlimited: false,
},
);
let mut variables = HashMap::new();
let mut var_attributes = HashMap::new();
var_attributes.insert(
"units".to_string(),
AttributeValue::Text("degrees_C".to_string()),
);
variables.insert(
"temperature".to_string(),
Variable {
name: "temperature".to_string(),
dimensions: vec!["lat".to_string(), "lon".to_string()],
shape: vec![2, 3],
attributes: var_attributes,
dtype: "f32".to_string(),
},
);
let mut coordinates = HashMap::new();
coordinates.insert("lat".to_string(), vec![10.0, 20.0]);
coordinates.insert("lon".to_string(), vec![100.0, 110.0, 120.0]);
let metadata = Metadata {
global_attributes: HashMap::new(),
dimensions,
variables,
coordinates,
};
let mut data = HashMap::new();
data.insert("temperature".to_string(), data_array);
let config = Config::default();
Arc::new(AppState::new(config, metadata, data))
}
fn create_test_state_with_aliases() -> Arc<AppState> {
let data_array =
Array::from_shape_vec(IxDyn(&[2, 3]), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
let mut dimensions = HashMap::new();
dimensions.insert(
"lat".to_string(),
Dimension {
name: "lat".to_string(),
size: 2,
is_unlimited: false,
},
);
dimensions.insert(
"lon".to_string(),
Dimension {
name: "lon".to_string(),
size: 3,
is_unlimited: false,
},
);
let mut variables = HashMap::new();
let mut var_attributes = HashMap::new();
var_attributes.insert(
"units".to_string(),
AttributeValue::Text("degrees_C".to_string()),
);
variables.insert(
"temperature".to_string(),
Variable {
name: "temperature".to_string(),
dimensions: vec!["lat".to_string(), "lon".to_string()],
shape: vec![2, 3],
attributes: var_attributes,
dtype: "f32".to_string(),
},
);
let mut coordinates = HashMap::new();
coordinates.insert("lat".to_string(), vec![10.0, 20.0]);
coordinates.insert("lon".to_string(), vec![100.0, 110.0, 120.0]);
let metadata = Metadata {
global_attributes: HashMap::new(),
dimensions,
variables,
coordinates,
};
let mut data = HashMap::new();
data.insert("temperature".to_string(), data_array);
let mut config = Config::default();
let mut aliases = HashMap::new();
aliases.insert("latitude".to_string(), "lat".to_string());
aliases.insert("longitude".to_string(), "lon".to_string());
config.data.dimension_aliases = aliases;
Arc::new(AppState::new(config, metadata, data))
}
#[test]
fn test_point_query_success() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0),
lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: Some("nearest".to_string()),
};
let result = process_point_query(state.clone(), params).unwrap();
let value = result.values.get("temperature").unwrap().as_f64().unwrap();
assert_eq!(value, 1.0);
let params = PointQuery {
lon: Some(105.0), lat: Some(15.0), time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: Some("bilinear".to_string()),
};
let result = process_point_query(state.clone(), params).unwrap();
let value = result.values.get("temperature").unwrap().as_f64().unwrap();
assert!((value - 3.0).abs() < 1e-5);
}
#[test]
fn test_multiple_variables() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0),
lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature,humidity".to_string(), interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::VariableNotFound { name }) = result {
assert_eq!(name, "humidity");
} else {
panic!("Expected VariableNotFound error");
}
}
#[test]
fn test_out_of_bounds() {
let state = create_test_state();
let params = PointQuery {
lon: Some(130.0), lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::InvalidCoordinates { .. }) = result {
} else {
panic!("Expected InvalidCoordinates error");
}
let params = PointQuery {
lon: Some(100.0),
lat: Some(30.0), time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::InvalidCoordinates { .. }) = result {
} else {
panic!("Expected InvalidCoordinates error");
}
}
#[test]
fn test_invalid_interpolation() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0),
lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: Some("invalid_method".to_string()),
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::InvalidParameter { param, .. }) = result {
assert_eq!(param, "interpolation");
} else {
panic!("Expected InvalidParameter error");
}
}
#[test]
fn test_empty_vars() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0),
lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "".to_string(), interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::InvalidParameter { param, .. }) = result {
assert_eq!(param, "vars");
} else {
panic!("Expected InvalidParameter error");
}
}
#[test]
fn test_dimension_aliases() {
let state = create_test_state();
let params = PointQuery {
lon: None,
lat: None,
time: None,
_longitude: Some(100.0),
_latitude: Some(10.0),
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: Some("nearest".to_string()),
};
let result = process_point_query(state.clone(), params);
assert!(
result.is_ok(),
"Query with prefixed canonical names failed: {:?}",
result
);
let value = result
.unwrap()
.values
.get("temperature")
.unwrap()
.as_f64()
.unwrap();
assert_eq!(value, 1.0);
let state_with_aliases = create_test_state_with_aliases();
let params = PointQuery {
lon: None,
lat: None,
time: None,
_longitude: Some(120.0), _latitude: Some(20.0),
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: Some("nearest".to_string()),
};
let result = process_point_query(state_with_aliases.clone(), params);
assert!(
result.is_ok(),
"Query with aliased names failed: {:?}",
result
);
let value = result
.unwrap()
.values
.get("temperature")
.unwrap()
.as_f64()
.unwrap();
assert_eq!(value, 6.0); }
#[test]
fn test_raw_indices() {
let state = create_test_state();
let params = PointQuery {
lon: None,
lat: None,
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: Some(0), __latitude_index: Some(0), __time_index: Some(0), time_index: None,
vars: "temperature".to_string(),
interpolation: Some("nearest".to_string()),
};
let result = process_point_query(state.clone(), params);
assert!(
result.is_ok(),
"Query with raw indices failed: {:?}",
result
);
let value = result
.unwrap()
.values
.get("temperature")
.unwrap()
.as_f64()
.unwrap();
assert_eq!(value, 1.0);
let params = PointQuery {
lon: None,
lat: None,
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: Some(3), __latitude_index: Some(0),
__time_index: None,
time_index: None,
vars: "temperature".to_string(),
interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_err());
if let Err(RossbyError::IndexOutOfBounds { param, value, max }) = result {
assert_eq!(param, "__longitude_index");
assert_eq!(value, "3");
assert_eq!(max, 2);
} else {
panic!("Expected IndexOutOfBounds error");
}
}
#[test]
fn test_deprecated_time_index() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0),
lat: Some(10.0),
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: None,
__time_index: None,
time_index: Some(0), vars: "temperature".to_string(),
interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_ok());
let value = result
.unwrap()
.values
.get("temperature")
.unwrap()
.as_f64()
.unwrap();
assert_eq!(value, 1.0);
}
#[test]
fn test_mixed_query_params() {
let state = create_test_state();
let params = PointQuery {
lon: Some(100.0), lat: None,
time: None,
_longitude: None,
_latitude: None,
_time: None,
__longitude_index: None,
__latitude_index: Some(0), __time_index: Some(0), time_index: None,
vars: "temperature".to_string(),
interpolation: None,
};
let result = process_point_query(state.clone(), params);
assert!(result.is_ok());
let value = result
.unwrap()
.values
.get("temperature")
.unwrap()
.as_f64()
.unwrap();
assert_eq!(value, 1.0);
}
}