Skip to main content

camel_language_js/
language.rs

1//! [`JsLanguage`] — the JavaScript [`Language`] implementation for rust-camel.
2
3use std::sync::Arc;
4
5use camel_language_api::{
6    Expression, JsLimitsConfig, Language, LanguageError, MutatingExpression, Predicate,
7};
8
9use crate::{
10    engine::JsEngine,
11    engines::boa::BoaEngine,
12    expression::{JsExpression, JsMutatingExpression, JsPredicate, validate_to_parse_error},
13};
14
15// ── Resource limits ───────────────────────────────────────────────────────────
16//
17// Coverage (via Boa 0.21 RuntimeLimits):
18//   - Loop iteration count
19//   - Recursion depth
20//   - Stack size
21//
22// Not covered (Boa 0.21 does not expose):
23//   - Heap cap — `deny_unknown_fields` in serde rejects any `max-heap-size`
24//     field in Camel.toml if a user tries to set it.
25//
26// Defaults when not configured (rust-camel runtime defaults, ADR-0011):
27//   - execution_timeout_ms: 5_000
28//   - max_loop_iterations: 100_000  (Boa upstream is u64::MAX)
29//   - max_recursion_depth: 512      (Boa 0.21 upstream default, pinned)
30//   - max_stack_size:     10_240     (Boa 0.21 upstream default, pinned)
31
32/// JavaScript language plugin backed by [Boa](https://boajs.dev).
33///
34/// Implements the [`Language`] trait to produce JS-backed expressions, predicates,
35/// and mutating expressions for use in Apache Camel route definitions.
36///
37/// # Thread Safety
38///
39/// `JsLanguage` is `Clone + Send + Sync`. Each evaluation creates a fresh Boa
40/// `Context` (via `BoaEngine`), so no shared mutable state exists across evaluations.
41///
42/// # Example
43///
44/// ```no_run
45/// use camel_language_js::JsLanguage;
46/// use camel_language_api::Language;
47///
48/// let lang = JsLanguage::new();
49/// let expr = lang.create_expression("camel.headers.get('foo')").unwrap();
50/// ```
51#[derive(Clone)]
52pub struct JsLanguage {
53    engine: Arc<dyn JsEngine>,
54    limits: JsLimitsConfig,
55}
56
57impl JsLanguage {
58    /// Create a new `JsLanguage` with the default [`BoaEngine`] and default limits.
59    pub fn new() -> Self {
60        Self::with_limits(JsLimitsConfig::default())
61    }
62
63    /// Create a `JsLanguage` with a custom [`JsLimitsConfig`].
64    ///
65    /// Uses the default [`BoaEngine`] internally.
66    pub fn with_limits(limits: JsLimitsConfig) -> Self {
67        Self {
68            engine: Arc::new(BoaEngine::new(limits.clone())),
69            limits,
70        }
71    }
72
73    /// Create a `JsLanguage` with a custom [`JsEngine`] implementation.
74    ///
75    /// Useful for testing or providing an alternative JS runtime.
76    /// Uses default limits.
77    pub fn with_engine<E: JsEngine>(engine: E) -> Self {
78        Self::with_engine_and_limits(engine, JsLimitsConfig::default())
79    }
80
81    /// Create a `JsLanguage` with a custom engine and custom limits.
82    pub fn with_engine_and_limits<E: JsEngine>(engine: E, limits: JsLimitsConfig) -> Self {
83        Self {
84            engine: Arc::new(engine),
85            limits,
86        }
87    }
88
89    /// Resolve the execution timeout from limits or the default rust-camel value.
90    fn execution_timeout_ms(&self) -> u64 {
91        self.limits.execution_timeout_ms.unwrap_or(5_000)
92    }
93}
94
95impl Default for JsLanguage {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101impl Language for JsLanguage {
102    fn name(&self) -> &'static str {
103        "js"
104    }
105
106    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
107        validate_to_parse_error(&self.engine, script)?;
108        Ok(Box::new(JsExpression::new(
109            script.to_string(),
110            Arc::clone(&self.engine),
111            self.execution_timeout_ms(),
112        )))
113    }
114
115    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
116        validate_to_parse_error(&self.engine, script)?;
117        Ok(Box::new(JsPredicate::new(
118            script.to_string(),
119            Arc::clone(&self.engine),
120            self.execution_timeout_ms(),
121        )))
122    }
123
124    fn create_mutating_expression(
125        &self,
126        script: &str,
127    ) -> Result<Box<dyn MutatingExpression>, LanguageError> {
128        validate_to_parse_error(&self.engine, script)?;
129        Ok(Box::new(JsMutatingExpression::new(
130            script.to_string(),
131            Arc::clone(&self.engine),
132            self.execution_timeout_ms(),
133        )))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use camel_language_api::{Body, Exchange, Message};
141    use serde_json::json;
142
143    async fn make_exchange() -> Exchange {
144        let mut msg = Message::default();
145        msg.headers.insert("env".to_string(), json!("prod"));
146        msg.body = Body::Text("payload".to_string());
147        let mut ex = Exchange::new(msg);
148        ex.properties.insert("trace".to_string(), json!("on"));
149        ex
150    }
151
152    #[tokio::test]
153    async fn test_language_name() {
154        assert_eq!(JsLanguage::new().name(), "js");
155    }
156
157    #[tokio::test]
158    async fn test_create_expression_valid() {
159        let lang = JsLanguage::new();
160        let result = lang.create_expression("1 + 1");
161        assert!(result.is_ok());
162    }
163
164    #[tokio::test]
165    async fn test_create_expression_invalid_syntax() {
166        let lang = JsLanguage::new();
167        let result = lang.create_expression("let x = {{{");
168        assert!(result.is_err());
169        // Should be a ParseError
170        assert!(matches!(result, Err(LanguageError::ParseError { .. })));
171    }
172
173    #[tokio::test]
174    async fn test_create_predicate_valid() {
175        let lang = JsLanguage::new();
176        assert!(lang.create_predicate("true").is_ok());
177    }
178
179    #[tokio::test]
180    async fn test_create_predicate_invalid() {
181        let lang = JsLanguage::new();
182        assert!(matches!(
183            lang.create_predicate("let !!!"),
184            Err(LanguageError::ParseError { .. })
185        ));
186    }
187
188    #[tokio::test]
189    async fn test_create_mutating_expression_invalid_syntax() {
190        let lang = JsLanguage::new();
191        let result = lang.create_mutating_expression("let !!!");
192        assert!(result.is_err());
193        assert!(matches!(result, Err(LanguageError::ParseError { .. })));
194    }
195
196    #[tokio::test]
197    async fn test_create_mutating_expression_valid() {
198        let lang = JsLanguage::new();
199        assert!(
200            lang.create_mutating_expression("camel.headers.set('k','v')")
201                .is_ok()
202        );
203    }
204
205    #[tokio::test]
206    async fn test_expression_evaluate() {
207        let lang = JsLanguage::new();
208        let expr = lang.create_expression("camel.headers.get('env')").unwrap();
209        let ex = make_exchange().await;
210        let val = expr.evaluate(&ex).await.unwrap();
211        assert_eq!(val.as_str().unwrap(), "prod");
212    }
213
214    #[tokio::test]
215    async fn test_predicate_matches() {
216        let lang = JsLanguage::new();
217        let pred = lang
218            .create_predicate("camel.headers.get('env') === 'prod'")
219            .unwrap();
220        let ex = make_exchange().await;
221        assert!(pred.matches(&ex).await.unwrap());
222    }
223
224    #[tokio::test]
225    async fn test_mutating_expression_propagates() {
226        let lang = JsLanguage::new();
227        let expr = lang
228            .create_mutating_expression(
229                "camel.headers.set('added', 'yes'); camel.body = 'new'; 'done'",
230            )
231            .unwrap();
232        let mut ex = make_exchange().await;
233        let result = expr.evaluate(&mut ex).await.unwrap();
234        assert_eq!(result.as_str().unwrap(), "done");
235        assert_eq!(
236            ex.input.headers.get("added").unwrap().as_str().unwrap(),
237            "yes"
238        );
239        assert_eq!(ex.input.body.as_text().unwrap(), "new");
240    }
241
242    #[tokio::test]
243    async fn test_default_creates_js_language() {
244        let lang = JsLanguage::default();
245        assert_eq!(lang.name(), "js");
246    }
247
248    #[tokio::test]
249    async fn test_clone_works() {
250        let lang = JsLanguage::new();
251        let lang2 = lang.clone();
252        assert_eq!(lang2.name(), "js");
253        // Both clones should work independently
254        let ex = make_exchange().await;
255        let expr = lang2.create_expression("42").unwrap();
256        let val = expr.evaluate(&ex).await.unwrap();
257        assert_eq!(val.as_i64().unwrap(), 42);
258    }
259}