Skip to main content

camel_language_rhai/
lib.rs

1use camel_api::Value;
2use camel_api::exchange::Exchange;
3use camel_language_api::{Expression, Language, LanguageError, MutatingExpression, Predicate};
4use rhai::{Engine, Scope};
5use std::sync::{Arc, RwLock};
6
7/// Default maximum number of Rhai operations per evaluation (prevents infinite loops).
8const DEFAULT_MAX_OPERATIONS: u64 = 100_000;
9/// Default maximum string size in bytes.
10const DEFAULT_MAX_STRING_SIZE: usize = 1_048_576; // 1 MB
11/// Default maximum array size.
12const DEFAULT_MAX_ARRAY_SIZE: usize = 10_000;
13/// Default maximum map size.
14const DEFAULT_MAX_MAP_SIZE: usize = 10_000;
15
16/// Rhai scripting language for rust-camel.
17///
18/// Scripts have access to:
19/// - `body` — exchange body as a string variable
20/// - `headers` — exchange headers as a Rhai Map
21/// - `header(name)` — look up a header value by name
22/// - `set_header(name, value)` — set a header (visible within the same script evaluation)
23/// - `property(name)` — look up an exchange property by name
24/// - `set_property(name, value)` — set a property (visible within the same script evaluation)
25///
26/// **Note:** `set_header` and `set_property` only affect values visible within the current
27/// script evaluation. Changes do **not** propagate back to the Exchange because expressions
28/// receive a read-only `&Exchange` reference.
29///
30/// ## Resource Limits
31///
32/// The engine enforces limits to prevent denial-of-service:
33/// - Max operations: 100,000 (prevents infinite loops)
34/// - Max string size: 1 MB
35/// - Max array size: 10,000 elements
36/// - Max map size: 10,000 entries
37/// - Max expression depth: 64 (32 in functions)
38pub struct RhaiLanguage {
39    engine: Engine,
40}
41
42impl RhaiLanguage {
43    pub fn new() -> Self {
44        let engine = Self::create_base_engine();
45        Self { engine }
46    }
47
48    /// Create a base engine with all resource limits configured.
49    fn create_base_engine() -> Engine {
50        let mut engine = Engine::new();
51        engine.set_max_expr_depths(64, 32);
52        engine.set_max_operations(DEFAULT_MAX_OPERATIONS);
53        engine.set_max_string_size(DEFAULT_MAX_STRING_SIZE);
54        engine.set_max_array_size(DEFAULT_MAX_ARRAY_SIZE);
55        engine.set_max_map_size(DEFAULT_MAX_MAP_SIZE);
56        engine
57    }
58
59    /// Build a scope with `body` and `headers` variables from the exchange.
60    fn make_scope(exchange: &Exchange) -> (Scope<'static>, rhai::Map, rhai::Map) {
61        let mut scope = Scope::new();
62        let body = exchange.input.body.as_text().unwrap_or("").to_string();
63        scope.push("body", body);
64
65        let mut headers = rhai::Map::new();
66        for (k, v) in &exchange.input.headers {
67            headers.insert(k.clone().into(), json_to_dynamic(v));
68        }
69        scope.push("headers", headers.clone());
70
71        let mut properties = rhai::Map::new();
72        for (k, v) in &exchange.properties {
73            properties.insert(k.clone().into(), json_to_dynamic(v));
74        }
75
76        (scope, headers, properties)
77    }
78
79    /// Create a fresh engine with `header()`, `set_header()`, `property()`, and
80    /// `set_property()` registered as native functions. A new engine per eval
81    /// avoids sharing mutable state between evaluations.
82    fn create_eval_engine(headers: rhai::Map, properties: rhai::Map) -> Engine {
83        let mut engine = Self::create_base_engine();
84
85        // Shared mutable headers map: header() reads, set_header() writes
86        let h = Arc::new(RwLock::new(headers));
87
88        let h_read = h.clone();
89        engine.register_fn("header", move |name: String| -> rhai::Dynamic {
90            h_read
91                .read()
92                .unwrap_or_else(|e| e.into_inner())
93                .get(name.as_str())
94                .cloned()
95                .unwrap_or(rhai::Dynamic::UNIT)
96        });
97
98        let h_write = h.clone();
99        engine.register_fn("set_header", move |name: String, value: rhai::Dynamic| {
100            h_write
101                .write()
102                .unwrap_or_else(|e| e.into_inner())
103                .insert(name.into(), value);
104        });
105
106        // Shared mutable properties map: property() reads, set_property() writes
107        let p = Arc::new(RwLock::new(properties));
108
109        let p_read = p.clone();
110        engine.register_fn("property", move |name: String| -> rhai::Dynamic {
111            p_read
112                .read()
113                .unwrap_or_else(|e| e.into_inner())
114                .get(name.as_str())
115                .cloned()
116                .unwrap_or(rhai::Dynamic::UNIT)
117        });
118
119        let p_write = p.clone();
120        engine.register_fn("set_property", move |name: String, value: rhai::Dynamic| {
121            p_write
122                .write()
123                .unwrap_or_else(|e| e.into_inner())
124                .insert(name.into(), value);
125        });
126
127        engine
128    }
129
130    fn eval_to_value(script: &str, exchange: &Exchange) -> Result<Value, LanguageError> {
131        let (mut scope, headers, properties) = Self::make_scope(exchange);
132        let engine = Self::create_eval_engine(headers, properties);
133
134        let result: rhai::Dynamic = engine
135            .eval_with_scope::<rhai::Dynamic>(&mut scope, script)
136            .map_err(|e| LanguageError::EvalError(e.to_string()))?;
137
138        dynamic_to_json(result)
139    }
140}
141
142/// Convert a serde_json::Value to a rhai::Dynamic.
143fn json_to_dynamic(v: &Value) -> rhai::Dynamic {
144    match v {
145        Value::String(s) => rhai::Dynamic::from(s.clone()),
146        Value::Number(n) => {
147            if let Some(i) = n.as_i64() {
148                rhai::Dynamic::from(i)
149            } else if let Some(f) = n.as_f64() {
150                rhai::Dynamic::from_float(f)
151            } else {
152                rhai::Dynamic::from(n.to_string())
153            }
154        }
155        Value::Bool(b) => rhai::Dynamic::from(*b),
156        Value::Null => rhai::Dynamic::UNIT,
157        Value::Array(arr) => {
158            let rhai_arr: rhai::Array = arr.iter().map(json_to_dynamic).collect();
159            rhai::Dynamic::from(rhai_arr)
160        }
161        Value::Object(obj) => {
162            let mut rhai_map = rhai::Map::new();
163            for (k, v) in obj {
164                rhai_map.insert(k.clone().into(), json_to_dynamic(v));
165            }
166            rhai::Dynamic::from(rhai_map)
167        }
168    }
169}
170
171fn dynamic_to_json(d: rhai::Dynamic) -> Result<Value, LanguageError> {
172    if d.is_string() {
173        Ok(Value::String(d.cast::<String>()))
174    } else if d.is::<bool>() {
175        Ok(Value::Bool(d.cast::<bool>()))
176    } else if d.is_float() {
177        Ok(Value::from(d.cast::<f64>()))
178    } else if d.is_int() {
179        Ok(Value::from(d.cast::<i64>()))
180    } else if d.is_unit() {
181        Ok(Value::Null)
182    } else {
183        Ok(Value::String(d.to_string()))
184    }
185}
186
187/// Convert a rhai::Dynamic to a serde_json::Value (infallible version for mutating expressions).
188fn dynamic_to_value(d: rhai::Dynamic) -> Value {
189    if d.is_string() {
190        Value::String(d.cast::<String>())
191    } else if d.is::<bool>() {
192        Value::Bool(d.cast::<bool>())
193    } else if d.is_int() {
194        Value::from(d.cast::<i64>())
195    } else if d.is_float() {
196        Value::from(d.cast::<f64>())
197    } else if d.is_unit() {
198        Value::Null
199    } else {
200        Value::String(d.to_string())
201    }
202}
203
204/// Convert a Rhai Map to a HashMap<String, Value> for syncing back to exchange.
205fn rhai_map_to_value_map(map: &rhai::Map) -> std::collections::HashMap<String, Value> {
206    let mut result = std::collections::HashMap::new();
207    for (k, v) in map {
208        result.insert(k.to_string(), dynamic_to_value(v.clone()));
209    }
210    result
211}
212
213struct RhaiExpression {
214    script: String,
215}
216
217struct RhaiPredicate {
218    script: String,
219}
220
221impl Expression for RhaiExpression {
222    fn evaluate(&self, exchange: &Exchange) -> Result<Value, LanguageError> {
223        RhaiLanguage::eval_to_value(&self.script, exchange)
224    }
225}
226
227impl Predicate for RhaiPredicate {
228    fn matches(&self, exchange: &Exchange) -> Result<bool, LanguageError> {
229        let val = RhaiLanguage::eval_to_value(&self.script, exchange)?;
230        Ok(match &val {
231            Value::Bool(b) => *b,
232            Value::Null => false,
233            _ => true,
234        })
235    }
236}
237
238/// A Rhai script expression that can mutate the Exchange during evaluation.
239///
240/// The script has access to three mutable scope variables:
241/// - `headers` — a Rhai map (`#{}`) representing the exchange headers
242/// - `properties` — a Rhai map (`#{}`) representing the exchange properties
243/// - `body` — a string representing the exchange body
244///
245/// Changes to these variables are propagated back to the Exchange after evaluation.
246/// If evaluation fails, all changes are **rolled back atomically**.
247///
248/// # Note on API differences
249///
250/// Unlike non-mutating Rhai expressions, this engine does NOT provide
251/// `header()`, `set_header()`, `property()`, or `set_property()` functions.
252/// Use direct map assignment syntax instead:
253///
254/// ```rhai
255/// headers["tenant"] = "acme";       // set header
256/// properties["trace"] = "enabled";  // set property
257/// body = "new content";             // set body
258/// let v = headers["existing"];      // read header
259/// ```
260struct RhaiMutatingExpression {
261    script: String,
262}
263
264impl MutatingExpression for RhaiMutatingExpression {
265    fn evaluate(&self, exchange: &mut Exchange) -> Result<Value, LanguageError> {
266        // 1. Snapshot original state for rollback on error
267        let original_headers = exchange.input.headers.clone();
268        let original_properties = exchange.properties.clone();
269        let original_body = exchange.input.body.clone();
270
271        // 2. Create scope with Rhai Map variables
272        let mut scope = Scope::new();
273
274        let mut headers_map = rhai::Map::new();
275        for (k, v) in &exchange.input.headers {
276            headers_map.insert(k.clone().into(), json_to_dynamic(v));
277        }
278
279        let mut properties_map = rhai::Map::new();
280        for (k, v) in &exchange.properties {
281            properties_map.insert(k.clone().into(), json_to_dynamic(v));
282        }
283
284        let body_str = exchange.input.body.as_text().unwrap_or("").to_string();
285
286        scope.push("headers", headers_map);
287        scope.push("properties", properties_map);
288        scope.push("body", body_str);
289
290        // 3. Evaluate script
291        let engine = RhaiLanguage::create_base_engine();
292
293        let result: rhai::Dynamic = match engine.eval_with_scope(&mut scope, &self.script) {
294            Ok(v) => v,
295            Err(e) => {
296                exchange.input.headers = original_headers;
297                exchange.properties = original_properties;
298                exchange.input.body = original_body;
299                return Err(LanguageError::EvalError(e.to_string()));
300            }
301        };
302
303        // 4. Sync changes back to exchange
304        if let Some(h) = scope.get_value::<rhai::Map>("headers") {
305            exchange.input.headers = rhai_map_to_value_map(&h);
306        }
307        if let Some(p) = scope.get_value::<rhai::Map>("properties") {
308            exchange.properties = rhai_map_to_value_map(&p);
309        }
310        if let Some(b) = scope.get_value::<String>("body") {
311            exchange.input.body = camel_api::Body::Text(b);
312        }
313
314        Ok(dynamic_to_value(result))
315    }
316}
317
318impl Language for RhaiLanguage {
319    fn name(&self) -> &'static str {
320        "rhai"
321    }
322
323    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
324        // Syntax-only validation — function resolution happens at eval time
325        self.engine
326            .compile(script)
327            .map_err(|e| LanguageError::ParseError {
328                expr: script.to_string(),
329                reason: e.to_string(),
330            })?;
331        Ok(Box::new(RhaiExpression {
332            script: script.to_string(),
333        }))
334    }
335
336    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
337        self.engine
338            .compile(script)
339            .map_err(|e| LanguageError::ParseError {
340                expr: script.to_string(),
341                reason: e.to_string(),
342            })?;
343        Ok(Box::new(RhaiPredicate {
344            script: script.to_string(),
345        }))
346    }
347
348    /// Create a mutating Rhai expression.
349    ///
350    /// The script can modify `headers`, `properties`, and `body` via assignment syntax.
351    /// See [`RhaiMutatingExpression`] for full documentation.
352    fn create_mutating_expression(
353        &self,
354        script: &str,
355    ) -> Result<Box<dyn MutatingExpression>, LanguageError> {
356        self.engine
357            .compile(script)
358            .map_err(|e| LanguageError::ParseError {
359                expr: script.to_string(),
360                reason: e.to_string(),
361            })?;
362        Ok(Box::new(RhaiMutatingExpression {
363            script: script.to_string(),
364        }))
365    }
366}
367
368impl Default for RhaiLanguage {
369    fn default() -> Self {
370        Self::new()
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use camel_api::{Value, exchange::Exchange, message::Message};
377    use camel_language_api::Language;
378
379    use super::RhaiLanguage;
380
381    fn exchange_with_header(key: &str, val: &str) -> Exchange {
382        let mut msg = Message::default();
383        msg.set_header(key, Value::String(val.to_string()));
384        Exchange::new(msg)
385    }
386
387    fn exchange_with_body(body: &str) -> Exchange {
388        Exchange::new(Message::new(body))
389    }
390
391    #[test]
392    fn test_rhai_predicate_simple() {
393        let lang = RhaiLanguage::new();
394        let pred = lang
395            .create_predicate(r#"header("type") == "order""#)
396            .unwrap();
397        let ex = exchange_with_header("type", "order");
398        assert!(pred.matches(&ex).unwrap());
399    }
400
401    #[test]
402    fn test_rhai_predicate_false() {
403        let lang = RhaiLanguage::new();
404        let pred = lang
405            .create_predicate(r#"header("type") == "order""#)
406            .unwrap();
407        let ex = exchange_with_header("type", "invoice");
408        assert!(!pred.matches(&ex).unwrap());
409    }
410
411    #[test]
412    fn test_rhai_expression_body() {
413        let lang = RhaiLanguage::new();
414        let expr = lang.create_expression("body").unwrap();
415        let ex = exchange_with_body("hello");
416        let val = expr.evaluate(&ex).unwrap();
417        assert_eq!(val, Value::String("hello".to_string()));
418    }
419
420    #[test]
421    fn test_rhai_expression_concat() {
422        let lang = RhaiLanguage::new();
423        let expr = lang.create_expression(r#"body + " world""#).unwrap();
424        let ex = exchange_with_body("hello");
425        let val = expr.evaluate(&ex).unwrap();
426        assert_eq!(val, Value::String("hello world".to_string()));
427    }
428
429    #[test]
430    fn test_rhai_set_header_visible_within_script() {
431        let lang = RhaiLanguage::new();
432        let expr = lang
433            .create_expression(
434                r#"
435            set_header("done", "yes");
436            header("done")
437        "#,
438            )
439            .unwrap();
440        let ex = exchange_with_body("test");
441        let val = expr.evaluate(&ex).unwrap();
442        assert_eq!(val, Value::String("yes".to_string()));
443    }
444
445    #[test]
446    fn test_rhai_property_access() {
447        let lang = RhaiLanguage::new();
448        let expr = lang.create_expression(r#"property("myProp")"#).unwrap();
449        let mut ex = exchange_with_body("test");
450        ex.set_property("myProp".to_string(), Value::String("propVal".to_string()));
451        let val = expr.evaluate(&ex).unwrap();
452        assert_eq!(val, Value::String("propVal".to_string()));
453    }
454
455    #[test]
456    fn test_rhai_set_property_visible_within_script() {
457        let lang = RhaiLanguage::new();
458        let expr = lang
459            .create_expression(
460                r#"
461            set_property("key", "value");
462            property("key")
463        "#,
464            )
465            .unwrap();
466        let ex = exchange_with_body("test");
467        let val = expr.evaluate(&ex).unwrap();
468        assert_eq!(val, Value::String("value".to_string()));
469    }
470
471    #[test]
472    fn test_rhai_missing_header_returns_null() {
473        let lang = RhaiLanguage::new();
474        let expr = lang.create_expression(r#"header("nonexistent")"#).unwrap();
475        let ex = exchange_with_body("test");
476        let val = expr.evaluate(&ex).unwrap();
477        assert_eq!(val, Value::Null);
478    }
479
480    #[test]
481    fn test_rhai_syntax_error() {
482        let lang = RhaiLanguage::new();
483        let result = lang.create_expression("let x = ;");
484        assert!(result.is_err());
485    }
486
487    #[test]
488    fn test_rhai_runtime_error() {
489        let lang = RhaiLanguage::new();
490        // Calling a nonexistent function will produce a runtime error
491        let expr = lang.create_expression(r#"nonexistent_fn()"#).unwrap();
492        let ex = exchange_with_body("test");
493        let result = expr.evaluate(&ex);
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn test_rhai_operations_limit_prevents_infinite_loop() {
499        let lang = RhaiLanguage::new();
500        // This script would loop forever without the operations limit
501        let expr = lang
502            .create_expression("let x = 0; loop { x += 1; } x")
503            .unwrap();
504        let ex = exchange_with_body("test");
505        let result = expr.evaluate(&ex);
506        assert!(result.is_err(), "should error due to operations limit");
507        let err_msg = format!("{}", result.unwrap_err());
508        assert!(
509            err_msg.contains("operations") || err_msg.contains("limit"),
510            "error should mention operations limit, got: {err_msg}"
511        );
512    }
513
514    #[test]
515    fn test_rhai_empty_body() {
516        let lang = RhaiLanguage::new();
517        let expr = lang.create_expression("body").unwrap();
518        let ex = Exchange::new(Message::default());
519        let val = expr.evaluate(&ex).unwrap();
520        assert_eq!(val, Value::String("".to_string()));
521    }
522
523    #[test]
524    fn test_rhai_numeric_header() {
525        let lang = RhaiLanguage::new();
526        let expr = lang.create_expression(r#"header("count") + 1"#).unwrap();
527        let mut msg = Message::default();
528        msg.set_header("count", Value::Number(41.into()));
529        let ex = Exchange::new(msg);
530        let val = expr.evaluate(&ex).unwrap();
531        assert_eq!(val, Value::from(42));
532    }
533
534    #[test]
535    fn test_rhai_json_array_header_is_native_array() {
536        // json_to_dynamic should convert JSON arrays to native Rhai arrays,
537        // not to their string representation.
538        let lang = RhaiLanguage::new();
539        let expr = lang.create_expression(r#"header("items").len()"#).unwrap();
540        let mut msg = Message::default();
541        msg.set_header(
542            "items",
543            Value::Array(vec![
544                Value::String("a".into()),
545                Value::String("b".into()),
546                Value::String("c".into()),
547            ]),
548        );
549        let ex = Exchange::new(msg);
550        let val = expr.evaluate(&ex).unwrap();
551        assert_eq!(
552            val,
553            Value::from(3_i64),
554            "array len should be 3, not a stringified value"
555        );
556    }
557
558    #[test]
559    fn test_rhai_json_object_header_is_native_map() {
560        // json_to_dynamic should convert JSON objects to native Rhai maps.
561        let lang = RhaiLanguage::new();
562        let expr = lang.create_expression(r#"header("obj")["key"]"#).unwrap();
563        let mut msg = Message::default();
564        // Build a JSON object via Value's FromStr impl (serde_json::Value)
565        let obj: Value = r#"{"key": "value"}"#.parse().unwrap();
566        msg.set_header("obj", obj);
567        let ex = Exchange::new(msg);
568        let val = expr.evaluate(&ex).unwrap();
569        assert_eq!(val, Value::String("value".to_string()));
570    }
571
572    #[test]
573    fn test_mutating_set_header_propagates_to_exchange() {
574        let lang = RhaiLanguage::new();
575        let expr = lang
576            .create_mutating_expression(r#"headers["tenant"] = "acme""#)
577            .unwrap();
578        let mut ex = Exchange::new(Message::default());
579        expr.evaluate(&mut ex).unwrap();
580        assert_eq!(
581            ex.input.headers.get("tenant"),
582            Some(&Value::String("acme".into()))
583        );
584    }
585
586    #[test]
587    fn test_mutating_set_body_propagates_to_exchange() {
588        let lang = RhaiLanguage::new();
589        let expr = lang
590            .create_mutating_expression(r#"body = "modified""#)
591            .unwrap();
592        let mut ex = Exchange::new(Message::new("original"));
593        expr.evaluate(&mut ex).unwrap();
594        assert_eq!(ex.input.body.as_text(), Some("modified"));
595    }
596
597    #[test]
598    fn test_mutating_set_property_propagates_to_exchange() {
599        let lang = RhaiLanguage::new();
600        let expr = lang
601            .create_mutating_expression(r#"properties["auth"] = "ok""#)
602            .unwrap();
603        let mut ex = Exchange::new(Message::default());
604        expr.evaluate(&mut ex).unwrap();
605        assert_eq!(ex.properties.get("auth"), Some(&Value::String("ok".into())));
606    }
607
608    #[test]
609    fn test_mutating_rollback_on_error() {
610        let lang = RhaiLanguage::new();
611        let expr = lang
612            .create_mutating_expression(r#"headers["x"] = "modified"; throw "error""#)
613            .unwrap();
614        let mut ex = Exchange::new(Message::default());
615        ex.input
616            .headers
617            .insert("x".to_string(), Value::String("original".into()));
618        let result = expr.evaluate(&mut ex);
619        assert!(result.is_err());
620        assert_eq!(
621            ex.input.headers.get("x"),
622            Some(&Value::String("original".into()))
623        );
624    }
625
626    #[test]
627    fn test_mutating_rollback_on_error_includes_body() {
628        let lang = RhaiLanguage::new();
629        let expr = lang
630            .create_mutating_expression(r#"body = "modified"; throw "error""#)
631            .unwrap();
632        let mut ex = Exchange::new(Message::new("original"));
633        let result = expr.evaluate(&mut ex);
634        assert!(result.is_err());
635        assert_eq!(ex.input.body.as_text(), Some("original"));
636    }
637
638    #[test]
639    fn test_mutating_rollback_on_error_includes_property() {
640        let lang = RhaiLanguage::new();
641        let expr = lang
642            .create_mutating_expression(r#"properties["p"] = "modified"; throw "error""#)
643            .unwrap();
644        let mut ex = Exchange::new(Message::default());
645        ex.properties
646            .insert("p".to_string(), Value::String("original".into()));
647        let result = expr.evaluate(&mut ex);
648        assert!(result.is_err());
649        assert_eq!(
650            ex.properties.get("p"),
651            Some(&Value::String("original".into()))
652        );
653    }
654
655    #[test]
656    fn test_mutating_combined_read_write() {
657        let lang = RhaiLanguage::new();
658        let expr = lang
659            .create_mutating_expression(r#"headers["out"] = headers["in"] + "_processed""#)
660            .unwrap();
661        let mut ex = Exchange::new(Message::default());
662        ex.input
663            .headers
664            .insert("in".to_string(), Value::String("value".into()));
665        expr.evaluate(&mut ex).unwrap();
666        assert_eq!(
667            ex.input.headers.get("out"),
668            Some(&Value::String("value_processed".into()))
669        );
670    }
671}