mas_config/sections/
database.rs1use std::{num::NonZeroU32, time::Duration};
9
10use camino::Utf8PathBuf;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_with::serde_as;
14
15use super::ConfigurationSection;
16use crate::schema;
17
18#[expect(clippy::unnecessary_wraps)]
19fn default_connection_string() -> Option<String> {
20 Some("postgresql://".to_owned())
21}
22
23fn default_max_connections() -> NonZeroU32 {
24 NonZeroU32::new(10).unwrap()
25}
26
27fn default_connect_timeout() -> Duration {
28 Duration::from_secs(30)
29}
30
31#[expect(clippy::unnecessary_wraps)]
32fn default_idle_timeout() -> Option<Duration> {
33 Some(Duration::from_mins(10))
34}
35
36#[expect(clippy::unnecessary_wraps)]
37fn default_max_lifetime() -> Option<Duration> {
38 Some(Duration::from_mins(30))
39}
40
41impl Default for DatabaseConfig {
42 fn default() -> Self {
43 Self {
44 uri: default_connection_string(),
45 host: None,
46 port: None,
47 socket: None,
48 username: None,
49 password: None,
50 password_file: None,
51 database: None,
52 ssl_mode: None,
53 ssl_ca: None,
54 ssl_ca_file: None,
55 ssl_certificate: None,
56 ssl_certificate_file: None,
57 ssl_key: None,
58 ssl_key_file: None,
59 max_connections: default_max_connections(),
60 min_connections: Default::default(),
61 connect_timeout: default_connect_timeout(),
62 idle_timeout: default_idle_timeout(),
63 max_lifetime: default_max_lifetime(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "kebab-case")]
72pub enum PgSslMode {
73 Disable,
75
76 Allow,
78
79 Prefer,
81
82 Require,
85
86 VerifyCa,
89
90 VerifyFull,
94}
95
96#[serde_as]
98#[derive(Debug, Serialize, Deserialize, JsonSchema)]
99pub struct DatabaseConfig {
100 #[serde(skip_serializing_if = "Option::is_none")]
105 #[schemars(url, default = "default_connection_string")]
106 pub uri: Option<String>,
107
108 #[serde(skip_serializing_if = "Option::is_none")]
112 #[schemars(with = "Option::<schema::Hostname>")]
113 pub host: Option<String>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
119 #[schemars(range(min = 1, max = 65535))]
120 pub port: Option<u16>,
121
122 #[serde(skip_serializing_if = "Option::is_none")]
126 #[schemars(with = "Option<String>")]
127 pub socket: Option<Utf8PathBuf>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
133 pub username: Option<String>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
139 pub password: Option<String>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
147 #[schemars(with = "Option<String>")]
148 pub password_file: Option<Utf8PathBuf>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
154 pub database: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub ssl_mode: Option<PgSslMode>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
164 pub ssl_ca: Option<String>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
170 #[schemars(with = "Option<String>")]
171 pub ssl_ca_file: Option<Utf8PathBuf>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
178 pub ssl_certificate: Option<String>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
184 #[schemars(with = "Option<String>")]
185 pub ssl_certificate_file: Option<Utf8PathBuf>,
186
187 #[serde(skip_serializing_if = "Option::is_none")]
191 pub ssl_key: Option<String>,
192
193 #[serde(skip_serializing_if = "Option::is_none")]
197 #[schemars(with = "Option<String>")]
198 pub ssl_key_file: Option<Utf8PathBuf>,
199
200 #[serde(default = "default_max_connections")]
202 pub max_connections: NonZeroU32,
203
204 #[serde(default)]
206 pub min_connections: u32,
207
208 #[schemars(with = "u64")]
210 #[serde(default = "default_connect_timeout")]
211 #[serde_as(as = "serde_with::DurationSeconds<u64>")]
212 pub connect_timeout: Duration,
213
214 #[schemars(with = "Option<u64>")]
216 #[serde(
217 default = "default_idle_timeout",
218 skip_serializing_if = "Option::is_none"
219 )]
220 #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
221 pub idle_timeout: Option<Duration>,
222
223 #[schemars(with = "u64")]
225 #[serde(
226 default = "default_max_lifetime",
227 skip_serializing_if = "Option::is_none"
228 )]
229 #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
230 pub max_lifetime: Option<Duration>,
231}
232
233impl ConfigurationSection for DatabaseConfig {
234 const PATH: Option<&'static str> = Some("database");
235
236 fn validate(
237 &self,
238 figment: &figment::Figment,
239 ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
240 let metadata = figment.find_metadata(Self::PATH.unwrap());
241 let annotate = |mut error: figment::Error| {
242 error.metadata = metadata.cloned();
243 error.profile = Some(figment::Profile::Default);
244 error.path = vec![Self::PATH.unwrap().to_owned()];
245 error
246 };
247
248 let has_split_options = self.host.is_some()
251 || self.port.is_some()
252 || self.socket.is_some()
253 || self.username.is_some()
254 || self.password.is_some()
255 || self.password_file.is_some()
256 || self.database.is_some();
257
258 if self.uri.is_some() && has_split_options {
259 return Err(annotate(figment::error::Error::from(
260 "uri must not be specified if host, port, socket, username, password, password_file, or database are specified".to_owned(),
261 )).into());
262 }
263
264 if self.password.is_some() && self.password_file.is_some() {
265 return Err(annotate(figment::error::Error::from(
266 "password must not be specified if password_file is specified".to_owned(),
267 ))
268 .into());
269 }
270
271 if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() {
272 return Err(annotate(figment::error::Error::from(
273 "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(),
274 ))
275 .into());
276 }
277
278 if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() {
279 return Err(annotate(figment::error::Error::from(
280 "ssl_certificate must not be specified if ssl_certificate_file is specified"
281 .to_owned(),
282 ))
283 .into());
284 }
285
286 if self.ssl_key.is_some() && self.ssl_key_file.is_some() {
287 return Err(annotate(figment::error::Error::from(
288 "ssl_key must not be specified if ssl_key_file is specified".to_owned(),
289 ))
290 .into());
291 }
292
293 if (self.ssl_key.is_some() || self.ssl_key_file.is_some())
294 ^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some())
295 {
296 return Err(annotate(figment::error::Error::from(
297 "both a ssl_certificate and a ssl_key must be set at the same time or none of them"
298 .to_owned(),
299 ))
300 .into());
301 }
302
303 Ok(())
304 }
305}
306#[cfg(test)]
307mod tests {
308 #![expect(clippy::result_large_err)]
311
312 use figment::{
313 Figment, Jail,
314 providers::{Format, Yaml},
315 };
316
317 use super::*;
318
319 #[test]
320 fn load_config() {
321 Jail::expect_with(|jail| {
322 jail.create_file(
323 "config.yaml",
324 r"
325 database:
326 uri: postgresql://user:password@host/database
327 ",
328 )?;
329
330 let config = Figment::new()
331 .merge(Yaml::file("config.yaml"))
332 .extract_inner::<DatabaseConfig>("database")?;
333
334 assert_eq!(
335 config.uri.as_deref(),
336 Some("postgresql://user:password@host/database")
337 );
338
339 Ok(())
340 });
341 }
342}