Skip to main content

featherbit/config/
loader.rs

1//! YAML loading and shell-style `${ENV_VAR:-default}` interpolation.
2//!
3//! Two loading modes: [`load_yaml_with_env`] interpolates the raw file text
4//! before parsing (used for `system.yaml`, which is never served back to
5//! clients), while [`load_yaml`] preserves placeholders verbatim (used for
6//! `gateway.yaml`, whose contents the Admin API serves to the Web UI —
7//! resolved secrets must never appear there). Placeholders in gateway config
8//! are resolved at the point of consumption instead: plugin node config at
9//! graph-compile time via [`interpolate_env_json`], route match rules and
10//! consumer credentials when the route table / consumer store are built.
11
12use regex::Regex;
13use serde::de::DeserializeOwned;
14use std::env;
15use std::fs;
16use std::path::Path;
17
18/// Replaces `${VAR}` and `${VAR:-default}` patterns with environment variable values.
19///
20/// Semantics:
21/// - `${VAR}` — substituted with the variable's value, or the empty string if unset.
22/// - `${VAR:-default}` — substituted with the variable's value, or `default` if unset.
23///
24/// Variable names must match `[A-Za-z_][A-Za-z0-9_]*`; text that does not
25/// match the pattern is left untouched. There is no escape syntax for a
26/// literal `${...}`.
27pub 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: &regex::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
37/// Recursively interpolates `${ENV_VAR:-default}` patterns in every string leaf
38/// of a JSON value — object values, array elements, and nested combinations —
39/// leaving numbers, booleans, and null untouched.
40///
41/// This is the single resolution point for structured plugin config from
42/// every source — `gateway.yaml` (loaded raw by [`load_yaml`]), the Admin
43/// API / Web UI, and etcd. Applied at graph-compile time it is
44/// source-agnostic: a `client_id: ${CLIENT_ID}` resolves identically however
45/// it was authored, and the stored config keeps the placeholder form (so the
46/// Admin API never serves resolved secrets).
47///
48/// A string that is exactly one `${...}` placeholder is **typed like the
49/// YAML scalar it stands in for**: a resolved value of `true`/`false`
50/// becomes a boolean and a value parsing as a number becomes a number
51/// (`port: ${PORT:-3010}` yields `3010`, not `"3010"`); anything else,
52/// including the empty string, stays a string. A placeholder embedded in
53/// wider text always resolves to a string.
54pub 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
74/// The replacement for a string leaf containing `${...}`, `None` for
75/// everything else (the `${` fast-path guard included).
76fn 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
92/// Types a resolved full-placeholder value the way YAML types the same
93/// unquoted scalar: booleans and finite numbers; everything else `None`.
94fn 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
112/// Loads a YAML file, interpolates environment variables, and deserializes into `T`.
113///
114/// Interpolation runs on the raw text *before* YAML parsing, so `${VAR}`
115/// works anywhere in the file — keys, values, and free-form plugin config
116/// alike. Returns an error if the file is unreadable or the interpolated
117/// text does not deserialize into `T`.
118pub 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
127/// Loads a YAML file and deserializes into `T` **without** resolving
128/// `${ENV_VAR}` placeholders — they are preserved verbatim in the parsed
129/// structure.
130///
131/// Used for `gateway.yaml`: its contents are served back to the Web UI by
132/// the Admin API, so resolving placeholders at load time would leak secret
133/// values into API responses and config exports. Resolution happens at the
134/// point of consumption instead (see [`interpolate_env_json`]).
135pub 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        // Mirrors a plugin node config authored through the Web UI: `${VAR}`
180        // arrives as a parsed JSON string, not raw YAML text.
181        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        // Non-string leaves are untouched.
191        assert_eq!(value["bearer_only"], serde_json::json!(false));
192        // Arrays and nested objects are interpolated recursively.
193        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        // A gateway.yaml author writes `port: ${PORT:-3010}` unquoted; loaded
204        // raw that is a JSON string. The resolved value must come back typed
205        // the way the old file-text interpolation produced it: numbers and
206        // booleans become numbers and booleans when the string is exactly one
207        // `${...}` placeholder.
208        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        // Non-scalar-looking values stay strings.
223        assert_eq!(value["name"], serde_json::json!("plain"));
224        // A placeholder embedded in wider text always stays a string.
225        assert_eq!(value["mixed"], serde_json::json!("3010:3010"));
226        // Unset without default resolves to the empty string, no coercion.
227        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        // No `${...}` -> value passes through byte-for-byte (fast-path guard).
235        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}