camel_language_xpath/
lib.rs1#![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 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 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}