Files
asepharyana-hub-scraper/src/shared/config/mod.rs
T

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()
});