1use serde::Deserialize;
15use std::path::Path;
16
17#[derive(Debug, Clone, Default, Deserialize)]
19#[serde(default)]
20pub struct Config {
21 pub app: AppConfig,
22 pub server: ServerConfig,
23 pub database: DatabaseConfig,
24 pub auth: AuthConfig,
25 pub docs: DocsConfig,
26 pub admin: AdminConfig,
27 pub public: PublicConfig,
28 pub email: EmailConfig,
29 pub cache: CacheConfig,
30}
31
32#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct AppConfig {
41 pub name: Option<String>,
44}
45
46fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
49 #[derive(Deserialize)]
50 #[serde(untagged)]
51 enum OneOrMany {
52 One(String),
53 Many(Vec<String>),
54 }
55 Ok(match OneOrMany::deserialize(de)? {
56 OneOrMany::One(s) => vec![s],
57 OneOrMany::Many(v) => v,
58 })
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(default)]
63pub struct ServerConfig {
64 pub host: String,
67 pub port: u16,
69 #[serde(deserialize_with = "one_or_many")]
75 pub domain: Vec<String>,
76 pub base_path: String,
79 pub workers: Option<usize>,
81}
82
83impl Default for ServerConfig {
84 fn default() -> Self {
85 ServerConfig {
86 host: "0.0.0.0".to_string(),
87 port: 8080,
88 domain: Vec::new(),
89 base_path: "/".to_string(),
90 workers: None,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Deserialize)]
96#[serde(default)]
97pub struct DatabaseConfig {
98 pub url: String,
100 pub host: String,
101 pub port: u16,
102 pub name: String,
103 pub user: String,
104 pub password: String,
105 pub max_connections: u32,
107 pub auto_migrate: bool,
109}
110
111impl Default for DatabaseConfig {
112 fn default() -> Self {
113 DatabaseConfig {
114 url: String::new(),
115 host: "localhost".to_string(),
116 port: 5432,
117 name: "apiplant".to_string(),
118 user: "postgres".to_string(),
119 password: "postgres".to_string(),
120 max_connections: 16,
121 auto_migrate: true,
122 }
123 }
124}
125
126impl DatabaseConfig {
127 pub fn resolved_url(&self) -> String {
129 if !self.url.is_empty() {
130 return self.url.clone();
131 }
132 format!(
133 "postgres://{}:{}@{}:{}/{}",
134 self.user, self.password, self.host, self.port, self.name
135 )
136 }
137}
138
139#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct AuthConfig {
142 pub jwt_secret: String,
145 pub session_ttl_secs: u64,
147 pub allow_registration: bool,
149}
150
151impl Default for AuthConfig {
152 fn default() -> Self {
153 AuthConfig {
154 jwt_secret: String::new(),
155 session_ttl_secs: 60 * 60 * 24 * 7,
156 allow_registration: true,
157 }
158 }
159}
160
161#[derive(Debug, Clone, Deserialize)]
163#[serde(default)]
164pub struct DocsConfig {
165 pub enabled: bool,
167 pub path: String,
169 pub title: Option<String>,
173}
174
175impl Default for DocsConfig {
176 fn default() -> Self {
177 DocsConfig {
178 enabled: true,
179 path: "/docs".to_string(),
180 title: None,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Deserialize)]
192#[serde(default)]
193pub struct AdminConfig {
194 pub enabled: bool,
196 pub path: String,
198 pub logo: Option<String>,
201}
202
203impl Default for AdminConfig {
204 fn default() -> Self {
205 AdminConfig {
206 enabled: true,
207 path: "/admin".to_string(),
208 logo: None,
209 }
210 }
211}
212
213#[derive(Debug, Clone, Deserialize)]
218#[serde(default)]
219pub struct PublicConfig {
220 pub enabled: bool,
222 pub dir: String,
224 pub not_found: Option<String>,
227}
228
229impl Default for PublicConfig {
230 fn default() -> Self {
231 PublicConfig {
232 enabled: true,
233 dir: "public".to_string(),
234 not_found: None,
235 }
236 }
237}
238
239#[derive(Debug, Clone, Deserialize)]
248#[serde(default)]
249pub struct EmailConfig {
250 pub provider: String,
253 pub from: String,
256 pub from_name: String,
258 pub reply_to: String,
260 pub api_key: String,
263 pub api_secret: String,
266 pub region: String,
268 pub domain: String,
270 pub timeout_secs: u64,
272 pub smtp: SmtpConfig,
274}
275
276impl Default for EmailConfig {
277 fn default() -> Self {
278 EmailConfig {
279 provider: "none".to_string(),
280 from: String::new(),
281 from_name: String::new(),
282 reply_to: String::new(),
283 api_key: String::new(),
284 api_secret: String::new(),
285 region: String::new(),
286 domain: String::new(),
287 timeout_secs: 15,
288 smtp: SmtpConfig::default(),
289 }
290 }
291}
292
293impl EmailConfig {
294 pub fn enabled(&self) -> bool {
297 !matches!(
298 self.provider.trim().to_ascii_lowercase().as_str(),
299 "" | "none"
300 )
301 }
302}
303
304#[derive(Debug, Clone, Deserialize)]
310#[serde(default)]
311pub struct SmtpConfig {
312 pub host: String,
313 pub port: u16,
316 pub username: String,
317 pub password: String,
318 pub encryption: String,
320}
321
322impl Default for SmtpConfig {
323 fn default() -> Self {
324 SmtpConfig {
325 host: String::new(),
326 port: 0,
327 username: String::new(),
328 password: String::new(),
329 encryption: "starttls".to_string(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Deserialize)]
344#[serde(default)]
345pub struct CacheConfig {
346 pub enabled: bool,
348 pub url: String,
351 pub prefix: String,
354 pub default_ttl_secs: u64,
356 pub timeout_secs: u64,
358}
359
360impl Default for CacheConfig {
361 fn default() -> Self {
362 CacheConfig {
363 enabled: true,
364 url: String::new(),
365 prefix: String::new(),
366 default_ttl_secs: 0,
367 timeout_secs: 5,
368 }
369 }
370}
371
372impl CacheConfig {
373 pub fn is_active(&self) -> bool {
376 self.enabled && !self.url.trim().is_empty()
377 }
378}
379
380impl Config {
381 pub fn load(app_dir: &Path) -> crate::Result<Self> {
384 let path = app_dir.join("main.toml");
385 let mut config = if path.exists() {
386 let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
387 path: path.clone(),
388 source: e,
389 })?;
390 crate::env::parse_toml::<Config>(&text, "main.toml")
393 .map_err(|e| crate::Error::Toml { path, source: e })?
394 } else {
395 tracing::info!("no main.toml found, using defaults");
396 Config::default()
397 };
398 config.normalise();
399 Ok(config)
400 }
401
402 fn normalise(&mut self) {
403 let host = self.server.host.trim();
406 if host.is_empty() || host == "*" {
407 self.server.host = "0.0.0.0".to_string();
408 } else {
409 self.server.host = host.to_string();
410 }
411
412 let domains = std::mem::take(&mut self.server.domain);
418 let mut wildcard = false;
419 for d in domains {
420 match d.trim() {
421 "" | "*" | "_" | "0.0.0.0" => wildcard = true,
422 d => self.server.domain.push(d.to_string()),
423 }
424 }
425 if wildcard {
426 self.server.domain.clear();
427 }
428
429 let bp = self.server.base_path.trim_end_matches('/');
430 self.server.base_path = if bp.is_empty() {
431 String::new()
432 } else if bp.starts_with('/') {
433 bp.to_string()
434 } else {
435 format!("/{bp}")
436 };
437
438 if !self.docs.path.starts_with('/') {
439 self.docs.path = format!("/{}", self.docs.path);
440 }
441
442 let admin = self.admin.path.trim_matches('/');
443 self.admin.path = if admin.is_empty() {
444 AdminConfig::default().path
445 } else {
446 format!("/{admin}")
447 };
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use std::fs;
455 use std::time::{SystemTime, UNIX_EPOCH};
456
457 fn temp_dir(label: &str) -> std::path::PathBuf {
458 let mut dir = std::env::temp_dir();
459 let stamp = SystemTime::now()
460 .duration_since(UNIX_EPOCH)
461 .unwrap()
462 .as_nanos();
463 dir.push(format!(
464 "apiplant-config-{label}-{}-{stamp}",
465 std::process::id()
466 ));
467 fs::create_dir_all(&dir).unwrap();
468 dir
469 }
470
471 #[test]
472 fn missing_main_toml_uses_defaults() {
473 let dir = temp_dir("defaults");
474 let config = Config::load(&dir).unwrap();
475
476 assert_eq!(config.server.host, "0.0.0.0");
477 assert_eq!(config.server.port, 8080);
478 assert_eq!(config.server.base_path, "");
479 assert_eq!(
480 config.database.resolved_url(),
481 "postgres://postgres:postgres@localhost:5432/apiplant"
482 );
483 assert!(config.auth.allow_registration);
484 assert!(config.docs.enabled);
485 assert_eq!(config.docs.path, "/docs");
486 assert!(config.admin.enabled);
488 assert_eq!(config.admin.path, "/admin");
489 assert!(config.public.enabled);
490 assert_eq!(config.public.dir, "public");
491 assert_eq!(config.public.not_found, None);
492 assert!(!config.email.enabled());
494 assert!(!config.cache.is_active());
495
496 fs::remove_dir_all(dir).unwrap();
497 }
498
499 #[test]
500 fn email_and_cache_load_from_their_sections() {
501 let dir = temp_dir("email-cache");
502 fs::write(
503 dir.join("main.toml"),
504 r#"
505[email]
506provider = "sendgrid"
507from = "no-reply@example.com"
508from_name = "Example"
509api_key = "SG.literal"
510
511[cache]
512url = "redis://127.0.0.1:6379"
513prefix = "example:"
514default_ttl_secs = 300
515"#,
516 )
517 .unwrap();
518
519 let config = Config::load(&dir).unwrap();
520
521 assert!(config.email.enabled());
522 assert_eq!(config.email.provider, "sendgrid");
523 assert_eq!(config.email.from, "no-reply@example.com");
524 assert_eq!(config.email.api_key, "SG.literal");
525 assert_eq!(config.email.timeout_secs, 15);
527 assert_eq!(config.email.smtp.encryption, "starttls");
528
529 assert!(config.cache.is_active());
530 assert_eq!(config.cache.prefix, "example:");
531 assert_eq!(config.cache.default_ttl_secs, 300);
532
533 fs::remove_dir_all(dir).unwrap();
534 }
535
536 #[test]
539 fn a_disabled_cache_stays_off_even_with_a_url() {
540 let config = CacheConfig {
541 enabled: false,
542 url: "redis://127.0.0.1:6379".into(),
543 ..CacheConfig::default()
544 };
545 assert!(!config.is_active());
546 }
547
548 #[test]
552 fn load_expands_environment_references_anywhere_in_the_file() {
553 std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
554 std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
555 std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
556 std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
557 let dir = temp_dir("env");
558 fs::write(
559 dir.join("main.toml"),
560 r#"
561[server]
562domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
563
564[database]
565url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
566
567[auth]
568jwt_secret = "$APIPLANT_TEST_JWT"
569
570[email]
571provider = "brevo"
572api_key = "${APIPLANT_TEST_MAIL}"
573from = "no-reply@example.com"
574"#,
575 )
576 .unwrap();
577
578 let config = Config::load(&dir).unwrap();
579 assert_eq!(
580 config.database.resolved_url(),
581 "postgres://alice:s3cret@db:5432/app"
582 );
583 assert_eq!(config.auth.jwt_secret, "from-env-jwt");
584 assert_eq!(config.email.api_key, "from-env-key");
585 assert_eq!(config.server.domain, ["api.example.com"]);
587
588 for name in [
589 "APIPLANT_TEST_JWT",
590 "APIPLANT_TEST_MAIL",
591 "APIPLANT_TEST_DB_USER",
592 "APIPLANT_TEST_DB_PASS",
593 ] {
594 std::env::remove_var(name);
595 }
596 fs::remove_dir_all(dir).unwrap();
597 }
598
599 #[test]
600 fn load_treats_wildcard_host_and_domain_as_everything() {
601 for (host, domain) in [
602 ("", "\"\""),
603 ("*", "\"*\""),
604 (" 0.0.0.0 ", "\"_\""),
605 ("*", "[]"),
606 ("*", "[\"api.example.com\", \"*\"]"),
608 ] {
609 let dir = temp_dir("wildcards");
610 fs::write(
611 dir.join("main.toml"),
612 format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
613 )
614 .unwrap();
615
616 let config = Config::load(&dir).unwrap();
617
618 assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
619 assert!(config.server.domain.is_empty(), "domain {domain}");
620 fs::remove_dir_all(&dir).unwrap();
621 }
622 }
623
624 #[test]
627 fn load_accepts_a_list_of_domains() {
628 let dir = temp_dir("domains");
629 fs::write(
630 dir.join("main.toml"),
631 "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
632 )
633 .unwrap();
634
635 let config = Config::load(&dir).unwrap();
636
637 assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
638 fs::remove_dir_all(dir).unwrap();
639 }
640
641 #[test]
642 fn load_normalises_paths_and_prefers_explicit_database_url() {
643 let dir = temp_dir("normalise");
644 fs::write(
645 dir.join("main.toml"),
646 r#"
647[server]
648base_path = "api/"
649workers = 8
650
651[database]
652url = "postgres://db.example/custom"
653host = "ignored"
654port = 9999
655name = "ignored"
656user = "ignored"
657password = "ignored"
658
659[docs]
660path = "swagger"
661
662[admin]
663path = "console/"
664
665[public]
666dir = "site"
667not_found = "oops.html"
668"#,
669 )
670 .unwrap();
671
672 let config = Config::load(&dir).unwrap();
673
674 assert_eq!(config.server.base_path, "/api");
675 assert_eq!(config.server.workers, Some(8));
676 assert_eq!(config.docs.path, "/swagger");
677 assert_eq!(config.admin.path, "/console");
678 assert_eq!(config.public.dir, "site");
679 assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
680 assert_eq!(
681 config.database.resolved_url(),
682 "postgres://db.example/custom"
683 );
684
685 fs::remove_dir_all(dir).unwrap();
686 }
687
688 #[test]
689 fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
690 let config = DatabaseConfig {
691 url: String::new(),
692 host: "db".into(),
693 port: 5433,
694 name: "plants".into(),
695 user: "alice".into(),
696 password: "secret".into(),
697 max_connections: 16,
698 auto_migrate: true,
699 };
700
701 assert_eq!(
702 config.resolved_url(),
703 "postgres://alice:secret@db:5433/plants"
704 );
705 }
706}