perfect-postcode/server-rs/src/routes/properties.rs
Andras Schmelczer 3fa95819e3
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 2m43s
CI / Check (push) Failing after 3m7s
lgtm 2
2026-05-14 22:39:41 +01:00

322 lines
11 KiB
Rust

use std::str::FromStr;
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::Extension;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::consts::DEFAULT_PROPERTIES_LIMIT;
use crate::data::RenovationEvent;
use crate::licensing::{check_license_bounds, resolve_share_code};
use crate::parsing::{
cell_for_row_cached, h3_cell_bounds, needs_parent, parse_filters_with_poi, row_passes_filters,
row_passes_poi_filters, validate_h3_resolution,
};
use crate::state::{AppState, SharedState};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
pub struct HexagonPropertiesParams {
pub h3: String,
pub resolution: u8,
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>,
/// Share-link code; grants bbox-scoped access for unlicensed users.
pub share: Option<String>,
}
#[derive(Serialize)]
pub struct Property {
// String fields
pub address: Option<String>,
pub postcode: Option<String>,
pub property_type: Option<String>,
pub built_form: Option<String>,
pub duration: Option<String>,
pub current_energy_rating: Option<String>,
pub potential_energy_rating: Option<String>,
pub property_sub_type: Option<String>,
pub price_qualifier: Option<String>,
pub former_council_house: Option<String>,
// Numeric fields
pub lat: f32,
pub lon: f32,
pub is_construction_date_approximate: Option<bool>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub renovation_history: Vec<RenovationEvent>,
#[serde(flatten)]
pub features: FxHashMap<String, f32>,
}
#[derive(Serialize)]
pub struct HexagonPropertiesResponse {
pub properties: Vec<Property>,
pub total: usize,
pub limit: usize,
pub offset: usize,
pub truncated: bool,
}
fn non_empty_string(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
/// Look up an enum feature value by column name.
/// Uses the unified feature model: enum values stored as u16 indices in feature_data.
fn lookup_enum_value(
feature_name_to_index: &FxHashMap<String, usize>,
data: &crate::data::PropertyData,
enum_values: &FxHashMap<usize, Vec<String>>,
row: usize,
name: &str,
) -> Option<String> {
let &feat_idx = feature_name_to_index.get(name)?;
let values = enum_values.get(&feat_idx)?;
let value = data.get_feature(row, feat_idx);
if value.is_finite() {
let idx = value as usize;
values.get(idx).cloned()
} else {
None
}
}
pub fn build_property(
row: usize,
state: &AppState,
feature_names: &[String],
feature_name_to_index: &FxHashMap<String, usize>,
enum_values: &FxHashMap<usize, Vec<String>>,
) -> Property {
let mut features = FxHashMap::default();
for (feat_idx, feat_name) in feature_names.iter().enumerate() {
if enum_values.contains_key(&feat_idx) {
continue;
}
let value = state.data.get_feature(row, feat_idx);
if value.is_finite() {
features.insert(feat_name.clone(), value);
}
}
for (metric_idx, metric_name) in state.data.poi_metrics.feature_names.iter().enumerate() {
let value = state.data.poi_metrics.get_for_property_row(row, metric_idx);
if value.is_finite() {
features.insert(metric_name.clone(), value);
}
}
Property {
address: non_empty_string(state.data.address(row)),
postcode: non_empty_string(state.data.postcode(row)),
is_construction_date_approximate: Some(state.data.is_approx_build_date(row)),
property_type: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Property type",
),
built_form: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Property type/built form",
),
duration: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Leasehold/Freehold",
),
current_energy_rating: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Current energy rating",
),
potential_energy_rating: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Potential energy rating",
),
lat: state.data.lat[row],
lon: state.data.lon[row],
renovation_history: state.data.renovation_history(row).to_vec(),
property_sub_type: state.data.property_sub_type(row).map(String::from),
price_qualifier: state.data.price_qualifier(row).map(String::from),
former_council_house: lookup_enum_value(
feature_name_to_index,
&state.data,
enum_values,
row,
"Former council house",
),
features,
}
}
pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
let state = shared.load_state();
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
(
StatusCode::BAD_REQUEST,
format!("Invalid H3 cell: {}", error),
)
.into_response()
})?;
let cell_u64: u64 = cell.into();
let resolution = params.resolution;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
// License check using H3 cell bounds
let h3_bounds = h3_cell_bounds(cell, 0.0);
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
check_license_bounds(&user.0, h3_bounds, share_bounds)?;
let h3_str = params.h3;
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 result = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let precomputed = &state.h3_cells;
let h3_res = h3o::Resolution::try_from(resolution)
.map_err(|err| format!("Invalid H3 resolution {}: {}", resolution, err))?;
let need_parent = needs_parent(resolution);
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 (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(cell, 0.001);
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
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 cell_for_row_cached(row, precomputed, h3_res, need_parent, &mut h3_cache)
== cell_u64
&& 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 {
let postcode = state.data.postcode(row);
if !row_passes_travel_filters(postcode, &travel_entries, &travel_data) {
return;
}
}
matching_rows.push(row);
}
});
// Sort so properties with addresses come first, unknown addresses last
matching_rows.sort_unstable_by_key(|&row| state.data.address(row).trim().is_empty());
let total = matching_rows.len();
let limit = params.limit.unwrap_or(DEFAULT_PROPERTIES_LIMIT);
let offset = params.offset.unwrap_or(0);
let truncated = total > offset + limit;
let properties: Vec<Property> = matching_rows
.iter()
.skip(offset)
.take(limit)
.map(|&row| {
build_property(
row,
&state,
feature_names,
feature_name_to_index,
enum_values,
)
})
.collect();
let elapsed = t0.elapsed();
info!(
h3 = %h3_str,
resolution,
total,
returned = properties.len(),
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/hexagon-properties"
);
Ok(HexagonPropertiesResponse {
properties,
total,
limit,
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))
}