perfect-postcode/server-rs/src/routes/postcode_properties.rs
2026-05-14 08:17:10 +01:00

199 lines
7.3 KiB
Rust

use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use serde::Deserialize;
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::{DEFAULT_PROPERTIES_LIMIT, POSTCODE_SEARCH_OFFSET};
use crate::licensing::{check_license_point, resolve_share_code};
use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_filters};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct PostcodePropertiesParams {
pub postcode: String,
pub filters: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`.
/// Optional min:max applies as a filter (exclude properties outside range).
pub travel: Option<String>,
pub limit: Option<usize>,
pub offset: Option<usize>,
/// Exact address to rank first when opening properties from address search.
pub focus_address: Option<String>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(&params.postcode);
let pc_idx = match state.postcode_data.postcode_to_idx.get(&normalized) {
Some(&idx) => idx,
None => {
warn!(postcode = %normalized, "Postcode not found");
return Err((
StatusCode::NOT_FOUND,
format!("Postcode not found: {}", normalized),
)
.into_response());
}
};
let (centroid_lat, centroid_lon) = state.postcode_data.centroids[pc_idx];
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_point(
&user.0,
centroid_lat as f64,
centroid_lon as f64,
share_bounds,
)?;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (parsed_filters, parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
&state.data.poi_metrics.name_to_index,
&poi_quant,
)
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let num_filters = parsed_filters.len() + parsed_enum_filters.len() + parsed_poi_filters.len();
let filters_str = params.filters;
let has_poi_filters = !parsed_poi_filters.is_empty();
let travel_entries = parse_optional_travel(params.travel.as_deref())
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
let postcode_str = normalized;
let focus_address = params
.focus_address
.as_deref()
.map(str::trim)
.filter(|address| !address.is_empty())
.map(str::to_ascii_lowercase);
let result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let feature_names = &state.data.feature_names;
let feature_name_to_index = &state.feature_name_to_index;
let enum_values = &state.data.enum_values;
let travel_data = load_travel_data(&state.travel_time_store, &travel_entries)?;
let has_travel = !travel_entries.is_empty();
let offset_deg: f64 = POSTCODE_SEARCH_OFFSET;
let min_lat = centroid_lat as f64 - offset_deg;
let max_lat = centroid_lat as f64 + offset_deg;
let min_lon = centroid_lon as f64 - offset_deg;
let max_lon = centroid_lon as f64 + offset_deg;
let mut matching_rows: Vec<usize> = Vec::new();
state
.grid
.for_each_in_bounds(min_lat, min_lon, max_lat, max_lon, |row_idx| {
let row = row_idx as usize;
if state.data.postcode(row) == postcode_str
&& row_passes_filters(
row,
&parsed_filters,
&parsed_enum_filters,
feature_data,
num_features,
)
&& (!has_poi_filters
|| row_passes_poi_filters(
row,
&parsed_poi_filters,
&state.data.poi_metrics,
))
{
if has_travel
&& !row_passes_travel_filters(
state.data.postcode(row),
&travel_entries,
&travel_data,
)
{
return;
}
matching_rows.push(row);
}
});
matching_rows.sort_unstable_by(|&left, &right| {
let left_address = state.data.address(left).trim();
let right_address = state.data.address(right).trim();
let left_focused = focus_address
.as_ref()
.is_some_and(|address| left_address.eq_ignore_ascii_case(address));
let right_focused = focus_address
.as_ref()
.is_some_and(|address| right_address.eq_ignore_ascii_case(address));
right_focused
.cmp(&left_focused)
.then(left_address.is_empty().cmp(&right_address.is_empty()))
});
let total = matching_rows.len();
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let page_offset = params.offset.unwrap_or(0);
let truncated = total > page_offset + limit;
let properties: Vec<Property> = matching_rows
.iter()
.skip(page_offset)
.take(limit)
.map(|&row| {
super::properties::build_property(
row,
&state,
feature_names,
feature_name_to_index,
enum_values,
)
})
.collect();
let elapsed = t0.elapsed();
info!(
postcode = %postcode_str,
total,
returned = properties.len(),
offset = page_offset,
filters = num_filters,
filters_raw = filters_str.as_deref().unwrap_or("-"),
travel_entries = travel_entries.len(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/postcode-properties"
);
Ok(HexagonPropertiesResponse {
properties,
total,
limit,
offset: page_offset,
truncated,
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error: String| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
Ok(Json(result))
}