//! /docs, /redoc and /openapi.json. The document is built from the route //! table below, whose parameter lists are the same constants the handlers //! validate with, so the documented parameters are the accepted ones. //! Summaries, operation ids and descriptions follow FastAPI's conventions //! (from the Python handler names and docstrings) so /docs reads as before. use super::params::{Param, python_title}; use super::{data, forecasts, hii, system}; use axum::http::header; use axum::response::{IntoResponse, Response}; use serde_json::{Map, Value, json}; use std::sync::OnceLock; pub const TITLE: &str = "Northern Thailand Ping River Monitor API"; const DESCRIPTION: &str = "Real-time water level monitoring system for Northern Thailand's Ping River Basin stations"; const VERSION: &str = "3.1.3"; enum Resp { Any, Html, Ref(&'static str), ArrayOf(&'static str), } struct Route { method: &'static str, path: &'static str, /// the Python handler's name (summary and operationId) name: &'static str, description: &'static str, params: &'static [Param], body: Option<&'static str>, response: Resp, } const NONE: &[Param] = &[]; const ROUTES: &[Route] = &[ Route { method: "get", path: "/", name: "root", description: "Root endpoint with basic dashboard", params: NONE, body: None, response: Resp::Html, }, Route { method: "get", path: "/health", name: "get_health", description: "Get system health status", params: NONE, body: None, response: Resp::Ref("HealthResponse"), }, Route { method: "get", path: "/metrics", name: "get_metrics", description: "Get application metrics", params: NONE, body: None, response: Resp::Ref("MetricsResponse"), }, Route { method: "get", path: "/stations", name: "get_stations", description: "Get list of all monitoring stations", params: NONE, body: None, response: Resp::ArrayOf("StationResponse"), }, Route { method: "post", path: "/stations", name: "create_station", description: "Create a new monitoring station", params: system::ADMIN, body: Some("StationCreateModel"), response: Resp::Ref("StationResponse"), }, Route { method: "put", path: "/stations/{station_id}", name: "update_station", description: "Update an existing monitoring station", params: system::ADMIN_STATION_ID, body: Some("StationUpdateModel"), response: Resp::Ref("StationResponse"), }, Route { method: "delete", path: "/stations/{station_id}", name: "delete_station", description: "Delete a monitoring station", params: system::ADMIN_STATION_ID, body: None, response: Resp::Any, }, Route { method: "get", path: "/stations/{station_id}", name: "get_station", description: "Get details of a specific monitoring station", params: system::STATION_ID, body: None, response: Resp::Ref("StationResponse"), }, Route { method: "get", path: "/sensors/thaiwater", name: "get_thaiwater_sensors", description: "Get current ThaiWater water-level sensors in the Ping basin.", params: NONE, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/hii/rainfall/latest", name: "get_hii_rainfall_latest", description: "Latest rainfall reading per HII station (Ping basin, collected hourly).", params: hii::LATEST, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/hii/rainfall/catchment", name: "get_hii_rainfall_catchment", description: "Upper-Ping catchment-mean hourly rain: HII gauges vs the Open-Meteo\nseries the flood model actually uses, plus their agreement over the window.\n\nEvidence-gathering endpoint (docs/FLOOD_FORECASTING.md, HII gauge rain):\nthe gauge table only exists since 2026-08 so it cannot be a training\nfeature yet; this makes the two sources' relationship observable meanwhile.", params: hii::CATCHMENT, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/hii/waterlevel/latest", name: "get_hii_waterlevel_latest", description: "Latest water-level reading per HII station (Ping basin, m MSL).", params: hii::LATEST, body: None, response: Resp::Any, }, Route { method: "get", path: "/measurements/history/{station_code}", name: "get_postgres_history", description: "Get historical measurements for a station from the configured database.", params: data::HISTORY, body: None, response: Resp::Any, }, Route { method: "get", path: "/forecast", name: "get_flood_forecasts", description: "Flood-risk forecasts per station for the 6/12/24 h horizons.", params: NONE, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/forecast/history/{station_code}", name: "get_forecast_history", description: "Issued model forecasts for a station — what the model predicted, when.\n\nPopulated by the hourly precompute; enables predicted-vs-actual charts.", params: forecasts::HISTORY, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/notifications", name: "get_notifications_config", description: "Public ntfy settings so the dashboard can offer subscribe links.", params: NONE, body: None, response: Resp::Any, }, Route { method: "get", path: "/api/forecast/skill", name: "get_forecast_skill", description: "Is the model getting better? Issued forecasts verified against what the\nriver then did, per model version, with a persistence baseline.\n\nRead from forecast_history (what each deployed version predicted, hourly)\njoined to water_measurements; no retraining involved. Cached like the HII\nfeeds because the join is a few hundred correlated subqueries.", params: forecasts::SKILL, body: None, response: Resp::Any, }, Route { method: "get", path: "/measurements/latest", name: "get_latest_measurements", description: "Get latest measurements from all stations", params: data::LATEST, body: None, response: Resp::ArrayOf("MeasurementResponse"), }, Route { method: "get", path: "/measurements/station/{station_code}", name: "get_station_measurements", description: "Get measurements for a specific station", params: data::STATION, body: None, response: Resp::ArrayOf("MeasurementResponse"), }, Route { method: "get", path: "/api/stats", name: "get_database_stats", description: "Get database coverage statistics (totals, date range, hourly coverage)", params: NONE, body: None, response: Resp::Any, }, Route { method: "post", path: "/scrape/trigger", name: "trigger_scraping", description: "Trigger manual data scraping", params: system::ADMIN, body: None, response: Resp::Any, }, Route { method: "get", path: "/scraping/status", name: "get_scraping_status", description: "Get current scraping status", params: NONE, body: None, response: Resp::Ref("ScrapingStatusResponse"), }, Route { method: "get", path: "/config", name: "get_config", description: "Get current configuration (sensitive data masked)", params: system::ADMIN, body: None, response: Resp::Any, }, ]; /// FastAPI's `generate_unique_id`: name + path with non-word characters as /// `_`, then the method. fn operation_id(r: &Route) -> String { let p: String = format!("{}{}", r.name, r.path) .chars() .map(|c| { if c.is_alphanumeric() || c == '_' { c } else { '_' } }) .collect(); format!("{p}_{}", r.method) } fn reference(name: &str) -> Value { json!({ "$ref": format!("#/components/schemas/{name}") }) } fn operation(r: &Route) -> Value { let op_id = operation_id(r); let ok_content = match r.response { Resp::Html => json!({"text/html": {"schema": {"type": "string"}}}), Resp::Any => json!({"application/json": {"schema": {}}}), Resp::Ref(name) => json!({"application/json": {"schema": reference(name)}}), Resp::ArrayOf(name) => json!({"application/json": {"schema": { "type": "array", "items": reference(name), "title": format!("Response {}", python_title(&op_id.replace('_', " "))), }}}), }; let mut responses = Map::new(); responses.insert( "200".into(), json!({"description": "Successful Response", "content": ok_content}), ); if !r.params.is_empty() || r.body.is_some() { responses.insert( "422".into(), json!({"description": "Validation Error", "content": {"application/json": {"schema": reference("HTTPValidationError")}}}), ); } let mut op = Map::new(); op.insert( "summary".into(), json!(python_title(&r.name.replace('_', " "))), ); op.insert("description".into(), json!(r.description)); op.insert("operationId".into(), json!(op_id)); if !r.params.is_empty() { op.insert( "parameters".into(), Value::Array(r.params.iter().map(Param::openapi).collect()), ); } if let Some(body) = r.body { op.insert( "requestBody".into(), json!({"required": true, "content": {"application/json": {"schema": reference(body)}}}), ); } op.insert("responses".into(), Value::Object(responses)); Value::Object(op) } pub fn document() -> &'static str { static DOC: OnceLock = OnceLock::new(); DOC.get_or_init(|| { let mut paths = Map::new(); for r in ROUTES { let entry = paths .entry(r.path) .or_insert_with(|| Value::Object(Map::new())); entry .as_object_mut() .expect("object") .insert(r.method.into(), operation(r)); } let schemas: Value = serde_json::from_str(include_str!("openapi_components.json")) .expect("openapi_components.json is valid JSON"); json!({ "openapi": "3.1.0", "info": {"title": TITLE, "description": DESCRIPTION, "version": VERSION}, "paths": paths, "components": {"schemas": schemas}, }) .to_string() }) } pub async fn spec() -> Response { ([(header::CONTENT_TYPE, "application/json")], document()).into_response() } fn html(body: String) -> Response { ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], body).into_response() } /// The Swagger UI page FastAPI serves (assets from the jsDelivr CDN). pub async fn swagger_ui() -> Response { html(format!( r##" {TITLE} - Swagger UI
"## )) } pub async fn redoc() -> Response { html(format!( r#" {TITLE} - ReDoc "# )) } #[cfg(test)] mod tests { use super::*; #[test] fn fastapi_naming() { let doc: Value = serde_json::from_str(document()).unwrap(); let op = &doc["paths"]["/measurements/history/{station_code}"]["get"]; assert_eq!( op["operationId"], "get_postgres_history_measurements_history__station_code__get" ); assert_eq!(op["summary"], "Get Postgres History"); assert_eq!(op["parameters"].as_array().unwrap().len(), 5); let latest = &doc["paths"]["/measurements/latest"]["get"]["responses"]["200"]; assert_eq!( latest["content"]["application/json"]["schema"]["title"], "Response Get Latest Measurements Measurements Latest Get" ); assert!(doc["paths"]["/stations"]["post"]["responses"]["422"].is_object()); assert!( doc["paths"]["/health"]["get"]["responses"] .get("422") .is_none() ); assert!(doc["components"]["schemas"]["HTTPValidationError"].is_object()); } }