Skip to main content

camel_language_xpath/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use camel_language_api::{Body, Exchange, Value};
4use camel_language_api::{Expression, Language, LanguageError, Predicate};
5use serde_json::Value as JsonValue;
6use sxd_document::parser;
7use sxd_xpath::{Context, Factory, Value as SxdValue};
8use tracing::{debug, warn};
9
10pub struct XPathLanguage;
11
12struct XPathExpression {
13    query: String,
14}
15
16struct XPathPredicate {
17    query: String,
18}
19
20fn extract_xml(exchange: &Exchange) -> Result<String, LanguageError> {
21    match &exchange.input.body {
22        Body::Xml(s) => Ok(s.clone()),
23        other => other
24            .clone()
25            .try_into_xml()
26            .map_err(|e| {
27                LanguageError::EvalError(format!("body is not XML and cannot be coerced: {e}"))
28            })
29            .and_then(|b| match b {
30                Body::Xml(s) => Ok(s),
31                _ => Err(LanguageError::EvalError(
32                    "body coercion did not produce XML".into(),
33                )),
34            }),
35    }
36}
37
38fn compile_xpath(query: &str) -> Result<sxd_xpath::XPath, LanguageError> {
39    let factory = Factory::new();
40    factory
41        .build(query)
42        .map_err(|e| {
43            warn!(error = %e, "xpath expression compile failed");
44            LanguageError::ParseError {
45                expr: query.to_string(),
46                reason: e.to_string(),
47            }
48        })
49        .and_then(|opt| {
50            opt.ok_or_else(|| {
51                warn!("xpath expression compile failed");
52                LanguageError::ParseError {
53                    expr: query.to_string(),
54                    reason: "empty XPath expression".into(),
55                }
56            })
57        })
58}
59
60fn run_query(query: &str, xml: &str) -> Result<JsonValue, LanguageError> {
61    let package = parser::parse(xml).map_err(|_| {
62        // sxd parse errors can embed document-derived content (e.g. MismatchedTag
63        // includes tag names from the exchange body which may be sensitive).
64        // Return a generic message; do NOT include the raw error in logs or errors.
65        warn!("xpath: body XML could not be parsed");
66        LanguageError::EvalError("xml parse error: body is not valid XML".to_string())
67    })?;
68    let doc = package.as_document();
69    let xpath = compile_xpath(query)?;
70    let context = Context::new();
71    let result = xpath.evaluate(&context, doc.root()).map_err(|_| {
72        // sxd_xpath eval errors describe query structure issues (unknown variable/function,
73        // type mismatch). No document-derived values are embedded, but we follow the
74        // same conservative pattern: generic message, no raw external error strings.
75        warn!("xpath: expression evaluation failed");
76        LanguageError::EvalError(
77            "xpath query failed: expression could not be evaluated".to_string(),
78        )
79    })?;
80
81    Ok(match result {
82        SxdValue::Nodeset(ns) => {
83            let nodes: Vec<_> = ns.document_order();
84            match nodes.len() {
85                0 => JsonValue::Null,
86                1 => JsonValue::String(nodes[0].string_value()),
87                _ => JsonValue::Array(
88                    nodes
89                        .into_iter()
90                        .map(|n| JsonValue::String(n.string_value()))
91                        .collect(),
92                ),
93            }
94        }
95        SxdValue::Boolean(b) => JsonValue::Bool(b),
96        SxdValue::Number(n) => serde_json::Number::from_f64(n)
97            .map(JsonValue::Number)
98            .unwrap_or(JsonValue::Null),
99        SxdValue::String(s) => JsonValue::String(s),
100    })
101}
102
103impl Expression for XPathExpression {
104    fn evaluate(&self, exchange: &Exchange) -> Result<Value, LanguageError> {
105        let xml = extract_xml(exchange)?;
106        run_query(&self.query, &xml)
107    }
108}
109
110impl Predicate for XPathPredicate {
111    fn matches(&self, exchange: &Exchange) -> Result<bool, LanguageError> {
112        let xml = extract_xml(exchange)?;
113        let result = run_query(&self.query, &xml)?;
114        Ok(match &result {
115            JsonValue::Null => false,
116            JsonValue::Bool(b) => *b,
117            JsonValue::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
118            JsonValue::String(s) => !s.is_empty(),
119            JsonValue::Array(arr) => !arr.is_empty(),
120            _ => true,
121        })
122    }
123}
124
125impl Language for XPathLanguage {
126    fn name(&self) -> &'static str {
127        "xpath"
128    }
129
130    fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
131        compile_xpath(script)?;
132        debug!("xpath expression compiled");
133        Ok(Box::new(XPathExpression {
134            query: script.to_string(),
135        }))
136    }
137
138    fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
139        compile_xpath(script)?;
140        debug!("xpath expression compiled");
141        Ok(Box::new(XPathPredicate {
142            query: script.to_string(),
143        }))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use camel_language_api::Message;
151
152    fn exchange_with_xml(xml: &str) -> Exchange {
153        Exchange::new(Message::new(Body::Xml(xml.to_string())))
154    }
155
156    fn exchange_with_text_body(text: &str) -> Exchange {
157        Exchange::new(Message::new(Body::Text(text.to_string())))
158    }
159
160    fn empty_exchange() -> Exchange {
161        Exchange::new(Message::default())
162    }
163
164    #[test]
165    fn expression_simple_path() {
166        let lang = XPathLanguage;
167        let expr = lang.create_expression("/root/name").unwrap();
168        let ex = exchange_with_xml("<root><name>books</name></root>");
169        let result = expr.evaluate(&ex).unwrap();
170        assert_eq!(result, JsonValue::String("books".to_string()));
171    }
172
173    #[test]
174    fn expression_nested_path() {
175        let lang = XPathLanguage;
176        let expr = lang.create_expression("/root/inner/value").unwrap();
177        let ex = exchange_with_xml("<root><inner><value>42</value></inner></root>");
178        let result = expr.evaluate(&ex).unwrap();
179        assert_eq!(result, JsonValue::String("42".to_string()));
180    }
181
182    #[test]
183    fn expression_attribute_access() {
184        let lang = XPathLanguage;
185        let expr = lang.create_expression("/root/item/@id").unwrap();
186        let ex = exchange_with_xml("<root><item id=\"123\"/></root>");
187        let result = expr.evaluate(&ex).unwrap();
188        assert_eq!(result, JsonValue::String("123".to_string()));
189    }
190
191    #[test]
192    fn expression_text_function() {
193        let lang = XPathLanguage;
194        let expr = lang.create_expression("/root/name/text()").unwrap();
195        let ex = exchange_with_xml("<root><name>hello</name></root>");
196        let result = expr.evaluate(&ex).unwrap();
197        assert_eq!(result, JsonValue::String("hello".to_string()));
198    }
199
200    #[test]
201    fn expression_wildcard() {
202        let lang = XPathLanguage;
203        let expr = lang.create_expression("/root/item").unwrap();
204        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>");
205        let result = expr.evaluate(&ex).unwrap();
206        assert_eq!(
207            result,
208            JsonValue::Array(vec![
209                JsonValue::String("a".to_string()),
210                JsonValue::String("b".to_string()),
211            ])
212        );
213    }
214
215    #[test]
216    fn expression_predicate_position() {
217        let lang = XPathLanguage;
218        let expr = lang.create_expression("/root/item[2]").unwrap();
219        let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>");
220        let result = expr.evaluate(&ex).unwrap();
221        assert_eq!(result, JsonValue::String("b".to_string()));
222    }
223
224    #[test]
225    fn expression_count_function() {
226        let lang = XPathLanguage;
227        let expr = lang.create_expression("count(/root/item)").unwrap();
228        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>");
229        let result = expr.evaluate(&ex).unwrap();
230        assert_eq!(
231            result,
232            JsonValue::Number(serde_json::Number::from_f64(2.0).unwrap())
233        );
234    }
235
236    #[test]
237    fn expression_text_body_with_valid_xml() {
238        let lang = XPathLanguage;
239        let expr = lang.create_expression("/root/value").unwrap();
240        let ex = exchange_with_text_body("<root><value>test</value></root>");
241        let result = expr.evaluate(&ex).unwrap();
242        assert_eq!(result, JsonValue::String("test".to_string()));
243    }
244
245    #[test]
246    fn expression_text_body_with_invalid_xml() {
247        let lang = XPathLanguage;
248        let expr = lang.create_expression("/root").unwrap();
249        let ex = exchange_with_text_body("not xml at all");
250        let result = expr.evaluate(&ex);
251        assert!(result.is_err());
252    }
253
254    #[test]
255    fn expression_empty_body_is_error() {
256        let lang = XPathLanguage;
257        let expr = lang.create_expression("/root").unwrap();
258        let ex = empty_exchange();
259        let result = expr.evaluate(&ex);
260        assert!(result.is_err());
261    }
262
263    #[test]
264    fn expression_empty_result_is_null() {
265        let lang = XPathLanguage;
266        let expr = lang.create_expression("/root/missing").unwrap();
267        let ex = exchange_with_xml("<root><name>test</name></root>");
268        let result = expr.evaluate(&ex).unwrap();
269        assert_eq!(result, JsonValue::Null);
270    }
271
272    #[test]
273    fn expression_invalid_xpath_syntax() {
274        let lang = XPathLanguage;
275        let result = lang.create_expression("//[invalid");
276        let err = match result {
277            Err(e) => e,
278            Ok(_) => panic!("expected ParseError"),
279        };
280        match err {
281            LanguageError::ParseError { expr, reason } => {
282                assert!(!expr.is_empty());
283                assert!(!reason.is_empty());
284            }
285            other => panic!("expected ParseError, got {other:?}"),
286        }
287    }
288
289    #[test]
290    fn predicate_non_empty_nodeset_is_true() {
291        let lang = XPathLanguage;
292        let pred = lang.create_predicate("/root/item").unwrap();
293        let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>");
294        assert!(pred.matches(&ex).unwrap());
295    }
296
297    #[test]
298    fn predicate_empty_result_is_false() {
299        let lang = XPathLanguage;
300        let pred = lang.create_predicate("/root/missing").unwrap();
301        let ex = exchange_with_xml("<root><name>test</name></root>");
302        assert!(!pred.matches(&ex).unwrap());
303    }
304
305    #[test]
306    fn predicate_boolean_expression() {
307        let lang = XPathLanguage;
308        let pred = lang.create_predicate("count(/root/item) > 2").unwrap();
309        let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>");
310        assert!(pred.matches(&ex).unwrap());
311    }
312
313    #[test]
314    fn predicate_numeric_comparison_false() {
315        let lang = XPathLanguage;
316        let pred = lang.create_predicate("count(/root/item) > 5").unwrap();
317        let ex = exchange_with_xml("<root><item>a</item></root>");
318        assert!(!pred.matches(&ex).unwrap());
319    }
320}