1use rustlavel_core::{Config, Error, Result};
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct DatabaseConfig {
8 pub driver: String,
10 pub host: String,
11 pub port: u16,
12 pub user: String,
13 pub password: String,
14 pub database: String,
15 pub application_name: String,
16 pub max_connections: usize,
18 pub connect_timeout: Duration,
19 pub query_timeout: Duration,
21 pub tls_mode: crate::tls::TlsMode,
25 pub tls_root_certificate: Option<String>,
28 pub credentials: Option<crate::credentials::Credentials>,
32}
33
34impl Default for DatabaseConfig {
35 fn default() -> Self {
36 DatabaseConfig {
37 driver: "postgres".into(),
38 host: "127.0.0.1".into(),
39 port: 5432,
40 user: "postgres".into(),
41 password: String::new(),
42 database: "postgres".into(),
43 application_name: "rustlavel".into(),
44 max_connections: 10,
45 connect_timeout: Duration::from_secs(10),
46 query_timeout: Duration::from_secs(30),
47 tls_mode: crate::tls::TlsMode::default(),
48 tls_root_certificate: None,
49 credentials: None,
50 }
51 }
52}
53
54impl DatabaseConfig {
55 pub fn from_url(url: &str) -> Result<Self> {
61 let (scheme, rest) = url.split_once("://").ok_or_else(|| {
62 Error::msg(format!(
63 "`{url}` has no scheme. Expected postgres://, mysql:// or sqlserver:// \
64 followed by user:password@host:port/database"
65 ))
66 })?;
67
68 let (driver, default_port) = match scheme.to_ascii_lowercase().as_str() {
71 "postgres" | "postgresql" | "pgsql" => ("postgres", 5432),
72 "mysql" | "mariadb" => ("mysql", 3306),
73 "sqlserver" | "mssql" => ("sqlserver", 1433),
74 other => {
75 return Err(Error::msg(format!(
76 "`{other}` is not a database this framework speaks. \
77 Available schemes: postgres, mysql, sqlserver."
78 )));
79 }
80 };
81
82 let mut config = DatabaseConfig {
83 driver: driver.to_string(),
84 port: default_port,
85 ..DatabaseConfig::default()
86 };
87
88 let (rest, query) = match rest.split_once('?') {
91 Some((rest, query)) => (rest, Some(query)),
92 None => (rest, None),
93 };
94
95 let (credentials, host_part) = match rest.rsplit_once('@') {
98 Some((credentials, host)) => (Some(credentials), host),
99 None => (None, rest),
100 };
101
102 if let Some(credentials) = credentials {
103 let (user, password) = match credentials.split_once(':') {
104 Some((user, password)) => (user, password),
105 None => (credentials, ""),
106 };
107 if !user.is_empty() {
108 config.user = decode(user);
109 }
110 config.password = decode(password);
111 }
112
113 let (host, database) = match host_part.split_once('/') {
114 Some((host, database)) => (host, database),
115 None => (host_part, ""),
116 };
117
118 if !database.is_empty() {
119 config.database = decode(database);
120 }
121
122 if !host.is_empty() {
123 let (name, port) = match host.rsplit_once(':') {
124 Some((name, port)) => (name, Some(port)),
125 None => (host, None),
126 };
127 if !name.is_empty() {
128 config.host = name.to_string();
129 }
130 if let Some(port) = port {
131 config.port = port
132 .parse()
133 .map_err(|_| Error::msg(format!("`{port}` is not a valid port number")))?;
134 }
135 }
136
137 for (key, value) in query.into_iter().flat_map(|q| q.split('&')).filter_map(|p| p.split_once('='))
138 {
139 match key {
140 "application_name" => config.application_name = decode(value),
141 "max_connections" => {
142 config.max_connections = value.parse().unwrap_or(config.max_connections)
143 }
144 "connect_timeout" => {
145 if let Ok(seconds) = value.parse() {
146 config.connect_timeout = Duration::from_secs(seconds);
147 }
148 }
149 "sslmode" | "ssl-mode" | "ssl_mode" => {
153 config.tls_mode = crate::tls::TlsMode::parse(&decode(value))?
154 }
155 "sslrootcert" | "ssl-ca" | "ssl_ca" => {
156 config.tls_root_certificate = Some(decode(value))
157 }
158 _ => {}
159 }
160 }
161
162 Ok(config)
163 }
164
165 pub fn resolved(&self) -> DatabaseConfig {
172 let Some(credentials) = &self.credentials else { return self.clone() };
173
174 let (user, password) = credentials.current();
175 DatabaseConfig { user, password, ..self.clone() }
176 }
177
178 pub fn generation(&self) -> u64 {
183 self.credentials.as_ref().map_or(0, |credentials| credentials.generation())
184 }
185
186 pub fn dialect(&self) -> Result<Box<dyn crate::dialect::Dialect>> {
188 crate::dialect::by_name(&self.driver)
189 }
190
191 pub fn from_app_config(config: &Config) -> Result<Self> {
193 if let Some(url) = config.get("database.url").and_then(|v| v.as_str().map(str::to_string))
194 && !url.is_empty() {
195 return DatabaseConfig::from_url(&url);
196 }
197 if let Ok(url) = std::env::var("DATABASE_URL")
198 && !url.is_empty() {
199 return DatabaseConfig::from_url(&url);
200 }
201
202 let mut settings = DatabaseConfig {
203 driver: config.string("database.driver", "postgres"),
204 host: config.string("database.host", "127.0.0.1"),
205 port: config.int("database.port", 5432) as u16,
206 user: config.string("database.user", "postgres"),
207 password: config.string("database.password", ""),
208 database: config.string("database.name", "postgres"),
209 ..DatabaseConfig::default()
210 };
211 settings.max_connections = config.int("database.max_connections", 10).max(1) as usize;
212 settings.application_name = config.string("app.name", "rustlavel");
213 Ok(settings)
214 }
215
216 pub fn redacted_url(&self) -> String {
218 let password = if self.password.is_empty() { "" } else { ":***" };
219 format!(
220 "{}://{}{password}@{}:{}/{}",
221 self.driver, self.user, self.host, self.port, self.database
222 )
223 }
224}
225
226fn decode(value: &str) -> String {
227 if !value.contains('%') {
228 return value.to_string();
229 }
230
231 let bytes = value.as_bytes();
232 let mut out = Vec::with_capacity(bytes.len());
233 let mut index = 0;
234 while index < bytes.len() {
235 if bytes[index] == b'%' && index + 2 < bytes.len()
236 && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
237 out.push(byte);
238 index += 3;
239 continue;
240 }
241 out.push(bytes[index]);
242 index += 1;
243 }
244 String::from_utf8_lossy(&out).into_owned()
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn parses_a_full_url() {
253 let config = DatabaseConfig::from_url("postgres://ada:hunter2@db.internal:6543/blog").unwrap();
254
255 assert_eq!(config.user, "ada");
256 assert_eq!(config.password, "hunter2");
257 assert_eq!(config.host, "db.internal");
258 assert_eq!(config.port, 6543);
259 assert_eq!(config.database, "blog");
260 }
261
262 #[test]
263 fn falls_back_to_defaults_for_missing_parts() {
264 let config = DatabaseConfig::from_url("postgres://localhost/blog").unwrap();
265
266 assert_eq!(config.host, "localhost");
267 assert_eq!(config.port, 5432);
268 assert_eq!(config.user, "postgres");
269 assert_eq!(config.database, "blog");
270 }
271
272 #[test]
273 fn a_password_may_contain_an_at_sign() {
274 let config = DatabaseConfig::from_url("postgres://ada:p%40ss@host/blog").unwrap();
275 assert_eq!(config.password, "p@ss");
276 }
277
278 #[test]
279 fn reads_query_parameters() {
280 let config =
281 DatabaseConfig::from_url("postgres://host/blog?application_name=worker&max_connections=25")
282 .unwrap();
283
284 assert_eq!(config.application_name, "worker");
285 assert_eq!(config.max_connections, 25);
286 }
287
288 #[test]
289 fn the_scheme_chooses_the_driver_and_its_default_port() {
290 for (url, driver, port) in [
291 ("postgres://host/blog", "postgres", 5432),
292 ("postgresql://host/blog", "postgres", 5432),
293 ("mysql://host/blog", "mysql", 3306),
294 ("mariadb://host/blog", "mysql", 3306),
295 ("sqlserver://host/blog", "sqlserver", 1433),
296 ("mssql://host/blog", "sqlserver", 1433),
297 ] {
298 let config = DatabaseConfig::from_url(url).unwrap();
299 assert_eq!(config.driver, driver, "for {url}");
300 assert_eq!(config.port, port, "for {url}");
301 }
302 }
303
304 #[test]
305 fn an_explicit_port_still_wins_over_the_default() {
306 assert_eq!(DatabaseConfig::from_url("mysql://host:3307/blog").unwrap().port, 3307);
307 }
308
309 #[test]
310 fn a_configuration_knows_its_dialect() {
311 for (url, dialect) in [
312 ("postgres://host/b", "postgres"),
313 ("mysql://host/b", "mysql"),
314 ("sqlserver://host/b", "sqlserver"),
315 ] {
316 assert_eq!(
317 DatabaseConfig::from_url(url).unwrap().dialect().unwrap().name(),
318 dialect
319 );
320 }
321 }
322
323 #[test]
324 fn an_unsupported_database_lists_the_ones_that_work() {
325 let error = DatabaseConfig::from_url("oracle://host/blog").unwrap_err().to_string();
326 assert!(error.contains("postgres, mysql, sqlserver"), "{error}");
327
328 let missing = DatabaseConfig::from_url("just-a-host/blog").unwrap_err().to_string();
329 assert!(missing.contains("has no scheme"), "{missing}");
330 }
331
332 #[test]
333 fn never_prints_the_password() {
334 for url in [
335 "postgres://ada:hunter2@host/blog",
336 "mysql://ada:hunter2@host/blog",
337 "sqlserver://ada:hunter2@host/blog",
338 ] {
339 let shown = DatabaseConfig::from_url(url).unwrap().redacted_url();
340
341 assert!(!shown.contains("hunter2"), "the password leaked into {shown}");
342 assert!(shown.contains("ada"));
343 }
344 }
345}