1use serde::{Deserialize, Serialize};
2use std::{
3 collections::BTreeMap,
4 fmt::{self, Display, Formatter},
5 time::Duration,
6};
7
8use crate::HttpMethod;
9
10#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
12pub struct CustomHttpConfigV2 {
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub match_pairing: Option<MatchPairingConfig>,
16
17 #[serde(skip_serializing_if = "Option::is_none")]
21 pub provides: Option<Vec<PairedValidatorConfig>>,
22
23 #[serde(default)]
25 pub calls: Vec<HttpCallConfig>,
26}
27
28impl CustomHttpConfigV2 {
29 pub fn with_call(mut self, call: HttpCallConfig) -> Self {
30 self.calls.push(call);
31 self
32 }
33}
34
35#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
37pub struct MatchPairingConfig {
38 pub kind: String,
40
41 #[serde(flatten)]
43 pub parameters: BTreeMap<String, String>,
44}
45
46impl MatchPairingConfig {
47 pub fn is_fulfilled_by(&self, template_variables: &[TemplateVariable]) -> bool {
48 self.parameters.iter().all(|(_name, template_name)| {
49 template_variables.iter().any(|v| v.name == *template_name)
50 })
51 }
52}
53
54#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
56pub struct HttpCallConfig {
57 pub request: HttpRequestConfig,
58 pub response: HttpResponseConfig,
59}
60
61#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
63pub struct HttpRequestConfig {
64 pub endpoint: TemplatedMatchString,
67
68 pub method: HttpMethod,
69
70 #[serde(default)]
73 pub hosts: Vec<TemplatedMatchString>,
74
75 #[serde(default)]
78 pub headers: BTreeMap<String, TemplatedMatchString>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub body: Option<TemplatedMatchString>,
83
84 #[serde(default = "default_timeout")]
85 pub timeout: Duration,
86}
87
88fn default_timeout() -> Duration {
89 Duration::from_secs(3)
90}
91
92#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
94pub struct HttpResponseConfig {
95 pub conditions: Vec<ResponseCondition>,
98}
99
100#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
102pub struct ResponseCondition {
103 #[serde(rename = "type")]
105 pub condition_type: ResponseConditionType,
106
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub status_code: Option<StatusCodeMatcher>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub raw_body: Option<BodyMatcher>,
114
115 #[serde(skip_serializing_if = "Option::is_none")]
119 pub body: Option<BTreeMap<String, BodyMatcher>>,
120}
121
122impl ResponseCondition {
123 pub fn matches(&self, status_code: u16, body: &str) -> ResponseConditionResult {
138 if let Some(status_code_matcher) = self.status_code.as_ref()
139 && !status_code_matcher.matches(status_code)
140 {
141 ResponseConditionResult::NotChecked
142 } else if let Some(raw_body_matcher) = self.raw_body.as_ref()
143 && !raw_body_matcher.matches(body)
144 {
145 ResponseConditionResult::NotChecked
146 } else if let Some(body_matcher) = self.body.as_ref()
147 && !matches_body(body_matcher, body)
148 {
149 ResponseConditionResult::NotChecked
150 } else {
151 self.condition_type.into()
152 }
153 }
154}
155
156fn matches_body(body_matcher: &BTreeMap<String, BodyMatcher>, body: &str) -> bool {
157 let parsed_body: serde_json::Value = match serde_json::from_str(body) {
158 Ok(value) => value,
159 Err(_) => return false,
160 };
161 for (path, matcher) in body_matcher.iter() {
162 let parts = path.split('.');
163 let mut value = &parsed_body;
164 for part in parts {
165 value = match value.get(part) {
166 Some(value) => value,
167 None => return false,
168 };
169 }
170 let value_str = match value {
171 serde_json::Value::String(s) => s.clone(),
172 other => other.to_string(),
173 };
174 if matcher.matches(&value_str) {
175 return true;
176 }
177 }
178 false
179}
180
181#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash, Copy)]
183#[serde(rename_all = "lowercase")]
184pub enum ResponseConditionType {
185 Valid,
186 Invalid,
187}
188
189#[derive(Debug, PartialEq)]
190pub enum ResponseConditionResult {
191 Valid,
192 Invalid,
193 NotChecked,
194}
195
196impl From<ResponseConditionType> for ResponseConditionResult {
197 fn from(condition_type: ResponseConditionType) -> Self {
198 match condition_type {
199 ResponseConditionType::Valid => ResponseConditionResult::Valid,
200 ResponseConditionType::Invalid => ResponseConditionResult::Invalid,
201 }
202 }
203}
204
205#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
207#[serde(untagged)]
208pub enum StatusCodeMatcher {
209 Single(u16),
211
212 List(Vec<u16>),
214
215 Range { start: u16, end: u16 },
217}
218
219impl StatusCodeMatcher {
220 pub fn matches(&self, status_code: u16) -> bool {
222 match self {
223 StatusCodeMatcher::Single(code) => status_code == *code,
224 StatusCodeMatcher::List(codes) => codes.contains(&status_code),
225 StatusCodeMatcher::Range { start, end } => status_code >= *start && status_code < *end,
226 }
227 }
228}
229
230#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
232#[serde(tag = "type", content = "config")]
233pub enum BodyMatcher {
234 Present,
236
237 ExactMatch(String),
239
240 Regex(String),
242}
243
244impl BodyMatcher {
245 pub fn matches(&self, body: &str) -> bool {
246 match self {
247 BodyMatcher::Present => !body.is_empty(),
248 BodyMatcher::ExactMatch(value) => body == *value,
249 BodyMatcher::Regex(pattern) => regex::Regex::new(pattern).unwrap().is_match(body),
250 }
251 }
252}
253
254#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
256pub struct PairedValidatorConfig {
257 pub kind: String,
259
260 pub name: String,
263}
264
265#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Hash)]
266pub struct TemplatedMatchString(pub String);
267
268impl TemplatedMatchString {
269 pub fn with_template_variable(&self, template_variable: &TemplateVariable) -> Self {
278 self.render(
279 template_variable.name.as_str(),
280 template_variable.value.as_str(),
281 )
282 }
283
284 fn render(&self, tag: &str, value: &str) -> Self {
285 TemplatedMatchString(self.0.replace(tag, value))
286 }
287}
288
289impl Display for TemplatedMatchString {
290 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
291 write!(f, "{}", self.0)
292 }
293}
294
295#[derive(Clone, Debug, PartialEq, Eq, Hash)]
296pub struct TemplateVariable {
297 pub name: String,
298 pub value: String,
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_status_code_matcher_single() {
307 let matcher = StatusCodeMatcher::Single(200);
308 assert!(matcher.matches(200));
309 assert!(!matcher.matches(201));
310 assert!(!matcher.matches(404));
311 }
312
313 #[test]
314 fn test_status_code_matcher_list() {
315 let matcher = StatusCodeMatcher::List(vec![200, 201, 204]);
316 assert!(matcher.matches(200));
317 assert!(matcher.matches(201));
318 assert!(matcher.matches(204));
319 assert!(!matcher.matches(202));
320 assert!(!matcher.matches(404));
321 }
322
323 #[test]
324 fn test_status_code_matcher_range() {
325 let matcher = StatusCodeMatcher::Range {
326 start: 200,
327 end: 300,
328 };
329 assert!(matcher.matches(200));
330 assert!(matcher.matches(250));
331 assert!(matcher.matches(299));
332 assert!(!matcher.matches(199));
333 assert!(!matcher.matches(300));
334 assert!(!matcher.matches(404));
335 }
336
337 #[test]
338 fn test_body_matcher_present() {
339 let matcher = BodyMatcher::Present;
340 assert!(matcher.matches("test"));
341 assert!(!matcher.matches(""));
342 }
343
344 #[test]
345 fn test_body_matcher_exact_match() {
346 let matcher = BodyMatcher::ExactMatch("test".to_string());
347 assert!(matcher.matches("test"));
348 assert!(!matcher.matches("test1"));
349 assert!(!matcher.matches(""));
350 }
351
352 #[test]
353 fn test_body_matcher_regex() {
354 let matcher = BodyMatcher::Regex("test".to_string());
355 assert!(matcher.matches("test"));
356 assert!(matcher.matches("test1"));
357 assert!(!matcher.matches("different"));
358 }
359
360 fn valid_condition_with_status_and_raw_body(
361 status: StatusCodeMatcher,
362 raw_body: BodyMatcher,
363 ) -> ResponseCondition {
364 ResponseCondition {
365 condition_type: ResponseConditionType::Valid,
366 status_code: Some(status),
367 raw_body: Some(raw_body),
368 body: None,
369 }
370 }
371
372 #[test]
375 fn test_condition_status_and_raw_body_both_match_returns_valid() {
376 let cond = valid_condition_with_status_and_raw_body(
377 StatusCodeMatcher::Single(200),
378 BodyMatcher::ExactMatch("ok".to_string()),
379 );
380 assert_eq!(cond.matches(200, "ok"), ResponseConditionResult::Valid);
381 }
382
383 #[test]
384 fn test_condition_status_matches_raw_body_does_not_returns_not_checked() {
385 let cond = valid_condition_with_status_and_raw_body(
386 StatusCodeMatcher::Single(200),
387 BodyMatcher::ExactMatch("ok".to_string()),
388 );
389 assert_eq!(
391 cond.matches(200, "error"),
392 ResponseConditionResult::NotChecked
393 );
394 }
395
396 #[test]
397 fn test_condition_raw_body_matches_status_does_not_returns_not_checked() {
398 let cond = valid_condition_with_status_and_raw_body(
399 StatusCodeMatcher::Single(200),
400 BodyMatcher::ExactMatch("ok".to_string()),
401 );
402 assert_eq!(cond.matches(401, "ok"), ResponseConditionResult::NotChecked);
404 }
405
406 #[test]
407 fn test_condition_status_and_raw_body_neither_matches_returns_not_checked() {
408 let cond = valid_condition_with_status_and_raw_body(
409 StatusCodeMatcher::Single(200),
410 BodyMatcher::ExactMatch("ok".to_string()),
411 );
412 assert_eq!(
413 cond.matches(401, "error"),
414 ResponseConditionResult::NotChecked
415 );
416 }
417
418 #[test]
419 fn test_condition_invalid_type_status_and_raw_body_both_match_returns_invalid() {
420 let cond = ResponseCondition {
421 condition_type: ResponseConditionType::Invalid,
422 status_code: Some(StatusCodeMatcher::Single(401)),
423 raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
424 body: None,
425 };
426 assert_eq!(
427 cond.matches(401, "unauthorized"),
428 ResponseConditionResult::Invalid
429 );
430 }
431
432 #[test]
433 fn test_condition_invalid_type_status_matches_raw_body_does_not_returns_not_checked() {
434 let cond = ResponseCondition {
435 condition_type: ResponseConditionType::Invalid,
436 status_code: Some(StatusCodeMatcher::Single(401)),
437 raw_body: Some(BodyMatcher::ExactMatch("unauthorized".to_string())),
438 body: None,
439 };
440 assert_eq!(
442 cond.matches(401, "some other body"),
443 ResponseConditionResult::NotChecked
444 );
445 }
446
447 #[test]
450 fn test_condition_status_and_body_map_both_match_returns_valid() {
451 let mut body_map = BTreeMap::new();
452 body_map.insert(
453 "status".to_string(),
454 BodyMatcher::ExactMatch("active".to_string()),
455 );
456 let cond = ResponseCondition {
457 condition_type: ResponseConditionType::Valid,
458 status_code: Some(StatusCodeMatcher::Single(200)),
459 raw_body: None,
460 body: Some(body_map),
461 };
462 assert_eq!(
463 cond.matches(200, r#"{"status":"active"}"#),
464 ResponseConditionResult::Valid
465 );
466 }
467
468 #[test]
469 fn test_condition_status_matches_body_map_does_not_returns_not_checked() {
470 let mut body_map = BTreeMap::new();
471 body_map.insert(
472 "status".to_string(),
473 BodyMatcher::ExactMatch("active".to_string()),
474 );
475 let cond = ResponseCondition {
476 condition_type: ResponseConditionType::Valid,
477 status_code: Some(StatusCodeMatcher::Single(200)),
478 raw_body: None,
479 body: Some(body_map),
480 };
481 assert_eq!(
483 cond.matches(200, r#"{"status":"inactive"}"#),
484 ResponseConditionResult::NotChecked
485 );
486 }
487
488 #[test]
489 fn test_condition_body_map_matches_status_does_not_returns_not_checked() {
490 let mut body_map = BTreeMap::new();
491 body_map.insert(
492 "status".to_string(),
493 BodyMatcher::ExactMatch("active".to_string()),
494 );
495 let cond = ResponseCondition {
496 condition_type: ResponseConditionType::Valid,
497 status_code: Some(StatusCodeMatcher::Single(200)),
498 raw_body: None,
499 body: Some(body_map),
500 };
501 assert_eq!(
503 cond.matches(401, r#"{"status":"active"}"#),
504 ResponseConditionResult::NotChecked
505 );
506 }
507
508 #[test]
509 fn test_custom_http_v2_config_with_provides() {
510 let config: CustomHttpConfigV2 = serde_yaml::from_str(
511 r#"
512provides:
513 - kind: "vendor_xyz"
514 name: "client_subdomain"
515calls:
516 - request:
517 endpoint: "https://example.com/validate?secret=$MATCH"
518 method: GET
519 response:
520 conditions: []
521"#,
522 )
523 .unwrap();
524
525 assert_eq!(config.provides.as_ref().map(Vec::len), Some(1));
526 let provided = &config.provides.as_ref().unwrap()[0];
527 assert_eq!(provided.kind, "vendor_xyz");
528 assert_eq!(provided.name, "client_subdomain");
529 }
530}