Skip to main content

mockforge_core/
conditions.rs

1//! Condition evaluation system for override rules
2//!
3//! This module provides support for conditional application of overrides based on
4//! JSONPath and XPath queries, as well as other conditional expressions.
5
6use jsonpath::Selector;
7use roxmltree::{Document, Node};
8use serde_json::Value;
9use std::collections::HashMap;
10use thiserror::Error;
11use tracing::debug;
12
13/// Errors that can occur during condition evaluation
14#[derive(Debug, Error)]
15pub enum ConditionError {
16    /// JSONPath expression is invalid or cannot be parsed
17    #[error("Invalid JSONPath expression: {0}")]
18    InvalidJsonPath(String),
19
20    /// XPath expression is invalid or cannot be parsed
21    #[error("Invalid XPath expression: {0}")]
22    InvalidXPath(String),
23
24    /// XML document is malformed or cannot be parsed
25    #[error("Invalid XML: {0}")]
26    InvalidXml(String),
27
28    /// Condition type is not supported by the evaluator
29    #[error("Unsupported condition type: {0}")]
30    UnsupportedCondition(String),
31
32    /// General condition evaluation failure with error message
33    #[error("Condition evaluation failed: {0}")]
34    EvaluationFailed(String),
35}
36
37/// Context for evaluating conditions
38#[derive(Debug, Clone)]
39pub struct ConditionContext {
40    /// Request body (JSON)
41    pub request_body: Option<Value>,
42    /// Response body (JSON)
43    pub response_body: Option<Value>,
44    /// Request body as XML string
45    pub request_xml: Option<String>,
46    /// Response body as XML string
47    pub response_xml: Option<String>,
48    /// Request headers
49    pub headers: HashMap<String, String>,
50    /// Query parameters
51    pub query_params: HashMap<String, String>,
52    /// Request path
53    pub path: String,
54    /// HTTP method
55    pub method: String,
56    /// Operation ID
57    pub operation_id: Option<String>,
58    /// Tags
59    pub tags: Vec<String>,
60}
61
62impl Default for ConditionContext {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl ConditionContext {
69    /// Create a new empty condition context
70    pub fn new() -> Self {
71        Self {
72            request_body: None,
73            response_body: None,
74            request_xml: None,
75            response_xml: None,
76            headers: HashMap::new(),
77            query_params: HashMap::new(),
78            path: String::new(),
79            method: String::new(),
80            operation_id: None,
81            tags: Vec::new(),
82        }
83    }
84
85    /// Set the request body as JSON
86    pub fn with_request_body(mut self, body: Value) -> Self {
87        self.request_body = Some(body);
88        self
89    }
90
91    /// Set the response body as JSON
92    pub fn with_response_body(mut self, body: Value) -> Self {
93        self.response_body = Some(body);
94        self
95    }
96
97    /// Set the request body as XML string
98    pub fn with_request_xml(mut self, xml: String) -> Self {
99        self.request_xml = Some(xml);
100        self
101    }
102
103    /// Set the response body as XML string
104    pub fn with_response_xml(mut self, xml: String) -> Self {
105        self.response_xml = Some(xml);
106        self
107    }
108
109    /// Set the request headers
110    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
111        self.headers = headers;
112        self
113    }
114
115    /// Set the query parameters
116    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
117        self.query_params = params;
118        self
119    }
120
121    /// Set the request path
122    pub fn with_path(mut self, path: String) -> Self {
123        self.path = path;
124        self
125    }
126
127    /// Set the HTTP method
128    pub fn with_method(mut self, method: String) -> Self {
129        self.method = method;
130        self
131    }
132
133    /// Set the OpenAPI operation ID
134    pub fn with_operation_id(mut self, operation_id: String) -> Self {
135        self.operation_id = Some(operation_id);
136        self
137    }
138
139    /// Set the OpenAPI tags
140    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
141        self.tags = tags;
142        self
143    }
144}
145
146/// Evaluate a condition expression
147pub fn evaluate_condition(
148    condition: &str,
149    context: &ConditionContext,
150) -> Result<bool, ConditionError> {
151    let condition = condition.trim();
152
153    if condition.is_empty() {
154        return Ok(true); // Empty condition always evaluates to true
155    }
156
157    // Handle logical operators
158    if let Some(and_conditions) = condition.strip_prefix("AND(") {
159        if let Some(inner) = and_conditions.strip_suffix(")") {
160            return evaluate_and_condition(inner, context);
161        }
162    }
163
164    if let Some(or_conditions) = condition.strip_prefix("OR(") {
165        if let Some(inner) = or_conditions.strip_suffix(")") {
166            return evaluate_or_condition(inner, context);
167        }
168    }
169
170    if let Some(not_condition) = condition.strip_prefix("NOT(") {
171        if let Some(inner) = not_condition.strip_suffix(")") {
172            return evaluate_not_condition(inner, context);
173        }
174    }
175
176    // Handle JSONPath queries
177    if condition.starts_with("$.") || condition.starts_with("$[") {
178        return evaluate_jsonpath(condition, context);
179    }
180
181    // Handle XPath queries
182    if condition.starts_with("/") || condition.starts_with("//") {
183        return evaluate_xpath(condition, context);
184    }
185
186    // Handle simple comparisons
187    evaluate_simple_condition(condition, context)
188}
189
190/// Evaluate AND condition with multiple sub-conditions
191fn evaluate_and_condition(
192    conditions: &str,
193    context: &ConditionContext,
194) -> Result<bool, ConditionError> {
195    let parts: Vec<&str> = conditions.split(',').map(|s| s.trim()).collect();
196
197    for part in parts {
198        if !evaluate_condition(part, context)? {
199            return Ok(false);
200        }
201    }
202
203    Ok(true)
204}
205
206/// Evaluate OR condition with multiple sub-conditions
207fn evaluate_or_condition(
208    conditions: &str,
209    context: &ConditionContext,
210) -> Result<bool, ConditionError> {
211    let parts: Vec<&str> = conditions.split(',').map(|s| s.trim()).collect();
212
213    for part in parts {
214        if evaluate_condition(part, context)? {
215            return Ok(true);
216        }
217    }
218
219    Ok(false)
220}
221
222/// Evaluate NOT condition
223fn evaluate_not_condition(
224    condition: &str,
225    context: &ConditionContext,
226) -> Result<bool, ConditionError> {
227    Ok(!evaluate_condition(condition, context)?)
228}
229
230/// Evaluate JSONPath query
231fn evaluate_jsonpath(query: &str, context: &ConditionContext) -> Result<bool, ConditionError> {
232    // Check if this is a comparison expression (e.g., $.user.role == 'admin')
233    // Supported operators: ==, !=
234    let (jsonpath_expr, comparison_op, expected_value) =
235        if let Some((path, value)) = query.split_once("==") {
236            let path = path.trim();
237            let value = value.trim().trim_matches('\'').trim_matches('"');
238            (path, Some("=="), Some(value))
239        } else if let Some((path, value)) = query.split_once("!=") {
240            let path = path.trim();
241            let value = value.trim().trim_matches('\'').trim_matches('"');
242            (path, Some("!="), Some(value))
243        } else {
244            (query, None, None)
245        };
246
247    // Determine if this is a request or response query
248    let (_is_request, json_value) = if jsonpath_expr.starts_with("$.request.") {
249        let _query = jsonpath_expr.replace("$.request.", "$.");
250        (true, &context.request_body)
251    } else if jsonpath_expr.starts_with("$.response.") {
252        let _query = jsonpath_expr.replace("$.response.", "$.");
253        (false, &context.response_body)
254    } else {
255        // Default to response body if available, otherwise request body
256        if context.response_body.is_some() {
257            (false, &context.response_body)
258        } else {
259            (true, &context.request_body)
260        }
261    };
262
263    let Some(json_value) = json_value else {
264        return Ok(false); // No body to query
265    };
266
267    match Selector::new(jsonpath_expr) {
268        Ok(selector) => {
269            let results: Vec<_> = selector.find(json_value).collect();
270
271            // If there's a comparison, check the value
272            if let (Some(op), Some(expected)) = (comparison_op, expected_value) {
273                if results.is_empty() {
274                    return Ok(false);
275                }
276
277                // Compare the first result with the expected value
278                let actual_value = match &results[0] {
279                    Value::String(s) => s.as_str(),
280                    Value::Number(n) => {
281                        return Ok(match op {
282                            "==" => n.to_string() == expected,
283                            "!=" => n.to_string() != expected,
284                            _ => false,
285                        })
286                    }
287                    Value::Bool(b) => {
288                        return Ok(match op {
289                            "==" => b.to_string() == expected,
290                            "!=" => b.to_string() != expected,
291                            _ => false,
292                        })
293                    }
294                    Value::Null => {
295                        return Ok(match op {
296                            "==" => expected == "null",
297                            "!=" => expected != "null",
298                            _ => false,
299                        })
300                    }
301                    _ => return Ok(false),
302                };
303
304                return Ok(match op {
305                    "==" => actual_value == expected,
306                    "!=" => actual_value != expected,
307                    _ => false,
308                });
309            }
310
311            // No comparison, just check if results exist
312            Ok(!results.is_empty())
313        }
314        Err(_) => Err(ConditionError::InvalidJsonPath(jsonpath_expr.to_string())),
315    }
316}
317
318/// Evaluate XPath query
319fn evaluate_xpath(query: &str, context: &ConditionContext) -> Result<bool, ConditionError> {
320    // Determine if this is a request or response query
321    let (_is_request, xml_content) = if query.starts_with("/request/") {
322        let _query = query.replace("/request/", "/");
323        (true, &context.request_xml)
324    } else if query.starts_with("/response/") {
325        let _query = query.replace("/response/", "/");
326        (false, &context.response_xml)
327    } else {
328        // Default to response XML if not specified
329        (false, &context.response_xml)
330    };
331
332    let Some(xml_content) = xml_content else {
333        debug!("No XML content available for query: {}", query);
334        return Ok(false); // No XML content to query
335    };
336
337    debug!("Evaluating XPath '{}' against XML content: {}", query, xml_content);
338
339    match Document::parse(xml_content) {
340        Ok(doc) => {
341            // Simple XPath evaluation - check if any nodes match
342            let root = doc.root_element();
343            debug!("XML root element: {}", root.tag_name().name());
344            let matches = evaluate_xpath_simple(&root, query);
345            debug!("XPath result: {}", matches);
346            Ok(matches)
347        }
348        Err(e) => {
349            debug!("Failed to parse XML: {}", e);
350            Err(ConditionError::InvalidXml(xml_content.clone()))
351        }
352    }
353}
354
355/// Simple XPath evaluator (basic implementation)
356fn evaluate_xpath_simple(node: &Node, xpath: &str) -> bool {
357    // This is a simplified XPath implementation
358    // For production use, consider a more complete XPath library
359
360    // Handle descendant-or-self axis: //element (check this FIRST before stripping //)
361    if let Some(element_name) = xpath.strip_prefix("//") {
362        debug!(
363            "Checking descendant-or-self for element '{}' on node '{}'",
364            element_name,
365            node.tag_name().name()
366        );
367        if node.tag_name().name() == element_name {
368            debug!("Found match: {} == {}", node.tag_name().name(), element_name);
369            return true;
370        }
371        // Check descendants
372        for child in node.children() {
373            if child.is_element() {
374                debug!("Checking child element: {}", child.tag_name().name());
375                if evaluate_xpath_simple(&child, &format!("//{}", element_name)) {
376                    return true;
377                }
378            }
379        }
380        return false; // If no descendant found, return false
381    }
382
383    let xpath = xpath.trim_start_matches('/');
384
385    if xpath.is_empty() {
386        return true;
387    }
388
389    // Handle attribute queries: element[@attribute='value']
390    if let Some((element_part, attr_part)) = xpath.split_once('[') {
391        if let Some(attr_query) = attr_part.strip_suffix(']') {
392            if let Some((attr_name, attr_value)) = attr_query.split_once("='") {
393                if let Some(expected_value) = attr_value.strip_suffix('\'') {
394                    if let Some(attr_val) = attr_name.strip_prefix('@') {
395                        if node.tag_name().name() == element_part {
396                            if let Some(attr) = node.attribute(attr_val) {
397                                return attr == expected_value;
398                            }
399                        }
400                    }
401                }
402            }
403        }
404        return false;
405    }
406
407    // Handle element name matching with optional predicates
408    if let Some((element_name, rest)) = xpath.split_once('/') {
409        if node.tag_name().name() == element_name {
410            if rest.is_empty() {
411                return true;
412            }
413            // Check child elements recursively
414            for child in node.children() {
415                if child.is_element() && evaluate_xpath_simple(&child, rest) {
416                    return true;
417                }
418            }
419        }
420    } else if node.tag_name().name() == xpath {
421        return true;
422    }
423
424    // Handle text content queries: element/text()
425    if let Some(text_query) = xpath.strip_suffix("/text()") {
426        if node.tag_name().name() == text_query {
427            return node.text().is_some_and(|t| !t.trim().is_empty());
428        }
429    }
430
431    false
432}
433
434/// Evaluate simple conditions like header checks, query param checks, etc.
435fn evaluate_simple_condition(
436    condition: &str,
437    context: &ConditionContext,
438) -> Result<bool, ConditionError> {
439    // Handle header conditions: header[name]=value or header[name]!=value
440    if let Some(header_condition) = condition.strip_prefix("header[") {
441        if let Some((header_name, rest)) = header_condition.split_once("]") {
442            // Headers are stored in lowercase in the context
443            let header_name_lower = header_name.to_lowercase();
444            let rest_trimmed = rest.trim();
445            // Check for != operator (with optional whitespace)
446            if let Some(expected_value) = rest_trimmed.strip_prefix("!=") {
447                let expected_value = expected_value.trim().trim_matches('\'').trim_matches('"');
448                if let Some(actual_value) = context.headers.get(&header_name_lower) {
449                    // Header exists: return true if actual value != expected value
450                    return Ok(actual_value != expected_value);
451                }
452                // Header doesn't exist: treat as empty string for comparison
453                // For != '' check, return false because missing header is treated as empty
454                return Ok(!expected_value.is_empty());
455            }
456            // Check for = operator (with optional whitespace)
457            if let Some(expected_value) = rest_trimmed.strip_prefix("=") {
458                let expected_value = expected_value.trim().trim_matches('\'').trim_matches('"');
459                if let Some(actual_value) = context.headers.get(&header_name_lower) {
460                    return Ok(actual_value == expected_value);
461                }
462                return Ok(false);
463            }
464        }
465    }
466
467    // Handle query parameter conditions: query[name]=value or query[name]==value
468    if let Some(query_condition) = condition.strip_prefix("query[") {
469        if let Some((param_name, rest)) = query_condition.split_once("]") {
470            let rest_trimmed = rest.trim();
471            // Check for == operator (with optional whitespace and quotes)
472            if let Some(expected_value) = rest_trimmed.strip_prefix("==") {
473                let expected_value = expected_value.trim().trim_matches('\'').trim_matches('"');
474                if let Some(actual_value) = context.query_params.get(param_name) {
475                    return Ok(actual_value == expected_value);
476                }
477                return Ok(false);
478            }
479            // Check for = operator (with optional whitespace)
480            if let Some(expected_value) = rest_trimmed.strip_prefix("=") {
481                let expected_value = expected_value.trim();
482                if let Some(actual_value) = context.query_params.get(param_name) {
483                    return Ok(actual_value == expected_value);
484                }
485                return Ok(false);
486            }
487        }
488    }
489
490    // Handle method conditions: method=POST
491    if let Some(method_condition) = condition.strip_prefix("method=") {
492        return Ok(context.method == method_condition);
493    }
494
495    // Handle path conditions: path=/api/users
496    if let Some(path_condition) = condition.strip_prefix("path=") {
497        return Ok(context.path == path_condition);
498    }
499
500    // Handle tag conditions: has_tag[admin]
501    if let Some(tag_condition) = condition.strip_prefix("has_tag[") {
502        if let Some(tag) = tag_condition.strip_suffix("]") {
503            return Ok(context.tags.contains(&tag.to_string()));
504        }
505    }
506
507    // Handle operation conditions: operation=getUser
508    if let Some(op_condition) = condition.strip_prefix("operation=") {
509        if let Some(operation_id) = &context.operation_id {
510            return Ok(operation_id == op_condition);
511        }
512        return Ok(false);
513    }
514
515    Err(ConditionError::UnsupportedCondition(condition.to_string()))
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use serde_json::json;
522
523    #[test]
524    fn test_jsonpath_condition() {
525        let context = ConditionContext::new().with_response_body(json!({
526            "user": {
527                "name": "John",
528                "role": "admin"
529            },
530            "items": [1, 2, 3]
531        }));
532
533        // Test simple path existence
534        assert!(evaluate_condition("$.user", &context).unwrap());
535
536        // Test specific value matching
537        assert!(evaluate_condition("$.user.role", &context).unwrap());
538
539        // Test array access
540        assert!(evaluate_condition("$.items[0]", &context).unwrap());
541
542        // Test non-existent path
543        assert!(!evaluate_condition("$.nonexistent", &context).unwrap());
544    }
545
546    #[test]
547    fn test_simple_conditions() {
548        let mut headers = HashMap::new();
549        headers.insert("authorization".to_string(), "Bearer token123".to_string());
550
551        let mut query_params = HashMap::new();
552        query_params.insert("limit".to_string(), "10".to_string());
553
554        let context = ConditionContext::new()
555            .with_headers(headers)
556            .with_query_params(query_params)
557            .with_method("POST".to_string())
558            .with_path("/api/users".to_string());
559
560        // Test header condition
561        assert!(evaluate_condition("header[authorization]=Bearer token123", &context).unwrap());
562        assert!(!evaluate_condition("header[authorization]=Bearer wrong", &context).unwrap());
563
564        // Test query parameter condition
565        assert!(evaluate_condition("query[limit]=10", &context).unwrap());
566        assert!(!evaluate_condition("query[limit]=20", &context).unwrap());
567
568        // Test method condition
569        assert!(evaluate_condition("method=POST", &context).unwrap());
570        assert!(!evaluate_condition("method=GET", &context).unwrap());
571
572        // Test path condition
573        assert!(evaluate_condition("path=/api/users", &context).unwrap());
574        assert!(!evaluate_condition("path=/api/posts", &context).unwrap());
575    }
576
577    #[test]
578    fn test_logical_conditions() {
579        let context = ConditionContext::new()
580            .with_method("POST".to_string())
581            .with_path("/api/users".to_string());
582
583        // Test AND condition
584        assert!(evaluate_condition("AND(method=POST,path=/api/users)", &context).unwrap());
585        assert!(!evaluate_condition("AND(method=GET,path=/api/users)", &context).unwrap());
586
587        // Test OR condition
588        assert!(evaluate_condition("OR(method=POST,path=/api/posts)", &context).unwrap());
589        assert!(!evaluate_condition("OR(method=GET,path=/api/posts)", &context).unwrap());
590
591        // Test NOT condition
592        assert!(!evaluate_condition("NOT(method=POST)", &context).unwrap());
593        assert!(evaluate_condition("NOT(method=GET)", &context).unwrap());
594    }
595
596    #[test]
597    fn test_xpath_condition() {
598        let xml_content = r#"
599            <user id="123">
600                <name>John Doe</name>
601                <role>admin</role>
602                <preferences>
603                    <theme>dark</theme>
604                    <notifications>true</notifications>
605                </preferences>
606            </user>
607        "#;
608
609        let context = ConditionContext::new().with_response_xml(xml_content.to_string());
610
611        // Test basic element existence
612        assert!(evaluate_condition("/user", &context).unwrap());
613
614        // Test nested element
615        assert!(evaluate_condition("/user/name", &context).unwrap());
616
617        // Test attribute query
618        assert!(evaluate_condition("/user[@id='123']", &context).unwrap());
619        assert!(!evaluate_condition("/user[@id='456']", &context).unwrap());
620
621        // Test text content
622        assert!(evaluate_condition("/user/name/text()", &context).unwrap());
623
624        // Test descendant axis
625        assert!(evaluate_condition("//theme", &context).unwrap());
626
627        // Test non-existent element
628        assert!(!evaluate_condition("/nonexistent", &context).unwrap());
629    }
630}