Skip to main content

klauthed_core/config/schema/
database.rs

1//! Database configuration (`DatabaseConfig`).
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// Supported database systems — relational and NoSQL.
8///
9/// This is config-layer metadata only; it tells downstream crates which driver
10/// to use and how to shape a connection string. Adding a system here does not by
11/// itself wire a pool — that lives in `klauthed-data`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum DbSystem {
15    /// PostgreSQL (the default).
16    #[default]
17    Postgres,
18    /// MySQL.
19    #[serde(rename = "mysql")]
20    MySql,
21    /// MariaDB.
22    #[serde(rename = "mariadb")]
23    MariaDb,
24    /// SQLite (file-based; no host/port).
25    Sqlite,
26    /// NoSQL document store.
27    #[serde(rename = "mongodb")]
28    MongoDb,
29}
30
31impl DbSystem {
32    /// The conventional default port, or `None` for file-based engines (SQLite).
33    pub fn default_port(&self) -> Option<u16> {
34        match self {
35            DbSystem::Postgres => Some(5432),
36            DbSystem::MySql | DbSystem::MariaDb => Some(3306),
37            DbSystem::MongoDb => Some(27017),
38            DbSystem::Sqlite => None,
39        }
40    }
41
42    /// The URL scheme used in a connection string.
43    pub fn scheme(&self) -> &'static str {
44        match self {
45            DbSystem::Postgres => "postgres",
46            DbSystem::MySql | DbSystem::MariaDb => "mysql",
47            DbSystem::Sqlite => "sqlite",
48            DbSystem::MongoDb => "mongodb",
49        }
50    }
51
52    /// Whether this system is relational (vs. a document/NoSQL store).
53    pub fn is_relational(&self) -> bool {
54        !matches!(self, DbSystem::MongoDb)
55    }
56}
57
58/// Connection-pool tuning shared by database and cache configs.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct PoolConfig {
61    /// Maximum number of connections in the pool.
62    #[serde(default = "PoolConfig::default_max_connections")]
63    pub max_connections: u32,
64    /// Minimum number of idle connections kept warm.
65    #[serde(default)]
66    pub min_connections: u32,
67    /// How long to wait for a connection before erroring.
68    #[serde(default = "PoolConfig::default_acquire_timeout_secs")]
69    pub acquire_timeout_secs: u64,
70    /// Close a connection after it has been idle this long (`None` = never).
71    #[serde(default)]
72    pub idle_timeout_secs: Option<u64>,
73    /// Recycle a connection after this total lifetime (`None` = never).
74    #[serde(default)]
75    pub max_lifetime_secs: Option<u64>,
76}
77
78impl PoolConfig {
79    fn default_max_connections() -> u32 {
80        10
81    }
82    fn default_acquire_timeout_secs() -> u64 {
83        30
84    }
85}
86
87impl Default for PoolConfig {
88    fn default() -> Self {
89        Self {
90            max_connections: Self::default_max_connections(),
91            min_connections: 0,
92            acquire_timeout_secs: Self::default_acquire_timeout_secs(),
93            idle_timeout_secs: None,
94            max_lifetime_secs: None,
95        }
96    }
97}
98
99/// Connection details for a database, expressed either as components
100/// (host/port/credentials) or a full `url` that overrides them.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
102pub struct DatabaseConfig {
103    /// Which database system this describes.
104    #[serde(default)]
105    pub system: DbSystem,
106    /// Hostname (ignored when `url` is set or `system` is SQLite).
107    #[serde(default = "default_host")]
108    pub host: String,
109    /// Port; falls back to [`DbSystem::default_port`] when unset.
110    #[serde(default)]
111    pub port: Option<u16>,
112    /// Database / catalog name (or file path for SQLite).
113    #[serde(default)]
114    pub database: String,
115    /// Username (ignored when `url` is set).
116    #[serde(default)]
117    pub username: Option<String>,
118    /// Password. Prefer sourcing this from Vault in staging/prod.
119    #[serde(default)]
120    pub password: Option<String>,
121    /// A complete connection URL. When present it is used verbatim and the
122    /// component fields above are ignored.
123    #[serde(default)]
124    pub url: Option<String>,
125    /// Extra connection parameters appended as a query string (e.g. `sslmode`,
126    /// `replicaSet`).
127    #[serde(default)]
128    pub options: BTreeMap<String, String>,
129    /// Pool tuning.
130    #[serde(default)]
131    pub pool: PoolConfig,
132}
133
134fn default_host() -> String {
135    "localhost".to_owned()
136}
137
138impl DatabaseConfig {
139    /// The effective port: explicit `port`, else the system default.
140    pub fn effective_port(&self) -> Option<u16> {
141        self.port.or_else(|| self.system.default_port())
142    }
143
144    /// Build a connection URL from the components, or return `url` verbatim if set.
145    ///
146    /// Note: credentials are inserted as-is and not percent-encoded, so a
147    /// password containing URL-reserved characters should be supplied via the
148    /// pre-built `url` field instead.
149    pub fn connection_url(&self) -> String {
150        if let Some(url) = &self.url {
151            return url.clone();
152        }
153
154        let scheme = self.system.scheme();
155        if self.system == DbSystem::Sqlite {
156            return format!("{scheme}://{}", self.database);
157        }
158
159        let mut url = format!("{scheme}://");
160        if let Some(user) = &self.username {
161            url.push_str(user);
162            if let Some(password) = &self.password {
163                url.push(':');
164                url.push_str(password);
165            }
166            url.push('@');
167        }
168        url.push_str(&self.host);
169        if let Some(port) = self.effective_port() {
170            url.push(':');
171            url.push_str(&port.to_string());
172        }
173        url.push('/');
174        url.push_str(&self.database);
175
176        if !self.options.is_empty() {
177            let query =
178                self.options.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join("&");
179            url.push('?');
180            url.push_str(&query);
181        }
182        url
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use serde_json::json;
190
191    #[test]
192    fn deserializes_with_defaults() {
193        let cfg: DatabaseConfig = serde_json::from_value(json!({
194            "system": "postgres",
195            "database": "app",
196            "username": "svc",
197            "password": "pw"
198        }))
199        .unwrap();
200
201        assert_eq!(cfg.host, "localhost");
202        assert_eq!(cfg.effective_port(), Some(5432));
203        assert_eq!(cfg.pool.max_connections, 10);
204        assert_eq!(cfg.connection_url(), "postgres://svc:pw@localhost:5432/app");
205    }
206
207    #[test]
208    fn url_field_overrides_components() {
209        let cfg = DatabaseConfig { url: Some("postgres://custom/db".into()), ..Default::default() };
210        assert_eq!(cfg.connection_url(), "postgres://custom/db");
211    }
212
213    #[test]
214    fn mongodb_url_with_options() {
215        let mut options = BTreeMap::new();
216        options.insert("replicaSet".to_string(), "rs0".to_string());
217        let cfg = DatabaseConfig {
218            system: DbSystem::MongoDb,
219            host: "mongo".into(),
220            database: "app".into(),
221            options,
222            ..Default::default()
223        };
224        assert_eq!(cfg.effective_port(), Some(27017));
225        assert!(!cfg.system.is_relational());
226        assert_eq!(cfg.connection_url(), "mongodb://mongo:27017/app?replicaSet=rs0");
227    }
228
229    #[test]
230    fn db_system_external_names_are_natural() {
231        let cases = [
232            ("postgres", DbSystem::Postgres),
233            ("mysql", DbSystem::MySql),
234            ("mariadb", DbSystem::MariaDb),
235            ("sqlite", DbSystem::Sqlite),
236            ("mongodb", DbSystem::MongoDb),
237        ];
238        for (name, expected) in cases {
239            let parsed: DbSystem = serde_json::from_value(json!(name)).unwrap();
240            assert_eq!(parsed, expected, "deserializing {name}");
241            assert_eq!(serde_json::to_value(expected).unwrap(), json!(name));
242        }
243    }
244
245    #[test]
246    fn sqlite_uses_path() {
247        let cfg = DatabaseConfig {
248            system: DbSystem::Sqlite,
249            database: "/var/lib/app.db".into(),
250            ..Default::default()
251        };
252        assert_eq!(cfg.effective_port(), None);
253        assert_eq!(cfg.connection_url(), "sqlite:///var/lib/app.db");
254    }
255}