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