Skip to main content

rift_types/
predicate.rs

1//! Predicate types for matching requests against stubs.
2//!
3//! These are pure data types (no matching logic) so they can be shared across the
4//! workspace — the proxy for matching, the linter for concrete-type validation.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A single predicate: matcher parameters plus the operation to apply.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct Predicate {
13    #[serde(flatten)]
14    pub parameters: PredicateParameters,
15    #[serde(flatten)]
16    pub operation: PredicateOperation,
17}
18
19/// The matching operation a predicate performs (Mountebank-compatible).
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub enum PredicateOperation {
23    Equals(HashMap<String, serde_json::Value>),
24    DeepEquals(HashMap<String, serde_json::Value>),
25    Contains(HashMap<String, serde_json::Value>),
26    StartsWith(HashMap<String, serde_json::Value>),
27    EndsWith(HashMap<String, serde_json::Value>),
28    Matches(HashMap<String, serde_json::Value>),
29    Exists(HashMap<String, serde_json::Value>),
30    Not(Box<Predicate>),
31    Or(Vec<Predicate>),
32    And(Vec<Predicate>),
33    Inject(String),
34}
35
36/// Matcher parameters shared across operations (case sensitivity, selectors, etc.).
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct PredicateParameters {
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub case_sensitive: Option<bool>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub key_case_sensitive: Option<bool>,
44    #[serde(default, skip_serializing_if = "String::is_empty")]
45    pub except: String,
46    #[serde(flatten)]
47    pub selector: Option<PredicateSelector>,
48}
49
50/// A structured selector applied to the request body before matching.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum PredicateSelector {
54    XPath {
55        selector: String,
56        #[serde(rename = "ns", default, skip_serializing_if = "Option::is_none")]
57        namespaces: Option<HashMap<String, String>>,
58    },
59    JsonPath {
60        selector: String,
61    },
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use serde_json::json;
68
69    #[test]
70    fn deserializes_into_concrete_operation() {
71        // The headline benefit for the linter: match on a typed operation instead of
72        // poking at a serde_json::Value.
73        let pred: Predicate =
74            serde_json::from_value(json!({ "equals": { "path": "/hello" } })).unwrap();
75        match pred.operation {
76            PredicateOperation::Equals(fields) => {
77                assert_eq!(fields.get("path").unwrap(), "/hello");
78            }
79            other => panic!("expected Equals, got {other:?}"),
80        }
81    }
82
83    #[test]
84    fn round_trips_parameters_and_selector() {
85        let value = json!({
86            "equals": { "body": "x" },
87            "caseSensitive": false,
88            "jsonpath": { "selector": "$.id" }
89        });
90        let pred: Predicate = serde_json::from_value(value).unwrap();
91        assert_eq!(pred.parameters.case_sensitive, Some(false));
92        assert!(matches!(
93            pred.parameters.selector,
94            Some(PredicateSelector::JsonPath { .. })
95        ));
96        assert!(matches!(pred.operation, PredicateOperation::Equals(_)));
97
98        // re-serializing keeps the camelCase wire shape
99        let back = serde_json::to_value(&pred).unwrap();
100        assert_eq!(back["caseSensitive"], json!(false));
101        assert!(back["jsonpath"]["selector"] == json!("$.id"));
102    }
103
104    #[test]
105    fn xpath_selector_round_trips_with_namespaces() {
106        // The XPath selector carries the most fragile serde attributes: the enum's
107        // `rename_all = "lowercase"` ("xpath") and `rename = "ns"` on namespaces.
108        let value = json!({
109            "equals": { "body": "x" },
110            "xpath": { "selector": "//a:user", "ns": { "a": "urn:y" } }
111        });
112        let pred: Predicate = serde_json::from_value(value).unwrap();
113        let Some(PredicateSelector::XPath {
114            selector,
115            namespaces,
116        }) = &pred.parameters.selector
117        else {
118            panic!("expected an XPath selector");
119        };
120        assert_eq!(selector, "//a:user");
121        assert_eq!(namespaces.as_ref().unwrap().get("a").unwrap(), "urn:y");
122
123        let back = serde_json::to_value(&pred).unwrap();
124        assert_eq!(back["xpath"]["selector"], json!("//a:user"));
125        assert_eq!(
126            back["xpath"]["ns"]["a"],
127            json!("urn:y"),
128            "the `ns` rename is preserved"
129        );
130    }
131
132    #[test]
133    fn except_and_key_case_sensitive_round_trip() {
134        let pred: Predicate = serde_json::from_value(json!({
135            "matches": { "path": "/x" },
136            "except": "^/skip",
137            "keyCaseSensitive": true
138        }))
139        .unwrap();
140        assert_eq!(pred.parameters.except, "^/skip");
141        assert_eq!(pred.parameters.key_case_sensitive, Some(true));
142        let back = serde_json::to_value(&pred).unwrap();
143        assert_eq!(back["except"], json!("^/skip"));
144        assert_eq!(back["keyCaseSensitive"], json!(true));
145
146        // empty `except` is omitted from the wire form (skip_serializing_if)
147        let empty: Predicate =
148            serde_json::from_value(json!({ "matches": { "path": "/x" } })).unwrap();
149        assert!(
150            serde_json::to_value(&empty)
151                .unwrap()
152                .get("except")
153                .is_none()
154        );
155    }
156
157    #[test]
158    fn nests_logical_operators() {
159        let pred: Predicate = serde_json::from_value(json!({
160            "and": [
161                { "equals": { "method": "GET" } },
162                { "not": { "exists": { "headers": { "X": true } } } }
163            ]
164        }))
165        .unwrap();
166        let PredicateOperation::And(subs) = pred.operation else {
167            panic!("expected And");
168        };
169        assert_eq!(subs.len(), 2);
170        assert!(matches!(subs[1].operation, PredicateOperation::Not(_)));
171    }
172}