290 lines
8.4 KiB
Rust
290 lines
8.4 KiB
Rust
//! Type-safe application configuration.
|
|||
|
|
//!
|
||
|
|
//! This module provides a strongly-typed configuration system that:
|
||
|
|
//! - Loads from environment variables and optional TOML files
|
||
|
|
//! - Fails fast at startup if required variables are missing
|
||
|
|
//! - Supports hierarchical configuration (default -> environment-specific)
|
||
|
|
|
||
|
|
use config::{Config, ConfigError, Environment, File};
|
||
|
|
use once_cell::sync::Lazy;
|
||
|
|
use serde::Deserialize;
|
||
|
|
use std::env;
|
||
|
|
|
||
|
|
/// Application configuration loaded at startup.
|
||
|
|
/// All fields are required unless marked as `Option<T>`.
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct AppConfig {
|
||
|
|
/// Database connection URL (PostgreSQL)
|
||
|
|
pub database_url: String,
|
||
|
|
|
||
|
|
/// Secret key for JWT signing
|
||
|
|
pub jwt_secret: String,
|
||
|
|
|
||
|
|
/// Redis connection URL
|
||
|
|
#[serde(default)]
|
||
|
|
pub redis_url: String,
|
||
|
|
|
||
|
|
/// Server port to bind to
|
||
|
|
#[serde(default = "default_port")]
|
||
|
|
pub server_port: u16,
|
||
|
|
|
||
|
|
/// Environment (development, staging, production)
|
||
|
|
#[serde(default = "default_env")]
|
||
|
|
pub environment: String,
|
||
|
|
|
||
|
|
/// Allowed CORS origins (comma-separated)
|
||
|
|
#[serde(default)]
|
||
|
|
pub cors_origins: Vec<String>,
|
||
|
|
|
||
|
|
/// Log level (trace, debug, info, warn, error)
|
||
|
|
#[serde(default = "default_log_level")]
|
||
|
|
pub log_level: String,
|
||
|
|
|
||
|
|
/// SMTP configuration for emails (optional)
|
||
|
|
pub smtp: Option<SmtpConfig>,
|
||
|
|
|
||
|
|
/// Database pool configuration
|
||
|
|
#[serde(default)]
|
||
|
|
pub db: DbConfig,
|
||
|
|
|
||
|
|
/// Max concurrent image processing tasks
|
||
|
|
#[serde(default = "default_image_processing_concurrency")]
|
||
|
|
pub image_processing_concurrency: usize,
|
||
|
|
|
||
|
|
/// Domain/URL values that may change between deployments
|
||
|
|
#[serde(default)]
|
||
|
|
pub urls: UrlConfig,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct UrlConfig {
|
||
|
|
#[serde(default = "default_site_url")]
|
||
|
|
pub site_url: String,
|
||
|
|
#[serde(default = "default_picser_api_url")]
|
||
|
|
pub picser_api_url: String,
|
||
|
|
#[serde(default = "default_fallback_upload_api_url")]
|
||
|
|
pub fallback_upload_api_url: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for UrlConfig {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self {
|
||
|
|
site_url: default_site_url(),
|
||
|
|
picser_api_url: default_picser_api_url(),
|
||
|
|
fallback_upload_api_url: default_fallback_upload_api_url(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct DbConfig {
|
||
|
|
#[serde(default = "default_db_max_connections")]
|
||
|
|
pub max_connections: u32,
|
||
|
|
#[serde(default = "default_db_min_connections")]
|
||
|
|
pub min_connections: u32,
|
||
|
|
#[serde(default = "default_db_connect_timeout")]
|
||
|
|
pub connect_timeout_seconds: u64,
|
||
|
|
#[serde(default = "default_db_idle_timeout")]
|
||
|
|
pub idle_timeout_seconds: u64,
|
||
|
|
#[serde(default = "default_db_acquire_timeout")]
|
||
|
|
pub acquire_timeout_seconds: u64,
|
||
|
|
#[serde(default = "default_db_max_lifetime")]
|
||
|
|
pub max_lifetime_seconds: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for DbConfig {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self {
|
||
|
|
max_connections: default_db_max_connections(),
|
||
|
|
min_connections: default_db_min_connections(),
|
||
|
|
connect_timeout_seconds: default_db_connect_timeout(),
|
||
|
|
idle_timeout_seconds: default_db_idle_timeout(),
|
||
|
|
acquire_timeout_seconds: default_db_acquire_timeout(),
|
||
|
|
max_lifetime_seconds: default_db_max_lifetime(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// SMTP configuration for sending emails
|
||
|
|
#[derive(Debug, Clone, Deserialize)]
|
||
|
|
pub struct SmtpConfig {
|
||
|
|
pub host: String,
|
||
|
|
pub port: u16,
|
||
|
|
pub username: String,
|
||
|
|
pub password: String,
|
||
|
|
pub from_email: String,
|
||
|
|
pub from_name: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// MinIO/S3-compatible storage configuration
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct MinioConfig {
|
||
|
|
/// MinIO endpoint URL (e.g., "https://cdn.asepharyana.my.id")
|
||
|
|
pub endpoint: String,
|
||
|
|
/// Bucket name
|
||
|
|
pub bucket_name: String,
|
||
|
|
/// Access key / username
|
||
|
|
pub access_key: String,
|
||
|
|
/// Secret key / password
|
||
|
|
pub secret_key: String,
|
||
|
|
/// Use HTTPS (true) or HTTP (false)
|
||
|
|
pub secure: bool,
|
||
|
|
/// AWS region (default: us-east-1)
|
||
|
|
pub region: String,
|
||
|
|
/// Public URL for serving files (optional)
|
||
|
|
pub public_url: Option<String>,
|
||
|
|
/// Prefix for avatar files (e.g., "avatars")
|
||
|
|
pub avatar_prefix: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl MinioConfig {
|
||
|
|
/// Load MinIO configuration from environment variables
|
||
|
|
pub fn from_env() -> Option<Self> {
|
||
|
|
let endpoint = env::var("MINIO_ENDPOINT").ok()?;
|
||
|
|
let bucket_name = env::var("MINIO_BUCKET_NAME").ok()?;
|
||
|
|
let access_key = env::var("MINIO_ACCESS_KEY").ok()?;
|
||
|
|
let secret_key = env::var("MINIO_SECRET_KEY").ok()?;
|
||
|
|
|
||
|
|
let secure = env::var("MINIO_SECURE")
|
||
|
|
.map(|v| v.to_lowercase() == "true")
|
||
|
|
.unwrap_or(true);
|
||
|
|
|
||
|
|
let region = env::var("MINIO_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||
|
|
|
||
|
|
let public_url = env::var("MINIO_PUBLIC_URL").ok();
|
||
|
|
|
||
|
|
let avatar_prefix =
|
||
|
|
env::var("MINIO_AVATAR_PREFIX").unwrap_or_else(|_| "avatars".to_string());
|
||
|
|
|
||
|
|
Some(Self {
|
||
|
|
endpoint,
|
||
|
|
bucket_name,
|
||
|
|
access_key,
|
||
|
|
secret_key,
|
||
|
|
secure,
|
||
|
|
region,
|
||
|
|
public_url,
|
||
|
|
avatar_prefix,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_port() -> u16 {
|
||
|
|
4091
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_env() -> String {
|
||
|
|
"development".to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_log_level() -> String {
|
||
|
|
"info".to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_image_processing_concurrency() -> usize {
|
||
|
|
5
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_site_url() -> String {
|
||
|
|
"https://asepharyana.my.id".to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_picser_api_url() -> String {
|
||
|
|
"https://picser.asepharyana.my.id/api/upload".to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_fallback_upload_api_url() -> String {
|
||
|
|
"https://upload.asepharyana.my.id/api/upload".to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_max_connections() -> u32 {
|
||
|
|
100
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_min_connections() -> u32 {
|
||
|
|
10
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_connect_timeout() -> u64 {
|
||
|
|
5
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_idle_timeout() -> u64 {
|
||
|
|
300
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_acquire_timeout() -> u64 {
|
||
|
|
10
|
||
|
|
}
|
||
|
|
|
||
|
|
fn default_db_max_lifetime() -> u64 {
|
||
|
|
1800
|
||
|
|
}
|
||
|
|
|
||
|
|
impl AppConfig {
|
||
|
|
/// Load configuration from environment and optional config files.
|
||
|
|
///
|
||
|
|
/// Priority (highest to lowest):
|
||
|
|
/// 1. Environment variables (prefixed with APP_)
|
||
|
|
/// 2. `config/{environment}.toml`
|
||
|
|
/// 3. `config/default.toml`
|
||
|
|
pub fn load() -> Result<Self, ConfigError> {
|
||
|
|
// Load .env file first
|
||
|
|
if let Err(e) = dotenvy::dotenv() {
|
||
|
|
tracing::debug!("Could not load .env file: {}", e);
|
||
|
|
}
|
||
|
|
|
||
|
|
let run_mode = env::var("RUN_MODE").unwrap_or_else(|_| "development".into());
|
||
|
|
|
||
|
|
let config = Config::builder()
|
||
|
|
// Start with default config file
|
||
|
|
.add_source(File::with_name("config/default").required(false))
|
||
|
|
// Layer on environment-specific values
|
||
|
|
.add_source(File::with_name(&format!("config/{}", run_mode)).required(false))
|
||
|
|
// Add environment variables (with APP_ prefix)
|
||
|
|
.add_source(
|
||
|
|
Environment::with_prefix("APP")
|
||
|
|
.separator("__")
|
||
|
|
.try_parsing(true)
|
||
|
|
.list_separator(","),
|
||
|
|
)
|
||
|
|
// Map legacy env vars to new config structure
|
||
|
|
.set_override_option("database_url", env::var("DATABASE_URL").ok())?
|
||
|
|
.set_override_option("jwt_secret", env::var("JWT_SECRET").ok())?
|
||
|
|
.set_override_option("redis_url", env::var("REDIS_URL").ok())?
|
||
|
|
.build()?;
|
||
|
|
|
||
|
|
config.try_deserialize()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if running in production mode
|
||
|
|
pub fn is_production(&self) -> bool {
|
||
|
|
self.environment == "production"
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Check if running in development mode
|
||
|
|
pub fn is_development(&self) -> bool {
|
||
|
|
self.environment == "development"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Global configuration instance, loaded once at startup.
|
||
|
|
/// Panics if configuration is invalid - this is intentional for fail-fast behavior.
|
||
|
|
pub static CONFIG: Lazy<AppConfig> = Lazy::new(|| {
|
||
|
|
AppConfig::load().unwrap_or_else(|e| {
|
||
|
|
eprintln!("❌ Failed to load configuration: {}", e);
|
||
|
|
eprintln!(" Make sure all required environment variables are set:");
|
||
|
|
eprintln!(" - DATABASE_URL");
|
||
|
|
eprintln!(" - JWT_SECRET");
|
||
|
|
eprintln!(" - REDIS_URL (or APP_REDIS_URL)");
|
||
|
|
std::process::exit(1);
|
||
|
|
})
|
||
|
|
});
|
||
|
|
|
||
|
|
/// Global MinIO configuration, loaded from environment variables.
|
||
|
|
/// Returns None if required MINIO_* variables are not set.
|
||
|
|
pub static MINIO_CONFIG: Lazy<Option<MinioConfig>> = Lazy::new(|| {
|
||
|
|
let _ = dotenvy::dotenv();
|
||
|
|
MinioConfig::from_env()
|
||
|
|
});
|