dd_sds/match_validation/
config_v2.rs

1use serde::{Deserialize, Serialize};
2use std::{
3    collections::BTreeMap,
4    fmt::{self, Display, Formatter},
5    time::Duration,
6};
7
8use crate::{HttpMethod, RuleMatch};
9
10/// Configuration for Online Validation V2
11#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
12pub struct CustomHttpConfigV2 {
13    /// Optional match pairing configuration for validating using matches from multiple rules
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub match_pairing: Option<MatchPairingConfig>,
16
17    /// Array of HTTP calls to attempt. Only one needs to succeed for validation.
18    pub calls: Vec<HttpCallConfig>,
19}
20
21impl CustomHttpConfigV2 {
22    pub fn with_call(mut self, call: HttpCallConfig) -> Self {
23        self.calls.push(call);
24        self
25    }
26}
27
28/// Configuration for pairing matches from multiple rules together
29#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
30pub struct MatchPairingConfig {
31    /// Vendor identifier to match across rules.
32    pub kind: String,
33
34    /// Map of parameter names to template variables
35    #[serde(flatten)]
36    pub parameters: BTreeMap<String, String>,
37}
38
39/// A single HTTP call configuration with request and response validation
40#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
41pub struct HttpCallConfig {
42    pub request: HttpRequestConfig,
43    pub response: HttpResponseConfig,
44}
45
46/// HTTP request configuration with templating support
47#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
48pub struct HttpRequestConfig {
49    /// Endpoint URL with optional template variables
50    /// Example: "https://$CLIENT_SUBDOMAIN.vendor.com/api/0/organizations/$MATCH"
51    pub endpoint: TemplatedMatchString,
52
53    #[serde(default = "default_http_method")]
54    pub method: HttpMethod,
55
56    /// Optional list of templated hosts for multi-datacenter support
57    /// If specified, $HOST in endpoint will be replaced with each host
58    #[serde(default)]
59    pub hosts: Vec<TemplatedMatchString>,
60
61    /// Request headers with template variable support
62    /// Example: {"Authorization": "Basic %base64($CLIENT_ID:$MATCH)"}
63    #[serde(default)]
64    pub headers: BTreeMap<String, TemplatedMatchString>,
65
66    /// Optional request body with template variable support
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub body: Option<TemplatedMatchString>,
69
70    #[serde(default = "default_timeout")]
71    pub timeout: Duration,
72}
73
74fn default_http_method() -> HttpMethod {
75    HttpMethod::Get
76}
77
78fn default_timeout() -> Duration {
79    Duration::from_secs(3)
80}
81
82/// Response validation configuration with multiple condition support
83#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
84pub struct HttpResponseConfig {
85    /// Array of response conditions to check
86    /// Conditions are evaluated sequentially until one matches
87    pub conditions: Vec<ResponseCondition>,
88}
89
90/// A response condition that determines if a secret is valid or invalid
91#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
92pub struct ResponseCondition {
93    /// Whether this condition indicates a valid or invalid secret
94    #[serde(rename = "type")]
95    pub condition_type: ResponseConditionType,
96
97    /// Optional status code matcher
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub status_code: Option<StatusCodeMatcher>,
100
101    /// Optional raw body matcher (before parsing)
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub raw_body: Option<BodyMatcher>,
104
105    /// Optional parsed body matchers (after JSON parsing)
106    /// Maps JSON paths to matchers
107    /// Example: {"message.stack[2].success.status": BodyMatcher}
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub body: Option<BTreeMap<String, BodyMatcher>>,
110}
111
112impl ResponseCondition {
113    pub fn matches(&self, status_code: u16, body: &str) -> ResponseConditionResult {
114        if let Some(status_code_matcher) = self.status_code.as_ref()
115            && status_code_matcher.matches(status_code)
116        {
117            return self.condition_type.clone().into();
118        }
119        if let Some(raw_body_matcher) = self.raw_body.as_ref()
120            && raw_body_matcher.matches(body)
121        {
122            return self.condition_type.clone().into();
123        }
124        ResponseConditionResult::NotChecked
125    }
126}
127
128/// Type of response condition
129#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
130#[serde(rename_all = "lowercase")]
131pub enum ResponseConditionType {
132    Valid,
133    Invalid,
134}
135
136#[derive(Debug, PartialEq)]
137pub enum ResponseConditionResult {
138    Valid,
139    Invalid,
140    NotChecked,
141}
142
143impl From<ResponseConditionType> for ResponseConditionResult {
144    fn from(condition_type: ResponseConditionType) -> Self {
145        match condition_type {
146            ResponseConditionType::Valid => ResponseConditionResult::Valid,
147            ResponseConditionType::Invalid => ResponseConditionResult::Invalid,
148        }
149    }
150}
151
152/// Status code matcher supporting single, list, or range
153#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
154#[serde(untagged)]
155pub enum StatusCodeMatcher {
156    /// Single status code: 200
157    Single(u16),
158
159    /// List of status codes: [401, 403, 404]
160    List(Vec<u16>),
161
162    /// Range of status codes: {"start": 400, "end": 420}
163    Range { start: u16, end: u16 },
164}
165
166impl StatusCodeMatcher {
167    /// Check if a status code matches this matcher
168    pub fn matches(&self, status_code: u16) -> bool {
169        match self {
170            StatusCodeMatcher::Single(code) => status_code == *code,
171            StatusCodeMatcher::List(codes) => codes.contains(&status_code),
172            StatusCodeMatcher::Range { start, end } => status_code >= *start && status_code < *end,
173        }
174    }
175}
176
177/// Body content matcher
178#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
179#[serde(tag = "type", content = "config")]
180pub enum BodyMatcher {
181    /// Check that the body/field is present (not null/undefined)
182    Present,
183
184    /// Check for exact string match
185    ExactMatch(String),
186
187    /// Check if the value matches a regex pattern
188    Regex(String),
189}
190
191impl BodyMatcher {
192    pub fn matches(&self, body: &str) -> bool {
193        match self {
194            BodyMatcher::Present => !body.is_empty(),
195            BodyMatcher::ExactMatch(value) => body == *value,
196            BodyMatcher::Regex(pattern) => regex::Regex::new(pattern).unwrap().is_match(body),
197        }
198    }
199}
200
201/// Secondary validator type for rules that forward their match to a paired validator
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
203pub struct PairedValidatorConfig {
204    /// Vendor identifier to match the main validator
205    pub kind: String,
206
207    /// Name of the parameter this rule provides
208    /// Example: "client_id", "client_subdomain"
209    pub name: String,
210}
211
212#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
213pub struct TemplatedMatchString(pub String);
214
215impl TemplatedMatchString {
216    pub fn with_rule_match(&self, rule_match: &RuleMatch) -> Self {
217        self.render("$MATCH", rule_match.match_value.as_ref().unwrap())
218    }
219
220    pub fn with_host(&self, host: &str) -> Self {
221        self.render("$HOST", host)
222    }
223
224    fn render(&self, tag: &str, value: &str) -> Self {
225        TemplatedMatchString(self.0.replace(tag, value))
226    }
227}
228
229impl Display for TemplatedMatchString {
230    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
231        write!(f, "{}", self.0)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_status_code_matcher_single() {
241        let matcher = StatusCodeMatcher::Single(200);
242        assert!(matcher.matches(200));
243        assert!(!matcher.matches(201));
244        assert!(!matcher.matches(404));
245    }
246
247    #[test]
248    fn test_status_code_matcher_list() {
249        let matcher = StatusCodeMatcher::List(vec![200, 201, 204]);
250        assert!(matcher.matches(200));
251        assert!(matcher.matches(201));
252        assert!(matcher.matches(204));
253        assert!(!matcher.matches(202));
254        assert!(!matcher.matches(404));
255    }
256
257    #[test]
258    fn test_status_code_matcher_range() {
259        let matcher = StatusCodeMatcher::Range {
260            start: 200,
261            end: 300,
262        };
263        assert!(matcher.matches(200));
264        assert!(matcher.matches(250));
265        assert!(matcher.matches(299));
266        assert!(!matcher.matches(199));
267        assert!(!matcher.matches(300));
268        assert!(!matcher.matches(404));
269    }
270
271    #[test]
272    fn test_body_matcher_present() {
273        let matcher = BodyMatcher::Present;
274        assert!(matcher.matches("test"));
275        assert!(!matcher.matches(""));
276    }
277
278    #[test]
279    fn test_body_matcher_exact_match() {
280        let matcher = BodyMatcher::ExactMatch("test".to_string());
281        assert!(matcher.matches("test"));
282        assert!(!matcher.matches("test1"));
283        assert!(!matcher.matches(""));
284    }
285
286    #[test]
287    fn test_body_matcher_regex() {
288        let matcher = BodyMatcher::Regex("test".to_string());
289        assert!(matcher.matches("test"));
290        assert!(matcher.matches("test1"));
291        assert!(!matcher.matches("different"));
292    }
293}