Phase 3 of docs/RUST_REWRITE.md: rust/collector/src/web (axum) serves what
the FastAPI app (src/web_api.py) served, so one process collects, forecasts,
notifies and serves. With WEB_LISTEN=0.0.0.0:8000 it runs inside
`ping-collector run`; `ping-collector serve` runs it alone.
Same routes, parameters, JSON fields and field order, status codes,
{"detail": ...} errors and pydantic-style 422 lists, Cache-Control, gzip
(>500 B), CORS, Umami API tracking, the dashboard and static files, and the
stale-while-revalidate response caches (now with byte budgets: a history
response can be 10 MB). /docs is the same Swagger UI (+ /redoc); its OpenAPI
document is built from the route table, whose per-route parameter
declarations also validate the requests, so docs and behaviour cannot drift.
Python output conventions are kept: isoformat(), round(), numpy's pairwise
sums (catchment means), Python's summation order (skill metrics).
scripts/api_parity.py compares the two servers route by route on one
database (53 cases incl. errors, admin and static files; query values vary
per run so FastAPI's caches answer fresh). Locally: 53 identical; the
station CRUD writes byte-identical stations.json files; the skill/trend
logic matches on 9 station/horizon pairs with synthetic model versions.
Intended differences: /health counts the ThaiWater telemetry of the same
gauges while RID lags (as the notifier); /scraping/status adds the collector
version and last forecast run; an old stored forecast is served marked
X-Data-Stale instead of recomputed; /sensors/thaiwater is cached a minute;
.geojson is served as application/geo+json. Load (local, 16 clients): the
dashboard's routes 1375 vs 552 req/s, uncached history 714 vs 53 req/s;
25 MB resident when serving only.
Matrix (MATRIX_*): the notifier sends the room the transitions ntfy gets
(per station, outlook, feed; not ntfy's per-topic summaries), each once,
with its own matrix: keys in notification_state, seeded from ntfy's on first
use so nothing already announced repeats. Checked against a fake homeserver.
It replaces the `run.py --alert-check` cron job (repeated alerts every 15
min), which install_collector.sh now comments out (crontab backup kept).
install_collector.sh: --check-api (ping-collector serve on :8001 next to
FastAPI, api_parity.py), --cutover-api (refuses on a difference; sets
WEB_LISTEN, stops and disables water-monitor.service), --rollback-api;
--rollback-forecast and --rollback bring the Python app back as server.
ping-collector.service: MemoryMax 512M. Config::from_vars for tests; route
tests against a throwaway schema.
431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
//! /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<String> = 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##"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
|
<title>{TITLE} - Swagger UI</title>
|
|
</head>
|
|
<body>
|
|
<div id="swagger-ui">
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
<script>
|
|
const ui = SwaggerUIBundle({{
|
|
url: '/openapi.json',
|
|
"dom_id": "#swagger-ui",
|
|
"layout": "BaseLayout",
|
|
"deepLinking": true,
|
|
"showExtensions": true,
|
|
"showCommonExtensions": true,
|
|
presets: [
|
|
SwaggerUIBundle.presets.apis,
|
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
|
],
|
|
}})
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"##
|
|
))
|
|
}
|
|
|
|
pub async fn redoc() -> Response {
|
|
html(format!(
|
|
r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{TITLE} - ReDoc</title>
|
|
<meta charset="utf-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>body {{ margin: 0; padding: 0; }}</style>
|
|
</head>
|
|
<body>
|
|
<noscript>ReDoc requires Javascript to function. Please enable it to browse the documentation.</noscript>
|
|
<redoc spec-url="/openapi.json"></redoc>
|
|
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"></script>
|
|
</body>
|
|
</html>
|
|
"#
|
|
))
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|