featherbit/config/
loader.rs1use regex::Regex;
6use serde::de::DeserializeOwned;
7use std::env;
8use std::fs;
9use std::path::Path;
10
11pub fn interpolate_env(input: &str) -> String {
21 let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-((?:[^}\\]|\\.)*)?)?\}").unwrap();
22 re.replace_all(input, |caps: ®ex::Captures| {
23 let var_name = &caps[1];
24 let default_value = caps.get(2).map(|m| m.as_str()).unwrap_or("");
25 env::var(var_name).unwrap_or_else(|_| default_value.to_string())
26 })
27 .to_string()
28}
29
30pub fn interpolate_env_json(value: &mut serde_json::Value) {
43 match value {
44 serde_json::Value::String(s) => {
45 if s.contains("${") {
46 *s = interpolate_env(s);
47 }
48 }
49 serde_json::Value::Array(items) => {
50 for item in items {
51 interpolate_env_json(item);
52 }
53 }
54 serde_json::Value::Object(map) => {
55 for v in map.values_mut() {
56 interpolate_env_json(v);
57 }
58 }
59 _ => {}
60 }
61}
62
63pub fn load_yaml_with_env<T: DeserializeOwned>(
70 path: &Path,
71) -> Result<T, Box<dyn std::error::Error>> {
72 let raw = fs::read_to_string(path)?;
73 let interpolated = interpolate_env(&raw);
74 let config: T = serde_yaml::from_str(&interpolated)?;
75 Ok(config)
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81
82 #[test]
83 fn test_interpolation_with_env_var() {
84 env::set_var("TEST_GW_VAR", "hello");
85 let result = interpolate_env("value: ${TEST_GW_VAR}");
86 assert_eq!(result, "value: hello");
87 env::remove_var("TEST_GW_VAR");
88 }
89
90 #[test]
91 fn test_interpolation_with_default() {
92 env::remove_var("NONEXISTENT_VAR_XYZ");
93 let result = interpolate_env("value: ${NONEXISTENT_VAR_XYZ:-fallback}");
94 assert_eq!(result, "value: fallback");
95 }
96
97 #[test]
98 fn test_interpolation_missing_no_default() {
99 env::remove_var("MISSING_VAR_ABC");
100 let result = interpolate_env("value: ${MISSING_VAR_ABC}");
101 assert_eq!(result, "value: ");
102 }
103
104 #[test]
105 fn test_interpolation_multiple() {
106 env::set_var("GW_HOST", "0.0.0.0");
107 env::set_var("GW_PORT", "8080");
108 let result = interpolate_env("bind: ${GW_HOST}:${GW_PORT}");
109 assert_eq!(result, "bind: 0.0.0.0:8080");
110 env::remove_var("GW_HOST");
111 env::remove_var("GW_PORT");
112 }
113
114 #[test]
115 fn test_interpolate_json_resolves_string_leaves() {
116 env::set_var("TEST_CLIENT_ID", "featherbit-app");
119 let mut value = serde_json::json!({
120 "client_id": "${TEST_CLIENT_ID}",
121 "bearer_only": false,
122 "scopes": ["openid", "${TEST_CLIENT_ID}"],
123 "session": { "secret": "${TEST_CLIENT_ID}:${MISSING_JSON_VAR:-fallback}" }
124 });
125 interpolate_env_json(&mut value);
126 assert_eq!(value["client_id"], serde_json::json!("featherbit-app"));
127 assert_eq!(value["bearer_only"], serde_json::json!(false));
129 assert_eq!(value["scopes"][1], serde_json::json!("featherbit-app"));
131 assert_eq!(
132 value["session"]["secret"],
133 serde_json::json!("featherbit-app:fallback")
134 );
135 env::remove_var("TEST_CLIENT_ID");
136 }
137
138 #[test]
139 fn test_interpolate_json_leaves_plain_strings_untouched() {
140 let mut value = serde_json::json!({ "path": "/api/v1", "n": 42 });
142 interpolate_env_json(&mut value);
143 assert_eq!(value["path"], serde_json::json!("/api/v1"));
144 assert_eq!(value["n"], serde_json::json!(42));
145 }
146}