Skip to main content

camel_language_xpath/
lib.rs

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