Skip to main content

featherbit/plugins/native/
condition.rs

1//! The `condition` node — a pure branching waypoint. Evaluates a boolean
2//! condition expression against the context and routes the request through
3//! the `true` or `false` outcome port. Evaluation is lenient, matching every
4//! other expression consumer in the gateway: an absent variable evaluates as
5//! the empty string (so a positive comparison over it is simply false) and a
6//! JSONPath subject over an empty or non-JSON body matches zero nodes; use
7//! the `present`/`absent` operators to test existence explicitly.
8//!
9//! The expression grammar is shared with `request-validation`
10//! ([`crate::vars::Expr`]): top-level rules ANDed, nested `AND`/`OR`/`NOT`
11//! groups, variable and JSONPath subjects. The node never mutates the
12//! request or response.
13
14use async_trait::async_trait;
15use std::collections::HashMap;
16
17use crate::context::Context;
18use crate::plugins::{Plugin, PluginOutput, PluginResult};
19
20/// Routes the context through `true` or `false` depending on a compiled
21/// condition expression.
22#[derive(Debug)]
23pub struct ConditionPlugin {
24    conditions: crate::vars::Expr,
25}
26
27impl ConditionPlugin {
28    /// Builds the plugin from node config.
29    ///
30    /// Accepted keys:
31    /// - `conditions` (array, required, non-empty): a condition expression
32    ///   (see [`crate::vars::Expr`]) — rules ANDed at top level, nested
33    ///   `AND`/`OR`/`NOT` groups, variable and JSONPath body subjects.
34    ///
35    /// ```yaml
36    /// type: condition
37    /// config:
38    ///   conditions:
39    ///     - ["$.user.tier", "==", "premium"]
40    /// ```
41    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
42        let raw = config
43            .get("conditions")
44            .ok_or("condition: 'conditions' is required")?;
45        if raw.as_array().is_some_and(|rules| rules.is_empty()) {
46            return Err("condition: 'conditions' must not be empty".to_string());
47        }
48        let conditions = crate::vars::Expr::parse(raw)
49            .map_err(|e| format!("condition: invalid 'conditions': {}", e))?;
50        Ok(Self { conditions })
51    }
52}
53
54#[async_trait]
55impl Plugin for ConditionPlugin {
56    fn plugin_type(&self) -> &str {
57        "condition"
58    }
59
60    async fn execute(&self, ctx: Context) -> PluginResult {
61        let port = if self.conditions.eval(&ctx) {
62            "true"
63        } else {
64            "false"
65        };
66        Ok(PluginOutput::on_port(ctx, port))
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use crate::context::{Context, GatewayRequest, GatewayResponse, Protocol};
73    use crate::plugins::native::condition::ConditionPlugin;
74    use crate::plugins::Plugin;
75    use bytes::Bytes;
76    use std::collections::HashMap;
77
78    fn test_context(body: &str) -> Context {
79        let mut headers = HashMap::new();
80        headers.insert("x-tier".to_string(), vec!["premium".to_string()]);
81        Context {
82            request: GatewayRequest {
83                method: "POST".to_string(),
84                path: "/api".to_string(),
85                host: "localhost".to_string(),
86                scheme: "http".to_string(),
87                headers,
88                query_params: HashMap::new(),
89                body: Bytes::from(body.to_string()),
90                remote_addr: "127.0.0.1:12345".to_string(),
91                protocol: Protocol::Http1,
92            },
93            response: GatewayResponse {
94                status_code: 0,
95                headers: HashMap::new(),
96                body: Bytes::new(),
97                stream: None,
98            },
99            message: HashMap::new(),
100            errors: Vec::new(),
101        }
102    }
103
104    fn plugin(conditions: serde_json::Value) -> ConditionPlugin {
105        let mut config = HashMap::new();
106        config.insert("conditions".to_string(), conditions);
107        ConditionPlugin::from_config(&config).unwrap()
108    }
109
110    #[tokio::test]
111    async fn test_condition_true_branch() {
112        let p = plugin(serde_json::json!([["http_x_tier", "==", "premium"]]));
113        let out = p.execute(test_context("")).await.unwrap();
114        assert_eq!(out.port, Some("true"));
115    }
116
117    #[tokio::test]
118    async fn test_condition_false_branch() {
119        let p = plugin(serde_json::json!([["http_x_tier", "==", "basic"]]));
120        let out = p.execute(test_context("")).await.unwrap();
121        assert_eq!(out.port, Some("false"));
122    }
123
124    #[tokio::test]
125    async fn test_condition_jsonpath_branches() {
126        let p = plugin(serde_json::json!([["$.user.tier", "==", "premium"]]));
127        let out = p
128            .execute(test_context(r#"{"user":{"tier":"premium"}}"#))
129            .await
130            .unwrap();
131        assert_eq!(out.port, Some("true"));
132
133        let out = p
134            .execute(test_context(r#"{"user":{"tier":"basic"}}"#))
135            .await
136            .unwrap();
137        assert_eq!(out.port, Some("false"));
138    }
139
140    #[tokio::test]
141    async fn test_condition_absent_var_comparison_is_false() {
142        // an absent variable evaluates as the empty string, so a positive
143        // comparison is simply false — not an error
144        let p = plugin(serde_json::json!([["arg_interactive", "==", "true"]]));
145        let out = p.execute(test_context("")).await.unwrap();
146        assert_eq!(out.port, Some("false"));
147        assert!(out.context.errors.is_empty());
148    }
149
150    #[tokio::test]
151    async fn test_condition_absent_var_ne_is_true() {
152        // empty-string semantics: an absent param is indeed "not equal"
153        let p = plugin(serde_json::json!([["arg_interactive", "!=", "true"]]));
154        let out = p.execute(test_context("")).await.unwrap();
155        assert_eq!(out.port, Some("true"));
156    }
157
158    #[tokio::test]
159    async fn test_condition_non_json_body_jsonpath_is_false() {
160        // a JSONPath subject over a non-JSON body matches zero nodes
161        let p = plugin(serde_json::json!([["$.user.tier", "==", "premium"]]));
162        let out = p.execute(test_context("not json")).await.unwrap();
163        assert_eq!(out.port, Some("false"));
164        assert!(out.context.errors.is_empty());
165    }
166
167    #[tokio::test]
168    async fn test_condition_existence_test_on_absent_var_is_checked() {
169        // `absent`/`present` legitimately ask about absence: no error
170        let p = plugin(serde_json::json!([["http_missing", "absent"]]));
171        let out = p.execute(test_context("")).await.unwrap();
172        assert_eq!(out.port, Some("true"));
173
174        let p = plugin(serde_json::json!([["http_missing", "present"]]));
175        let out = p.execute(test_context("")).await.unwrap();
176        assert_eq!(out.port, Some("false"));
177    }
178
179    #[tokio::test]
180    async fn test_condition_does_not_mutate_context() {
181        let p = plugin(serde_json::json!([["$.user.tier", "==", "premium"]]));
182        let body = r#"{"user":  {"tier": "premium"}}"#;
183        let out = p.execute(test_context(body)).await.unwrap();
184        // unlike request-validation, the body is not re-serialized
185        assert_eq!(out.context.request.body, Bytes::from(body));
186        assert_eq!(out.context.response.status_code, 0);
187        assert!(out.context.response.body.is_empty());
188    }
189
190    #[test]
191    fn test_condition_config_rejections() {
192        // 'conditions' is required
193        assert!(ConditionPlugin::from_config(&HashMap::new()).is_err());
194
195        // malformed conditions fail at config load with a plugin-prefixed message
196        let mut config = HashMap::new();
197        config.insert(
198            "conditions".to_string(),
199            serde_json::json!([["$.a", "bogus_op", 1]]),
200        );
201        let err = ConditionPlugin::from_config(&config).unwrap_err();
202        assert!(err.starts_with("condition: invalid 'conditions'"), "{err}");
203
204        // an empty rule list would branch unconditionally — reject it
205        let mut config = HashMap::new();
206        config.insert("conditions".to_string(), serde_json::json!([]));
207        assert!(ConditionPlugin::from_config(&config).is_err());
208    }
209
210    #[test]
211    fn test_condition_registered() {
212        assert!(crate::plugins::KNOWN_PLUGIN_TYPES.contains(&"condition"));
213
214        let mut config = HashMap::new();
215        config.insert(
216            "conditions".to_string(),
217            serde_json::json!([["http_x", "present"]]),
218        );
219        let p = crate::plugins::create_plugin(
220            "condition",
221            &config,
222            &crate::plugins::resources::PluginResources::empty(),
223        )
224        .unwrap();
225        assert_eq!(p.plugin_type(), "condition");
226
227        let spec = crate::plugins::port_spec("condition").unwrap();
228        let names: Vec<&str> = spec.outputs.iter().map(|p| p.name).collect();
229        assert_eq!(names, vec!["true", "false", "error"]);
230    }
231}