1use rustlavel_core::{Config, Error, Result};
4use std::time::Duration;
5
6pub const SQLITE_IN_MEMORY: &str = ":memory:";
12
13#[derive(Debug, Clone)]
14pub struct DatabaseConfig {
15 pub driver: String,
17 pub host: String,
18 pub port: u16,
19 pub user: String,
20 pub password: String,
21 pub database: String,
22 pub application_name: String,
23 pub max_connections: usize,
25 pub connect_timeout: Duration,
26 pub query_timeout: Duration,
28 pub tls_mode: crate::tls::TlsMode,
32 pub tls_root_certificate: Option<String>,
35 pub credentials: Option<crate::credentials::Credentials>,
39}
40
41impl Default for DatabaseConfig {
42 fn default() -> Self {
43 DatabaseConfig {
44 driver: "postgres".into(),
45 host: "127.0.0.1".into(),
46 port: 5432,
47 user: "postgres".into(),
48 password: String::new(),
49 database: "postgres".into(),
50 application_name: "rustlavel".into(),
51 max_connections: 10,
52 connect_timeout: Duration::from_secs(10),
53 query_timeout: Duration::from_secs(30),
54 tls_mode: crate::tls::TlsMode::default(),
55 tls_root_certificate: None,
56 credentials: None,
57 }
58 }
59}
60
61impl DatabaseConfig {
62 pub fn sqlite(path: &str) -> DatabaseConfig {
77 let path = path.split('?').next().unwrap_or(path);
81 let path = if path.is_empty() { SQLITE_IN_MEMORY } else { path };
82
83 DatabaseConfig {
84 driver: "sqlite".into(),
85 host: String::new(),
86 port: 0,
87 user: String::new(),
88 password: String::new(),
89 database: path.to_string(),
90 max_connections: 4,
94 tls_mode: crate::tls::TlsMode::Disable,
95 ..DatabaseConfig::default()
96 }
97 }
98
99 pub fn from_url(url: &str) -> Result<Self> {
105 let (scheme, rest) = url.split_once("://").ok_or_else(|| {
106 Error::msg(format!(
107 "`{url}` has no scheme. Expected postgres://, mysql:// or sqlserver:// \
108 followed by user:password@host:port/database, or sqlite:// followed by a path"
109 ))
110 })?;
111
112 let (driver, default_port) = match scheme.to_ascii_lowercase().as_str() {
115 "postgres" | "postgresql" | "pgsql" => ("postgres", 5432),
116 "mysql" | "mariadb" => ("mysql", 3306),
117 "sqlserver" | "mssql" => ("sqlserver", 1433),
118 "sqlite" | "sqlite3" => return Ok(DatabaseConfig::sqlite(rest)),
121 other => {
122 return Err(Error::msg(format!(
123 "`{other}` is not a database this framework speaks. \
124 Available schemes: postgres, mysql, sqlserver, sqlite."
125 )));
126 }
127 };
128
129 let mut config = DatabaseConfig {
130 driver: driver.to_string(),
131 port: default_port,
132 ..DatabaseConfig::default()
133 };
134
135 let (rest, query) = match rest.split_once('?') {
138 Some((rest, query)) => (rest, Some(query)),
139 None => (rest, None),
140 };
141
142 let (credentials, host_part) = match rest.rsplit_once('@') {
145 Some((credentials, host)) => (Some(credentials), host),
146 None => (None, rest),
147 };
148
149 if let Some(credentials) = credentials {
150 let (user, password) = match credentials.split_once(':') {
151 Some((user, password)) => (user, password),
152 None => (credentials, ""),
153 };
154 if !user.is_empty() {
155 config.user = decode(user);
156 }
157 config.password = decode(password);
158 }
159
160 let (host, database) = match host_part.split_once('/') {
161 Some((host, database)) => (host, database),
162 None => (host_part, ""),
163 };
164
165 if !database.is_empty() {
166 config.database = decode(database);
167 }
168
169 if !host.is_empty() {
170 let (name, port) = match host.rsplit_once(':') {
171 Some((name, port)) => (name, Some(port)),
172 None => (host, None),
173 };
174 if !name.is_empty() {
175 config.host = name.to_string();
176 }
177 if let Some(port) = port {
178 config.port = port
179 .parse()
180 .map_err(|_| Error::msg(format!("`{port}` is not a valid port number")))?;
181 }
182 }
183
184 for (key, value) in query.into_iter().flat_map(|q| q.split('&')).filter_map(|p| p.split_once('='))
185 {
186 match key {
187 "application_name" => config.application_name = decode(value),
188 "max_connections" => {
189 config.max_connections = value.parse().unwrap_or(config.max_connections)
190 }
191 "connect_timeout" => {
192 if let Ok(seconds) = value.parse() {
193 config.connect_timeout = Duration::from_secs(seconds);
194 }
195 }
196 "sslmode" | "ssl-mode" | "ssl_mode" => {
200 config.tls_mode = crate::tls::TlsMode::parse(&decode(value))?
201 }
202 "sslrootcert" | "ssl-ca" | "ssl_ca" => {
203 config.tls_root_certificate = Some(decode(value))
204 }
205 _ => {}
206 }
207 }
208
209 Ok(config)
210 }
211
212 pub fn resolved(&self) -> DatabaseConfig {
219 let Some(credentials) = &self.credentials else { return self.clone() };
220
221 let (user, password) = credentials.current();
222 DatabaseConfig { user, password, ..self.clone() }
223 }
224
225 pub fn generation(&self) -> u64 {
230 self.credentials.as_ref().map_or(0, |credentials| credentials.generation())
231 }
232
233 pub fn dialect(&self) -> Result<Box<dyn crate::dialect::Dialect>> {
235 crate::dialect::by_name(&self.driver)
236 }
237
238 pub fn from_app_config(config: &Config) -> Result<Self> {
240 if let Some(url) = config.get("database.url").and_then(|v| v.as_str().map(str::to_string))
241 && !url.is_empty() {
242 return DatabaseConfig::from_url(&url);
243 }
244 if let Ok(url) = std::env::var("DATABASE_URL")
245 && !url.is_empty() {
246 return DatabaseConfig::from_url(&url);
247 }
248
249 let mut settings = DatabaseConfig {
250 driver: config.string("database.driver", "postgres"),
251 host: config.string("database.host", "127.0.0.1"),
252 port: config.int("database.port", 5432) as u16,
253 user: config.string("database.user", "postgres"),
254 password: config.string("database.password", ""),
255 database: config.string("database.name", "postgres"),
256 ..DatabaseConfig::default()
257 };
258 settings.max_connections = config.int("database.max_connections", 10).max(1) as usize;
259 settings.application_name = config.string("app.name", "rustlavel");
260 Ok(settings)
261 }
262
263 pub fn redacted_url(&self) -> String {
265 let password = if self.password.is_empty() { "" } else { ":***" };
266 format!(
267 "{}://{}{password}@{}:{}/{}",
268 self.driver, self.user, self.host, self.port, self.database
269 )
270 }
271}
272
273fn decode(value: &str) -> String {
274 if !value.contains('%') {
275 return value.to_string();
276 }
277
278 let bytes = value.as_bytes();
279 let mut out = Vec::with_capacity(bytes.len());
280 let mut index = 0;
281 while index < bytes.len() {
282 if bytes[index] == b'%' && index + 2 < bytes.len()
283 && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
284 out.push(byte);
285 index += 3;
286 continue;
287 }
288 out.push(bytes[index]);
289 index += 1;
290 }
291 String::from_utf8_lossy(&out).into_owned()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn parses_a_full_url() {
300 let config = DatabaseConfig::from_url("postgres://ada:hunter2@db.internal:6543/blog").unwrap();
301
302 assert_eq!(config.user, "ada");
303 assert_eq!(config.password, "hunter2");
304 assert_eq!(config.host, "db.internal");
305 assert_eq!(config.port, 6543);
306 assert_eq!(config.database, "blog");
307 }
308
309 #[test]
310 fn falls_back_to_defaults_for_missing_parts() {
311 let config = DatabaseConfig::from_url("postgres://localhost/blog").unwrap();
312
313 assert_eq!(config.host, "localhost");
314 assert_eq!(config.port, 5432);
315 assert_eq!(config.user, "postgres");
316 assert_eq!(config.database, "blog");
317 }
318
319 #[test]
320 fn a_password_may_contain_an_at_sign() {
321 let config = DatabaseConfig::from_url("postgres://ada:p%40ss@host/blog").unwrap();
322 assert_eq!(config.password, "p@ss");
323 }
324
325 #[test]
326 fn reads_query_parameters() {
327 let config =
328 DatabaseConfig::from_url("postgres://host/blog?application_name=worker&max_connections=25")
329 .unwrap();
330
331 assert_eq!(config.application_name, "worker");
332 assert_eq!(config.max_connections, 25);
333 }
334
335 #[test]
336 fn the_scheme_chooses_the_driver_and_its_default_port() {
337 for (url, driver, port) in [
338 ("postgres://host/blog", "postgres", 5432),
339 ("postgresql://host/blog", "postgres", 5432),
340 ("mysql://host/blog", "mysql", 3306),
341 ("mariadb://host/blog", "mysql", 3306),
342 ("sqlserver://host/blog", "sqlserver", 1433),
343 ("mssql://host/blog", "sqlserver", 1433),
344 ] {
345 let config = DatabaseConfig::from_url(url).unwrap();
346 assert_eq!(config.driver, driver, "for {url}");
347 assert_eq!(config.port, port, "for {url}");
348 }
349 }
350
351 #[test]
352 fn an_explicit_port_still_wins_over_the_default() {
353 assert_eq!(DatabaseConfig::from_url("mysql://host:3307/blog").unwrap().port, 3307);
354 }
355
356 #[test]
357 fn a_configuration_knows_its_dialect() {
358 for (url, dialect) in [
359 ("postgres://host/b", "postgres"),
360 ("mysql://host/b", "mysql"),
361 ("sqlserver://host/b", "sqlserver"),
362 ] {
363 assert_eq!(
364 DatabaseConfig::from_url(url).unwrap().dialect().unwrap().name(),
365 dialect
366 );
367 }
368 }
369
370 #[test]
371 fn an_unsupported_database_lists_the_ones_that_work() {
372 let error = DatabaseConfig::from_url("oracle://host/blog").unwrap_err().to_string();
373 assert!(error.contains("postgres, mysql, sqlserver"), "{error}");
374
375 let missing = DatabaseConfig::from_url("just-a-host/blog").unwrap_err().to_string();
376 assert!(missing.contains("has no scheme"), "{missing}");
377 }
378
379 #[test]
380 fn never_prints_the_password() {
381 for url in [
382 "postgres://ada:hunter2@host/blog",
383 "mysql://ada:hunter2@host/blog",
384 "sqlserver://ada:hunter2@host/blog",
385 ] {
386 let shown = DatabaseConfig::from_url(url).unwrap().redacted_url();
387
388 assert!(!shown.contains("hunter2"), "the password leaked into {shown}");
389 assert!(shown.contains("ada"));
390 }
391 }
392}