use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Config {
pub app: AppConfig,
pub server: ServerConfig,
pub database: DatabaseConfig,
pub auth: AuthConfig,
pub docs: DocsConfig,
pub admin: AdminConfig,
pub public: PublicConfig,
pub email: EmailConfig,
pub cache: CacheConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub name: Option<String>,
}
fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match OneOrMany::deserialize(de)? {
OneOrMany::One(s) => vec![s],
OneOrMany::Many(v) => v,
})
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
#[serde(deserialize_with = "one_or_many")]
pub domain: Vec<String>,
pub base_path: String,
pub workers: Option<usize>,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: "0.0.0.0".to_string(),
port: 8080,
domain: Vec::new(),
base_path: "/".to_string(),
workers: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DatabaseConfig {
pub url: String,
pub host: String,
pub port: u16,
pub name: String,
pub user: String,
pub password: String,
pub max_connections: u32,
pub auto_migrate: bool,
}
impl Default for DatabaseConfig {
fn default() -> Self {
DatabaseConfig {
url: String::new(),
host: "localhost".to_string(),
port: 5432,
name: "apiplant".to_string(),
user: "postgres".to_string(),
password: "postgres".to_string(),
max_connections: 16,
auto_migrate: true,
}
}
}
impl DatabaseConfig {
pub fn resolved_url(&self) -> String {
if !self.url.is_empty() {
return self.url.clone();
}
format!(
"postgres://{}:{}@{}:{}/{}",
self.user, self.password, self.host, self.port, self.name
)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AuthConfig {
pub jwt_secret: String,
pub session_ttl_secs: u64,
pub allow_registration: bool,
}
impl Default for AuthConfig {
fn default() -> Self {
AuthConfig {
jwt_secret: String::new(),
session_ttl_secs: 60 * 60 * 24 * 7,
allow_registration: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct DocsConfig {
pub enabled: bool,
pub path: String,
pub title: Option<String>,
}
impl Default for DocsConfig {
fn default() -> Self {
DocsConfig {
enabled: true,
path: "/docs".to_string(),
title: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AdminConfig {
pub enabled: bool,
pub path: String,
pub logo: Option<String>,
}
impl Default for AdminConfig {
fn default() -> Self {
AdminConfig {
enabled: true,
path: "/admin".to_string(),
logo: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PublicConfig {
pub enabled: bool,
pub dir: String,
pub not_found: Option<String>,
}
impl Default for PublicConfig {
fn default() -> Self {
PublicConfig {
enabled: true,
dir: "public".to_string(),
not_found: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct EmailConfig {
pub provider: String,
pub from: String,
pub from_name: String,
pub reply_to: String,
pub api_key: String,
pub api_secret: String,
pub region: String,
pub domain: String,
pub timeout_secs: u64,
pub smtp: SmtpConfig,
}
impl Default for EmailConfig {
fn default() -> Self {
EmailConfig {
provider: "none".to_string(),
from: String::new(),
from_name: String::new(),
reply_to: String::new(),
api_key: String::new(),
api_secret: String::new(),
region: String::new(),
domain: String::new(),
timeout_secs: 15,
smtp: SmtpConfig::default(),
}
}
}
impl EmailConfig {
pub fn enabled(&self) -> bool {
!matches!(
self.provider.trim().to_ascii_lowercase().as_str(),
"" | "none"
)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct SmtpConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub encryption: String,
}
impl Default for SmtpConfig {
fn default() -> Self {
SmtpConfig {
host: String::new(),
port: 0,
username: String::new(),
password: String::new(),
encryption: "starttls".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub url: String,
pub prefix: String,
pub default_ttl_secs: u64,
pub timeout_secs: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
CacheConfig {
enabled: true,
url: String::new(),
prefix: String::new(),
default_ttl_secs: 0,
timeout_secs: 5,
}
}
}
impl CacheConfig {
pub fn is_active(&self) -> bool {
self.enabled && !self.url.trim().is_empty()
}
}
impl Config {
pub fn load(app_dir: &Path) -> crate::Result<Self> {
let path = app_dir.join("main.toml");
let mut config = if path.exists() {
let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
path: path.clone(),
source: e,
})?;
crate::env::parse_toml::<Config>(&text, "main.toml")
.map_err(|e| crate::Error::Toml { path, source: e })?
} else {
tracing::info!("no main.toml found, using defaults");
Config::default()
};
config.normalise();
Ok(config)
}
fn normalise(&mut self) {
let host = self.server.host.trim();
if host.is_empty() || host == "*" {
self.server.host = "0.0.0.0".to_string();
} else {
self.server.host = host.to_string();
}
let domains = std::mem::take(&mut self.server.domain);
let mut wildcard = false;
for d in domains {
match d.trim() {
"" | "*" | "_" | "0.0.0.0" => wildcard = true,
d => self.server.domain.push(d.to_string()),
}
}
if wildcard {
self.server.domain.clear();
}
let bp = self.server.base_path.trim_end_matches('/');
self.server.base_path = if bp.is_empty() {
String::new()
} else if bp.starts_with('/') {
bp.to_string()
} else {
format!("/{bp}")
};
if !self.docs.path.starts_with('/') {
self.docs.path = format!("/{}", self.docs.path);
}
let admin = self.admin.path.trim_matches('/');
self.admin.path = if admin.is_empty() {
AdminConfig::default().path
} else {
format!("/{admin}")
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
dir.push(format!(
"apiplant-config-{label}-{}-{stamp}",
std::process::id()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn missing_main_toml_uses_defaults() {
let dir = temp_dir("defaults");
let config = Config::load(&dir).unwrap();
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.base_path, "");
assert_eq!(
config.database.resolved_url(),
"postgres://postgres:postgres@localhost:5432/apiplant"
);
assert!(config.auth.allow_registration);
assert!(config.docs.enabled);
assert_eq!(config.docs.path, "/docs");
assert!(config.admin.enabled);
assert_eq!(config.admin.path, "/admin");
assert!(config.public.enabled);
assert_eq!(config.public.dir, "public");
assert_eq!(config.public.not_found, None);
assert!(!config.email.enabled());
assert!(!config.cache.is_active());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn email_and_cache_load_from_their_sections() {
let dir = temp_dir("email-cache");
fs::write(
dir.join("main.toml"),
r#"
[email]
provider = "sendgrid"
from = "no-reply@example.com"
from_name = "Example"
api_key = "SG.literal"
[cache]
url = "redis://127.0.0.1:6379"
prefix = "example:"
default_ttl_secs = 300
"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(config.email.enabled());
assert_eq!(config.email.provider, "sendgrid");
assert_eq!(config.email.from, "no-reply@example.com");
assert_eq!(config.email.api_key, "SG.literal");
assert_eq!(config.email.timeout_secs, 15);
assert_eq!(config.email.smtp.encryption, "starttls");
assert!(config.cache.is_active());
assert_eq!(config.cache.prefix, "example:");
assert_eq!(config.cache.default_ttl_secs, 300);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_disabled_cache_stays_off_even_with_a_url() {
let config = CacheConfig {
enabled: false,
url: "redis://127.0.0.1:6379".into(),
..CacheConfig::default()
};
assert!(!config.is_active());
}
#[test]
fn load_expands_environment_references_anywhere_in_the_file() {
std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
let dir = temp_dir("env");
fs::write(
dir.join("main.toml"),
r#"
[server]
domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
[database]
url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
[auth]
jwt_secret = "$APIPLANT_TEST_JWT"
[email]
provider = "brevo"
api_key = "${APIPLANT_TEST_MAIL}"
from = "no-reply@example.com"
"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert_eq!(
config.database.resolved_url(),
"postgres://alice:s3cret@db:5432/app"
);
assert_eq!(config.auth.jwt_secret, "from-env-jwt");
assert_eq!(config.email.api_key, "from-env-key");
assert_eq!(config.server.domain, ["api.example.com"]);
for name in [
"APIPLANT_TEST_JWT",
"APIPLANT_TEST_MAIL",
"APIPLANT_TEST_DB_USER",
"APIPLANT_TEST_DB_PASS",
] {
std::env::remove_var(name);
}
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn load_treats_wildcard_host_and_domain_as_everything() {
for (host, domain) in [
("", "\"\""),
("*", "\"*\""),
(" 0.0.0.0 ", "\"_\""),
("*", "[]"),
("*", "[\"api.example.com\", \"*\"]"),
] {
let dir = temp_dir("wildcards");
fs::write(
dir.join("main.toml"),
format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
assert!(config.server.domain.is_empty(), "domain {domain}");
fs::remove_dir_all(&dir).unwrap();
}
}
#[test]
fn load_accepts_a_list_of_domains() {
let dir = temp_dir("domains");
fs::write(
dir.join("main.toml"),
"[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn load_normalises_paths_and_prefers_explicit_database_url() {
let dir = temp_dir("normalise");
fs::write(
dir.join("main.toml"),
r#"
[server]
base_path = "api/"
workers = 8
[database]
url = "postgres://db.example/custom"
host = "ignored"
port = 9999
name = "ignored"
user = "ignored"
password = "ignored"
[docs]
path = "swagger"
[admin]
path = "console/"
[public]
dir = "site"
not_found = "oops.html"
"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert_eq!(config.server.base_path, "/api");
assert_eq!(config.server.workers, Some(8));
assert_eq!(config.docs.path, "/swagger");
assert_eq!(config.admin.path, "/console");
assert_eq!(config.public.dir, "site");
assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
assert_eq!(
config.database.resolved_url(),
"postgres://db.example/custom"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
let config = DatabaseConfig {
url: String::new(),
host: "db".into(),
port: 5433,
name: "plants".into(),
user: "alice".into(),
password: "secret".into(),
max_connections: 16,
auto_migrate: true,
};
assert_eq!(
config.resolved_url(),
"postgres://alice:secret@db:5433/plants"
);
}
}