use crate::schema::{Access, Policy};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
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 rate_limit: RateLimitConfig,
pub docs: DocsConfig,
pub admin: AdminConfig,
pub public: PublicConfig,
pub email: EmailConfig,
pub cache: CacheConfig,
pub storage: StorageConfig,
pub queues: QueuesConfig,
pub payments: PaymentsConfig,
pub ai: AiConfig,
pub oauth: OAuthConfig,
pub observability: ObservabilityConfig,
pub organization: OrganizationConfig,
}
#[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>,
pub public_url: String,
}
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,
public_url: String::new(),
}
}
}
impl ServerConfig {
pub fn public_origin(&self) -> String {
if !self.public_url.is_empty() {
return self.public_url.trim_end_matches('/').to_string();
}
if let Some(domain) = self.domain.first() {
return if domain.contains("://") {
domain.trim_end_matches('/').to_string()
} else {
format!("https://{domain}")
};
}
let host = match self.host.as_str() {
"0.0.0.0" | "" | "*" | "::" => "localhost",
host => host,
};
format!("http://{host}:{}", self.port)
}
}
#[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,
pub require_email_verification: Option<bool>,
pub allow_invitations: Option<bool>,
pub allow_password_reset: Option<bool>,
pub invite_ttl_secs: u64,
pub verification_ttl_secs: u64,
pub password_reset_ttl_secs: u64,
}
impl Default for AuthConfig {
fn default() -> Self {
AuthConfig {
jwt_secret: String::new(),
session_ttl_secs: 60 * 60 * 24 * 7,
allow_registration: true,
require_email_verification: None,
allow_invitations: None,
allow_password_reset: None,
invite_ttl_secs: 60 * 60 * 24 * 7,
verification_ttl_secs: 60 * 60 * 24,
password_reset_ttl_secs: 60 * 60,
}
}
}
impl AuthConfig {
pub fn requires_email_verification(&self, email_enabled: bool) -> bool {
self.require_email_verification.unwrap_or(email_enabled) && email_enabled
}
pub fn invitations_enabled(&self, email_enabled: bool) -> bool {
self.allow_invitations.unwrap_or(email_enabled) && email_enabled
}
pub fn password_reset_enabled(&self, email_enabled: bool) -> bool {
self.allow_password_reset.unwrap_or(email_enabled) && email_enabled
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RateLimitConfig {
pub enabled: bool,
pub default: crate::schema::RateLimitRule,
pub trust_proxy_headers: bool,
pub cleanup_interval_secs: u64,
pub stale_after_secs: u64,
}
impl Default for RateLimitConfig {
fn default() -> Self {
RateLimitConfig {
enabled: true,
default: crate::schema::RateLimitRule::Off,
trust_proxy_headers: false,
cleanup_interval_secs: 60,
stale_after_secs: 600,
}
}
}
#[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>,
pub gravatar: bool,
pub ai_assistance: AdminAiAssistanceConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AdminAiAssistanceConfig {
pub enabled: bool,
pub system: String,
pub prompt_placeholder: String,
}
impl Default for AdminAiAssistanceConfig {
fn default() -> Self {
AdminAiAssistanceConfig {
enabled: false,
system: String::new(),
prompt_placeholder: "Describe what you want AI to write for this field.".to_string(),
}
}
}
impl Default for AdminConfig {
fn default() -> Self {
AdminConfig {
enabled: true,
path: "/admin".to_string(),
logo: None,
gravatar: false,
ai_assistance: AdminAiAssistanceConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct OrganizationConfig {
pub org_class_editors: String,
pub default_org_class: String,
}
impl Default for OrganizationConfig {
fn default() -> Self {
OrganizationConfig {
org_class_editors: "private".to_string(),
default_org_class: String::new(),
}
}
}
impl OrganizationConfig {
pub fn org_class_policy(&self) -> Policy {
Policy::parse(&self.org_class_editors)
}
pub fn default_class(&self) -> Option<&str> {
let value = self.default_org_class.trim();
(!value.is_empty()).then_some(value)
}
}
#[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 logo: String,
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,
logo: "logo.png".to_string(),
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()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub backend: String,
pub dir: String,
pub public_base: String,
pub max_size_mb: u64,
pub allowed_types: Vec<String>,
pub bucket: String,
pub region: String,
pub endpoint: String,
pub access_key_id: String,
pub secret_access_key: String,
pub path_style: Option<bool>,
pub prefix: String,
pub base_url: String,
}
impl Default for StorageConfig {
fn default() -> Self {
StorageConfig {
backend: "local".to_string(),
dir: "storage".to_string(),
public_base: "/files".to_string(),
max_size_mb: 10,
allowed_types: Vec::new(),
bucket: String::new(),
region: "auto".to_string(),
endpoint: String::new(),
access_key_id: String::new(),
secret_access_key: String::new(),
path_style: None,
prefix: String::new(),
base_url: String::new(),
}
}
}
impl StorageConfig {
pub fn is_active(&self) -> bool {
!matches!(self.backend.trim().to_lowercase().as_str(), "none" | "")
}
pub fn normalized_public_base(&self) -> String {
let trimmed = self.public_base.trim().trim_matches('/');
match trimmed.is_empty() {
true => "/files".to_string(),
false => format!("/{trimmed}"),
}
}
pub fn uses_path_style(&self) -> bool {
self.path_style.unwrap_or(!self.endpoint.trim().is_empty())
}
pub fn max_size_bytes(&self) -> u64 {
self.max_size_mb.saturating_mul(1024 * 1024)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct QueuesConfig {
pub enabled: bool,
pub prefix: String,
#[serde(deserialize_with = "topic_subscriptions")]
pub subscribe: BTreeMap<String, Vec<String>>,
pub poll_secs: u64,
pub batch: u32,
pub max_attempts: u32,
pub retry_backoff_secs: u64,
pub lease_secs: u64,
pub retain_hours: u64,
pub publish: String,
}
impl Default for QueuesConfig {
fn default() -> Self {
QueuesConfig {
enabled: true,
prefix: "apiplant".to_string(),
subscribe: BTreeMap::new(),
poll_secs: 30,
batch: 10,
max_attempts: 5,
retry_backoff_secs: 10,
lease_secs: 300,
retain_hours: 24,
publish: "private".to_string(),
}
}
}
fn topic_subscriptions<'de, D: serde::Deserializer<'de>>(
de: D,
) -> Result<BTreeMap<String, Vec<String>>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
let raw = BTreeMap::<String, OneOrMany>::deserialize(de)?;
Ok(raw
.into_iter()
.map(|(topic, subscribers)| {
let subscribers = match subscribers {
OneOrMany::One(name) => vec![name],
OneOrMany::Many(names) => names,
};
(
topic.trim().to_string(),
subscribers
.into_iter()
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.collect(),
)
})
.filter(|(topic, subscribers): &(String, Vec<String>)| {
!topic.is_empty() && !subscribers.is_empty()
})
.collect())
}
impl QueuesConfig {
pub fn channel(&self) -> String {
let prefix = self.prefix.trim().trim_matches('_');
match prefix.is_empty() {
true => "apiplant_queue".to_string(),
false => format!("{prefix}_queue"),
}
}
pub fn valid_topic(topic: &str) -> bool {
let topic = topic.trim();
!topic.is_empty()
&& topic.len() <= 200
&& topic
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':'))
}
pub fn subscribers(&self, topic: &str) -> &[String] {
self.subscribe
.get(topic.trim())
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn subscribed_functions(&self) -> BTreeSet<&str> {
self.subscribe
.values()
.flatten()
.map(String::as_str)
.collect()
}
pub fn is_active(&self) -> bool {
self.enabled && !self.subscribe.is_empty()
}
pub fn publish_access(&self) -> Policy {
let policy = Policy::parse(&self.publish);
match policy.level {
Access::Owner => Access::Private.into(),
_ => policy,
}
}
pub fn retry_delay_secs(&self, attempts: u32) -> u64 {
let doubling = 1u64
.checked_shl(attempts.saturating_sub(1))
.unwrap_or(u64::MAX);
self.retry_backoff_secs
.saturating_mul(doubling)
.min(60 * 60)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct OAuthConfig {
pub link_by_verified_email: bool,
pub state_ttl_secs: u64,
pub success_redirect: String,
pub failure_redirect: String,
pub token_delivery: String,
pub name_field: String,
pub avatar_field: String,
#[serde(flatten)]
pub providers: std::collections::BTreeMap<String, OAuthProviderConfig>,
}
impl Default for OAuthConfig {
fn default() -> Self {
OAuthConfig {
link_by_verified_email: true,
state_ttl_secs: 600,
success_redirect: "/".to_string(),
failure_redirect: String::new(),
token_delivery: "fragment".to_string(),
name_field: "display_name".to_string(),
avatar_field: "avatar_url".to_string(),
providers: std::collections::BTreeMap::new(),
}
}
}
impl OAuthConfig {
pub fn enabled(&self) -> bool {
self.providers.values().any(OAuthProviderConfig::is_active)
}
pub fn active_providers(&self) -> Vec<&str> {
self.providers
.iter()
.filter(|(_, p)| p.is_active())
.map(|(name, _)| name.as_str())
.collect()
}
pub fn state_ttl(&self) -> u64 {
self.state_ttl_secs.clamp(60, 3600)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct OAuthProviderConfig {
pub client_id: String,
pub client_secret: String,
pub scopes: String,
pub authorize_url: String,
pub token_url: String,
pub userinfo_url: String,
pub style: String,
pub label: String,
pub redirect_uri: String,
pub pkce: Option<bool>,
pub enabled: Option<bool>,
pub icon: String,
}
impl OAuthProviderConfig {
pub fn is_active(&self) -> bool {
self.enabled.unwrap_or(true) && !self.client_id.trim().is_empty()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PaymentsConfig {
pub provider: String,
pub secret_key: String,
pub publishable_key: String,
pub webhook_secret: String,
pub currency: String,
pub automatic_tax: bool,
pub tax_id_collection: Option<bool>,
pub billing_address: String,
pub shipping_countries: Vec<String>,
pub digital_tax_code: String,
pub physical_tax_code: String,
pub success_url: String,
pub cancel_url: String,
pub portal_return_url: String,
pub timeout_secs: u64,
}
impl Default for PaymentsConfig {
fn default() -> Self {
PaymentsConfig {
provider: "none".to_string(),
secret_key: String::new(),
publishable_key: String::new(),
webhook_secret: String::new(),
currency: "usd".to_string(),
automatic_tax: true,
tax_id_collection: None,
billing_address: "auto".to_string(),
shipping_countries: Vec::new(),
digital_tax_code: "txcd_10000000".to_string(),
physical_tax_code: "txcd_99999999".to_string(),
success_url: String::new(),
cancel_url: String::new(),
portal_return_url: String::new(),
timeout_secs: 20,
}
}
}
impl PaymentsConfig {
pub fn enabled(&self) -> bool {
!matches!(
self.provider.trim().to_ascii_lowercase().as_str(),
"" | "none"
)
}
pub fn default_currency(&self) -> String {
let currency = self.currency.trim().to_ascii_lowercase();
if currency.is_empty() {
"usd".to_string()
} else {
currency
}
}
pub fn collects_tax_ids(&self) -> bool {
self.tax_id_collection.unwrap_or(self.automatic_tax)
}
pub fn shipping_destinations(&self) -> Vec<String> {
let mut seen = Vec::new();
for country in &self.shipping_countries {
let code = country.trim().to_ascii_uppercase();
if !code.is_empty() && !seen.contains(&code) {
seen.push(code);
}
}
seen
}
pub fn ships(&self) -> bool {
!self.shipping_destinations().is_empty()
}
pub fn default_tax_code(&self, shippable: bool) -> String {
let configured = match shippable {
true => self.physical_tax_code.trim(),
false => self.digital_tax_code.trim(),
};
configured.to_string()
}
pub fn webhooks_enabled(&self) -> bool {
self.enabled() && !self.webhook_secret.trim().is_empty()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AiConfig {
pub provider: String,
pub endpoint: String,
pub model: String,
pub api_key: String,
pub system: String,
pub max_tokens: u32,
pub temperature: f32,
pub reasoning: bool,
pub thinking: Option<bool>,
pub access: String,
pub timeout_secs: u64,
}
impl Default for AiConfig {
fn default() -> Self {
AiConfig {
provider: "none".to_string(),
endpoint: String::new(),
model: String::new(),
api_key: String::new(),
system: String::new(),
max_tokens: 2048,
temperature: -1.0,
reasoning: false,
thinking: None,
access: "authenticated".to_string(),
timeout_secs: 300,
}
}
}
impl AiConfig {
pub fn enabled(&self) -> bool {
!matches!(
self.provider.trim().to_ascii_lowercase().as_str(),
"" | "none"
)
}
pub fn default_temperature(&self) -> Option<f32> {
(self.temperature >= 0.0).then_some(self.temperature)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ObservabilityConfig {
pub enabled: bool,
pub service_name: Option<String>,
pub service_version: Option<String>,
pub environment: Option<String>,
pub resource_attributes: BTreeMap<String, String>,
pub logs: LogsConfig,
pub traces: TracesConfig,
pub metrics: MetricsConfig,
pub otlp: OtlpConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LogsConfig {
pub format: LogFormat,
pub level: String,
pub span_fields: bool,
}
impl Default for LogsConfig {
fn default() -> Self {
LogsConfig {
format: LogFormat::Pretty,
level: "info,apiplant=debug,ntex_server=warn".to_string(),
span_fields: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Pretty,
Compact,
Json,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TracesConfig {
pub enabled: bool,
pub sample_ratio: f64,
pub response_header: bool,
pub capture_headers: Vec<String>,
pub exclude_paths: Vec<String>,
}
impl Default for TracesConfig {
fn default() -> Self {
TracesConfig {
enabled: true,
sample_ratio: 1.0,
response_header: true,
capture_headers: Vec::new(),
exclude_paths: vec!["/_health".to_string()],
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MetricsConfig {
pub enabled: bool,
pub interval_secs: u64,
}
impl Default for MetricsConfig {
fn default() -> Self {
MetricsConfig {
enabled: true,
interval_secs: 60,
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OtlpConfig {
pub endpoint: Option<String>,
pub protocol: OtlpProtocol,
pub headers: BTreeMap<String, String>,
pub timeout_secs: u64,
}
impl Default for OtlpConfig {
fn default() -> Self {
OtlpConfig {
endpoint: None,
protocol: OtlpProtocol::HttpProtobuf,
headers: BTreeMap::new(),
timeout_secs: 10,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
pub enum OtlpProtocol {
#[default]
#[serde(rename = "http/protobuf")]
HttpProtobuf,
#[serde(rename = "http/json")]
HttpJson,
}
impl ObservabilityConfig {
pub fn endpoint(&self) -> Option<String> {
self.otlp
.endpoint
.clone()
.or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())
.map(|e| e.trim().trim_end_matches('/').to_string())
.filter(|e| !e.is_empty())
}
pub fn service_name(&self, app_name: &str) -> String {
self.service_name
.clone()
.or_else(|| std::env::var("OTEL_SERVICE_NAME").ok())
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| app_name.to_string())
}
pub fn export_headers(&self) -> BTreeMap<String, String> {
let mut headers = BTreeMap::new();
if let Ok(from_env) = std::env::var("OTEL_EXPORTER_OTLP_HEADERS") {
for pair in from_env.split(',') {
if let Some((key, value)) = pair.split_once('=') {
headers.insert(key.trim().to_string(), value.trim().to_string());
}
}
}
headers.extend(self.otlp.headers.clone());
headers
}
pub fn is_active(&self) -> bool {
self.enabled && (self.traces.enabled || self.metrics.enabled)
}
}
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 defaults = RateLimitConfig::default();
if self.rate_limit.cleanup_interval_secs == 0 {
self.rate_limit.cleanup_interval_secs = defaults.cleanup_interval_secs;
}
if self.rate_limit.stale_after_secs == 0 {
self.rate_limit.stale_after_secs = defaults.stale_after_secs;
}
self.observability.traces.sample_ratio =
self.observability.traces.sample_ratio.clamp(0.0, 1.0);
if self.observability.metrics.interval_secs == 0 {
self.observability.metrics.interval_secs = MetricsConfig::default().interval_secs;
}
if self.observability.otlp.timeout_secs == 0 {
self.observability.otlp.timeout_secs = OtlpConfig::default().timeout_secs;
}
for path in &mut self.observability.traces.exclude_paths {
if !path.starts_with('/') {
*path = format!("/{path}");
}
}
for header in &mut self.observability.traces.capture_headers {
*header = header.trim().to_ascii_lowercase();
}
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.admin.ai_assistance.enabled);
assert_eq!(
config.admin.ai_assistance.prompt_placeholder,
"Describe what you want AI to write for this field."
);
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());
assert!(!config.payments.enabled());
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 payments_load_from_their_section() {
let dir = temp_dir("payments");
fs::write(
dir.join("main.toml"),
r#"
[payments]
provider = "stripe"
secret_key = "sk_test_literal"
webhook_secret = "whsec_literal"
currency = "EUR"
"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
assert!(config.payments.enabled());
assert!(config.payments.webhooks_enabled());
assert_eq!(config.payments.default_currency(), "eur");
assert!(config.payments.automatic_tax);
assert_eq!(config.payments.timeout_secs, 20);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn webhooks_need_their_own_secret() {
let payments = PaymentsConfig {
provider: "stripe".into(),
secret_key: "sk_test".into(),
..PaymentsConfig::default()
};
assert!(payments.enabled());
assert!(!payments.webhooks_enabled());
}
#[test]
fn tax_id_collection_follows_automatic_tax_unless_told_otherwise() {
let with_tax = PaymentsConfig::default();
assert!(with_tax.automatic_tax && with_tax.collects_tax_ids());
let no_tax = PaymentsConfig {
automatic_tax: false,
..PaymentsConfig::default()
};
assert!(!no_tax.collects_tax_ids());
let explicit = PaymentsConfig {
automatic_tax: false,
tax_id_collection: Some(true),
..PaymentsConfig::default()
};
assert!(explicit.collects_tax_ids());
}
#[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/"
[admin.ai_assistance]
enabled = true
system = "Return only the field content."
prompt_placeholder = "Tell AI what to draft"
[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!(config.admin.ai_assistance.enabled);
assert_eq!(
config.admin.ai_assistance.system,
"Return only the field content."
);
assert_eq!(
config.admin.ai_assistance.prompt_placeholder,
"Tell AI what to draft"
);
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"
);
}
#[test]
fn observability_is_off_until_it_is_asked_for() {
let config = Config::default();
assert!(!config.observability.enabled);
assert!(!config.observability.is_active());
assert_eq!(config.observability.logs.format, LogFormat::Pretty);
assert!(config.observability.logs.level.contains("info"));
}
#[test]
fn an_observability_section_is_read_whole() {
let dir = temp_dir("observability");
fs::write(
dir.join("main.toml"),
r#"
[observability]
enabled = true
service_name = "checkout"
environment = "production"
resource_attributes = { region = "eu-west-1" }
[observability.logs]
format = "json"
[observability.traces]
sample_ratio = 0.25
capture_headers = ["X-Request-Id"]
exclude_paths = ["_health", "/metrics"]
[observability.otlp]
endpoint = "http://collector:4318/"
protocol = "http/json"
headers = { authorization = "Bearer t" }
"#,
)
.unwrap();
let config = Config::load(&dir).unwrap();
let observability = &config.observability;
assert!(observability.is_active());
assert_eq!(observability.logs.format, LogFormat::Json);
assert_eq!(observability.otlp.protocol, OtlpProtocol::HttpJson);
assert_eq!(
observability.endpoint().as_deref(),
Some("http://collector:4318")
);
assert_eq!(observability.service_name("fallback"), "checkout");
assert_eq!(
observability.export_headers().get("authorization").unwrap(),
"Bearer t"
);
assert_eq!(observability.traces.exclude_paths, ["/_health", "/metrics"]);
assert_eq!(observability.traces.capture_headers, ["x-request-id"]);
assert_eq!(observability.traces.sample_ratio, 0.25);
}
#[test]
fn a_sample_ratio_outside_the_range_is_a_typo_for_one_of_the_ends() {
let dir = temp_dir("sampling");
fs::write(
dir.join("main.toml"),
"[observability.traces]\nsample_ratio = 10.0\n",
)
.unwrap();
assert_eq!(
Config::load(&dir)
.unwrap()
.observability
.traces
.sample_ratio,
1.0
);
}
#[test]
fn the_app_name_is_the_service_name_when_nothing_else_says_otherwise() {
let observability = ObservabilityConfig::default();
assert!(
observability.endpoint().is_none()
|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok()
);
assert_eq!(observability.service_name("my-app"), "my-app");
}
}