perfect-postcode/server-rs/src/routes/crime_records.rs
2026-06-25 22:29:52 +01:00

194 lines
7.4 KiB
Rust

//! `GET /api/crime-records` — the individual police.uk crimes (last 7 years)
//! behind a selected hexagon or postcode, paginated. Display-only and
//! independent of the property filters, like the population figure: the records
//! are an attribute of the area, not of the filter-matching subset.
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, FxHashSet};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::auth::OptionalUser;
use crate::licensing::{check_license_bounds, check_license_point, resolve_share_code};
use crate::parsing::{cell_for_row_cached, h3_cell_bounds, needs_parent, validate_h3_resolution};
use crate::state::SharedState;
use crate::utils::normalize_postcode;
/// Default and hard-cap page sizes for the records list.
const DEFAULT_LIMIT: usize = 200;
const MAX_LIMIT: usize = 500;
#[derive(Deserialize)]
pub struct CrimeRecordsParams {
/// Hexagon selection: H3 cell + resolution. Mutually exclusive with `postcode`.
pub h3: Option<String>,
pub resolution: Option<u8>,
/// Postcode selection.
pub postcode: Option<String>,
pub offset: Option<usize>,
pub limit: Option<usize>,
/// Lower bound on `month_index` (`year*12 + month0`) to restrict to a recent
/// window; omitted = all stored records (last 7 years).
pub since: Option<u32>,
/// Share-link code; grants scoped access for unlicensed users.
pub share: Option<String>,
}
#[derive(Serialize)]
pub struct CrimeRecord {
/// `"YYYY-MM"`.
pub month: String,
#[serde(rename = "type")]
pub crime_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outcome: Option<String>,
pub lat: f32,
pub lon: f32,
}
#[derive(Serialize)]
pub struct CrimeRecordsResponse {
pub records: Vec<CrimeRecord>,
pub total: usize,
pub offset: usize,
pub truncated: bool,
}
fn format_month(month_index: u32) -> String {
let year = month_index / 12;
let month = month_index % 12 + 1;
format!("{year:04}-{month:02}")
}
pub async fn get_crime_records(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Extension(geo): Extension<crate::demo_zone::DemoZone>,
Query(params): Query<CrimeRecordsParams>,
) -> Result<Json<CrimeRecordsResponse>, axum::response::Response> {
let state = shared.load_state();
let share_bounds = resolve_share_code(&state, params.share.as_deref()).await;
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
let since = params.since;
// Resolve the selection to a set of postcodes, after a license check scoped
// to the selection's geometry (bounds for a hexagon, point for a postcode).
enum Selection {
Hexagon { cell: u64, resolution: u8 },
Postcode(String),
}
let selection = if let Some(h3) = params.h3.clone() {
let cell = h3o::CellIndex::from_str(&h3).map_err(|error| {
warn!(h3 = %h3, error = %error, "Invalid H3 cell index");
(StatusCode::BAD_REQUEST, format!("Invalid H3 cell: {error}")).into_response()
})?;
let resolution = params.resolution.ok_or_else(|| {
(StatusCode::BAD_REQUEST, "resolution is required with h3").into_response()
})?;
validate_h3_resolution(resolution).map_err(IntoResponse::into_response)?;
let bounds = h3_cell_bounds(cell, 0.0);
check_license_bounds(&user.0, bounds, geo.free_zone, share_bounds)?;
Selection::Hexagon {
cell: cell.into(),
resolution,
}
} else if let Some(postcode) = params.postcode.clone() {
let normalized = normalize_postcode(&postcode);
let &pc_idx = state
.postcode_data
.postcode_to_idx
.get(&normalized)
.ok_or_else(|| {
(StatusCode::NOT_FOUND, format!("Postcode not found: {normalized}")).into_response()
})?;
let (lat, lon) = state.postcode_data.centroids[pc_idx];
check_license_point(&user.0, lat as f64, lon as f64, geo.free_zone, share_bounds)?;
Selection::Postcode(normalized)
} else {
return Err((StatusCode::BAD_REQUEST, "h3 or postcode is required").into_response());
};
let result = tokio::task::spawn_blocking(move || -> Result<CrimeRecordsResponse, String> {
// Distinct postcodes covered by the selection.
let postcodes: Vec<String> = match selection {
Selection::Postcode(pc) => vec![pc],
Selection::Hexagon { cell, resolution } => {
let h3_res = h3o::Resolution::try_from(resolution)
.map_err(|err| format!("Invalid H3 resolution {resolution}: {err}"))?;
let need_parent = needs_parent(resolution);
let h3o_cell = h3o::CellIndex::try_from(cell)
.map_err(|err| format!("Invalid H3 cell: {err}"))?;
let (min_lat, min_lon, max_lat, max_lon) = h3_cell_bounds(h3o_cell, 0.001);
let mut h3_cache: FxHashMap<u64, u64> = FxHashMap::default();
let mut seen: FxHashSet<&str> = FxHashSet::default();
let mut out: Vec<String> = 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,
&state.h3_cells,
h3_res,
need_parent,
&mut h3_cache,
) == cell
{
let pc = state.data.postcode(row);
if seen.insert(pc) {
out.push(pc.to_string());
}
}
},
);
out
}
};
let pc_refs: Vec<&str> = postcodes.iter().map(String::as_str).collect();
let indices = state.crime_records.gather(&pc_refs, since);
let total = indices.len();
let records: Vec<CrimeRecord> = indices
.iter()
.skip(offset)
.take(limit)
.map(|&idx| {
let v = state.crime_records.view(idx);
CrimeRecord {
month: format_month(v.month_index),
crime_type: v.crime_type.to_string(),
location: v.location.map(str::to_string),
outcome: v.outcome.map(str::to_string),
lat: v.lat,
lon: v.lon,
}
})
.collect();
let truncated = offset + records.len() < total;
Ok(CrimeRecordsResponse {
records,
total,
offset,
truncated,
})
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response())?
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error).into_response())?;
info!(total = result.total, returned = result.records.len(), "GET /api/crime-records");
Ok(Json(result))
}