Skip to main content

featherbit/config/
warnings.rs

1//! Load-time scan for unresolved `{{...}}` template syntax.
2//!
3//! [`collect_template_warnings`] walks every string leaf of every node
4//! `config` across policies, supernodes, and `plugin_configs`, collecting one
5//! coordinated warning per unresolved reference via
6//! [`Template::source_warnings_only`]. It is pure logging support: it never
7//! fails the config, it only surfaces likely typos (e.g.
8//! `{{request.headres.x}}`) at load time instead of leaving them to render as
9//! silent literals on every request.
10//!
11//! Call this on the gateway config *after* [`resolve_plugin_configs`] has run
12//! (`SharedState::compile_routes` does so right after resolution; the debug
13//! sandbox does the same on its synthesized/stored single-policy gateway
14//! before compiling). Walking the resolved copy means a `config_ref`'d node's
15//! merged-in shared config is covered too, reached through the policy/supernode
16//! walk. `plugin_configs` are *also* walked directly (as their own container)
17//! so a shared config with no current referrer still gets flagged.
18//!
19//! Warnings are advisory only, not proof of a bug: the walker inspects every
20//! string leaf generically and has no notion of per-plugin field semantics.
21//! A handful of fields intentionally use their string value verbatim, never
22//! templated — e.g. `limit-conn`'s `key` in `key_type: constant` mode — so a
23//! constant value that happens to contain `{{...}}` draws a spurious warning
24//! here. Accepted false positive; do not try to special-case field names in
25//! this generic walker.
26//!
27//! [`resolve_plugin_configs`]: super::resolve_plugin_configs
28
29use serde_json::Value;
30
31use super::gateway::GatewayConfig;
32use crate::vars::template::Template;
33
34/// Walks every string leaf of every node config in `gw` (policies, supernodes,
35/// plugin_configs) and returns one fully-coordinated warning per unresolved
36/// `{{...}}` reference found. See the module doc for placement and caveats.
37pub fn collect_template_warnings(gw: &GatewayConfig) -> Vec<String> {
38    let mut warnings = Vec::new();
39
40    for policy in &gw.policies {
41        for node in &policy.nodes {
42            for (key, value) in &node.config {
43                for (path, warning) in leaves(value, key) {
44                    warnings.push(format!(
45                        "policy '{}' node '{}' key '{}': {}",
46                        policy.name, node.id, path, warning
47                    ));
48                }
49            }
50        }
51    }
52
53    for sn in &gw.supernodes {
54        for node in &sn.nodes {
55            for (key, value) in &node.config {
56                for (path, warning) in leaves(value, key) {
57                    warnings.push(format!(
58                        "supernode '{}' node '{}' key '{}': {}",
59                        sn.name, node.id, path, warning
60                    ));
61                }
62            }
63        }
64    }
65
66    for def in &gw.plugin_configs {
67        for (key, value) in &def.config {
68            for (path, warning) in leaves(value, key) {
69                warnings.push(format!(
70                    "plugin-config '{}' key '{}': {}",
71                    def.name, path, warning
72                ));
73            }
74        }
75    }
76
77    warnings
78}
79
80/// Recursively collects `(path, warning)` pairs from every string leaf of
81/// `value`. `path` starts at `root` (the top-level config key) and accumulates
82/// dotted object keys (`headers.x-custom`) and `[i]` array indices
83/// (`list[1]`) down to the leaf that produced the warning.
84fn leaves(value: &Value, root: &str) -> Vec<(String, String)> {
85    let mut out = Vec::new();
86    walk(value, root, &mut out);
87    out
88}
89
90fn walk(value: &Value, path: &str, out: &mut Vec<(String, String)>) {
91    match value {
92        Value::String(s) => {
93            for warning in Template::source_warnings_only(s) {
94                out.push((path.to_string(), warning));
95            }
96        }
97        Value::Array(items) => {
98            for (i, item) in items.iter().enumerate() {
99                walk(item, &format!("{path}[{i}]"), out);
100            }
101        }
102        Value::Object(map) => {
103            for (k, v) in map {
104                walk(v, &format!("{path}.{k}"), out);
105            }
106        }
107        _ => {}
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::config::{NodeConfig, PluginConfigDef, PolicyConfig, SupernodeConfig};
115    use serde_json::json;
116
117    fn node(id: &str, ty: &str, config: serde_json::Value) -> NodeConfig {
118        NodeConfig {
119            id: id.into(),
120            node_type: ty.into(),
121            config: serde_json::from_value(config).unwrap(),
122            config_ref: None,
123            position: None,
124        }
125    }
126
127    fn gw() -> GatewayConfig {
128        serde_yaml::from_str("{}").unwrap()
129    }
130
131    /// A malformed reference (`headres` typo'd for `headers`) inside a single
132    /// policy node config key must yield exactly one warning naming the
133    /// policy, node, and key.
134    #[test]
135    fn test_unknown_reference_yields_one_named_warning() {
136        let mut gw = gw();
137        gw.policies = vec![PolicyConfig {
138            name: "p".into(),
139            error_handler: None,
140            nodes: vec![node(
141                "n",
142                "proxy-rewrite",
143                json!({"uri": "{{request.headres.x}}"}),
144            )],
145            edges: Vec::new(),
146        }];
147
148        let warnings = collect_template_warnings(&gw);
149
150        assert_eq!(warnings.len(), 1, "{warnings:?}");
151        assert!(warnings[0].contains("policy 'p'"), "{}", warnings[0]);
152        assert!(warnings[0].contains("node 'n'"), "{}", warnings[0]);
153        assert!(warnings[0].contains("key 'uri'"), "{}", warnings[0]);
154        assert!(
155            warnings[0].contains("{{request.headres.x}}"),
156            "{}",
157            warnings[0]
158        );
159    }
160
161    /// A valid, fully-recognized reference must never produce a warning.
162    #[test]
163    fn test_valid_reference_yields_no_warnings() {
164        let mut gw = gw();
165        gw.policies = vec![PolicyConfig {
166            name: "p".into(),
167            error_handler: None,
168            nodes: vec![node(
169                "n",
170                "proxy-rewrite",
171                json!({"uri": "{{request.path}}"}),
172            )],
173            edges: Vec::new(),
174        }];
175
176        let warnings = collect_template_warnings(&gw);
177
178        assert!(warnings.is_empty(), "{warnings:?}");
179    }
180
181    /// Leaves nested inside objects and arrays must be reached and their
182    /// accumulated path reported.
183    #[test]
184    fn test_nested_object_and_array_leaves_covered() {
185        let mut gw = gw();
186        gw.policies = vec![PolicyConfig {
187            name: "p".into(),
188            error_handler: None,
189            nodes: vec![node(
190                "n",
191                "proxy-rewrite",
192                json!({
193                    "headers": { "x-custom": "{{request.headres.x}}" },
194                    "list": ["fine", "{{client.bogus}}"]
195                }),
196            )],
197            edges: Vec::new(),
198        }];
199
200        let warnings = collect_template_warnings(&gw);
201
202        assert_eq!(warnings.len(), 2, "{warnings:?}");
203        assert!(
204            warnings
205                .iter()
206                .any(|w| w.contains("key 'headers.x-custom'")),
207            "{warnings:?}"
208        );
209        assert!(
210            warnings.iter().any(|w| w.contains("key 'list[1]'")),
211            "{warnings:?}"
212        );
213    }
214
215    /// Supernode node configs must be walked too, named by their own
216    /// container ("supernode '<name>' node '<id>'"), not "policy".
217    #[test]
218    fn test_supernode_container_named() {
219        let mut gw = gw();
220        gw.supernodes = vec![SupernodeConfig {
221            name: "sn".into(),
222            description: None,
223            nodes: vec![node(
224                "inner",
225                "proxy-rewrite",
226                json!({"uri": "{{request.headres.x}}"}),
227            )],
228            edges: Vec::new(),
229        }];
230
231        let warnings = collect_template_warnings(&gw);
232
233        assert_eq!(warnings.len(), 1, "{warnings:?}");
234        assert!(warnings[0].contains("supernode 'sn'"), "{}", warnings[0]);
235        assert!(warnings[0].contains("node 'inner'"), "{}", warnings[0]);
236        assert!(warnings[0].contains("key 'uri'"), "{}", warnings[0]);
237    }
238
239    /// `plugin_configs` definitions must be walked directly (as their own
240    /// container, not requiring a referencing node) and named
241    /// "plugin-config '<name>'", with no "node" segment.
242    #[test]
243    fn test_plugin_config_container_named() {
244        let mut gw = gw();
245        gw.plugin_configs = vec![PluginConfigDef {
246            name: "shared".into(),
247            plugin_type: "proxy-rewrite".into(),
248            description: None,
249            config: serde_json::from_value(json!({"uri": "{{request.headres.x}}"})).unwrap(),
250        }];
251
252        let warnings = collect_template_warnings(&gw);
253
254        assert_eq!(warnings.len(), 1, "{warnings:?}");
255        assert!(
256            warnings[0].contains("plugin-config 'shared'"),
257            "{}",
258            warnings[0]
259        );
260        assert!(warnings[0].contains("key 'uri'"), "{}", warnings[0]);
261        assert!(!warnings[0].contains("node "), "{}", warnings[0]);
262    }
263
264    #[test]
265    fn test_empty_gateway_yields_no_warnings() {
266        assert!(collect_template_warnings(&gw()).is_empty());
267    }
268}