Skip to main content

featherbit/config/
loader.rs

1//! YAML loading with shell-style environment variable interpolation, applied
2//! to the raw file text before deserialization so every config value —
3//! including nested plugin config — supports `${ENV_VAR:-default}`.
4
5use regex::Regex;
6use serde::de::DeserializeOwned;
7use std::env;
8use std::fs;
9use std::path::Path;
10
11/// Replaces `${VAR}` and `${VAR:-default}` patterns with environment variable values.
12///
13/// Semantics:
14/// - `${VAR}` — substituted with the variable's value, or the empty string if unset.
15/// - `${VAR:-default}` — substituted with the variable's value, or `default` if unset.
16///
17/// Variable names must match `[A-Za-z_][A-Za-z0-9_]*`; text that does not
18/// match the pattern is left untouched. There is no escape syntax for a
19/// literal `${...}`.
20pub 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: &regex::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
30/// Recursively interpolates `${ENV_VAR:-default}` patterns in every string leaf
31/// of a JSON value — object values, array elements, and nested combinations —
32/// leaving numbers, booleans, and null untouched.
33///
34/// This brings the same `${ENV_VAR}` substitution that [`load_yaml_with_env`]
35/// applies to raw YAML *text* to config that arrives as already-parsed
36/// structured data — i.e. plugin node config authored through the Admin API /
37/// Web UI or delivered over etcd, which never passes through the file-text
38/// interpolation path. Applied at graph-compile time it is source-agnostic: a
39/// `client_id: ${CLIENT_ID}` set in the UI resolves exactly as it would in
40/// `gateway.yaml`. File-loaded values were already interpolated before parsing,
41/// so on them this is a no-op (the `${` fast-path guard skips them).
42pub 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
63/// Loads a YAML file, interpolates environment variables, and deserializes into `T`.
64///
65/// Interpolation runs on the raw text *before* YAML parsing, so `${VAR}`
66/// works anywhere in the file — keys, values, and free-form plugin config
67/// alike. Returns an error if the file is unreadable or the interpolated
68/// text does not deserialize into `T`.
69pub 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        // Mirrors a plugin node config authored through the Web UI: `${VAR}`
117        // arrives as a parsed JSON string, not raw YAML text.
118        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        // Non-string leaves are untouched.
128        assert_eq!(value["bearer_only"], serde_json::json!(false));
129        // Arrays and nested objects are interpolated recursively.
130        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        // No `${...}` -> value passes through byte-for-byte (fast-path guard).
141        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}