featherbit/config/
loader.rs1use regex::Regex;
13use serde::de::DeserializeOwned;
14use std::env;
15use std::fs;
16use std::path::Path;
17
18pub fn interpolate_env(input: &str) -> String {
28 let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-((?:[^}\\]|\\.)*)?)?\}").unwrap();
29 re.replace_all(input, |caps: ®ex::Captures| {
30 let var_name = &caps[1];
31 let default_value = caps.get(2).map(|m| m.as_str()).unwrap_or("");
32 env::var(var_name).unwrap_or_else(|_| default_value.to_string())
33 })
34 .to_string()
35}
36
37pub fn interpolate_env_json(value: &mut serde_json::Value) {
55 if let Some(resolved) = interpolated_replacement(value) {
56 *value = resolved;
57 return;
58 }
59 match value {
60 serde_json::Value::Array(items) => {
61 for item in items {
62 interpolate_env_json(item);
63 }
64 }
65 serde_json::Value::Object(map) => {
66 for v in map.values_mut() {
67 interpolate_env_json(v);
68 }
69 }
70 _ => {}
71 }
72}
73
74fn interpolated_replacement(value: &serde_json::Value) -> Option<serde_json::Value> {
77 let s = value.as_str()?;
78 if !s.contains("${") {
79 return None;
80 }
81 let whole = Regex::new(r"^\$\{[A-Za-z_][A-Za-z0-9_]*(?::-((?:[^}\\]|\\.)*)?)?\}$")
82 .unwrap()
83 .is_match(s);
84 let resolved = interpolate_env(s);
85 Some(if whole {
86 coerce_scalar(&resolved).unwrap_or(serde_json::Value::String(resolved))
87 } else {
88 serde_json::Value::String(resolved)
89 })
90}
91
92fn coerce_scalar(s: &str) -> Option<serde_json::Value> {
95 match s {
96 "true" => Some(serde_json::Value::Bool(true)),
97 "false" => Some(serde_json::Value::Bool(false)),
98 _ => {
99 if let Ok(i) = s.parse::<i64>() {
100 return Some(serde_json::Value::Number(i.into()));
101 }
102 if let Ok(f) = s.parse::<f64>() {
103 if f.is_finite() {
104 return serde_json::Number::from_f64(f).map(serde_json::Value::Number);
105 }
106 }
107 None
108 }
109 }
110}
111
112pub fn load_yaml_with_env<T: DeserializeOwned>(
119 path: &Path,
120) -> Result<T, Box<dyn std::error::Error>> {
121 let raw = fs::read_to_string(path)?;
122 let interpolated = interpolate_env(&raw);
123 let config: T = serde_yaml::from_str(&interpolated)?;
124 Ok(config)
125}
126
127pub fn load_yaml<T: DeserializeOwned>(path: &Path) -> Result<T, Box<dyn std::error::Error>> {
136 let raw = fs::read_to_string(path)?;
137 let config: T = serde_yaml::from_str(&raw)?;
138 Ok(config)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn test_interpolation_with_env_var() {
147 env::set_var("TEST_GW_VAR", "hello");
148 let result = interpolate_env("value: ${TEST_GW_VAR}");
149 assert_eq!(result, "value: hello");
150 env::remove_var("TEST_GW_VAR");
151 }
152
153 #[test]
154 fn test_interpolation_with_default() {
155 env::remove_var("NONEXISTENT_VAR_XYZ");
156 let result = interpolate_env("value: ${NONEXISTENT_VAR_XYZ:-fallback}");
157 assert_eq!(result, "value: fallback");
158 }
159
160 #[test]
161 fn test_interpolation_missing_no_default() {
162 env::remove_var("MISSING_VAR_ABC");
163 let result = interpolate_env("value: ${MISSING_VAR_ABC}");
164 assert_eq!(result, "value: ");
165 }
166
167 #[test]
168 fn test_interpolation_multiple() {
169 env::set_var("GW_HOST", "0.0.0.0");
170 env::set_var("GW_PORT", "8080");
171 let result = interpolate_env("bind: ${GW_HOST}:${GW_PORT}");
172 assert_eq!(result, "bind: 0.0.0.0:8080");
173 env::remove_var("GW_HOST");
174 env::remove_var("GW_PORT");
175 }
176
177 #[test]
178 fn test_interpolate_json_resolves_string_leaves() {
179 env::set_var("TEST_CLIENT_ID", "featherbit-app");
182 let mut value = serde_json::json!({
183 "client_id": "${TEST_CLIENT_ID}",
184 "bearer_only": false,
185 "scopes": ["openid", "${TEST_CLIENT_ID}"],
186 "session": { "secret": "${TEST_CLIENT_ID}:${MISSING_JSON_VAR:-fallback}" }
187 });
188 interpolate_env_json(&mut value);
189 assert_eq!(value["client_id"], serde_json::json!("featherbit-app"));
190 assert_eq!(value["bearer_only"], serde_json::json!(false));
192 assert_eq!(value["scopes"][1], serde_json::json!("featherbit-app"));
194 assert_eq!(
195 value["session"]["secret"],
196 serde_json::json!("featherbit-app:fallback")
197 );
198 env::remove_var("TEST_CLIENT_ID");
199 }
200
201 #[test]
202 fn test_interpolate_json_coerces_full_placeholder_scalars() {
203 env::set_var("TEST_COERCE_PORT", "3010");
209 env::set_var("TEST_COERCE_FLAG", "true");
210 let mut value = serde_json::json!({
211 "port": "${TEST_COERCE_PORT}",
212 "flag": "${TEST_COERCE_FLAG}",
213 "ratio": "${MISSING_COERCE_RATIO:-0.25}",
214 "name": "${MISSING_COERCE_NAME:-plain}",
215 "mixed": "${TEST_COERCE_PORT}:${TEST_COERCE_PORT}",
216 "unset": "${MISSING_COERCE_UNSET}"
217 });
218 interpolate_env_json(&mut value);
219 assert_eq!(value["port"], serde_json::json!(3010));
220 assert_eq!(value["flag"], serde_json::json!(true));
221 assert_eq!(value["ratio"], serde_json::json!(0.25));
222 assert_eq!(value["name"], serde_json::json!("plain"));
224 assert_eq!(value["mixed"], serde_json::json!("3010:3010"));
226 assert_eq!(value["unset"], serde_json::json!(""));
228 env::remove_var("TEST_COERCE_PORT");
229 env::remove_var("TEST_COERCE_FLAG");
230 }
231
232 #[test]
233 fn test_interpolate_json_leaves_plain_strings_untouched() {
234 let mut value = serde_json::json!({ "path": "/api/v1", "n": 42 });
236 interpolate_env_json(&mut value);
237 assert_eq!(value["path"], serde_json::json!("/api/v1"));
238 assert_eq!(value["n"], serde_json::json!(42));
239 }
240}