#[cfg(feature = "http")]
use std::collections::BTreeMap;
use std::time::Duration;
use crate::adapters::PartnerRouter;
#[cfg(feature = "http")]
use crate::adapters::http::HttpWireRequest;
#[cfg(feature = "http")]
use crate::adapters::redact_wire_path;
use crate::document::PartnerExpectation;
#[cfg(feature = "http")]
use crate::document::{CountBound, PathFilter};
use super::ScenarioFailure;
#[cfg(feature = "http")]
const PARTNER_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(feature = "http")]
pub(crate) fn matching_requests(
requests: &[HttpWireRequest],
method: Option<&str>,
path_filter: Option<&PathFilter>,
query: Option<&BTreeMap<String, String>>,
) -> usize {
let matches_regex = match path_filter {
Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
_ => None,
};
requests
.iter()
.filter(|request| {
let path_matches = match path_filter {
None => true,
Some(PathFilter::Exact(p)) => p.as_str() == request.path.as_str(),
Some(PathFilter::Contains(s)) => request.path.contains(s.as_str()),
Some(PathFilter::Matches(_)) => matches_regex
.as_ref()
.is_some_and(|re| re.is_match(&request.path)),
};
let query_subset = query.is_none_or(|declared| {
let pairs = query_pairs(&request.path);
declared
.iter()
.all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
});
method.is_none_or(|m| m.eq_ignore_ascii_case(&request.method))
&& path_matches
&& query_subset
})
.count()
}
#[cfg(feature = "http")]
fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
match path_and_query.split_once('?') {
Some((_, query)) => form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect(),
None => Vec::new(),
}
}
#[cfg(feature = "http")]
fn bound_holds(bound: &CountBound, actual: usize) -> bool {
let actual = actual as u64;
match bound {
CountBound::Exact(n) => actual == *n,
CountBound::AtLeast(n) => actual >= *n,
CountBound::AtMost(n) => actual <= *n,
CountBound::Range(min, max) => actual >= *min && actual <= *max,
}
}
#[cfg(feature = "http")]
fn settles_early(bound: &CountBound, actual: usize) -> bool {
match bound {
CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
CountBound::AtMost(_) | CountBound::Range(..) => false,
}
}
#[cfg(feature = "http")]
fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
let actual = actual as u64;
match bound {
CountBound::Exact(_) | CountBound::AtLeast(_) => false,
CountBound::AtMost(n) => actual > *n,
CountBound::Range(_, max) => actual > *max,
}
}
#[cfg(feature = "http")]
pub(super) async fn partner_validate_action(
index: usize,
uri: &str,
expected: &PartnerExpectation,
deadline: Option<Duration>,
router: &PartnerRouter,
) -> Result<(), ScenarioFailure> {
let snapshot = || {
let requests = router.recorded_requests(uri);
let actual = matching_requests(
&requests,
expected.method.as_deref(),
expected.path.as_ref(),
expected.query.as_ref(),
);
(requests, actual)
};
let mismatch = |actual: usize, requests: &[HttpWireRequest]| {
let recorded: Vec<String> = requests
.iter()
.map(|request| request.path.clone())
.collect();
ScenarioFailure::ValidationMismatch {
action: index,
detail: partner_mismatch_detail(
uri,
expected,
actual,
&recorded,
&router.secret_query_keys(),
),
}
};
match deadline {
None => {
let (requests, actual) = snapshot();
if bound_holds(&expected.bound, actual) {
Ok(())
} else {
Err(mismatch(actual, &requests))
}
}
Some(deadline) => {
let until = tokio::time::Instant::now() + deadline;
loop {
let (requests, actual) = snapshot();
if above_ceiling(&expected.bound, actual) {
return Err(mismatch(actual, &requests));
}
if settles_early(&expected.bound, actual) {
return Ok(());
}
let now = tokio::time::Instant::now();
if now >= until {
return if bound_holds(&expected.bound, actual) {
Ok(())
} else {
Err(mismatch(actual, &requests))
};
}
tokio::time::sleep((until - now).min(PARTNER_POLL_INTERVAL)).await;
}
}
}
}
#[cfg(not(feature = "http"))]
pub(super) async fn partner_validate_action(
index: usize,
uri: &str,
expected: &PartnerExpectation,
deadline: Option<Duration>,
router: &PartnerRouter,
) -> Result<(), ScenarioFailure> {
let _ = (uri, expected, deadline, router);
Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: "partner validation requires the `http` feature".to_string(),
})
}
#[cfg(feature = "http")]
pub(crate) fn partner_mismatch_detail(
uri: &str,
expected: &PartnerExpectation,
actual: usize,
recorded: &[String],
secret_keys: &[String],
) -> String {
let mut detail = format!("partner {}", redact_wire_path(uri, secret_keys));
let filters = render_filters(expected, secret_keys);
if !filters.is_empty() {
detail.push_str(&format!(" ({filters})"));
}
detail.push_str(&format!(
", {}, actual {actual}",
render_bound(&expected.bound)
));
let mut unique: Vec<&str> = Vec::new();
for path in recorded {
if !unique.contains(&path.as_str()) {
unique.push(path);
}
}
if !unique.is_empty() {
let redacted: Vec<String> = unique
.iter()
.map(|path| redact_wire_path(path, secret_keys))
.collect();
detail.push_str(&format!(", recorded: [{}]", redacted.join(", ")));
}
detail
}
#[cfg(feature = "http")]
pub(crate) fn render_bound(bound: &CountBound) -> String {
match bound {
CountBound::Exact(n) => format!("expected {n}"),
CountBound::AtLeast(n) => format!("expected at least {n}"),
CountBound::AtMost(n) => format!("expected at most {n}"),
CountBound::Range(min, max) => format!("expected between {min} and {max}"),
}
}
#[cfg(feature = "http")]
pub(crate) fn render_filters(expected: &PartnerExpectation, secret_keys: &[String]) -> String {
let mut clauses: Vec<String> = Vec::new();
if let Some(method) = expected.method.as_deref() {
clauses.push(format!("method {method}"));
}
match expected.path.as_ref() {
Some(PathFilter::Exact(path)) => {
clauses.push(format!("path {}", redact_wire_path(path, secret_keys)));
}
Some(PathFilter::Contains(_)) => {
clauses.push("pathContains <pattern elided>".to_string());
}
Some(PathFilter::Matches(_)) => {
clauses.push("pathMatches <pattern elided>".to_string());
}
None => {}
}
if let Some(query) = expected.query.as_ref() {
for (key, value) in query {
if secret_keys.iter().any(|secret| secret == key) {
clauses.push(format!("{key}=<redacted>"));
} else {
clauses.push(format!("{key}={value}"));
}
}
}
clauses.join(", ")
}