1#![doc = include_str!("../README.md")]
2
3use async_trait::async_trait;
4use camel_language_api::{Body, Exchange, Value};
5use camel_language_api::{Expression, Language, LanguageError, Predicate};
6use serde_json::Value as JsonValue;
7use sxd_document::parser;
8use sxd_xpath::{Context, Factory, Value as SxdValue};
9use tracing::{debug, warn};
10
11#[derive(Debug, Clone)]
27pub struct XPathConfig {
28 pub max_input_bytes: Option<usize>,
30}
31
32impl Default for XPathConfig {
33 fn default() -> Self {
34 Self {
35 max_input_bytes: Some(1_048_576), }
37 }
38}
39
40pub struct XPathLanguage {
41 config: XPathConfig,
42}
43
44struct XPathExpression {
45 xpath: sxd_xpath::XPath,
46 config: XPathConfig,
47}
48
49struct XPathPredicate {
50 xpath: sxd_xpath::XPath,
51 config: XPathConfig,
52}
53
54unsafe impl Send for XPathExpression {}
65unsafe impl Sync for XPathExpression {}
66unsafe impl Send for XPathPredicate {}
67unsafe impl Sync for XPathPredicate {}
68
69fn extract_xml(exchange: &Exchange) -> Result<String, LanguageError> {
70 match &exchange.input.body {
71 Body::Xml(s) => Ok(s.clone()),
72 other => other
73 .clone()
74 .try_into_xml()
75 .map_err(|e| {
76 LanguageError::EvalError(format!("body is not XML and cannot be coerced: {e}"))
77 })
78 .and_then(|b| match b {
79 Body::Xml(s) => Ok(s),
80 _ => Err(LanguageError::EvalError(
81 "body coercion did not produce XML".into(),
82 )),
83 }),
84 }
85}
86
87#[cfg(test)]
88thread_local! {
89 static COMPILE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
90}
91
92fn compile_xpath(query: &str) -> Result<sxd_xpath::XPath, LanguageError> {
93 #[cfg(test)]
94 {
95 COMPILE_COUNT.with(|c| c.set(c.get() + 1));
96 }
97 let factory = Factory::new();
98 factory
99 .build(query)
100 .map_err(|e| {
101 warn!(error = %e, "xpath expression compile failed");
102 LanguageError::ParseError {
103 expr: query.to_string(),
104 reason: e.to_string(),
105 }
106 })
107 .and_then(|opt| {
108 opt.ok_or_else(|| {
109 warn!("xpath expression compile failed");
110 LanguageError::ParseError {
111 expr: query.to_string(),
112 reason: "empty XPath expression".into(),
113 }
114 })
115 })
116}
117
118fn run_query(
119 xpath: &sxd_xpath::XPath,
120 xml: &str,
121 config: &XPathConfig,
122) -> Result<JsonValue, LanguageError> {
123 if let Some(max) = config.max_input_bytes
124 && xml.len() > max
125 {
126 return Err(LanguageError::EvalError(
127 "input exceeds maximum allowed size".into(),
128 ));
129 }
130 let package = parser::parse(xml).map_err(|_| {
131 warn!("xpath: body XML could not be parsed");
135 LanguageError::EvalError("xml parse error: body is not valid XML".to_string())
136 })?;
137 let doc = package.as_document();
138 let context = Context::new();
142 let result = xpath.evaluate(&context, doc.root()).map_err(|_| {
143 warn!("xpath: expression evaluation failed");
147 LanguageError::EvalError(
148 "xpath query failed: expression could not be evaluated".to_string(),
149 )
150 })?;
151
152 Ok(match result {
153 SxdValue::Nodeset(ns) => {
154 let nodes: Vec<_> = ns.document_order();
155 match nodes.len() {
156 0 => JsonValue::Null,
157 1 => JsonValue::String(nodes[0].string_value()),
158 _ => JsonValue::Array(
159 nodes
160 .into_iter()
161 .map(|n| JsonValue::String(n.string_value()))
162 .collect(),
163 ),
164 }
165 }
166 SxdValue::Boolean(b) => JsonValue::Bool(b),
167 SxdValue::Number(n) => serde_json::Number::from_f64(n)
168 .map(JsonValue::Number)
169 .unwrap_or(JsonValue::Null),
170 SxdValue::String(s) => JsonValue::String(s),
171 })
172}
173
174#[async_trait]
175impl Expression for XPathExpression {
176 async fn evaluate(&self, exchange: &Exchange) -> Result<Value, LanguageError> {
177 let xml = extract_xml(exchange)?;
178 run_query(&self.xpath, &xml, &self.config)
179 }
180}
181
182#[async_trait]
183impl Predicate for XPathPredicate {
184 async fn matches(&self, exchange: &Exchange) -> Result<bool, LanguageError> {
185 let xml = extract_xml(exchange)?;
186 let result = run_query(&self.xpath, &xml, &self.config)?;
187 Ok(match &result {
188 JsonValue::Null => false,
189 JsonValue::Bool(b) => *b,
190 JsonValue::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
191 JsonValue::String(s) => !s.is_empty(),
192 JsonValue::Array(arr) => !arr.is_empty(),
193 _ => true,
194 })
195 }
196}
197
198impl Default for XPathLanguage {
199 fn default() -> Self {
200 Self::new()
201 }
202}
203
204impl XPathLanguage {
205 pub fn new() -> Self {
207 Self::with_config(XPathConfig::default())
208 }
209
210 pub fn with_config(config: XPathConfig) -> Self {
212 Self { config }
213 }
214}
215
216impl Language for XPathLanguage {
217 fn name(&self) -> &'static str {
218 "xpath"
219 }
220
221 fn create_expression(&self, script: &str) -> Result<Box<dyn Expression>, LanguageError> {
222 let xpath = compile_xpath(script)?;
223 debug!("xpath expression compiled");
224 Ok(Box::new(XPathExpression {
225 xpath,
226 config: self.config.clone(),
227 }))
228 }
229
230 fn create_predicate(&self, script: &str) -> Result<Box<dyn Predicate>, LanguageError> {
231 let xpath = compile_xpath(script)?;
232 debug!("xpath expression compiled");
233 Ok(Box::new(XPathPredicate {
234 xpath,
235 config: self.config.clone(),
236 }))
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use camel_language_api::Message;
244 use std::cell::Cell;
245
246 fn reset_compile_count() -> usize {
248 COMPILE_COUNT.with(|c| c.replace(0))
249 }
250
251 fn read_compile_count() -> usize {
253 COMPILE_COUNT.with(Cell::get)
254 }
255
256 async fn exchange_with_xml(xml: &str) -> Exchange {
257 Exchange::new(Message::new(Body::Xml(xml.to_string())))
258 }
259
260 async fn exchange_with_text_body(text: &str) -> Exchange {
261 Exchange::new(Message::new(Body::Text(text.to_string())))
262 }
263
264 async fn empty_exchange() -> Exchange {
265 Exchange::new(Message::default())
266 }
267
268 #[tokio::test]
269 async fn expression_simple_path() {
270 let lang = XPathLanguage::new();
271 let expr = lang.create_expression("/root/name").unwrap();
272 let ex = exchange_with_xml("<root><name>books</name></root>").await;
273 let result = expr.evaluate(&ex).await.unwrap();
274 assert_eq!(result, JsonValue::String("books".to_string()));
275 }
276
277 #[tokio::test]
278 async fn expression_nested_path() {
279 let lang = XPathLanguage::new();
280 let expr = lang.create_expression("/root/inner/value").unwrap();
281 let ex = exchange_with_xml("<root><inner><value>42</value></inner></root>").await;
282 let result = expr.evaluate(&ex).await.unwrap();
283 assert_eq!(result, JsonValue::String("42".to_string()));
284 }
285
286 #[tokio::test]
287 async fn expression_attribute_access() {
288 let lang = XPathLanguage::new();
289 let expr = lang.create_expression("/root/item/@id").unwrap();
290 let ex = exchange_with_xml("<root><item id=\"123\"/></root>").await;
291 let result = expr.evaluate(&ex).await.unwrap();
292 assert_eq!(result, JsonValue::String("123".to_string()));
293 }
294
295 #[tokio::test]
296 async fn expression_text_function() {
297 let lang = XPathLanguage::new();
298 let expr = lang.create_expression("/root/name/text()").unwrap();
299 let ex = exchange_with_xml("<root><name>hello</name></root>").await;
300 let result = expr.evaluate(&ex).await.unwrap();
301 assert_eq!(result, JsonValue::String("hello".to_string()));
302 }
303
304 #[tokio::test]
305 async fn expression_wildcard() {
306 let lang = XPathLanguage::new();
307 let expr = lang.create_expression("/root/item").unwrap();
308 let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
309 let result = expr.evaluate(&ex).await.unwrap();
310 assert_eq!(
311 result,
312 JsonValue::Array(vec![
313 JsonValue::String("a".to_string()),
314 JsonValue::String("b".to_string()),
315 ])
316 );
317 }
318
319 #[tokio::test]
320 async fn expression_predicate_position() {
321 let lang = XPathLanguage::new();
322 let expr = lang.create_expression("/root/item[2]").unwrap();
323 let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>").await;
324 let result = expr.evaluate(&ex).await.unwrap();
325 assert_eq!(result, JsonValue::String("b".to_string()));
326 }
327
328 #[tokio::test]
329 async fn expression_count_function() {
330 let lang = XPathLanguage::new();
331 let expr = lang.create_expression("count(/root/item)").unwrap();
332 let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
333 let result = expr.evaluate(&ex).await.unwrap();
334 assert_eq!(
335 result,
336 JsonValue::Number(serde_json::Number::from_f64(2.0).unwrap())
337 );
338 }
339
340 #[tokio::test]
341 async fn expression_text_body_with_valid_xml() {
342 let lang = XPathLanguage::new();
343 let expr = lang.create_expression("/root/value").unwrap();
344 let ex = exchange_with_text_body("<root><value>test</value></root>").await;
345 let result = expr.evaluate(&ex).await.unwrap();
346 assert_eq!(result, JsonValue::String("test".to_string()));
347 }
348
349 #[tokio::test]
350 async fn expression_text_body_with_invalid_xml() {
351 let lang = XPathLanguage::new();
352 let expr = lang.create_expression("/root").unwrap();
353 let ex = exchange_with_text_body("not xml at all").await;
354 let result = expr.evaluate(&ex).await;
355 assert!(result.is_err());
356 }
357
358 #[tokio::test]
359 async fn expression_empty_body_is_error() {
360 let lang = XPathLanguage::new();
361 let expr = lang.create_expression("/root").unwrap();
362 let ex = empty_exchange().await;
363 let result = expr.evaluate(&ex).await;
364 assert!(result.is_err());
365 }
366
367 #[tokio::test]
368 async fn expression_empty_result_is_null() {
369 let lang = XPathLanguage::new();
370 let expr = lang.create_expression("/root/missing").unwrap();
371 let ex = exchange_with_xml("<root><name>test</name></root>").await;
372 let result = expr.evaluate(&ex).await.unwrap();
373 assert_eq!(result, JsonValue::Null);
374 }
375
376 #[tokio::test]
377 async fn expression_invalid_xpath_syntax() {
378 let lang = XPathLanguage::new();
379 let result = lang.create_expression("//[invalid");
380 let err = match result {
381 Err(e) => e,
382 Ok(_) => panic!("expected ParseError"),
383 };
384 match err {
385 LanguageError::ParseError { expr, reason } => {
386 assert!(!expr.is_empty());
387 assert!(!reason.is_empty());
388 }
389 other => panic!("expected ParseError, got {other:?}"),
390 }
391 }
392
393 #[tokio::test]
394 async fn predicate_non_empty_nodeset_is_true() {
395 let lang = XPathLanguage::new();
396 let pred = lang.create_predicate("/root/item").unwrap();
397 let ex = exchange_with_xml("<root><item>a</item><item>b</item></root>").await;
398 assert!(pred.matches(&ex).await.unwrap());
399 }
400
401 #[tokio::test]
402 async fn predicate_empty_result_is_false() {
403 let lang = XPathLanguage::new();
404 let pred = lang.create_predicate("/root/missing").unwrap();
405 let ex = exchange_with_xml("<root><name>test</name></root>").await;
406 assert!(!pred.matches(&ex).await.unwrap());
407 }
408
409 #[tokio::test]
410 async fn predicate_boolean_expression() {
411 let lang = XPathLanguage::new();
412 let pred = lang.create_predicate("count(/root/item) > 2").unwrap();
413 let ex = exchange_with_xml("<root><item>a</item><item>b</item><item>c</item></root>").await;
414 assert!(pred.matches(&ex).await.unwrap());
415 }
416
417 #[tokio::test]
418 async fn predicate_numeric_comparison_false() {
419 let lang = XPathLanguage::new();
420 let pred = lang.create_predicate("count(/root/item) > 5").unwrap();
421 let ex = exchange_with_xml("<root><item>a</item></root>").await;
422 assert!(!pred.matches(&ex).await.unwrap());
423 }
424
425 #[tokio::test]
426 async fn expression_rejects_oversized_input() {
427 let lang = XPathLanguage::with_config(XPathConfig {
428 max_input_bytes: Some(100),
429 });
430 let expr = lang.create_expression("/root").unwrap();
431 let big_xml = format!("<root>{}</root>", "x".repeat(200));
432 let ex = exchange_with_xml(&big_xml).await;
433 let result = expr.evaluate(&ex).await;
434 assert!(result.is_err());
435 match result.unwrap_err() {
436 LanguageError::EvalError(msg) => {
437 assert!(msg.contains("input exceeds maximum allowed size"));
438 }
439 other => panic!("expected EvalError, got {other:?}"),
440 }
441 }
442
443 #[tokio::test]
444 async fn predicate_rejects_oversized_input() {
445 let lang = XPathLanguage::with_config(XPathConfig {
446 max_input_bytes: Some(100),
447 });
448 let pred = lang.create_predicate("/root").unwrap();
449 let big_xml = format!("<root>{}</root>", "x".repeat(200));
450 let ex = exchange_with_xml(&big_xml).await;
451 let result = pred.matches(&ex).await;
452 assert!(result.is_err());
453 }
454
455 #[tokio::test]
461 async fn expression_compiles_only_once() {
462 let lang = XPathLanguage::new();
463 let _ = reset_compile_count();
464 let expr = lang.create_expression("/root/item").unwrap();
465 assert_eq!(
466 read_compile_count(),
467 1,
468 "create_expression should compile exactly once (got {})",
469 read_compile_count()
470 );
471
472 for i in 0..5 {
473 let ex = exchange_with_xml("<root><item>a</item></root>").await;
474 let _ = expr.evaluate(&ex).await.unwrap();
475 assert_eq!(
476 read_compile_count(),
477 1,
478 "evaluate call #{i} must not trigger recompilation (got {})",
479 read_compile_count()
480 );
481 }
482 }
483
484 #[tokio::test]
486 async fn predicate_compiles_only_once() {
487 let lang = XPathLanguage::new();
488 let _ = reset_compile_count();
489 let pred = lang.create_predicate("/root/item").unwrap();
490 assert_eq!(
491 read_compile_count(),
492 1,
493 "create_predicate should compile exactly once (got {})",
494 read_compile_count()
495 );
496
497 for i in 0..5 {
498 let ex = exchange_with_xml("<root><item>a</item></root>").await;
499 let _ = pred.matches(&ex).await.unwrap();
500 assert_eq!(
501 read_compile_count(),
502 1,
503 "matches call #{i} must not trigger recompilation (got {})",
504 read_compile_count()
505 );
506 }
507 }
508}