From 1e518c64bbf805342f334933c04e974327b060e1 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 11:16:22 +0530 Subject: [PATCH 01/21] refactor: split config parsing from file reading --- src/config/settings.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 70f1b30..86af4a6 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,5 +1,6 @@ use crate::error::{EdgeProxyError, Result}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::fs; use std::path::PathBuf; use validator::Validate; @@ -154,12 +155,12 @@ impl AppSettings { } pub fn from_file(path: &PathBuf) -> Result { - let contents = fs::read_to_string(path).map_err(|e| { - EdgeProxyError::ConfigurationError(format!("Failed to read config file: {}", e)) - })?; + Self::from_value(read_config_file(path)?) + } - let settings: AppSettings = serde_json::from_str(&contents).map_err(|e| { - EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) + pub fn from_value(value: Value) -> Result { + let settings: AppSettings = serde_json::from_value(value).map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Failed to parse config: {}", e)) })?; settings.validate().map_err(|e| { @@ -174,6 +175,16 @@ impl AppSettings { } } +fn read_config_file(path: &PathBuf) -> Result { + let contents = fs::read_to_string(path).map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Failed to read config file: {}", e)) + })?; + + serde_json::from_str(&contents).map_err(|e| { + EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) + }) +} + pub fn get_settings() -> Result { let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); let path = PathBuf::from(config_path); From e69d705e341ee5b81aa1610ee72d0b119c4f13da Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 11:18:44 +0530 Subject: [PATCH 02/21] feat: start without a config file --- src/config/settings.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 86af4a6..699a63a 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,7 +1,8 @@ use crate::error::{EdgeProxyError, Result}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{Map, Value}; use std::fs; +use std::io; use std::path::PathBuf; use validator::Validate; @@ -176,9 +177,17 @@ impl AppSettings { } fn read_config_file(path: &PathBuf) -> Result { - let contents = fs::read_to_string(path).map_err(|e| { - EdgeProxyError::ConfigurationError(format!("Failed to read config file: {}", e)) - })?; + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + // Settings may come entirely from environment variables + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Value::Object(Map::new())), + Err(e) => { + return Err(EdgeProxyError::ConfigurationError(format!( + "Failed to read config file: {}", + e + ))); + } + }; serde_json::from_str(&contents).map_err(|e| { EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) @@ -226,6 +235,19 @@ mod tests { assert!(settings.validate().is_ok()); } + #[test] + fn test_missing_config_file_loads_defaults() { + // Given + let path = PathBuf::from("/nonexistent/config.json"); + + // When + let settings = AppSettings::from_file(&path).unwrap(); + + // Then + assert_eq!(settings.api_url, default_api_url()); + assert!(settings.environment_key_pairs.is_empty()); + } + #[test] fn test_client_side_key_validation_valid() { // Given From 5c4e45288ede29070ebd8bf59a4b9eea1de9b193 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 11:43:48 +0530 Subject: [PATCH 03/21] feat: override settings from environment variables --- src/config/settings.rs | 174 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 699a63a..a3ec565 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -194,11 +194,72 @@ fn read_config_file(path: &PathBuf) -> Result { }) } +enum EnvKind { + Raw, + Json, +} + +// Mirrors the Python proxy: top-level fields only, string fields taken +// verbatim, everything else JSON-decoded +const ENV_FIELDS: &[(&str, EnvKind)] = &[ + ("proxy_key", EnvKind::Raw), + ("api_url", EnvKind::Raw), + ("environment_key_pairs", EnvKind::Json), + ("api_poll_frequency_seconds", EnvKind::Json), + ("api_poll_timeout_seconds", EnvKind::Json), + ("allow_origins", EnvKind::Json), + ("server", EnvKind::Json), + ("logging", EnvKind::Json), + ("health_check", EnvKind::Json), +]; + +fn apply_env_overrides( + mut config: Value, + env: impl IntoIterator, +) -> Result { + let Value::Object(fields) = &mut config else { + return Err(EdgeProxyError::ConfigurationError( + "Config file must contain a JSON object".to_string(), + )); + }; + + for (name, raw) in env { + let key = name.to_lowercase(); + let Some((field, kind)) = ENV_FIELDS.iter().find(|(field, _)| *field == key) else { + continue; + }; + let value = match kind { + EnvKind::Raw => Value::String(raw), + EnvKind::Json => serde_json::from_str(&raw).map_err(|e| { + EdgeProxyError::ConfigurationError(format!( + "Invalid JSON in environment variable {}: {}", + name, e + )) + })?, + }; + deep_merge(fields.entry(*field).or_insert(Value::Null), value); + } + + Ok(config) +} + +fn deep_merge(target: &mut Value, value: Value) { + match (target, value) { + (Value::Object(target), Value::Object(value)) => { + for (key, value) in value { + deep_merge(target.entry(key).or_insert(Value::Null), value); + } + } + (target, value) => *target = value, + } +} + pub fn get_settings() -> Result { let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); let path = PathBuf::from(config_path); - AppSettings::from_file(&path) + let config = apply_env_overrides(read_config_file(&path)?, std::env::vars())?; + AppSettings::from_value(config) } #[cfg(test)] @@ -248,6 +309,117 @@ mod tests { assert!(settings.environment_key_pairs.is_empty()); } + fn env(vars: &[(&str, &str)]) -> Vec<(String, String)> { + vars.iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() + } + + fn settings_from(config: Value, vars: &[(&str, &str)]) -> Result { + AppSettings::from_value(apply_env_overrides(config, env(vars))?) + } + + #[test] + fn test_env_only_config_loads_environment_key_pairs() { + // Given + let vars = [( + "ENVIRONMENT_KEY_PAIRS", + r#"[{"server_side_key":"ser.abc","client_side_key":"abc"}]"#, + )]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!(settings.environment_key_pairs.len(), 1); + assert_eq!(settings.environment_key_pairs[0].client_side_key, "abc"); + } + + #[test] + fn test_env_takes_precedence_over_config_file() { + // Given + let config = serde_json::json!({ + "api_url": "https://file.example.com/api/v1", + "api_poll_frequency_seconds": 30, + }); + let vars = [ + ("API_URL", "https://env.example.com/api/v1"), + ("API_POLL_FREQUENCY_SECONDS", "5"), + ]; + + // When + let settings = settings_from(config, &vars).unwrap(); + + // Then + assert_eq!(settings.api_url, "https://env.example.com/api/v1"); + assert_eq!(settings.api_poll_frequency_seconds, 5); + } + + #[test] + fn test_env_raw_field_keeps_numeric_looking_value_as_string() { + // Given + let vars = [("PROXY_KEY", "123")]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!(settings.proxy_key.as_deref(), Some("123")); + } + + #[test] + fn test_env_names_are_case_insensitive() { + // Given + let vars = [("api_poll_timeout_seconds", "9")]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!(settings.api_poll_timeout_seconds, 9); + } + + #[test] + fn test_env_partial_object_deep_merges_with_config_file() { + // Given + let config = serde_json::json!({ + "logging": {"log_level": "INFO", "log_format": "json"}, + }); + let vars = [("LOGGING", r#"{"log_level":"DEBUG"}"#)]; + + // When + let settings = settings_from(config, &vars).unwrap(); + + // Then + assert_eq!(settings.logging.log_level, "DEBUG"); + assert_eq!(settings.logging.log_format, "json"); + } + + #[test] + fn test_env_invalid_json_errors_naming_the_variable() { + // Given + let vars = [("ENVIRONMENT_KEY_PAIRS", "not json")]; + + // When + let result = apply_env_overrides(serde_json::json!({}), env(&vars)); + + // Then + let error = result.unwrap_err().to_string(); + assert!(error.contains("ENVIRONMENT_KEY_PAIRS"), "{error}"); + } + + #[test] + fn test_env_unrelated_variables_are_ignored() { + // Given + let vars = [("PATH", "/usr/bin"), ("HOME", "not json")]; + + // When + let config = apply_env_overrides(serde_json::json!({}), env(&vars)).unwrap(); + + // Then + assert_eq!(config, serde_json::json!({})); + } + #[test] fn test_client_side_key_validation_valid() { // Given From c09af1828ae78b77fbd8a1db960da3bd15353938 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 11:44:54 +0530 Subject: [PATCH 04/21] docs: document environment variable configuration --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d70a34d..db17f12 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,8 @@ docker run --rm -p 8000:8000 edge-proxy-rs-local ## Configuration -Edge Proxy expects to load configuration from `./config.json`. +Edge Proxy loads configuration from `./config.json` and environment variables. Environment variables take precedence, and +the config file is optional. ### Configuration Options @@ -125,6 +126,20 @@ docker run \ flagsmith/edge-proxy-rs:latest ``` +### Settings from Environment Variables + +Any top-level setting can be set with an environment variable of the same name, case-insensitive. `proxy_key` and +`api_url` are read as plain strings; all other values are JSON. Objects are merged into the config file's values, so +`LOGGING='{"log_level":"DEBUG"}'` keeps the file's other logging settings. + +```bash +docker run \ + -e ENVIRONMENT_KEY_PAIRS='[{"server_side_key":"ser.YOUR_SERVER_KEY","client_side_key":"YOUR_CLIENT_KEY"}]' \ + -e API_POLL_FREQUENCY_SECONDS=30 \ + -p 8000:8000 \ + flagsmith/edge-proxy-rs:latest +``` + ### Environment Variables | Variable | Description | Default | From b8c39c86d70d29632465634db46ec8b8d98e1298 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 11:56:46 +0530 Subject: [PATCH 05/21] refactor: rename config file contents variable --- src/config/settings.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index a3ec565..b2b864c 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -177,8 +177,8 @@ impl AppSettings { } fn read_config_file(path: &PathBuf) -> Result { - let contents = match fs::read_to_string(path) { - Ok(contents) => contents, + let config_str = match fs::read_to_string(path) { + Ok(config_str) => config_str, // Settings may come entirely from environment variables Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Value::Object(Map::new())), Err(e) => { @@ -189,7 +189,7 @@ fn read_config_file(path: &PathBuf) -> Result { } }; - serde_json::from_str(&contents).map_err(|e| { + serde_json::from_str(&config_str).map_err(|e| { EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) }) } From 3babebf2b5ea985c3efc4547559d6858871c5817 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:13:03 +0530 Subject: [PATCH 06/21] refactor: map env fields to conversion functions --- src/config/settings.rs | 48 ++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index b2b864c..837c98b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -194,23 +194,28 @@ fn read_config_file(path: &PathBuf) -> Result { }) } -enum EnvKind { - Raw, - Json, +type ToValue = fn(String) -> serde_json::Result; + +fn string(value: String) -> serde_json::Result { + Ok(Value::String(value)) +} + +fn json(value: String) -> serde_json::Result { + serde_json::from_str(&value) } // Mirrors the Python proxy: top-level fields only, string fields taken // verbatim, everything else JSON-decoded -const ENV_FIELDS: &[(&str, EnvKind)] = &[ - ("proxy_key", EnvKind::Raw), - ("api_url", EnvKind::Raw), - ("environment_key_pairs", EnvKind::Json), - ("api_poll_frequency_seconds", EnvKind::Json), - ("api_poll_timeout_seconds", EnvKind::Json), - ("allow_origins", EnvKind::Json), - ("server", EnvKind::Json), - ("logging", EnvKind::Json), - ("health_check", EnvKind::Json), +const ENV_FIELDS: &[(&str, ToValue)] = &[ + ("proxy_key", string), + ("api_url", string), + ("environment_key_pairs", json), + ("api_poll_frequency_seconds", json), + ("api_poll_timeout_seconds", json), + ("allow_origins", json), + ("server", json), + ("logging", json), + ("health_check", json), ]; fn apply_env_overrides( @@ -225,18 +230,15 @@ fn apply_env_overrides( for (name, raw) in env { let key = name.to_lowercase(); - let Some((field, kind)) = ENV_FIELDS.iter().find(|(field, _)| *field == key) else { + let Some((field, to_value)) = ENV_FIELDS.iter().find(|(field, _)| *field == key) else { continue; }; - let value = match kind { - EnvKind::Raw => Value::String(raw), - EnvKind::Json => serde_json::from_str(&raw).map_err(|e| { - EdgeProxyError::ConfigurationError(format!( - "Invalid JSON in environment variable {}: {}", - name, e - )) - })?, - }; + let value = to_value(raw).map_err(|e| { + EdgeProxyError::ConfigurationError(format!( + "Invalid JSON in environment variable {}: {}", + name, e + )) + })?; deep_merge(fields.entry(*field).or_insert(Value::Null), value); } From 285af62b44dd532c1f9c35464ba601e9e8baeb05 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:28:04 +0530 Subject: [PATCH 07/21] refactor: derive env-settable fields from AppSettings --- README.md | 4 +- src/config/settings.rs | 97 ++++++++++++++++++++++++++++-------------- 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index db17f12..60b8620 100644 --- a/README.md +++ b/README.md @@ -128,8 +128,8 @@ docker run \ ### Settings from Environment Variables -Any top-level setting can be set with an environment variable of the same name, case-insensitive. `proxy_key` and -`api_url` are read as plain strings; all other values are JSON. Objects are merged into the config file's values, so +Any top-level setting can be set with an environment variable of the same name, case-insensitive. Values starting with +`[` or `{` are parsed as JSON; anything else is used as-is. Objects are merged into the config file's values, so `LOGGING='{"log_level":"DEBUG"}'` keeps the file's other logging settings. ```bash diff --git a/src/config/settings.rs b/src/config/settings.rs index 837c98b..497c5e1 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,5 +1,5 @@ use crate::error::{EdgeProxyError, Result}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; use serde_json::{Map, Value}; use std::fs; use std::io; @@ -104,9 +104,15 @@ pub struct AppSettings { pub proxy_key: Option, #[serde(default = "default_api_url")] pub api_url: String, - #[serde(default = "default_api_poll_frequency")] + #[serde( + default = "default_api_poll_frequency", + deserialize_with = "number_or_string" + )] pub api_poll_frequency_seconds: u64, - #[serde(default = "default_api_poll_timeout")] + #[serde( + default = "default_api_poll_timeout", + deserialize_with = "number_or_string" + )] pub api_poll_timeout_seconds: u64, #[serde(default = "default_allow_origins")] pub allow_origins: Vec, @@ -194,34 +200,31 @@ fn read_config_file(path: &PathBuf) -> Result { }) } -type ToValue = fn(String) -> serde_json::Result; - -fn string(value: String) -> serde_json::Result { - Ok(Value::String(value)) -} +// Env values are strings; numeric top-level fields opt in to parsing them +fn number_or_string<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum NumberOrString { + Number(u64), + String(String), + } -fn json(value: String) -> serde_json::Result { - serde_json::from_str(&value) + match NumberOrString::deserialize(deserializer)? { + NumberOrString::Number(number) => Ok(number), + NumberOrString::String(string) => string.parse().map_err(de::Error::custom), + } } -// Mirrors the Python proxy: top-level fields only, string fields taken -// verbatim, everything else JSON-decoded -const ENV_FIELDS: &[(&str, ToValue)] = &[ - ("proxy_key", string), - ("api_url", string), - ("environment_key_pairs", json), - ("api_poll_frequency_seconds", json), - ("api_poll_timeout_seconds", json), - ("allow_origins", json), - ("server", json), - ("logging", json), - ("health_check", json), -]; - fn apply_env_overrides( mut config: Value, env: impl IntoIterator, ) -> Result { + let Value::Object(known_fields) = serde_json::to_value(AppSettings::default())? else { + unreachable!("AppSettings serializes to an object"); + }; let Value::Object(fields) = &mut config else { return Err(EdgeProxyError::ConfigurationError( "Config file must contain a JSON object".to_string(), @@ -230,16 +233,20 @@ fn apply_env_overrides( for (name, raw) in env { let key = name.to_lowercase(); - let Some((field, to_value)) = ENV_FIELDS.iter().find(|(field, _)| *field == key) else { + if !known_fields.contains_key(&key) { continue; + } + let value = if raw.trim_start().starts_with(['[', '{']) { + serde_json::from_str(&raw).map_err(|e| { + EdgeProxyError::ConfigurationError(format!( + "Invalid JSON in environment variable {}: {}", + name, e + )) + })? + } else { + Value::String(raw) }; - let value = to_value(raw).map_err(|e| { - EdgeProxyError::ConfigurationError(format!( - "Invalid JSON in environment variable {}: {}", - name, e - )) - })?; - deep_merge(fields.entry(*field).or_insert(Value::Null), value); + deep_merge(fields.entry(key).or_insert(Value::Null), value); } Ok(config) @@ -400,7 +407,7 @@ mod tests { #[test] fn test_env_invalid_json_errors_naming_the_variable() { // Given - let vars = [("ENVIRONMENT_KEY_PAIRS", "not json")]; + let vars = [("ENVIRONMENT_KEY_PAIRS", "[{oops")]; // When let result = apply_env_overrides(serde_json::json!({}), env(&vars)); @@ -410,6 +417,30 @@ mod tests { assert!(error.contains("ENVIRONMENT_KEY_PAIRS"), "{error}"); } + #[test] + fn test_config_file_numbers_still_parse() { + // Given + let config = serde_json::json!({"api_poll_timeout_seconds": 7}); + + // When + let settings = settings_from(config, &[]).unwrap(); + + // Then + assert_eq!(settings.api_poll_timeout_seconds, 7); + } + + #[test] + fn test_env_non_numeric_value_for_number_field_is_rejected() { + // Given + let vars = [("API_POLL_FREQUENCY_SECONDS", "soon")]; + + // When + let result = settings_from(serde_json::json!({}), &vars); + + // Then + assert!(result.is_err()); + } + #[test] fn test_env_unrelated_variables_are_ignored() { // Given From 12b8b2dd11eb1764dc8a0c555e5648dcb2bfcade Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:34:29 +0530 Subject: [PATCH 08/21] refactor: decode env values per field type --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 6 +-- src/config/settings.rs | 88 +++++++++++++++++++----------------------- 4 files changed, 44 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b54ddeb..a74253a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,6 +521,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_path_to_error", "thiserror 2.0.17", "tokio", "tokio-test", diff --git a/Cargo.toml b/Cargo.toml index a6dde39..394feeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace", reqwest = { version = "0.13", features = ["json", "gzip"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_path_to_error = "0.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } thiserror = "2" diff --git a/README.md b/README.md index 60b8620..40d7e44 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,9 @@ docker run \ ### Settings from Environment Variables -Any top-level setting can be set with an environment variable of the same name, case-insensitive. Values starting with -`[` or `{` are parsed as JSON; anything else is used as-is. Objects are merged into the config file's values, so -`LOGGING='{"log_level":"DEBUG"}'` keeps the file's other logging settings. +Any top-level setting can be set with an environment variable of the same name, case-insensitive. String settings take +the value as-is; all others take JSON. An environment variable replaces the whole setting, so +`LOGGING='{"log_level":"DEBUG"}'` resets the other logging settings to their defaults. ```bash docker run \ diff --git a/src/config/settings.rs b/src/config/settings.rs index 497c5e1..3f1fb9f 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -1,5 +1,6 @@ use crate::error::{EdgeProxyError, Result}; -use serde::{Deserialize, Deserializer, Serialize, de}; +use serde::de::{self, DeserializeOwned}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; use std::fs; use std::io; @@ -94,7 +95,7 @@ impl Default for HealthCheckSettings { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct AppSettings { // Optional so the environment set can also come from runtime discovery - #[serde(default)] + #[serde(default, deserialize_with = "json_or_value")] #[validate(nested)] pub environment_key_pairs: Vec, /// Credential for the proxy config endpoint; when set, the served @@ -106,21 +107,21 @@ pub struct AppSettings { pub api_url: String, #[serde( default = "default_api_poll_frequency", - deserialize_with = "number_or_string" + deserialize_with = "json_or_value" )] pub api_poll_frequency_seconds: u64, #[serde( default = "default_api_poll_timeout", - deserialize_with = "number_or_string" + deserialize_with = "json_or_value" )] pub api_poll_timeout_seconds: u64, - #[serde(default = "default_allow_origins")] + #[serde(default = "default_allow_origins", deserialize_with = "json_or_value")] pub allow_origins: Vec, - #[serde(default)] + #[serde(default, deserialize_with = "json_or_value")] pub server: ServerSettings, - #[serde(default)] + #[serde(default, deserialize_with = "json_or_value")] pub logging: LoggingSettings, - #[serde(default)] + #[serde(default, deserialize_with = "json_or_value")] pub health_check: HealthCheckSettings, } @@ -166,8 +167,12 @@ impl AppSettings { } pub fn from_value(value: Value) -> Result { - let settings: AppSettings = serde_json::from_value(value).map_err(|e| { - EdgeProxyError::ConfigurationError(format!("Failed to parse config: {}", e)) + let settings: AppSettings = serde_path_to_error::deserialize(value).map_err(|e| { + EdgeProxyError::ConfigurationError(format!( + "Failed to parse config at {}: {}", + e.path(), + e.inner() + )) })?; settings.validate().map_err(|e| { @@ -200,21 +205,15 @@ fn read_config_file(path: &PathBuf) -> Result { }) } -// Env values are strings; numeric top-level fields opt in to parsing them -fn number_or_string<'de, D>(deserializer: D) -> std::result::Result +// Env values arrive as strings, so non-string fields JSON-decode them +fn json_or_value<'de, D, T>(deserializer: D) -> std::result::Result where D: Deserializer<'de>, + T: DeserializeOwned, { - #[derive(Deserialize)] - #[serde(untagged)] - enum NumberOrString { - Number(u64), - String(String), - } - - match NumberOrString::deserialize(deserializer)? { - NumberOrString::Number(number) => Ok(number), - NumberOrString::String(string) => string.parse().map_err(de::Error::custom), + match Value::deserialize(deserializer)? { + Value::String(json) => serde_json::from_str(&json).map_err(de::Error::custom), + value => T::deserialize(value).map_err(de::Error::custom), } } @@ -236,33 +235,12 @@ fn apply_env_overrides( if !known_fields.contains_key(&key) { continue; } - let value = if raw.trim_start().starts_with(['[', '{']) { - serde_json::from_str(&raw).map_err(|e| { - EdgeProxyError::ConfigurationError(format!( - "Invalid JSON in environment variable {}: {}", - name, e - )) - })? - } else { - Value::String(raw) - }; - deep_merge(fields.entry(key).or_insert(Value::Null), value); + fields.insert(key, Value::String(raw)); } Ok(config) } -fn deep_merge(target: &mut Value, value: Value) { - match (target, value) { - (Value::Object(target), Value::Object(value)) => { - for (key, value) in value { - deep_merge(target.entry(key).or_insert(Value::Null), value); - } - } - (target, value) => *target = value, - } -} - pub fn get_settings() -> Result { let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); let path = PathBuf::from(config_path); @@ -389,7 +367,7 @@ mod tests { } #[test] - fn test_env_partial_object_deep_merges_with_config_file() { + fn test_env_object_replaces_config_file_object() { // Given let config = serde_json::json!({ "logging": {"log_level": "INFO", "log_format": "json"}, @@ -401,20 +379,32 @@ mod tests { // Then assert_eq!(settings.logging.log_level, "DEBUG"); - assert_eq!(settings.logging.log_format, "json"); + assert_eq!(settings.logging.log_format, default_log_format()); } #[test] - fn test_env_invalid_json_errors_naming_the_variable() { + fn test_env_string_field_starting_with_brace_stays_a_string() { + // Given + let vars = [("PROXY_KEY", "{abc")]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!(settings.proxy_key.as_deref(), Some("{abc")); + } + + #[test] + fn test_env_invalid_json_errors_naming_the_field() { // Given let vars = [("ENVIRONMENT_KEY_PAIRS", "[{oops")]; // When - let result = apply_env_overrides(serde_json::json!({}), env(&vars)); + let result = settings_from(serde_json::json!({}), &vars); // Then let error = result.unwrap_err().to_string(); - assert!(error.contains("ENVIRONMENT_KEY_PAIRS"), "{error}"); + assert!(error.contains("environment_key_pairs"), "{error}"); } #[test] From 800debb75fb9e2708d68f6531eb7292376964b83 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:40:50 +0530 Subject: [PATCH 09/21] refactor: drop json_or_value comment --- src/config/settings.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 3f1fb9f..f12d966 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -205,7 +205,6 @@ fn read_config_file(path: &PathBuf) -> Result { }) } -// Env values arrive as strings, so non-string fields JSON-decode them fn json_or_value<'de, D, T>(deserializer: D) -> std::result::Result where D: Deserializer<'de>, From 0c78c6c849fe40bf8077bc3b613ec9703e37198d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:44:47 +0530 Subject: [PATCH 10/21] refactor: look up env fields in serialized defaults --- src/config/settings.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index f12d966..926329e 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -220,9 +220,7 @@ fn apply_env_overrides( mut config: Value, env: impl IntoIterator, ) -> Result { - let Value::Object(known_fields) = serde_json::to_value(AppSettings::default())? else { - unreachable!("AppSettings serializes to an object"); - }; + let defaults = serde_json::to_value(AppSettings::default())?; let Value::Object(fields) = &mut config else { return Err(EdgeProxyError::ConfigurationError( "Config file must contain a JSON object".to_string(), @@ -231,7 +229,7 @@ fn apply_env_overrides( for (name, raw) in env { let key = name.to_lowercase(); - if !known_fields.contains_key(&key) { + if defaults.get(&key).is_none() { continue; } fields.insert(key, Value::String(raw)); From 83d23e745f6341ca14ab08f5b48df7c1a62a6f0a Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:48:45 +0530 Subject: [PATCH 11/21] refactor: rename json_or_value to json_str_or_native --- src/config/settings.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 926329e..ecdb444 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -95,7 +95,7 @@ impl Default for HealthCheckSettings { #[derive(Debug, Clone, Serialize, Deserialize, Validate)] pub struct AppSettings { // Optional so the environment set can also come from runtime discovery - #[serde(default, deserialize_with = "json_or_value")] + #[serde(default, deserialize_with = "json_str_or_native")] #[validate(nested)] pub environment_key_pairs: Vec, /// Credential for the proxy config endpoint; when set, the served @@ -107,21 +107,24 @@ pub struct AppSettings { pub api_url: String, #[serde( default = "default_api_poll_frequency", - deserialize_with = "json_or_value" + deserialize_with = "json_str_or_native" )] pub api_poll_frequency_seconds: u64, #[serde( default = "default_api_poll_timeout", - deserialize_with = "json_or_value" + deserialize_with = "json_str_or_native" )] pub api_poll_timeout_seconds: u64, - #[serde(default = "default_allow_origins", deserialize_with = "json_or_value")] + #[serde( + default = "default_allow_origins", + deserialize_with = "json_str_or_native" + )] pub allow_origins: Vec, - #[serde(default, deserialize_with = "json_or_value")] + #[serde(default, deserialize_with = "json_str_or_native")] pub server: ServerSettings, - #[serde(default, deserialize_with = "json_or_value")] + #[serde(default, deserialize_with = "json_str_or_native")] pub logging: LoggingSettings, - #[serde(default, deserialize_with = "json_or_value")] + #[serde(default, deserialize_with = "json_str_or_native")] pub health_check: HealthCheckSettings, } @@ -205,7 +208,7 @@ fn read_config_file(path: &PathBuf) -> Result { }) } -fn json_or_value<'de, D, T>(deserializer: D) -> std::result::Result +fn json_str_or_native<'de, D, T>(deserializer: D) -> std::result::Result where D: Deserializer<'de>, T: DeserializeOwned, From 1ff645afd675d3bcb88138559fb8a1e9009cd863 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:53:48 +0530 Subject: [PATCH 12/21] fix: ignore non-UTF-8 environment variables --- src/config/settings.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index ecdb444..58baaa2 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -245,7 +245,10 @@ pub fn get_settings() -> Result { let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); let path = PathBuf::from(config_path); - let config = apply_env_overrides(read_config_file(&path)?, std::env::vars())?; + // vars() panics on non-UTF-8 entries, even ones unrelated to the proxy + let env = std::env::vars_os() + .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); + let config = apply_env_overrides(read_config_file(&path)?, env)?; AppSettings::from_value(config) } From 053abde0664f04a23b36bdf6ae183c150801b9a7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:54:26 +0530 Subject: [PATCH 13/21] fix: require the config file when CONFIG_PATH is set --- src/config/settings.rs | 51 +++++++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 58baaa2..1d5213b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -165,10 +165,6 @@ impl AppSettings { Self::default() } - pub fn from_file(path: &PathBuf) -> Result { - Self::from_value(read_config_file(path)?) - } - pub fn from_value(value: Value) -> Result { let settings: AppSettings = serde_path_to_error::deserialize(value).map_err(|e| { EdgeProxyError::ConfigurationError(format!( @@ -190,11 +186,25 @@ impl AppSettings { } } -fn read_config_file(path: &PathBuf) -> Result { +fn load_config_file(config_path: Option) -> Result { + let explicit = config_path.is_some(); + let path = PathBuf::from(config_path.unwrap_or_else(|| "./config.json".to_string())); + + match read_config_file(&path)? { + Some(config) => Ok(config), + // Settings may come entirely from environment variables + None if !explicit => Ok(Value::Object(Map::new())), + None => Err(EdgeProxyError::ConfigurationError(format!( + "Config file not found: {}", + path.display() + ))), + } +} + +fn read_config_file(path: &PathBuf) -> Result> { let config_str = match fs::read_to_string(path) { Ok(config_str) => config_str, - // Settings may come entirely from environment variables - Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Value::Object(Map::new())), + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), Err(e) => { return Err(EdgeProxyError::ConfigurationError(format!( "Failed to read config file: {}", @@ -203,7 +213,7 @@ fn read_config_file(path: &PathBuf) -> Result { } }; - serde_json::from_str(&config_str).map_err(|e| { + serde_json::from_str(&config_str).map(Some).map_err(|e| { EdgeProxyError::ConfigurationError(format!("Failed to parse config file: {}", e)) }) } @@ -242,13 +252,10 @@ fn apply_env_overrides( } pub fn get_settings() -> Result { - let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); - let path = PathBuf::from(config_path); - // vars() panics on non-UTF-8 entries, even ones unrelated to the proxy let env = std::env::vars_os() .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); - let config = apply_env_overrides(read_config_file(&path)?, env)?; + let config = apply_env_overrides(load_config_file(std::env::var("CONFIG_PATH").ok())?, env)?; AppSettings::from_value(config) } @@ -287,16 +294,28 @@ mod tests { } #[test] - fn test_missing_config_file_loads_defaults() { + fn test_missing_config_file_reads_as_none() { // Given let path = PathBuf::from("/nonexistent/config.json"); // When - let settings = AppSettings::from_file(&path).unwrap(); + let config = read_config_file(&path).unwrap(); // Then - assert_eq!(settings.api_url, default_api_url()); - assert!(settings.environment_key_pairs.is_empty()); + assert!(config.is_none()); + } + + #[test] + fn test_missing_explicit_config_path_is_an_error() { + // Given + let config_path = Some("/nonexistent/config.json".to_string()); + + // When + let result = load_config_file(config_path); + + // Then + let error = result.unwrap_err().to_string(); + assert!(error.contains("/nonexistent/config.json"), "{error}"); } fn env(vars: &[(&str, &str)]) -> Vec<(String, String)> { From 42384918a2ad3091545ab306d443046e1f642707 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:54:45 +0530 Subject: [PATCH 14/21] fix: ignore empty environment variables --- src/config/settings.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 1d5213b..ac5ac6f 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -242,7 +242,8 @@ fn apply_env_overrides( for (name, raw) in env { let key = name.to_lowercase(); - if defaults.get(&key).is_none() { + // Compose passes `VAR: ${VAR}` through as empty when unset on the host + if raw.is_empty() || defaults.get(&key).is_none() { continue; } fields.insert(key, Value::String(raw)); @@ -453,6 +454,20 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_env_empty_values_are_ignored() { + // Given + let config = serde_json::json!({"proxy_key": "pk.from-file"}); + let vars = [("PROXY_KEY", ""), ("ENVIRONMENT_KEY_PAIRS", "")]; + + // When + let settings = settings_from(config, &vars).unwrap(); + + // Then + assert_eq!(settings.proxy_key.as_deref(), Some("pk.from-file")); + assert!(settings.environment_key_pairs.is_empty()); + } + #[test] fn test_env_unrelated_variables_are_ignored() { // Given From fb47eda73f2e05fc5237d2f1b49f741be0c86d73 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 12:54:56 +0530 Subject: [PATCH 15/21] docs: add ALLOW_ORIGINS example and CONFIG_PATH rule --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 40d7e44..c78e43c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ docker run --rm -p 8000:8000 edge-proxy-rs-local ## Configuration Edge Proxy loads configuration from `./config.json` and environment variables. Environment variables take precedence, and -the config file is optional. +the config file is optional unless `CONFIG_PATH` is set. ### Configuration Options @@ -130,12 +130,13 @@ docker run \ Any top-level setting can be set with an environment variable of the same name, case-insensitive. String settings take the value as-is; all others take JSON. An environment variable replaces the whole setting, so -`LOGGING='{"log_level":"DEBUG"}'` resets the other logging settings to their defaults. +`LOGGING='{"log_level":"DEBUG"}'` resets the other logging settings to their defaults. Empty values are ignored. ```bash docker run \ -e ENVIRONMENT_KEY_PAIRS='[{"server_side_key":"ser.YOUR_SERVER_KEY","client_side_key":"YOUR_CLIENT_KEY"}]' \ -e API_POLL_FREQUENCY_SECONDS=30 \ + -e ALLOW_ORIGINS='["https://app.example.com"]' \ -p 8000:8000 \ flagsmith/edge-proxy-rs:latest ``` @@ -144,7 +145,7 @@ docker run \ | Variable | Description | Default | |----------|-------------|---------| -| `CONFIG_PATH` | Path to config file | `./config.json` | +| `CONFIG_PATH` | Path to config file; must exist when set | `./config.json` | | `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`) | `info` | | `TOKIO_WORKER_THREADS` | Number of async worker threads | CPU cores | From e4323c4b81eaa1ffff8b5bb9232b987cfa1bbe25 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 13:25:25 +0530 Subject: [PATCH 16/21] fix: log a missing config file instead of failing --- README.md | 4 ++-- src/config/settings.rs | 42 +++++++++++------------------------------- src/main.rs | 6 +++++- 3 files changed, 18 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c78e43c..4566286 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ docker run --rm -p 8000:8000 edge-proxy-rs-local ## Configuration Edge Proxy loads configuration from `./config.json` and environment variables. Environment variables take precedence, and -the config file is optional unless `CONFIG_PATH` is set. +the config file is optional. ### Configuration Options @@ -145,7 +145,7 @@ docker run \ | Variable | Description | Default | |----------|-------------|---------| -| `CONFIG_PATH` | Path to config file; must exist when set | `./config.json` | +| `CONFIG_PATH` | Path to config file | `./config.json` | | `RUST_LOG` | Log level (`error`, `warn`, `info`, `debug`, `trace`) | `info` | | `TOKIO_WORKER_THREADS` | Number of async worker threads | CPU cores | diff --git a/src/config/settings.rs b/src/config/settings.rs index ac5ac6f..56445ce 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -186,21 +186,6 @@ impl AppSettings { } } -fn load_config_file(config_path: Option) -> Result { - let explicit = config_path.is_some(); - let path = PathBuf::from(config_path.unwrap_or_else(|| "./config.json".to_string())); - - match read_config_file(&path)? { - Some(config) => Ok(config), - // Settings may come entirely from environment variables - None if !explicit => Ok(Value::Object(Map::new())), - None => Err(EdgeProxyError::ConfigurationError(format!( - "Config file not found: {}", - path.display() - ))), - } -} - fn read_config_file(path: &PathBuf) -> Result> { let config_str = match fs::read_to_string(path) { Ok(config_str) => config_str, @@ -252,12 +237,20 @@ fn apply_env_overrides( Ok(config) } -pub fn get_settings() -> Result { +pub fn get_settings() -> Result<(AppSettings, Option)> { + let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); + let path = PathBuf::from(config_path); + + let file_config = read_config_file(&path)?; + let missing_config_file = file_config.is_none().then(|| path.clone()); + // Settings may come entirely from environment variables + let file_config = file_config.unwrap_or_else(|| Value::Object(Map::new())); + // vars() panics on non-UTF-8 entries, even ones unrelated to the proxy let env = std::env::vars_os() .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); - let config = apply_env_overrides(load_config_file(std::env::var("CONFIG_PATH").ok())?, env)?; - AppSettings::from_value(config) + let config = apply_env_overrides(file_config, env)?; + Ok((AppSettings::from_value(config)?, missing_config_file)) } #[cfg(test)] @@ -306,19 +299,6 @@ mod tests { assert!(config.is_none()); } - #[test] - fn test_missing_explicit_config_path_is_an_error() { - // Given - let config_path = Some("/nonexistent/config.json".to_string()); - - // When - let result = load_config_file(config_path); - - // Then - let error = result.unwrap_err().to_string(); - assert!(error.contains("/nonexistent/config.json"), "{error}"); - } - fn env(vars: &[(&str, &str)]) -> Vec<(String, String)> { vars.iter() .map(|(name, value)| (name.to_string(), value.to_string())) diff --git a/src/main.rs b/src/main.rs index 405c5fd..620ade2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,14 @@ const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); #[tokio::main] async fn main() -> anyhow::Result<()> { - let settings = get_settings()?; + let (settings, missing_config_file) = get_settings()?; setup_logging(&settings.logging); + if let Some(path) = missing_config_file { + info!("Configuration file at {} not found", path.display()); + } + info!("Starting Edge Proxy server..."); info!("API URL: {}", settings.api_url); info!( From 9bcf446d1d76632161692c74c196f87395672ebb Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 17 Oct 2026 14:06:49 +0530 Subject: [PATCH 17/21] fix: default the health check grace period when omitted --- src/config/settings.rs | 45 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 56445ce..1dd9beb 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -81,13 +81,19 @@ impl Default for LoggingSettings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HealthCheckSettings { + #[serde(default = "default_environment_update_grace_period_seconds")] pub environment_update_grace_period_seconds: Option, } +fn default_environment_update_grace_period_seconds() -> Option { + Some(120) +} + impl Default for HealthCheckSettings { fn default() -> Self { Self { - environment_update_grace_period_seconds: Some(120), + environment_update_grace_period_seconds: + default_environment_update_grace_period_seconds(), } } } @@ -434,6 +440,43 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_health_check_without_grace_period_uses_default() { + // Given + let vars = [("HEALTH_CHECK", "{}")]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!( + settings + .health_check + .environment_update_grace_period_seconds, + Some(120) + ); + } + + #[test] + fn test_health_check_with_null_grace_period_disables_staleness_check() { + // Given + let vars = [( + "HEALTH_CHECK", + r#"{"environment_update_grace_period_seconds":null}"#, + )]; + + // When + let settings = settings_from(serde_json::json!({}), &vars).unwrap(); + + // Then + assert_eq!( + settings + .health_check + .environment_update_grace_period_seconds, + None + ); + } + #[test] fn test_env_empty_values_are_ignored() { // Given From 9fb884d2b31301ac308d6524b6d2307e10418ffd Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 31 Oct 2026 12:12:13 +0530 Subject: [PATCH 18/21] refactor: rename config path variables in get_settings --- src/config/settings.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 1dd9beb..da19e87 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -244,11 +244,11 @@ fn apply_env_overrides( } pub fn get_settings() -> Result<(AppSettings, Option)> { - let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); - let path = PathBuf::from(config_path); + let config_file_path = + PathBuf::from(std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string())); - let file_config = read_config_file(&path)?; - let missing_config_file = file_config.is_none().then(|| path.clone()); + let file_config = read_config_file(&config_file_path)?; + let missing_config_file = file_config.is_none().then(|| config_file_path.clone()); // Settings may come entirely from environment variables let file_config = file_config.unwrap_or_else(|| Value::Object(Map::new())); From bd9055b3963d3abf79b8b6ec7815464c9d2686f7 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 31 Oct 2026 13:00:59 +0530 Subject: [PATCH 19/21] refactor: resolve missing config file in one match --- src/config/settings.rs | 11 ++++++----- src/main.rs | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index da19e87..863f59b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -247,16 +247,17 @@ pub fn get_settings() -> Result<(AppSettings, Option)> { let config_file_path = PathBuf::from(std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string())); - let file_config = read_config_file(&config_file_path)?; - let missing_config_file = file_config.is_none().then(|| config_file_path.clone()); - // Settings may come entirely from environment variables - let file_config = file_config.unwrap_or_else(|| Value::Object(Map::new())); + let (file_config, missing_config_file_path) = match read_config_file(&config_file_path)? { + Some(file_config) => (file_config, None), + // Settings may come entirely from environment variables + None => (Value::Object(Map::new()), Some(config_file_path)), + }; // vars() panics on non-UTF-8 entries, even ones unrelated to the proxy let env = std::env::vars_os() .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); let config = apply_env_overrides(file_config, env)?; - Ok((AppSettings::from_value(config)?, missing_config_file)) + Ok((AppSettings::from_value(config)?, missing_config_file_path)) } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 620ade2..b6509ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,11 +8,11 @@ const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::UNSPECIFIED); #[tokio::main] async fn main() -> anyhow::Result<()> { - let (settings, missing_config_file) = get_settings()?; + let (settings, missing_config_file_path) = get_settings()?; setup_logging(&settings.logging); - if let Some(path) = missing_config_file { + if let Some(path) = missing_config_file_path { info!("Configuration file at {} not found", path.display()); } From a7689cb59bbba11cd4b085858c7d310f12c41eb5 Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 31 Oct 2026 13:05:40 +0530 Subject: [PATCH 20/21] refactor: keep the config file path as a String --- src/config/settings.rs | 11 +++++------ src/main.rs | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index 863f59b..a88f46a 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; use std::fs; use std::io; -use std::path::PathBuf; use validator::Validate; #[derive(Debug, Clone, Serialize, Deserialize, Validate)] @@ -192,7 +191,7 @@ impl AppSettings { } } -fn read_config_file(path: &PathBuf) -> Result> { +fn read_config_file(path: &str) -> Result> { let config_str = match fs::read_to_string(path) { Ok(config_str) => config_str, Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), @@ -243,9 +242,9 @@ fn apply_env_overrides( Ok(config) } -pub fn get_settings() -> Result<(AppSettings, Option)> { +pub fn get_settings() -> Result<(AppSettings, Option)> { let config_file_path = - PathBuf::from(std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string())); + std::env::var("CONFIG_PATH").unwrap_or_else(|_| "./config.json".to_string()); let (file_config, missing_config_file_path) = match read_config_file(&config_file_path)? { Some(file_config) => (file_config, None), @@ -297,10 +296,10 @@ mod tests { #[test] fn test_missing_config_file_reads_as_none() { // Given - let path = PathBuf::from("/nonexistent/config.json"); + let path = "/nonexistent/config.json"; // When - let config = read_config_file(&path).unwrap(); + let config = read_config_file(path).unwrap(); // Then assert!(config.is_none()); diff --git a/src/main.rs b/src/main.rs index b6509ec..a71dc64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ async fn main() -> anyhow::Result<()> { setup_logging(&settings.logging); if let Some(path) = missing_config_file_path { - info!("Configuration file at {} not found", path.display()); + info!("Configuration file at {} not found", path); } info!("Starting Edge Proxy server..."); From 2581f0b7160ad44ada9a09b8aa6d408ec6c5fb2d Mon Sep 17 00:00:00 2001 From: Gagan Trivedi Date: Sat, 31 Oct 2026 13:12:46 +0530 Subject: [PATCH 21/21] refactor: rename env var iterator variables --- src/config/settings.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/settings.rs b/src/config/settings.rs index a88f46a..599144b 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -221,7 +221,7 @@ where fn apply_env_overrides( mut config: Value, - env: impl IntoIterator, + env_vars: impl IntoIterator, ) -> Result { let defaults = serde_json::to_value(AppSettings::default())?; let Value::Object(fields) = &mut config else { @@ -230,7 +230,7 @@ fn apply_env_overrides( )); }; - for (name, raw) in env { + for (name, raw) in env_vars { let key = name.to_lowercase(); // Compose passes `VAR: ${VAR}` through as empty when unset on the host if raw.is_empty() || defaults.get(&key).is_none() { @@ -253,9 +253,9 @@ pub fn get_settings() -> Result<(AppSettings, Option)> { }; // vars() panics on non-UTF-8 entries, even ones unrelated to the proxy - let env = std::env::vars_os() + let string_env_vars = std::env::vars_os() .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?))); - let config = apply_env_overrides(file_config, env)?; + let config = apply_env_overrides(file_config, string_env_vars)?; Ok((AppSettings::from_value(config)?, missing_config_file_path)) }