Built for people who think better out loud.
at main 347 lines 13 kB view raw
1use std::env; 2 3use thiserror::Error; 4 5/// Application configuration loaded from environment variables. 6/// 7/// Usage: 8/// ```rust,no_run 9/// # use slipnote_backend::config::Config; 10/// let config = Config::from_env().expect("config"); 11/// ``` 12#[derive(Debug, Clone)] 13pub struct Config { 14 /// `OpenAI` API key used for Whisper requests. 15 pub openai_api_key: String, 16 /// `OpenAI` base URL. 17 pub openai_base_url: String, 18 /// Default Whisper model. 19 pub openai_whisper_model: String, 20 /// Default Whisper response format. 21 pub openai_whisper_response_format: String, 22 /// Address/port to bind the HTTP server to. 23 pub bind_addr: String, 24 /// Postgres connection string. 25 pub database_url: String, 26 /// Named environment used for logging and diagnostics. 27 pub slipnote_env: String, 28 /// Tail-sampling rate for request logs (0.0-1.0). 29 pub log_sample_rate: f64, 30 /// Estimated cost per audio second in dollars. 31 pub transcription_cost_per_second_dollars: f64, 32 /// Axiom ingest token (required in prod, optional in dev). 33 pub axiom_token: Option<String>, 34 /// Axiom dataset name (required in prod, optional in dev). 35 pub axiom_dataset: Option<String>, 36 /// Optional Axiom API URL override. 37 pub axiom_url: Option<String>, 38 /// OAuth client ID (typically the client metadata URL). 39 pub oauth_client_id: String, 40 /// OAuth redirect URI for callbacks. 41 pub oauth_redirect_uri: String, 42 /// OAuth client display name. 43 pub oauth_client_name: Option<String>, 44 /// OAuth client URI for metadata. 45 pub oauth_client_uri: Option<String>, 46 /// OAuth JWKS URI for signing keys. 47 pub oauth_jwks_uri: Option<String>, 48 /// OAuth signing key (did:key private key). 49 pub oauth_signing_key: String, 50 /// OAuth scopes (space-delimited). 51 pub oauth_scopes: String, 52 /// OAuth post-auth redirect path or URL. 53 pub oauth_post_auth_redirect: String, 54 /// Session cookie name. 55 pub oauth_cookie_name: String, 56 /// Session TTL in seconds. 57 pub oauth_session_ttl_seconds: i64, 58 /// PLC directory hostname. 59 pub plc_hostname: String, 60 /// Optional OAuth base URL used to derive client metadata/redirect URIs. 61 pub oauth_base_url: Option<String>, 62} 63 64/// Errors that can occur when loading configuration. 65#[derive(Debug, Error)] 66pub enum ConfigError { 67 #[error("missing required environment variable {0}")] 68 MissingEnvVar(&'static str), 69} 70 71impl Config { 72 /// Loads configuration from environment variables. 73 /// 74 /// Usage: 75 /// ```rust,no_run 76 /// # use slipnote_backend::config::Config; 77 /// let config = Config::from_env().expect("config"); 78 /// ``` 79 pub fn from_env() -> Result<Self, ConfigError> { 80 load_dotenv(); 81 let require_axiom = !cfg!(debug_assertions); 82 let default_env = if cfg!(debug_assertions) { 83 "local" 84 } else { 85 "prod" 86 }; 87 88 let oauth_base_url = env::var("OAUTH_BASE_URL") 89 .ok() 90 .map(|value| value.trim_end_matches('/').to_string()); 91 let oauth_client_id = env::var("OAUTH_CLIENT_ID") 92 .ok() 93 .or_else(|| { 94 oauth_base_url 95 .as_ref() 96 .map(|base| format!("{base}/oauth/client-metadata.json")) 97 }) 98 .ok_or(ConfigError::MissingEnvVar("OAUTH_CLIENT_ID"))?; 99 let oauth_redirect_uri = env::var("OAUTH_REDIRECT_URI") 100 .ok() 101 .or_else(|| oauth_base_url.as_ref().map(|base| format!("{base}/oauth/callback"))) 102 .ok_or(ConfigError::MissingEnvVar("OAUTH_REDIRECT_URI"))?; 103 let oauth_jwks_uri = env::var("OAUTH_JWKS_URI") 104 .ok() 105 .or_else(|| { 106 oauth_base_url 107 .as_ref() 108 .map(|base| format!("{base}/.well-known/jwks.json")) 109 }); 110 let oauth_post_auth_redirect = env::var("OAUTH_POST_AUTH_REDIRECT").ok(); 111 let oauth_post_auth_redirect_route = env::var("OAUTH_POST_AUTH_REDIRECT_ROUTE").ok(); 112 let oauth_post_auth_redirect = oauth_post_auth_redirect.or_else(|| { 113 if let Some(route) = oauth_post_auth_redirect_route { 114 if let Some(base) = oauth_base_url.as_ref() { 115 let trimmed_route = if route.starts_with('/') { 116 route 117 } else { 118 format!("/{route}") 119 }; 120 return Some(format!("{base}{trimmed_route}")); 121 } 122 return Some(route); 123 } 124 oauth_base_url.clone() 125 }); 126 127 Ok(Self { 128 openai_api_key: require_env("OPENAI_API_KEY")?, 129 openai_base_url: env_or("OPENAI_BASE_URL", "https://api.openai.com/v1"), 130 openai_whisper_model: env_or("OPENAI_WHISPER_MODEL", "whisper-1"), 131 openai_whisper_response_format: env_or( 132 "OPENAI_WHISPER_RESPONSE_FORMAT", 133 "verbose_json", 134 ), 135 bind_addr: env_or("SLIPNOTE_BIND_ADDR", "0.0.0.0:3001"), 136 database_url: require_env("DATABASE_URL")?, 137 slipnote_env: env_or("SLIPNOTE_ENV", default_env), 138 log_sample_rate: env_or_f64("SLIPNOTE_LOG_SAMPLE_RATE", 1.0), 139 transcription_cost_per_second_dollars: env_or_f64( 140 "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS", 141 DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS, 142 ), 143 axiom_token: if require_axiom { 144 Some(require_env("AXIOM_TOKEN")?) 145 } else { 146 env::var("AXIOM_TOKEN").ok() 147 }, 148 axiom_dataset: if require_axiom { 149 Some(require_env("AXIOM_DATASET")?) 150 } else { 151 env::var("AXIOM_DATASET").ok() 152 }, 153 axiom_url: env::var("AXIOM_URL").ok(), 154 oauth_client_id, 155 oauth_redirect_uri, 156 oauth_client_name: env::var("OAUTH_CLIENT_NAME").ok(), 157 oauth_client_uri: env::var("OAUTH_CLIENT_URI") 158 .ok() 159 .or_else(|| oauth_base_url.clone()), 160 oauth_jwks_uri, 161 oauth_signing_key: require_env("OAUTH_SIGNING_KEY")?, 162 oauth_scopes: env_or("OAUTH_SCOPES", "atproto transition:generic"), 163 oauth_post_auth_redirect: oauth_post_auth_redirect 164 .unwrap_or_else(|| "/".to_string()), 165 oauth_cookie_name: env_or("OAUTH_COOKIE_NAME", "slipnote_session"), 166 oauth_session_ttl_seconds: env_or_i64("OAUTH_SESSION_TTL_SECONDS", 60 * 60 * 24 * 7), 167 plc_hostname: env_or("PLC_HOSTNAME", "plc.directory"), 168 oauth_base_url, 169 }) 170 } 171} 172 173const DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS: f64 = 0.000_094_34; 174 175fn require_env(key: &'static str) -> Result<String, ConfigError> { 176 env::var(key).map_err(|_| ConfigError::MissingEnvVar(key)) 177} 178 179fn env_or(key: &'static str, default_value: &'static str) -> String { 180 env::var(key).unwrap_or_else(|_| default_value.to_string()) 181} 182 183fn env_or_f64(key: &'static str, default_value: f64) -> f64 { 184 env::var(key) 185 .ok() 186 .and_then(|value| value.parse::<f64>().ok()) 187 .unwrap_or(default_value) 188} 189 190fn env_or_i64(key: &'static str, default_value: i64) -> i64 { 191 env::var(key) 192 .ok() 193 .and_then(|value| value.parse::<i64>().ok()) 194 .unwrap_or(default_value) 195} 196fn load_dotenv() { 197 if env::var("SLIPNOTE_DISABLE_DOTENV").is_err() { 198 dotenvy::dotenv().ok(); 199 } 200} 201 202#[cfg(test)] 203mod tests { 204 use super::*; 205 use std::sync::{Mutex, OnceLock}; 206 207 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); 208 209 fn with_env_lock<F: FnOnce()>(f: F) { 210 let lock = ENV_LOCK.get_or_init(|| Mutex::new(())); 211 let _guard = lock.lock().unwrap_or_else(|err| err.into_inner()); 212 f(); 213 } 214 215 struct EnvSnapshot { 216 entries: Vec<(&'static str, Option<String>)>, 217 } 218 219 impl EnvSnapshot { 220 fn capture(keys: &[&'static str]) -> Self { 221 let entries = keys 222 .iter() 223 .map(|key| (*key, env::var(key).ok())) 224 .collect(); 225 Self { entries } 226 } 227 } 228 229 impl Drop for EnvSnapshot { 230 fn drop(&mut self) { 231 for (key, value) in self.entries.iter() { 232 match value { 233 Some(current) => set_env_var(key, current), 234 None => remove_env_var(key), 235 } 236 } 237 } 238 } 239 240 fn set_env_var(key: &str, value: &str) { 241 unsafe { 242 env::set_var(key, value); 243 } 244 } 245 246 fn remove_env_var(key: &str) { 247 unsafe { 248 env::remove_var(key); 249 } 250 } 251 252 #[test] 253 fn from_env_requires_api_key() { 254 with_env_lock(|| { 255 let _snapshot = EnvSnapshot::capture(&[ 256 "OPENAI_API_KEY", 257 "SLIPNOTE_DISABLE_DOTENV", 258 "AXIOM_TOKEN", 259 "AXIOM_DATASET", 260 "AXIOM_URL", 261 "OAUTH_CLIENT_ID", 262 "OAUTH_REDIRECT_URI", 263 "OAUTH_SIGNING_KEY", 264 "OAUTH_BASE_URL", 265 ]); 266 set_env_var("SLIPNOTE_DISABLE_DOTENV", "1"); 267 remove_env_var("OPENAI_API_KEY"); 268 set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json"); 269 set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback"); 270 set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o"); 271 remove_env_var("OAUTH_BASE_URL"); 272 let result = Config::from_env(); 273 assert!(matches!(result, Err(ConfigError::MissingEnvVar("OPENAI_API_KEY")))); 274 }); 275 } 276 277 #[test] 278 fn from_env_uses_defaults() { 279 with_env_lock(|| { 280 let _snapshot = EnvSnapshot::capture(&[ 281 "OPENAI_API_KEY", 282 "OPENAI_BASE_URL", 283 "OPENAI_WHISPER_MODEL", 284 "OPENAI_WHISPER_RESPONSE_FORMAT", 285 "SLIPNOTE_BIND_ADDR", 286 "DATABASE_URL", 287 "SLIPNOTE_ENV", 288 "SLIPNOTE_LOG_SAMPLE_RATE", 289 "SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS", 290 "SLIPNOTE_DISABLE_DOTENV", 291 "AXIOM_TOKEN", 292 "AXIOM_DATASET", 293 "AXIOM_URL", 294 "OAUTH_CLIENT_ID", 295 "OAUTH_REDIRECT_URI", 296 "OAUTH_SIGNING_KEY", 297 "OAUTH_SCOPES", 298 "OAUTH_POST_AUTH_REDIRECT", 299 "OAUTH_COOKIE_NAME", 300 "OAUTH_SESSION_TTL_SECONDS", 301 "PLC_HOSTNAME", 302 "OAUTH_BASE_URL", 303 ]); 304 set_env_var("SLIPNOTE_DISABLE_DOTENV", "1"); 305 set_env_var("OPENAI_API_KEY", "test-key"); 306 remove_env_var("OPENAI_BASE_URL"); 307 remove_env_var("OPENAI_WHISPER_MODEL"); 308 remove_env_var("OPENAI_WHISPER_RESPONSE_FORMAT"); 309 remove_env_var("SLIPNOTE_BIND_ADDR"); 310 set_env_var("DATABASE_URL", "postgres://test:test@localhost:5432/test"); 311 remove_env_var("SLIPNOTE_ENV"); 312 remove_env_var("SLIPNOTE_LOG_SAMPLE_RATE"); 313 remove_env_var("SLIPNOTE_TRANSCRIPTION_COST_PER_SECOND_DOLLARS"); 314 set_env_var("OAUTH_CLIENT_ID", "https://example.com/oauth/client-metadata.json"); 315 set_env_var("OAUTH_REDIRECT_URI", "https://example.com/oauth/callback"); 316 set_env_var("OAUTH_SIGNING_KEY", "did:key:z42tnbHmmnhF11nwSnp5kQJbcZQw2Vbw5WF3ABDSxPtDgU2o"); 317 remove_env_var("OAUTH_SCOPES"); 318 remove_env_var("OAUTH_POST_AUTH_REDIRECT"); 319 remove_env_var("OAUTH_COOKIE_NAME"); 320 remove_env_var("OAUTH_SESSION_TTL_SECONDS"); 321 remove_env_var("PLC_HOSTNAME"); 322 remove_env_var("OAUTH_BASE_URL"); 323 324 let config = Config::from_env().expect("config"); 325 assert_eq!(config.openai_base_url, "https://api.openai.com/v1"); 326 assert_eq!(config.openai_whisper_model, "whisper-1"); 327 assert_eq!(config.openai_whisper_response_format, "verbose_json"); 328 assert_eq!(config.bind_addr, "0.0.0.0:3001"); 329 assert_eq!( 330 config.database_url, 331 "postgres://test:test@localhost:5432/test" 332 ); 333 assert_eq!(config.slipnote_env, "local"); 334 assert_eq!(config.log_sample_rate, 1.0); 335 assert_eq!( 336 config.transcription_cost_per_second_dollars, 337 DEFAULT_TRANSCRIPTION_COST_PER_SECOND_DOLLARS 338 ); 339 assert_eq!(config.oauth_scopes, "atproto transition:generic"); 340 assert_eq!(config.oauth_post_auth_redirect, "/"); 341 assert_eq!(config.oauth_cookie_name, "slipnote_session"); 342 assert_eq!(config.oauth_session_ttl_seconds, 60 * 60 * 24 * 7); 343 assert_eq!(config.plc_hostname, "plc.directory"); 344 assert_eq!(config.oauth_base_url, None); 345 }); 346 } 347}