Skip to main content

featherbit/plugins/native/
set_vars.rs

1//! Derived variables plugin (`set-vars`, featherbit-native): pull values out
2//! of the context — a path segment, a header, a query parameter, a JSON body
3//! field — optionally through a JSONPath and/or a regex capture, and store
4//! them in `context.message` so every downstream node can read them as
5//! `$msg_<name>` / `{{message.<name>}}`.
6//!
7//! Always pass-through: wire `success` onward; the `error` port is never
8//! taken. Malformed regexes and JSONPaths reject the policy at compile time.
9
10use async_trait::async_trait;
11use regex::Regex;
12use serde_json_path::JsonPath;
13use std::collections::{HashMap, HashSet};
14
15use crate::context::Context;
16use crate::plugins::{Plugin, PluginOutput, PluginResult};
17use crate::vars::template::Template;
18
19/// Evaluates every rule in order and writes each result into
20/// `context.message[name]` as a string. Later rules see earlier results
21/// (`$msg_<name>`), so rules can build on each other.
22pub struct SetVarsPlugin {
23    vars: Vec<VarRule>,
24}
25
26impl std::fmt::Debug for SetVarsPlugin {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("SetVarsPlugin")
29            .field(
30                "vars",
31                &self.vars.iter().map(|v| &v.name).collect::<Vec<_>>(),
32            )
33            .finish()
34    }
35}
36
37/// Which regex capture becomes the value.
38enum Group {
39    /// `0` is the whole match.
40    Index(usize),
41    Name(String),
42}
43
44struct VarRule {
45    name: String,
46    /// The source text: a `{{namespace.path}}` / `$var` template. Defaults to
47    /// `$request_body` when only `json_path` is given.
48    from: Template,
49    /// Optional RFC 9535 JSONPath applied to `from` parsed as JSON.
50    json_path: Option<JsonPath>,
51    /// Optional regex applied to the (JSONPath-reduced) text.
52    regex: Option<(Regex, Group)>,
53    /// Used when the source is absent/non-JSON, the path matches nothing,
54    /// or the regex does not match. Without it the value is `""`.
55    default: Option<String>,
56}
57
58const ALLOWED_KEYS: &[&str] = &["name", "from", "json_path", "regex", "group", "default"];
59
60fn str_field<'a>(
61    obj: &'a serde_json::Map<String, serde_json::Value>,
62    key: &str,
63    at: &str,
64) -> Result<Option<&'a str>, String> {
65    match obj.get(key) {
66        None | Some(serde_json::Value::Null) => Ok(None),
67        Some(serde_json::Value::String(s)) => Ok(Some(s.as_str())),
68        Some(_) => Err(format!("{at}.{key} must be a string")),
69    }
70}
71
72impl SetVarsPlugin {
73    /// Builds the plugin from node config; every regex and JSONPath is
74    /// compiled here so mistakes fail policy compilation, not requests.
75    ///
76    /// Accepted keys:
77    /// - `vars` (array, **required**, non-empty), each entry:
78    ///   - `name` (string, **required**): message key; readable downstream as
79    ///     `$msg_<name>` / `{{message.<name>}}`. Letters, digits, `_`, `-`, `.`.
80    ///   - `from` (string): source template — `$uri`, `$http_<h>`, `$arg_<q>`,
81    ///     `$cookie_<c>`, `$msg_<k>`, or `{{request.path}}`-style references.
82    ///     Required unless `json_path` is set (then defaults to the request body).
83    ///   - `json_path` (string): RFC 9535 JSONPath applied to `from` parsed as
84    ///     JSON. One scalar node → its text; one object/array → its JSON text;
85    ///     several nodes → a JSON array text; none → `default`.
86    ///   - `regex` (string): applied after `json_path`; `group` (integer or
87    ///     capture name, default `1`; `0` = whole match) selects the value.
88    ///   - `default` (string): fallback when nothing matches.
89    ///
90    /// ```yaml
91    /// type: set-vars
92    /// config:
93    ///   vars:
94    ///     - { name: user, from: $uri, regex: '^/hello/([^/]+)', default: stranger }
95    ///     - { name: tenant, from: $http_x_tenant }
96    ///     - { name: order_id, json_path: $.order.id }
97    /// ```
98    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
99        let raw = config
100            .get("vars")
101            .ok_or("set-vars requires a 'vars' array")?
102            .as_array()
103            .filter(|a| !a.is_empty())
104            .ok_or("set-vars requires a non-empty 'vars' array")?;
105
106        let mut seen = HashSet::new();
107        let mut vars = Vec::with_capacity(raw.len());
108        for (idx, entry) in raw.iter().enumerate() {
109            let at = format!("vars[{idx}]");
110            let obj = entry
111                .as_object()
112                .ok_or_else(|| format!("{at} must be an object"))?;
113            if let Some(k) = obj.keys().find(|k| !ALLOWED_KEYS.contains(&k.as_str())) {
114                return Err(format!("{at}: unknown key '{k}'"));
115            }
116
117            let name = str_field(obj, "name", &at)?
118                .filter(|n| !n.is_empty())
119                .ok_or_else(|| format!("{at}.name is required"))?;
120            if !name
121                .chars()
122                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
123            {
124                return Err(format!(
125                    "{at}.name '{name}' may only contain letters, digits, '_', '-' and '.'"
126                ));
127            }
128            if !seen.insert(name.to_string()) {
129                return Err(format!("{at}: duplicate name '{name}'"));
130            }
131
132            let json_path = match str_field(obj, "json_path", &at)? {
133                None => None,
134                Some(p) => Some(
135                    JsonPath::parse(p)
136                        .map_err(|e| format!("{at}.json_path: invalid JSONPath '{p}': {e}"))?,
137                ),
138            };
139            let from_src = match (str_field(obj, "from", &at)?, json_path.is_some()) {
140                (Some(s), _) => s.to_string(),
141                (None, true) => "$request_body".to_string(),
142                (None, false) => {
143                    return Err(format!(
144                        "{at}: 'from' is required unless 'json_path' is set"
145                    ))
146                }
147            };
148            let (from, _warnings) = Template::parse(&from_src);
149
150            let regex = match str_field(obj, "regex", &at)? {
151                None => {
152                    if obj.contains_key("group") {
153                        return Err(format!("{at}.group needs a 'regex'"));
154                    }
155                    None
156                }
157                Some(pattern) => {
158                    let re = Regex::new(pattern)
159                        .map_err(|e| format!("{at}.regex: invalid regex: {e}"))?;
160                    let group = match obj.get("group") {
161                        None => Group::Index(1.min(re.captures_len() - 1)),
162                        Some(serde_json::Value::Number(n)) => {
163                            let i = n.as_u64().ok_or_else(|| {
164                                format!("{at}.group must be a non-negative integer")
165                            })? as usize;
166                            if i >= re.captures_len() {
167                                return Err(format!(
168                                    "{at}.group {i} is out of range: the regex has {} capture group(s)",
169                                    re.captures_len() - 1
170                                ));
171                            }
172                            Group::Index(i)
173                        }
174                        // The UI form sends the group as text: "2" means index 2.
175                        Some(serde_json::Value::String(g)) if g.parse::<usize>().is_ok() => {
176                            let i: usize = g.parse().unwrap_or_default();
177                            if i >= re.captures_len() {
178                                return Err(format!(
179                                    "{at}.group {i} is out of range: the regex has {} capture group(s)",
180                                    re.captures_len() - 1
181                                ));
182                            }
183                            Group::Index(i)
184                        }
185                        Some(serde_json::Value::String(g)) => {
186                            if !re.capture_names().any(|n| n == Some(g.as_str())) {
187                                return Err(format!(
188                                    "{at}.group '{g}' is not a named capture group of the regex"
189                                ));
190                            }
191                            Group::Name(g.clone())
192                        }
193                        Some(_) => {
194                            return Err(format!(
195                                "{at}.group must be an integer or a capture-group name"
196                            ))
197                        }
198                    };
199                    Some((re, group))
200                }
201            };
202
203            let default = str_field(obj, "default", &at)?.map(str::to_string);
204
205            vars.push(VarRule {
206                name: name.to_string(),
207                from,
208                json_path,
209                regex,
210                default,
211            });
212        }
213        Ok(Self { vars })
214    }
215}
216
217/// Text for a JSONPath selection: one scalar → its text, one container → its
218/// JSON, several nodes → a JSON array; nothing → `None`.
219fn json_nodes_text(nodes: &[&serde_json::Value]) -> Option<String> {
220    match nodes {
221        [] => None,
222        [serde_json::Value::String(s)] => Some(s.clone()),
223        [one] => Some(one.to_string()),
224        many => {
225            Some(serde_json::Value::Array(many.iter().map(|v| (*v).clone()).collect()).to_string())
226        }
227    }
228}
229
230impl VarRule {
231    fn evaluate(&self, ctx: &Context) -> Option<String> {
232        let mut value = self.from.render_with_legacy(ctx);
233        if let Some(path) = &self.json_path {
234            let doc: serde_json::Value = serde_json::from_str(&value).ok()?;
235            value = json_nodes_text(&path.query(&doc).all())?;
236        }
237        if let Some((re, group)) = &self.regex {
238            let caps = re.captures(&value)?;
239            let m = match group {
240                Group::Index(i) => caps.get(*i),
241                Group::Name(n) => caps.name(n),
242            }?;
243            value = m.as_str().to_string();
244        }
245        Some(value)
246    }
247}
248
249#[async_trait]
250impl Plugin for SetVarsPlugin {
251    fn plugin_type(&self) -> &str {
252        "set-vars"
253    }
254
255    async fn execute(&self, mut ctx: Context) -> PluginResult {
256        for rule in &self.vars {
257            let value = rule
258                .evaluate(&ctx)
259                .filter(|v| !v.is_empty())
260                .or_else(|| rule.default.clone())
261                .unwrap_or_default();
262            ctx.message
263                .insert(rule.name.clone(), serde_json::Value::String(value));
264        }
265        Ok(PluginOutput::success(ctx))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
273    use bytes::Bytes;
274    use std::collections::HashMap;
275
276    fn ctx(path: &str, headers: &[(&str, &str)], query: &[(&str, &str)], body: &str) -> Context {
277        Context {
278            request: GatewayRequest {
279                method: "GET".to_string(),
280                path: path.to_string(),
281                host: "example.com".to_string(),
282                scheme: "http".to_string(),
283                headers: headers
284                    .iter()
285                    .map(|(k, v)| (k.to_string(), vec![v.to_string()]))
286                    .collect(),
287                query_params: query
288                    .iter()
289                    .map(|(k, v)| (k.to_string(), vec![v.to_string()]))
290                    .collect(),
291                body: Bytes::from(body.to_string()),
292                remote_addr: "10.1.2.3:44321".to_string(),
293                protocol: Protocol::Http1,
294            },
295            response: GatewayResponse {
296                status_code: 0,
297                headers: HashMap::new(),
298                body: Bytes::new(),
299                stream: None,
300            },
301            message: HashMap::new(),
302            errors: Vec::new(),
303        }
304    }
305
306    fn plugin(config: serde_json::Value) -> Result<SetVarsPlugin, String> {
307        let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
308        SetVarsPlugin::from_config(&map)
309    }
310
311    async fn run(config: serde_json::Value, c: Context) -> Context {
312        let out = plugin(config).unwrap().execute(c).await.unwrap();
313        assert!(out.port.is_none(), "always the success port");
314        out.context
315    }
316
317    fn msg<'a>(c: &'a Context, key: &str) -> Option<&'a str> {
318        c.message.get(key).and_then(|v| v.as_str())
319    }
320
321    #[tokio::test]
322    async fn test_path_segment_via_regex_capture() {
323        let out = run(
324            serde_json::json!({"vars": [
325                {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)"}
326            ]}),
327            ctx("/hello/frenk", &[], &[], ""),
328        )
329        .await;
330        assert_eq!(msg(&out, "user"), Some("frenk"));
331        // Visible to the var engine as `$msg_user`.
332        assert_eq!(
333            crate::vars::interpolate(&out, "hello $msg_user"),
334            "hello frenk"
335        );
336    }
337
338    #[tokio::test]
339    async fn test_plain_copy_header_and_template_source() {
340        let out = run(
341            serde_json::json!({"vars": [
342                {"name": "tenant", "from": "$http_x_tenant"},
343                {"name": "where", "from": "{{request.method}} {{request.path}}"}
344            ]}),
345            ctx("/x", &[("x-tenant", "acme")], &[], ""),
346        )
347        .await;
348        assert_eq!(msg(&out, "tenant"), Some("acme"));
349        assert_eq!(msg(&out, "where"), Some("GET /x"));
350    }
351
352    #[tokio::test]
353    async fn test_query_param_with_default_when_absent_or_no_match() {
354        let out = run(
355            serde_json::json!({"vars": [
356                {"name": "plan", "from": "$arg_plan", "default": "free"},
357                {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)", "default": "stranger"},
358                {"name": "empty", "from": "$uri", "regex": "^/nope/(.*)"}
359            ]}),
360            ctx("/hello/", &[], &[], ""),
361        )
362        .await;
363        assert_eq!(msg(&out, "plan"), Some("free"));
364        assert_eq!(msg(&out, "user"), Some("stranger"));
365        assert_eq!(
366            msg(&out, "empty"),
367            Some(""),
368            "no match and no default → empty string"
369        );
370    }
371
372    #[tokio::test]
373    async fn test_json_path_on_request_body() {
374        let body = r#"{"order":{"id":42,"ok":true,"tags":["a","b"],"ship":{"city":"Turin"}}}"#;
375        let out = run(
376            serde_json::json!({"vars": [
377                {"name": "order_id", "json_path": "$.order.id"},
378                {"name": "ok", "json_path": "$.order.ok"},
379                {"name": "tags", "json_path": "$.order.tags[*]"},
380                {"name": "ship", "json_path": "$.order.ship"},
381                {"name": "missing", "json_path": "$.order.none", "default": "n/a"},
382                {"name": "city_upper", "json_path": "$.order.ship.city", "regex": "^(T[a-z]+)"}
383            ]}),
384            ctx("/orders", &[], &[], body),
385        )
386        .await;
387        assert_eq!(msg(&out, "order_id"), Some("42"));
388        assert_eq!(msg(&out, "ok"), Some("true"));
389        assert_eq!(
390            msg(&out, "tags"),
391            Some(r#"["a","b"]"#),
392            "several nodes → JSON array text"
393        );
394        assert_eq!(
395            msg(&out, "ship"),
396            Some(r#"{"city":"Turin"}"#),
397            "object → JSON text"
398        );
399        assert_eq!(msg(&out, "missing"), Some("n/a"));
400        assert_eq!(msg(&out, "city_upper"), Some("Turin"));
401    }
402
403    #[tokio::test]
404    async fn test_json_path_on_an_explicit_source_and_non_json_input() {
405        let out = run(
406            serde_json::json!({"vars": [
407                {"name": "sub", "from": "$http_x_claims", "json_path": "$.sub"},
408                {"name": "bad", "from": "$uri", "json_path": "$.a", "default": "fallback"}
409            ]}),
410            ctx("/x", &[("x-claims", r#"{"sub":"alice"}"#)], &[], "not json"),
411        )
412        .await;
413        assert_eq!(msg(&out, "sub"), Some("alice"));
414        assert_eq!(
415            msg(&out, "bad"),
416            Some("fallback"),
417            "non-JSON source → default"
418        );
419    }
420
421    #[tokio::test]
422    async fn test_regex_groups_by_index_name_and_whole_match() {
423        let out = run(
424            serde_json::json!({"vars": [
425                {"name": "minor", "from": "$http_x_version", "regex": r"^(?P<major>\d+)\.(?P<minor>\d+)", "group": "minor"},
426                {"name": "major", "from": "$http_x_version", "regex": r"^(\d+)\.(\d+)", "group": 1},
427                {"name": "whole", "from": "$http_x_version", "regex": r"\d+\.\d+", "group": 0},
428                {"name": "minor_text", "from": "$http_x_version", "regex": r"^(\d+)\.(\d+)", "group": "2"}
429            ]}),
430            ctx("/x", &[("x-version", "3.14.1")], &[], ""),
431        )
432        .await;
433        assert_eq!(msg(&out, "minor"), Some("14"));
434        assert_eq!(msg(&out, "major"), Some("3"));
435        assert_eq!(msg(&out, "whole"), Some("3.14"));
436        assert_eq!(
437            msg(&out, "minor_text"),
438            Some("14"),
439            "a numeric string group is an index (UI form)"
440        );
441    }
442
443    #[tokio::test]
444    async fn test_later_vars_can_read_earlier_ones() {
445        let out = run(
446            serde_json::json!({"vars": [
447                {"name": "user", "from": "$uri", "regex": "^/hello/([^/]+)"},
448                {"name": "greeting", "from": "hello $msg_user"}
449            ]}),
450            ctx("/hello/frenk", &[], &[], ""),
451        )
452        .await;
453        assert_eq!(msg(&out, "greeting"), Some("hello frenk"));
454    }
455
456    #[test]
457    fn test_config_errors() {
458        let err = |c: serde_json::Value| plugin(c).unwrap_err();
459        assert!(err(serde_json::json!({})).contains("vars"));
460        assert!(err(serde_json::json!({"vars": []})).contains("non-empty"));
461        assert!(err(serde_json::json!({"vars": [{"from": "$uri"}]})).contains("name"));
462        assert!(
463            err(serde_json::json!({"vars": [{"name": "a b", "from": "$uri"}]})).contains("name")
464        );
465        assert!(err(serde_json::json!({"vars": [{"name": "x"}]})).contains("from"));
466        assert!(
467            err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "("}]}))
468                .contains("regex")
469        );
470        assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "^(a)", "group": 2}]}))
471            .contains("group"));
472        assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "regex": "^(a)", "group": "nope"}]}))
473            .contains("group"));
474        assert!(
475            err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "group": 1}]}))
476                .contains("group")
477        );
478        assert!(
479            err(serde_json::json!({"vars": [{"name": "x", "json_path": "$["}]}))
480                .contains("JSONPath")
481        );
482        assert!(
483            err(serde_json::json!({"vars": [{"name": "x", "from": "$uri", "bogus": 1}]}))
484                .contains("bogus")
485        );
486        assert!(err(serde_json::json!({"vars": [{"name": "x", "from": "$uri"}, {"name": "x", "from": "$uri"}]}))
487            .contains("duplicate"));
488    }
489}