//! Configuration from the same environment / `.env` file as the Python app, //! so both services can share `/opt/thailand-water-monitor/.env`. use anyhow::{Context, Result, bail}; use std::env; use std::path::PathBuf; pub const RID_API_URL: &str = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"; pub const HII_API_BASE: &str = "https://api-v3.thaiwater.net/api/v1/thaiwater30/public"; pub const RID_DAMS_URL: &str = "https://app.rid.go.th/reservoir/api/dams"; pub const OPENMETEO_URL: &str = "https://api.open-meteo.com/v1/forecast"; /// The RID endpoint serves the same JSON to any client; keep the UA the /// Python collector has used for years. pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \ (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"; #[derive(Clone, Debug)] pub struct Config { pub database_url: String, pub rid_api_url: String, pub hii_api_base: String, pub dams_url: String, pub openmeteo_url: String, pub hii_enabled: bool, pub dams_enabled: bool, pub rain_enabled: bool, pub hii_basin_code: i64, /// RID is polled this many minutes past each hour. pub poll_offset_minutes: u32, /// While the current hour is not published yet, retry this often. pub retry_minutes: u32, /// Days of RID holes re-fetched at startup (0 = off). pub gap_fill_days_on_start: u32, pub station_config_path: PathBuf, /// Status JSON read by the Python API's /scraping/status. pub status_file: Option, } fn var(name: &str) -> Option { env::var(name) .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) } fn flag(name: &str, default: bool) -> bool { var(name).map_or(default, |v| { matches!(v.to_lowercase().as_str(), "1" | "true" | "yes") }) } fn number(name: &str, default: T) -> Result where T::Err: std::fmt::Display, { match var(name) { None => Ok(default), Some(v) => v.parse().map_err(|e| anyhow::anyhow!("{name}={v:?}: {e}")), } } /// Percent-encode everything outside RFC 3986 "unreserved" (as Python's /// `urllib.parse.quote(password, safe="")`). fn encode_userinfo(s: &str) -> String { s.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } /// SQLAlchemy URLs carry a driver suffix (`postgresql+psycopg2://`) that /// sqlx does not understand. pub fn normalize_database_url(url: &str) -> String { match url.split_once("://") { Some((scheme, rest)) if scheme.starts_with("postgresql") || scheme.starts_with("postgres") => { format!("postgres://{rest}") } _ => url.to_string(), } } impl Config { pub fn from_env() -> Result { let database_url = match var("POSTGRES_CONNECTION_STRING") { Some(url) => normalize_database_url(&url), None => { let password = var("POSTGRES_PASSWORD") .context("POSTGRES_CONNECTION_STRING or POSTGRES_PASSWORD must be set")?; format!( "postgres://{}:{}@{}:{}/{}", encode_userinfo(&var("POSTGRES_USER").unwrap_or_else(|| "postgres".into())), encode_userinfo(&password), var("POSTGRES_HOST").unwrap_or_else(|| "localhost".into()), var("POSTGRES_PORT").unwrap_or_else(|| "5432".into()), var("POSTGRES_DB").unwrap_or_else(|| "water_monitoring".into()), ) } }; if let Some(db_type) = var("DB_TYPE") && db_type != "postgresql" { bail!("ping-collector supports PostgreSQL only (DB_TYPE={db_type})"); } let cfg = Config { database_url, rid_api_url: var("RID_API_URL").unwrap_or_else(|| RID_API_URL.into()), hii_api_base: var("HII_API_BASE").unwrap_or_else(|| HII_API_BASE.into()), dams_url: var("RID_DAMS_URL").unwrap_or_else(|| RID_DAMS_URL.into()), openmeteo_url: var("OPENMETEO_URL").unwrap_or_else(|| OPENMETEO_URL.into()), hii_enabled: flag("ENABLE_HII_COLLECTION", true), dams_enabled: flag("ENABLE_RESERVOIR_COLLECTION", true), rain_enabled: flag("ENABLE_RAIN_COLLECTION", true), hii_basin_code: number("HII_BASIN_CODE", 6)?, poll_offset_minutes: number("RID_POLL_OFFSET_MINUTES", 5)?, retry_minutes: number("RID_RETRY_MINUTES", 10)?, gap_fill_days_on_start: number("GAP_FILL_DAYS_ON_START", 14)?, station_config_path: var("STATION_CONFIG_PATH") .unwrap_or_else(|| "stations.json".into()) .into(), status_file: var("SCRAPING_STATUS_FILE").map(PathBuf::from), }; if cfg.poll_offset_minutes >= 60 { bail!("RID_POLL_OFFSET_MINUTES must be between 0 and 59"); } if cfg.retry_minutes == 0 { bail!("RID_RETRY_MINUTES must be positive"); } Ok(cfg) } /// The URL with the password masked, for logs. pub fn redacted_database_url(&self) -> String { match (self.database_url.find("://"), self.database_url.rfind('@')) { (Some(s), Some(at)) if at > s => { let creds = &self.database_url[s + 3..at]; let user = creds.split(':').next().unwrap_or(""); format!( "{}{user}:***{}", &self.database_url[..s + 3], &self.database_url[at..] ) } _ => self.database_url.clone(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn sqlalchemy_urls_are_normalized() { assert_eq!( normalize_database_url("postgresql+psycopg2://u:p@h:5432/db"), "postgres://u:p@h:5432/db" ); assert_eq!( normalize_database_url("postgresql://u@h/db"), "postgres://u@h/db" ); } #[test] fn passwords_are_percent_encoded() { assert_eq!(encode_userinfo("p@ss:w/rd"), "p%40ss%3Aw%2Frd"); } }