194 lines
7.4 KiB
Rust
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))
|
|
}
|