#[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::PathFilter;
#[cfg(feature = "http")]
pub(crate) use camel_matchers::render_bound;
#[cfg(feature = "http")]
use camel_matchers::{above_ceiling, bound_holds, settles_early};
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 {
camel_matchers::matching_count(
requests
.iter()
.map(|request| (request.method.as_str(), request.path.as_str())),
method,
path_filter,
query,
)
}
#[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_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());
}
Some(_) => {
clauses.push("path <filter 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(", ")
}