use std::time::Duration; use sea_orm::ConnectOptions; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct DatabaseConfig { pub url: Option, pub host: Option, pub port: Option, pub username: Option, pub password: Option, pub database: Option, pub schema_search_path: Option, pub application_name: Option, pub ssl_mode: Option, pub max_connections: Option, pub min_connections: Option, pub connect_timeout_secs: Option, pub idle_timeout_secs: Option, pub acquire_timeout_secs: Option, pub max_lifetime_secs: Option, pub sqlx_logging: Option, pub replicas: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct DatabaseEndpoint { pub url: Option, pub host: Option, pub port: Option, pub username: Option, pub password: Option, pub database: Option, pub application_name: Option, pub ssl_mode: Option, pub max_connections: Option, pub min_connections: Option, pub connect_timeout_secs: Option, pub idle_timeout_secs: Option, pub acquire_timeout_secs: Option, pub max_lifetime_secs: Option, } impl DatabaseEndpoint { pub fn url(&self) -> String { if let Some(url) = self.url.as_deref().filter(|s| !s.is_empty()) { return url.to_string(); } let host = self.host.as_deref().unwrap_or("localhost"); let port = self.port.unwrap_or(5432); let user = self.username.as_deref().unwrap_or("postgres"); let db = self.database.as_deref().unwrap_or("postgres"); let password = match &self.password { Some(p) if !p.is_empty() => format!(":{p}"), _ => String::new(), }; let mut url = format!("postgres://{user}{password}@{host}:{port}/{db}"); let mut params: Vec = Vec::new(); if let Some(app) = self.application_name.as_deref().filter(|s| !s.is_empty()) { params.push(format!("application_name={}", url_encode(app))); } if let Some(mode) = self.ssl_mode { params.push(format!("sslmode={}", mode.as_query_value())); } if !params.is_empty() { url.push('?'); url.push_str(¶ms.join("&")); } url } pub fn validate(&self) -> Result<(), String> { if self.url.as_deref().is_some_and(|s| !s.is_empty()) { return Ok(()); } if self.host.as_deref().filter(|s| !s.is_empty()).is_none() { return Err("endpoint.host must be set when endpoint.url is not provided".to_string()); } if matches!(self.port, Some(0)) { return Err("endpoint.port must be greater than 0".to_string()); } Ok(()) } pub fn apply_to(&self, opts: &mut ConnectOptions) -> Result<(), String> { self.validate()?; if let Some(v) = self.max_connections { opts.max_connections(v); } if let Some(v) = self.min_connections { opts.min_connections(v); } if let Some(v) = self.connect_timeout_secs { opts.connect_timeout(Duration::from_secs(v)); } if let Some(v) = self.idle_timeout_secs { opts.idle_timeout(Duration::from_secs(v)); } if let Some(v) = self.acquire_timeout_secs { opts.acquire_timeout(Duration::from_secs(v)); } if let Some(v) = self.max_lifetime_secs { opts.max_lifetime(Duration::from_secs(v)); } Ok(()) } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SslMode { #[default] Disable, Prefer, Require, VerifyCa, VerifyFull, } impl SslMode { pub fn as_query_value(&self) -> &'static str { match self { SslMode::Disable => "disable", SslMode::Prefer => "prefer", SslMode::Require => "require", SslMode::VerifyCa => "verify-ca", SslMode::VerifyFull => "verify-full", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DatabaseRole { Primary, Replica, } impl DatabaseConfig { pub fn primary_url(&self) -> String { let endpoint = DatabaseEndpoint { url: self.url.clone(), host: self.host.clone(), port: self.port, username: self.username.clone(), password: self.password.clone(), database: self.database.clone(), application_name: self.application_name.clone(), ssl_mode: self.ssl_mode, max_connections: self.max_connections, min_connections: self.min_connections, connect_timeout_secs: self.connect_timeout_secs, idle_timeout_secs: self.idle_timeout_secs, acquire_timeout_secs: self.acquire_timeout_secs, max_lifetime_secs: self.max_lifetime_secs, }; endpoint.url() } pub fn replica_url(&self, index: usize) -> Option { self.replicas.get(index).map(|r| r.url()) } pub fn replica_urls(&self) -> Vec { self.replicas.iter().map(|r| r.url()).collect() } pub fn replica_count(&self) -> usize { self.replicas.len() } pub fn has_replicas(&self) -> bool { !self.replicas.is_empty() } pub fn validate(&self) -> Result<(), String> { if self.url.as_deref().is_some_and(|s| !s.is_empty()) { return Ok(()); } if self.host.as_deref().filter(|s| !s.is_empty()).is_none() { return Err("database.host must be set when database.url is not provided".to_string()); } if matches!(self.port, Some(0)) { return Err("database.port must be greater than 0".to_string()); } for (i, replica) in self.replicas.iter().enumerate() { replica .validate() .map_err(|e| format!("replicas[{i}]: {e}"))?; } Ok(()) } pub fn primary_connect_options(&self) -> Result { self.validate()?; let url = self.primary_url(); let mut opts = ConnectOptions::new(url); if let Some(v) = self.schema_search_path.as_deref().filter(|s| !s.is_empty()) { opts.set_schema_search_path(v); } if let Some(v) = self.sqlx_logging { opts.sqlx_logging(v); } let endpoint = DatabaseEndpoint { url: self.url.clone(), host: self.host.clone(), port: self.port, username: self.username.clone(), password: self.password.clone(), database: self.database.clone(), application_name: self.application_name.clone(), ssl_mode: self.ssl_mode, max_connections: self.max_connections, min_connections: self.min_connections, connect_timeout_secs: self.connect_timeout_secs, idle_timeout_secs: self.idle_timeout_secs, acquire_timeout_secs: self.acquire_timeout_secs, max_lifetime_secs: self.max_lifetime_secs, }; endpoint.apply_to(&mut opts)?; Ok(opts) } pub fn replica_connect_options(&self, index: usize) -> Result { let replica = self .replicas .get(index) .ok_or_else(|| format!("replica index {index} out of range"))?; replica.validate()?; let url = replica.url(); let mut opts = ConnectOptions::new(url); if let Some(v) = self.schema_search_path.as_deref().filter(|s| !s.is_empty()) { opts.set_schema_search_path(v); } if let Some(v) = self.sqlx_logging { opts.sqlx_logging(v); } replica.apply_to(&mut opts)?; Ok(opts) } pub fn connect_options_for(&self, role: DatabaseRole) -> Result { match role { DatabaseRole::Primary => self.primary_connect_options(), DatabaseRole::Replica => { if self.replicas.is_empty() { return self.primary_connect_options(); } self.replica_connect_options(0) } } } } fn url_encode(value: &str) -> String { let mut out = String::with_capacity(value.len()); for b in value.as_bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { out.push(*b as char); } _ => out.push_str(&format!("%{:02X}", b)), } } out }