use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))]
pub struct Config<T = ()>
where
T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
pub service: ServiceConfig,
#[serde(default)]
pub token: Option<TokenConfig>,
pub rate_limit: RateLimitConfig,
#[serde(default)]
pub middleware: MiddlewareConfig,
#[serde(default)]
pub database: Option<DatabaseConfig>,
#[cfg(feature = "turso")]
#[serde(default)]
pub turso: Option<TursoConfig>,
#[cfg(feature = "surrealdb")]
#[serde(default)]
pub surrealdb: Option<SurrealDbConfig>,
#[serde(default)]
pub redis: Option<RedisConfig>,
#[serde(default)]
pub nats: Option<NatsConfig>,
#[cfg(feature = "clickhouse")]
#[serde(default)]
pub clickhouse: Option<ClickHouseConfig>,
#[serde(default)]
pub otlp: Option<OtlpConfig>,
#[serde(default)]
pub grpc: Option<GrpcConfig>,
#[cfg(feature = "websocket")]
#[serde(default)]
pub websocket: Option<crate::websocket::WebSocketConfig>,
#[cfg(feature = "cedar-authz")]
#[serde(default)]
pub cedar: Option<CedarConfig>,
#[cfg(feature = "session")]
#[serde(default)]
pub session: Option<crate::session::SessionConfig>,
#[cfg(feature = "audit")]
#[serde(default)]
pub audit: Option<crate::audit::AuditConfig>,
#[cfg(feature = "auth")]
#[serde(default)]
pub auth: Option<crate::auth::AuthConfig>,
#[cfg(feature = "login-lockout")]
#[serde(default)]
pub lockout: Option<crate::lockout::LockoutConfig>,
#[cfg(feature = "tls")]
#[serde(default)]
pub tls: Option<TlsConfig>,
#[cfg(feature = "journald")]
#[serde(default)]
pub journald: Option<JournaldConfig>,
#[cfg(feature = "accounts")]
#[serde(default)]
pub accounts: Option<crate::accounts::AccountsConfig>,
#[serde(default)]
pub background_worker: Option<crate::agents::BackgroundWorkerConfig>,
#[serde(flatten)]
pub custom: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
pub name: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_environment")]
pub environment: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "format", rename_all = "lowercase")]
pub enum TokenConfig {
Paseto(PasetoConfig),
#[cfg(feature = "jwt")]
Jwt(JwtConfig),
}
impl Default for TokenConfig {
fn default() -> Self {
TokenConfig::Paseto(PasetoConfig::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasetoConfig {
#[serde(default = "default_paseto_version")]
pub version: String,
#[serde(default = "default_paseto_purpose")]
pub purpose: String,
pub key_path: PathBuf,
#[serde(default)]
pub issuer: Option<String>,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub public_paths: Vec<String>,
}
impl Default for PasetoConfig {
fn default() -> Self {
Self {
version: default_paseto_version(),
purpose: default_paseto_purpose(),
key_path: PathBuf::from("./keys/paseto.key"),
issuer: None,
audience: None,
public_paths: Vec::new(),
}
}
}
fn default_paseto_version() -> String {
"v4".to_string()
}
fn default_paseto_purpose() -> String {
"local".to_string()
}
#[cfg(feature = "jwt")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtConfig {
pub public_key_path: PathBuf,
#[serde(default = "default_jwt_algorithm")]
pub algorithm: String,
#[serde(default)]
pub issuer: Option<String>,
#[serde(default)]
pub audience: Option<String>,
#[serde(default)]
pub public_paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
#[serde(default = "default_per_user_rpm")]
pub per_user_rpm: u32,
#[serde(default = "default_per_client_rpm")]
pub per_client_rpm: u32,
#[serde(default = "default_window_secs")]
pub window_secs: u64,
#[serde(default)]
pub routes: std::collections::HashMap<String, RouteRateLimitConfig>,
#[serde(default = "default_true")]
pub auto_apply: bool,
#[serde(default = "default_false")]
pub trust_forwarded_headers: bool,
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
per_user_rpm: default_per_user_rpm(),
per_client_rpm: default_per_client_rpm(),
window_secs: default_window_secs(),
routes: std::collections::HashMap::new(),
auto_apply: true,
trust_forwarded_headers: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteRateLimitConfig {
pub requests_per_minute: u32,
#[serde(default = "default_route_burst_size")]
pub burst_size: u32,
#[serde(default = "default_true")]
pub per_user: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u64,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
#[cfg(feature = "turso")]
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TursoMode {
#[default]
Local,
Remote,
EmbeddedReplica,
}
#[cfg(feature = "turso")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TursoConfig {
#[serde(default)]
pub mode: TursoMode,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub auth_token: Option<String>,
#[serde(default)]
pub sync_interval_secs: Option<u64>,
#[serde(default)]
pub encryption_key: Option<String>,
#[serde(default = "default_true")]
pub read_your_writes: bool,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
#[cfg(feature = "surrealdb")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurrealDbConfig {
pub url: String,
#[serde(default = "default_surrealdb_namespace")]
pub namespace: String,
#[serde(default = "default_surrealdb_database")]
pub database: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedisConfig {
pub url: String,
#[serde(default = "default_redis_max_connections")]
pub max_connections: usize,
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u64,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NatsConfig {
pub url: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default = "default_max_reconnects")]
pub max_reconnects: usize,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClickHouseConfig {
pub url: String,
#[serde(default = "default_clickhouse_database")]
pub database: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_retry_delay")]
pub retry_delay_secs: u64,
#[serde(default = "default_false")]
pub optional: bool,
#[serde(default = "default_lazy_init")]
pub lazy_init: bool,
}
fn default_clickhouse_database() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OtlpConfig {
pub endpoint: String,
#[serde(default)]
pub service_name: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrpcConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_false")]
pub use_separate_port: bool,
#[serde(default = "default_grpc_port")]
pub port: u16,
#[serde(default = "default_true")]
pub reflection_enabled: bool,
#[serde(default = "default_true")]
pub health_check_enabled: bool,
#[serde(default = "default_grpc_max_message_mb")]
pub max_message_size_mb: usize,
#[serde(default = "default_connection_timeout")]
pub connection_timeout_secs: u64,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub proto: ProtoConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProtoConfig {
#[serde(default = "default_proto_dir")]
pub dir: String,
#[serde(default)]
pub service_registry: Option<String>,
#[serde(default)]
pub service_mesh_endpoint: Option<String>,
#[serde(default = "default_false")]
pub validation_enabled: bool,
#[serde(default)]
pub metadata: std::collections::HashMap<String, String>,
}
impl Default for ProtoConfig {
fn default() -> Self {
Self {
dir: default_proto_dir(),
service_registry: None,
service_mesh_endpoint: None,
validation_enabled: false,
metadata: std::collections::HashMap::new(),
}
}
}
impl GrpcConfig {
pub fn effective_port(&self, http_port: u16) -> u16 {
if self.use_separate_port {
self.port
} else {
http_port
}
}
pub fn max_message_size_bytes(&self) -> usize {
self.max_message_size_mb * 1024 * 1024
}
pub fn connection_timeout(&self) -> Duration {
Duration::from_secs(self.connection_timeout_secs)
}
pub fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_secs)
}
}
#[cfg(feature = "cedar-authz")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CedarConfig {
#[serde(default = "default_false")]
pub enabled: bool,
pub policy_path: PathBuf,
#[serde(default = "default_false")]
pub hot_reload: bool,
#[serde(default = "default_cedar_hot_reload_interval")]
pub hot_reload_interval_secs: u64,
#[serde(default = "default_true")]
pub cache_enabled: bool,
#[serde(default = "default_cedar_policy_cache_ttl")]
pub cache_ttl_secs: u64,
#[serde(default = "default_false")]
pub fail_open: bool,
}
#[cfg(feature = "cedar-authz")]
impl CedarConfig {
pub fn hot_reload_interval(&self) -> Duration {
Duration::from_secs(self.hot_reload_interval_secs)
}
pub fn cache_ttl(&self) -> Duration {
Duration::from_secs(self.cache_ttl_secs)
}
}
#[cfg(feature = "tls")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TlsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub cert_path: PathBuf,
pub key_path: PathBuf,
}
#[cfg(feature = "journald")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournaldConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub syslog_identifier: Option<String>,
#[serde(default)]
pub field_prefix: Option<String>,
#[serde(default = "default_false")]
pub disable_fmt_layer: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityHeadersConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub hsts: bool,
#[serde(default = "default_hsts_max_age")]
pub hsts_max_age_secs: u64,
#[serde(default = "default_false")]
pub hsts_include_subdomains: bool,
#[serde(default = "default_false")]
pub hsts_preload: bool,
#[serde(default = "default_true")]
pub x_content_type_options: bool,
#[serde(default = "default_x_frame_options")]
pub x_frame_options: String,
#[serde(default = "default_true")]
pub x_xss_protection: bool,
#[serde(default = "default_referrer_policy")]
pub referrer_policy: String,
#[serde(default)]
pub permissions_policy: Option<String>,
}
impl Default for SecurityHeadersConfig {
fn default() -> Self {
Self {
enabled: true,
hsts: true,
hsts_max_age_secs: default_hsts_max_age(),
hsts_include_subdomains: false,
hsts_preload: false,
x_content_type_options: true,
x_frame_options: default_x_frame_options(),
x_xss_protection: true,
referrer_policy: default_referrer_policy(),
permissions_policy: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MiddlewareConfig {
#[serde(default)]
pub request_tracking: RequestTrackingConfig,
#[serde(default)]
pub resilience: Option<ResilienceConfig>,
#[serde(default)]
pub metrics: Option<MetricsConfig>,
#[serde(default)]
pub governor: Option<LocalRateLimitConfig>,
#[serde(default = "default_body_limit_mb")]
pub body_limit_mb: usize,
#[serde(default = "default_true")]
pub catch_panic: bool,
#[serde(default = "default_true")]
pub compression: bool,
#[serde(default = "default_cors_mode")]
pub cors_mode: String,
#[serde(default)]
pub security_headers: SecurityHeadersConfig,
}
impl Default for MiddlewareConfig {
fn default() -> Self {
Self {
request_tracking: RequestTrackingConfig::default(),
resilience: None,
metrics: None,
governor: None,
body_limit_mb: default_body_limit_mb(),
catch_panic: true,
compression: true,
cors_mode: default_cors_mode(),
security_headers: SecurityHeadersConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestTrackingConfig {
#[serde(default = "default_true")]
pub request_id_enabled: bool,
#[serde(default = "default_request_id_header")]
pub request_id_header: String,
#[serde(default = "default_true")]
pub propagate_headers: bool,
#[serde(default = "default_true")]
pub mask_sensitive_headers: bool,
}
impl Default for RequestTrackingConfig {
fn default() -> Self {
Self {
request_id_enabled: true,
request_id_header: default_request_id_header(),
propagate_headers: true,
mask_sensitive_headers: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResilienceConfig {
#[serde(default = "default_true")]
pub circuit_breaker_enabled: bool,
#[serde(default = "default_circuit_breaker_threshold")]
pub circuit_breaker_threshold: f64,
#[serde(default = "default_circuit_breaker_min_requests")]
pub circuit_breaker_min_requests: u64,
#[serde(default = "default_circuit_breaker_wait_secs")]
pub circuit_breaker_wait_secs: u64,
#[serde(default = "default_true")]
pub retry_enabled: bool,
#[serde(default = "default_retry_max_attempts")]
pub retry_max_attempts: usize,
#[serde(default = "default_retry_base_delay_ms")]
pub retry_base_delay_ms: u64,
#[serde(default = "default_retry_max_delay_ms")]
pub retry_max_delay_ms: u64,
#[serde(default = "default_true")]
pub bulkhead_enabled: bool,
#[serde(default = "default_bulkhead_max_concurrent")]
pub bulkhead_max_concurrent: usize,
#[serde(default = "default_bulkhead_max_queued")]
pub bulkhead_max_queued: usize,
}
impl ResilienceConfig {
pub fn circuit_breaker_wait_duration(&self) -> Duration {
Duration::from_secs(self.circuit_breaker_wait_secs)
}
pub fn retry_base_delay(&self) -> Duration {
Duration::from_millis(self.retry_base_delay_ms)
}
pub fn retry_max_delay(&self) -> Duration {
Duration::from_millis(self.retry_max_delay_ms)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub include_path: bool,
#[serde(default = "default_true")]
pub include_method: bool,
#[serde(default = "default_true")]
pub include_status: bool,
#[serde(default = "default_latency_buckets")]
pub latency_buckets_ms: Vec<f64>,
}
impl MetricsConfig {
pub fn latency_buckets_as_duration(&self) -> Vec<Duration> {
self.latency_buckets_ms
.iter()
.map(|&ms| Duration::from_millis(ms as u64))
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalRateLimitConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_governor_requests")]
pub requests_per_period: u32,
#[serde(default = "default_governor_period_secs")]
pub period_secs: u64,
#[serde(default = "default_governor_burst")]
pub burst_size: u32,
}
impl LocalRateLimitConfig {
pub fn period(&self) -> Duration {
Duration::from_secs(self.period_secs)
}
}
fn default_port() -> u16 {
8080
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_timeout() -> u64 {
30
}
fn default_environment() -> String {
"dev".to_string()
}
#[cfg(feature = "jwt")]
fn default_jwt_algorithm() -> String {
"RS256".to_string()
}
fn default_per_user_rpm() -> u32 {
200
}
fn default_per_client_rpm() -> u32 {
1000
}
fn default_window_secs() -> u64 {
60
}
fn default_route_burst_size() -> u32 {
10 }
fn default_max_connections() -> u32 {
50
}
fn default_min_connections() -> u32 {
5
}
fn default_connection_timeout() -> u64 {
10
}
fn default_redis_max_connections() -> usize {
20
}
fn default_max_reconnects() -> usize {
10
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
fn default_max_retries() -> u32 {
5
}
fn default_retry_delay() -> u64 {
2
}
fn default_lazy_init() -> bool {
true
}
#[cfg(feature = "surrealdb")]
fn default_surrealdb_namespace() -> String {
"default".to_string()
}
#[cfg(feature = "surrealdb")]
fn default_surrealdb_database() -> String {
"default".to_string()
}
fn default_hsts_max_age() -> u64 {
63_072_000 }
fn default_x_frame_options() -> String {
"DENY".to_string()
}
fn default_referrer_policy() -> String {
"strict-origin-when-cross-origin".to_string()
}
fn default_body_limit_mb() -> usize {
10 }
fn default_cors_mode() -> String {
"restrictive".to_string()
}
fn default_request_id_header() -> String {
"x-request-id".to_string()
}
fn default_circuit_breaker_threshold() -> f64 {
0.5 }
fn default_circuit_breaker_min_requests() -> u64 {
10
}
fn default_circuit_breaker_wait_secs() -> u64 {
30
}
fn default_retry_max_attempts() -> usize {
3
}
fn default_retry_base_delay_ms() -> u64 {
100
}
fn default_retry_max_delay_ms() -> u64 {
10000 }
fn default_bulkhead_max_concurrent() -> usize {
100
}
fn default_bulkhead_max_queued() -> usize {
200
}
fn default_latency_buckets() -> Vec<f64> {
vec![
5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0, 10000.0,
]
}
fn default_governor_requests() -> u32 {
100
}
fn default_governor_period_secs() -> u64 {
60
}
fn default_governor_burst() -> u32 {
10
}
fn default_grpc_port() -> u16 {
9090
}
fn default_grpc_max_message_mb() -> usize {
4 }
fn default_proto_dir() -> String {
"proto".to_string()
}
#[cfg(feature = "cedar-authz")]
fn default_cedar_hot_reload_interval() -> u64 {
60 }
#[cfg(feature = "cedar-authz")]
fn default_cedar_policy_cache_ttl() -> u64 {
300 }
impl<T> Config<T>
where
T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
pub fn load() -> Result<Self> {
let service_name = std::env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "acton-service".to_string());
Self::load_for_service(&service_name)
}
pub fn load_for_service(service_name: &str) -> Result<Self> {
let config_paths = Self::find_config_paths(service_name);
tracing::debug!("Searching for config files in order:");
for path in &config_paths {
tracing::debug!(" - {}", path.display());
}
let mut figment = Figment::new()
.merge(Serialized::defaults(Config::<T>::default()));
for path in config_paths.iter().rev() {
if path.exists() {
tracing::info!("Loading configuration from: {}", path.display());
figment = figment.merge(Toml::file(path));
}
}
figment = figment.merge(Env::prefixed("ACTON_").split("_"));
let config = figment.extract()?;
Ok(config)
}
pub fn load_from(path: &str) -> Result<Self> {
let config = Figment::new()
.merge(Serialized::defaults(Config::<T>::default()))
.merge(Toml::file(path))
.merge(Env::prefixed("ACTON_").split("_"))
.extract()?;
Ok(config)
}
fn find_config_paths(service_name: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.push(PathBuf::from("config.toml"));
let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
let config_file_path = Path::new(service_name).join("config.toml");
if let Some(path) = xdg_dirs.find_config_file(&config_file_path) {
paths.push(path);
}
paths.push(
PathBuf::from("/etc/acton-service")
.join(service_name)
.join("config.toml"),
);
paths
}
pub fn recommended_path(service_name: &str) -> PathBuf {
let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
let config_file_path = Path::new(service_name).join("config.toml");
xdg_dirs
.place_config_file(&config_file_path)
.unwrap_or_else(|_| {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| String::from("~")))
.join(".config/acton-service")
.join(service_name)
.join("config.toml")
})
}
pub fn create_config_dir(service_name: &str) -> Result<PathBuf> {
let xdg_dirs = xdg::BaseDirectories::with_prefix("acton-service");
let config_file_path = Path::new(service_name).join("config.toml");
let config_path = xdg_dirs.place_config_file(&config_file_path).map_err(|e| {
crate::error::Error::Internal(format!("Failed to create config directory: {}", e))
})?;
Ok(config_path
.parent()
.ok_or_else(|| crate::error::Error::Internal("Invalid config path".to_string()))?
.to_path_buf())
}
pub fn database_url(&self) -> Option<&str> {
self.database.as_ref().map(|db| db.url.as_str())
}
pub fn redis_url(&self) -> Option<&str> {
self.redis.as_ref().map(|r| r.url.as_str())
}
pub fn nats_url(&self) -> Option<&str> {
self.nats.as_ref().map(|n| n.url.as_str())
}
#[cfg(feature = "turso")]
pub fn turso_url(&self) -> Option<&str> {
self.turso.as_ref().and_then(|t| t.url.as_deref())
}
#[cfg(feature = "surrealdb")]
pub fn surrealdb_url(&self) -> Option<&str> {
self.surrealdb.as_ref().map(|s| s.url.as_str())
}
pub fn with_development_cors(&mut self) -> &mut Self {
tracing::warn!(
"⚠️ CORS set to permissive mode - DO NOT USE IN PRODUCTION! \
This allows any origin to access your API. \
Use only for local development."
);
self.middleware.cors_mode = "permissive".to_string();
self
}
}
impl<T> Default for Config<T>
where
T: Serialize + DeserializeOwned + Clone + Default + Send + Sync + 'static,
{
fn default() -> Self {
Self {
service: ServiceConfig {
name: "acton-service".to_string(),
port: default_port(),
log_level: default_log_level(),
timeout_secs: default_timeout(),
environment: default_environment(),
},
token: None,
rate_limit: RateLimitConfig::default(),
middleware: MiddlewareConfig::default(),
database: None,
#[cfg(feature = "turso")]
turso: None,
#[cfg(feature = "surrealdb")]
surrealdb: None,
redis: None,
nats: None,
#[cfg(feature = "clickhouse")]
clickhouse: None,
otlp: None,
grpc: None,
#[cfg(feature = "websocket")]
websocket: None,
#[cfg(feature = "cedar-authz")]
cedar: None,
#[cfg(feature = "session")]
session: None,
#[cfg(feature = "audit")]
audit: None,
#[cfg(feature = "auth")]
auth: None,
#[cfg(feature = "login-lockout")]
lockout: None,
#[cfg(feature = "tls")]
tls: None,
#[cfg(feature = "journald")]
journald: None,
#[cfg(feature = "accounts")]
accounts: None,
background_worker: None,
custom: T::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_default_config() {
let config = Config::<()>::default();
assert_eq!(config.service.port, 8080);
assert_eq!(config.service.log_level, "info");
assert_eq!(config.rate_limit.per_user_rpm, 200);
}
#[test]
fn test_default_config_with_unit_type() {
let config = Config::<()>::default();
assert_eq!(config.service.port, 8080);
assert_eq!(config.service.name, "acton-service");
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
struct CustomConfig {
api_key: String,
timeout_ms: u32,
feature_flags: HashMap<String, bool>,
}
#[test]
fn test_config_with_custom_type() {
let custom = CustomConfig {
api_key: "test-key-123".to_string(),
timeout_ms: 5000,
feature_flags: {
let mut map = HashMap::new();
map.insert("new_ui".to_string(), true);
map.insert("beta_features".to_string(), false);
map
},
};
let config = Config {
service: ServiceConfig {
name: "test-service".to_string(),
port: 9090,
log_level: "debug".to_string(),
timeout_secs: 30,
environment: "test".to_string(),
},
token: Some(TokenConfig::Paseto(PasetoConfig {
version: "v4".to_string(),
purpose: "local".to_string(),
key_path: PathBuf::from("./test-key.key"),
issuer: Some("test-issuer".to_string()),
audience: None,
public_paths: Vec::new(),
})),
rate_limit: RateLimitConfig {
per_user_rpm: 100,
per_client_rpm: 500,
window_secs: 60,
routes: std::collections::HashMap::new(),
auto_apply: true,
trust_forwarded_headers: false,
},
middleware: MiddlewareConfig::default(),
database: None,
#[cfg(feature = "turso")]
turso: None,
#[cfg(feature = "surrealdb")]
surrealdb: None,
redis: None,
nats: None,
#[cfg(feature = "clickhouse")]
clickhouse: None,
otlp: None,
grpc: None,
#[cfg(feature = "websocket")]
websocket: None,
#[cfg(feature = "cedar-authz")]
cedar: None,
#[cfg(feature = "session")]
session: None,
#[cfg(feature = "audit")]
audit: None,
#[cfg(feature = "auth")]
auth: None,
#[cfg(feature = "login-lockout")]
lockout: None,
#[cfg(feature = "tls")]
tls: None,
#[cfg(feature = "journald")]
journald: None,
#[cfg(feature = "accounts")]
accounts: None,
background_worker: None,
custom,
};
assert_eq!(config.service.name, "test-service");
assert_eq!(config.custom.api_key, "test-key-123");
assert_eq!(config.custom.timeout_ms, 5000);
assert_eq!(config.custom.feature_flags.get("new_ui"), Some(&true));
}
#[test]
fn test_config_serialization_with_custom() {
let custom = CustomConfig {
api_key: "secret-key".to_string(),
timeout_ms: 3000,
feature_flags: HashMap::new(),
};
let config = Config {
service: ServiceConfig {
name: "test".to_string(),
port: 8080,
log_level: "info".to_string(),
timeout_secs: 30,
environment: "dev".to_string(),
},
token: None,
rate_limit: RateLimitConfig {
per_user_rpm: 200,
per_client_rpm: 1000,
window_secs: 60,
routes: std::collections::HashMap::new(),
auto_apply: true,
trust_forwarded_headers: false,
},
middleware: MiddlewareConfig::default(),
database: None,
#[cfg(feature = "turso")]
turso: None,
#[cfg(feature = "surrealdb")]
surrealdb: None,
redis: None,
nats: None,
#[cfg(feature = "clickhouse")]
clickhouse: None,
otlp: None,
grpc: None,
#[cfg(feature = "websocket")]
websocket: None,
#[cfg(feature = "cedar-authz")]
cedar: None,
#[cfg(feature = "session")]
session: None,
#[cfg(feature = "audit")]
audit: None,
#[cfg(feature = "auth")]
auth: None,
#[cfg(feature = "login-lockout")]
lockout: None,
#[cfg(feature = "tls")]
tls: None,
#[cfg(feature = "journald")]
journald: None,
#[cfg(feature = "accounts")]
accounts: None,
background_worker: None,
custom: custom.clone(),
};
let json = serde_json::to_string(&config).expect("Failed to serialize");
let deserialized: Config<CustomConfig> =
serde_json::from_str(&json).expect("Failed to deserialize");
assert_eq!(deserialized.custom, custom);
assert_eq!(deserialized.service.name, "test");
}
#[test]
fn test_config_deserialization_with_flatten() {
let json_str = r#"{
"service": {
"name": "my-service",
"port": 9000,
"log_level": "debug",
"timeout_secs": 60,
"environment": "production"
},
"token": {
"format": "paseto",
"version": "v4",
"purpose": "local",
"key_path": "./keys/paseto.key"
},
"rate_limit": {
"per_user_rpm": 150,
"per_client_rpm": 750,
"window_secs": 60
},
"middleware": {
"cors_mode": "restrictive",
"body_limit_mb": 10,
"compression_enabled": true
},
"api_key": "prod-api-key",
"timeout_ms": 10000,
"feature_flags": {
"new_dashboard": true,
"analytics": true
}
}"#;
let config: Config<CustomConfig> =
serde_json::from_str(json_str).expect("Failed to parse JSON");
assert_eq!(config.service.name, "my-service");
assert_eq!(config.service.port, 9000);
assert_eq!(config.service.log_level, "debug");
assert_eq!(config.custom.api_key, "prod-api-key");
assert_eq!(config.custom.timeout_ms, 10000);
assert_eq!(
config.custom.feature_flags.get("new_dashboard"),
Some(&true)
);
assert_eq!(config.custom.feature_flags.get("analytics"), Some(&true));
}
}