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 database: None,
51 ssl_mode: None,
52 ssl_ca: None,
53 ssl_ca_file: None,
54 ssl_certificate: None,
55 ssl_certificate_file: None,
56 ssl_key: None,
57 ssl_key_file: None,
58 max_connections: default_max_connections(),
59 min_connections: Default::default(),
60 connect_timeout: default_connect_timeout(),
61 idle_timeout: default_idle_timeout(),
62 max_lifetime: default_max_lifetime(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
70#[serde(rename_all = "kebab-case")]
71pub enum PgSslMode {
72 Disable,
74
75 Allow,
77
78 Prefer,
80
81 Require,
84
85 VerifyCa,
88
89 VerifyFull,
93}
94
95#[serde_as]
97#[derive(Debug, Serialize, Deserialize, JsonSchema)]
98pub struct DatabaseConfig {
99 #[serde(skip_serializing_if = "Option::is_none")]
104 #[schemars(url, default = "default_connection_string")]
105 pub uri: Option<String>,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
111 #[schemars(with = "Option::<schema::Hostname>")]
112 pub host: Option<String>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
118 #[schemars(range(min = 1, max = 65535))]
119 pub port: Option<u16>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
125 #[schemars(with = "Option<String>")]
126 pub socket: Option<Utf8PathBuf>,
127
128 #[serde(skip_serializing_if = "Option::is_none")]
132 pub username: Option<String>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
138 pub password: Option<String>,
139
140 #[serde(skip_serializing_if = "Option::is_none")]
144 pub database: Option<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub ssl_mode: Option<PgSslMode>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
154 pub ssl_ca: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
160 #[schemars(with = "Option<String>")]
161 pub ssl_ca_file: Option<Utf8PathBuf>,
162
163 #[serde(skip_serializing_if = "Option::is_none")]
168 pub ssl_certificate: Option<String>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
174 #[schemars(with = "Option<String>")]
175 pub ssl_certificate_file: Option<Utf8PathBuf>,
176
177 #[serde(skip_serializing_if = "Option::is_none")]
181 pub ssl_key: Option<String>,
182
183 #[serde(skip_serializing_if = "Option::is_none")]
187 #[schemars(with = "Option<String>")]
188 pub ssl_key_file: Option<Utf8PathBuf>,
189
190 #[serde(default = "default_max_connections")]
192 pub max_connections: NonZeroU32,
193
194 #[serde(default)]
196 pub min_connections: u32,
197
198 #[schemars(with = "u64")]
200 #[serde(default = "default_connect_timeout")]
201 #[serde_as(as = "serde_with::DurationSeconds<u64>")]
202 pub connect_timeout: Duration,
203
204 #[schemars(with = "Option<u64>")]
206 #[serde(
207 default = "default_idle_timeout",
208 skip_serializing_if = "Option::is_none"
209 )]
210 #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
211 pub idle_timeout: Option<Duration>,
212
213 #[schemars(with = "u64")]
215 #[serde(
216 default = "default_max_lifetime",
217 skip_serializing_if = "Option::is_none"
218 )]
219 #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
220 pub max_lifetime: Option<Duration>,
221}
222
223impl ConfigurationSection for DatabaseConfig {
224 const PATH: Option<&'static str> = Some("database");
225
226 fn validate(
227 &self,
228 figment: &figment::Figment,
229 ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
230 let metadata = figment.find_metadata(Self::PATH.unwrap());
231 let annotate = |mut error: figment::Error| {
232 error.metadata = metadata.cloned();
233 error.profile = Some(figment::Profile::Default);
234 error.path = vec![Self::PATH.unwrap().to_owned()];
235 error
236 };
237
238 let has_split_options = self.host.is_some()
241 || self.port.is_some()
242 || self.socket.is_some()
243 || self.username.is_some()
244 || self.password.is_some()
245 || self.database.is_some();
246
247 if self.uri.is_some() && has_split_options {
248 return Err(annotate(figment::error::Error::from(
249 "uri must not be specified if host, port, socket, username, password, or database are specified".to_owned(),
250 )).into());
251 }
252
253 if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() {
254 return Err(annotate(figment::error::Error::from(
255 "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(),
256 ))
257 .into());
258 }
259
260 if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() {
261 return Err(annotate(figment::error::Error::from(
262 "ssl_certificate must not be specified if ssl_certificate_file is specified"
263 .to_owned(),
264 ))
265 .into());
266 }
267
268 if self.ssl_key.is_some() && self.ssl_key_file.is_some() {
269 return Err(annotate(figment::error::Error::from(
270 "ssl_key must not be specified if ssl_key_file is specified".to_owned(),
271 ))
272 .into());
273 }
274
275 if (self.ssl_key.is_some() || self.ssl_key_file.is_some())
276 ^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some())
277 {
278 return Err(annotate(figment::error::Error::from(
279 "both a ssl_certificate and a ssl_key must be set at the same time or none of them"
280 .to_owned(),
281 ))
282 .into());
283 }
284
285 Ok(())
286 }
287}
288#[cfg(test)]
289mod tests {
290 #![expect(clippy::result_large_err)]
293
294 use figment::{
295 Figment, Jail,
296 providers::{Format, Yaml},
297 };
298
299 use super::*;
300
301 #[test]
302 fn load_config() {
303 Jail::expect_with(|jail| {
304 jail.create_file(
305 "config.yaml",
306 r"
307 database:
308 uri: postgresql://user:password@host/database
309 ",
310 )?;
311
312 let config = Figment::new()
313 .merge(Yaml::file("config.yaml"))
314 .extract_inner::<DatabaseConfig>("database")?;
315
316 assert_eq!(
317 config.uri.as_deref(),
318 Some("postgresql://user:password@host/database")
319 );
320
321 Ok(())
322 });
323 }
324}