use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use camel_api::datasource::DatasourceCatalog;
use camel_api::{Body, Exchange, Value};
use camel_matchers::{expectation_matches, stringify};
use crate::adapters::redact_wire_path;
use crate::adapters::{
IncomingMessage, OutgoingMessage, PartnerRouter, ReceiveError, TransportError, lanes_suffix,
};
use crate::document::{
EndpointRef, Expectation, LogLevel, LogsAssertion, Provisioning, ScenarioAction,
ScenarioDocument, ScenarioTarget, ValidateExpectation,
};
mod partner_validate;
#[cfg(test)]
mod partner_validate_test;
use partner_validate::partner_validate_action;
#[cfg(all(test, feature = "http"))]
pub(crate) use partner_validate::{
matching_requests, partner_mismatch_detail, render_bound, render_filters,
};
mod sql_validate;
#[cfg(all(test, feature = "sql"))]
pub(crate) use sql_validate::any_row_to_tuple;
pub(crate) use sql_validate::sql_validate_action;
const SEND_DEADLINE: Duration = Duration::from_secs(30);
pub(crate) fn effective_send_deadline(doc: &ScenarioDocument) -> Duration {
doc.send_deadline.unwrap_or(SEND_DEADLINE)
}
#[derive(Debug, Default)]
pub struct ScenarioVars {
variables: BTreeMap<String, Value>,
last_received: BTreeMap<String, IncomingMessage>,
}
impl ScenarioVars {
pub fn new() -> Self {
Self::default()
}
pub fn get(&self, name: &str) -> Option<&Value> {
self.variables.get(name)
}
pub fn set(&mut self, name: impl Into<String>, value: Value) {
self.variables.insert(name.into(), value);
}
pub fn last_received(&self, endpoint: &str) -> Option<&IncomingMessage> {
self.last_received.get(endpoint)
}
fn remember(&mut self, endpoint: String, message: IncomingMessage) {
self.last_received.insert(endpoint, message);
}
}
pub(crate) fn resolve_placeholders(
input: &str,
vars: &ScenarioVars,
) -> Result<String, ScenarioFailure> {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
if i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
out.extend_from_slice(b"${");
i += 3;
continue;
}
if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
let name_start = i + 2;
let mut j = name_start;
while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j > name_start && j < bytes.len() && bytes[j] == b'}' {
let name = &input[name_start..j];
match vars.get(name) {
Some(value) => {
let replacement = stringify(value);
out.extend_from_slice(replacement.as_bytes());
i = j + 1;
continue;
}
None => {
return Err(ScenarioFailure::VarUnresolved {
name: name.to_string(),
});
}
}
}
}
out.push(b'$');
i += 1;
continue;
}
out.push(bytes[i]);
i += 1;
}
Ok(String::from_utf8(out).expect("placeholder output preserves input UTF-8")) }
pub(crate) fn interpolate_value(
value: &Value,
vars: &ScenarioVars,
) -> Result<Value, ScenarioFailure> {
match value {
Value::String(text) => Ok(Value::String(resolve_placeholders(text, vars)?)),
Value::Array(items) => items
.iter()
.map(|item| interpolate_value(item, vars))
.collect::<Result<Vec<_>, _>>()
.map(Value::Array),
Value::Object(map) => {
let rebuilt = map
.iter()
.map(|(key, item)| Ok((key.clone(), interpolate_value(item, vars)?)))
.collect::<Result<_, _>>()?;
Ok(Value::Object(rebuilt))
}
other => Ok(other.clone()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScenarioVerdict {
Pass,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ScenarioFailure {
#[error("receive-timeout: {endpoint} delivered nothing within {deadline:?}{lanes}")]
ReceiveTimeout {
endpoint: String,
deadline: Duration,
lanes: String,
},
#[error("validation-mismatch: action {action}: {detail}")]
ValidationMismatch {
action: usize,
detail: String,
},
#[error("scenario-var-unresolved: {name}")]
VarUnresolved {
name: String,
},
#[error("action-transport-failure: action {action}: {source}")]
ActionTransport {
action: usize,
source: TransportError,
},
#[error("partner-startup-failure: {message}")]
PartnerStartup {
message: String,
},
#[error("arrival-lane-overflow: {endpoint} dropped {dropped} arrivals")]
ArrivalLaneOverflow {
endpoint: String,
dropped: usize,
},
#[error("shutdown-failure: {message}")]
ShutdownFailure {
message: String,
},
#[error("log-capture-unavailable: {detail}")]
LogCaptureUnavailable {
detail: String,
},
}
pub fn fill_bind_vars(wired: &[EndpointRef], router: &PartnerRouter, vars: &mut ScenarioVars) {
for reference in wired {
if reference.provisioning != Some(Provisioning::Harness) {
continue;
}
let Some(bind_var) = reference.bind_var.as_deref() else {
continue;
};
let Some(authority) = router
.adapter(&reference.endpoint)
.and_then(|adapter| adapter.bound_authority())
else {
continue;
};
vars.set(bind_var, Value::String(authority));
}
}
pub async fn run_scenario(
doc: &ScenarioDocument,
router: &PartnerRouter,
vars: &mut ScenarioVars,
) -> Result<ScenarioVerdict, ScenarioFailure> {
let started_at = Instant::now();
let send_deadline = effective_send_deadline(doc);
for (index, action) in doc.scenario.iter().enumerate() {
run_action(action, index, router, vars, started_at, send_deadline, None).await?;
}
Ok(ScenarioVerdict::Pass)
}
#[derive(Debug, Clone, PartialEq)]
pub struct DocumentOutcome {
pub per_action: Vec<Result<ScenarioVerdict, ScenarioFailure>>,
pub verdict: Option<ScenarioVerdict>,
pub final_failure: Option<ScenarioFailure>,
pub inbound_bound: Option<std::net::SocketAddr>,
pub logs_failure: Option<String>,
}
pub async fn run_scenario_document(
doc: &ScenarioDocument,
router: &PartnerRouter,
vars: &mut ScenarioVars,
datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
) -> DocumentOutcome {
let capture_window = match &doc.logs {
None => None,
Some(_) if crate::log_capture::capture_installed() => {
Some(crate::log_capture::open_window())
}
Some(_) => {
return DocumentOutcome {
per_action: vec![Err(ScenarioFailure::LogCaptureUnavailable {
detail: "the `logs:` block needs the harness log-capture subscriber, but a foreign tracing subscriber owns this process (first-wins try_init); install nothing before the scenario harness".to_string(),
})],
verdict: None,
final_failure: None,
logs_failure: None,
inbound_bound: None,
};
}
};
let started_at = Instant::now();
let send_deadline = effective_send_deadline(doc);
let mut per_action = Vec::with_capacity(doc.scenario.len());
let mut failed = false;
for (index, action) in doc.scenario.iter().enumerate() {
if failed {
break;
}
match run_action(
action,
index,
router,
vars,
started_at,
send_deadline,
datasource_catalog,
)
.await
{
Ok(()) => per_action.push(Ok(ScenarioVerdict::Pass)),
Err(failure) => {
per_action.push(Err(failure));
failed = true;
}
}
}
let logs_failure = match (&doc.logs, capture_window) {
(Some(assertion), Some(window)) if !failed => {
let events = window.close();
evaluate_logs(assertion, &events)
}
_ => None,
};
let verdict = if failed || logs_failure.is_some() {
None
} else {
Some(ScenarioVerdict::Pass)
};
DocumentOutcome {
per_action,
verdict,
final_failure: None,
logs_failure,
inbound_bound: None,
}
}
fn evaluate_logs(
assertion: &LogsAssertion,
events: &[crate::log_capture::LogEvent],
) -> Option<String> {
let mut violations: Vec<String> = Vec::new();
for marker in &assertion.contains {
if !events
.iter()
.any(|event| event.message.contains(marker.as_str()))
{
violations.push(format!(
"`logs.contains` entry `{marker}` matched no captured event"
));
}
}
for pattern in &assertion.regex {
match regex::Regex::new(pattern) {
Ok(compiled) => {
if !events.iter().any(|event| compiled.is_match(&event.message)) {
violations.push(format!(
"`logs.regex` entry `{pattern}` matched no captured event"
));
}
}
Err(error) => violations.push(format!(
"`logs.regex` entry `{pattern}` does not compile: {error}"
)),
}
}
if let Some(cap) = assertion.no_level_above {
let offenders: Vec<&crate::log_capture::LogEvent> = events
.iter()
.filter(|event| event.level < as_tracing_level(cap))
.collect();
if !offenders.is_empty() {
let listed = offenders
.iter()
.map(|event| format!("{} {} {}", event.level, event.target, event.message))
.collect::<Vec<_>>()
.join("; ");
violations.push(format!(
"`logs.noLevelAbove` violated by {} event(s): {listed}",
offenders.len()
));
}
}
if violations.is_empty() {
None
} else {
Some(violations.join("; "))
}
}
fn as_tracing_level(level: LogLevel) -> tracing::Level {
match level {
LogLevel::Trace => tracing::Level::TRACE,
LogLevel::Debug => tracing::Level::DEBUG,
LogLevel::Info => tracing::Level::INFO,
LogLevel::Warn => tracing::Level::WARN,
LogLevel::Error => tracing::Level::ERROR,
}
}
async fn run_action(
action: &ScenarioAction,
index: usize,
router: &PartnerRouter,
vars: &mut ScenarioVars,
started_at: Instant,
send_deadline: Duration,
datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
) -> Result<(), ScenarioFailure> {
match action {
ScenarioAction::Send {
to,
body,
headers,
method,
expect_reply,
} => {
send_action(
index,
to,
body.as_ref(),
headers.as_ref(),
method,
expect_reply.as_ref(),
router,
vars,
send_deadline,
)
.await?;
}
ScenarioAction::Receive {
from,
deadline,
extract,
} => {
receive_action(index, from, *deadline, extract.as_ref(), router, vars).await?;
}
ScenarioAction::Sleep { duration } => {
tokio::time::sleep(*duration).await;
}
ScenarioAction::Validate { .. } => {
validate_action(action, index, started_at, router, vars, datasource_catalog).await?;
}
ScenarioAction::Sql {
datasource,
prepare,
} => {
#[cfg(feature = "sql")]
{
let Some(catalog) = datasource_catalog else {
return Err(ScenarioFailure::ActionTransport {
action: index,
source: TransportError::Other {
message: "sql action: no datasource catalog is available; the \
boot-owning caller must pass the cascade's catalog"
.to_string(),
},
});
};
let sql = crate::sql_action::SqlAction {
datasource: datasource.clone(),
prepare: prepare.clone(),
};
crate::sql_action::execute_sql_prepare(catalog, &sql)
.await
.map_err(|message| ScenarioFailure::ActionTransport {
action: index,
source: TransportError::Other { message },
})?;
}
#[cfg(not(feature = "sql"))]
{
let _ = (datasource, prepare, datasource_catalog);
return Err(ScenarioFailure::ActionTransport {
action: index,
source: TransportError::Other {
message: "the `sql:` action requires the `sql` feature, which this \
harness build does not enable: rebuild with \
`--features sql` (demand-gated activation)"
.to_string(),
},
});
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn send_action(
index: usize,
to: &EndpointRef,
body: Option<&Value>,
headers: Option<&BTreeMap<String, Value>>,
method: &str,
expect_reply: Option<&Expectation>,
router: &PartnerRouter,
vars: &ScenarioVars,
send_deadline: Duration,
) -> Result<(), ScenarioFailure> {
let declared = to.endpoint.as_str();
let interpolated = resolve_placeholders(declared, vars)?;
let body = body
.map(|value| interpolate_value(value, vars))
.transpose()?;
let headers = headers
.map(|map| -> Result<BTreeMap<String, Value>, ScenarioFailure> {
map.iter()
.map(|(name, value)| Ok((name.clone(), interpolate_value(value, vars)?)))
.collect()
})
.transpose()?;
let msg = OutgoingMessage {
body: body.unwrap_or(Value::Null),
headers: headers.unwrap_or_default(),
method: method.to_string(),
};
let bounded =
tokio::time::timeout(send_deadline, router.send(declared, &interpolated, msg)).await;
let sent = bounded.map_err(|_| ScenarioFailure::ActionTransport {
action: index,
source: TransportError::Deadline {
after: send_deadline,
},
})?;
let reply = sent.map_err(|source| {
let source = match source {
TransportError::LaneFifoOverflow { lane_key, bound } => {
let rendered = match lane_key.split_once(' ') {
Some((key_half, path_half))
if !key_half.contains(' ') && path_half.starts_with('/') =>
{
format!(
"{} {}",
redact_wire_path(key_half, &router.secret_query_keys()),
redact_wire_path(path_half, &router.secret_query_keys())
)
}
_ => redact_wire_path(&lane_key, &router.secret_query_keys()),
};
TransportError::LaneFifoOverflow {
lane_key: rendered,
bound,
}
}
other => other,
};
ScenarioFailure::ActionTransport {
action: index,
source,
}
})?;
if let Some(expectation) = expect_reply {
let Some(reply) = reply else {
return Err(ScenarioFailure::ActionTransport {
action: index,
source: TransportError::Other {
message: "direct send produced no reply".to_string(),
},
});
};
let value = reply_body_value(&reply);
if !expectation_matches(expectation, &value) {
return Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: format!(
"direct reply on {}: expected {}, got {}",
to.endpoint,
render_expectation(expectation),
stringify(&value)
),
});
}
}
Ok(())
}
pub(crate) fn reply_body_value(exchange: &Exchange) -> Value {
let message = exchange.output.as_ref().unwrap_or(&exchange.input);
match &message.body {
Body::Json(value) => value.clone(),
Body::Text(text) => reply_bytes_value(text.as_bytes()),
Body::Xml(text) => reply_bytes_value(text.as_bytes()),
Body::Bytes(bytes) => reply_bytes_value(bytes),
_ => Value::String(String::new()),
}
}
pub(crate) fn reply_bytes_value(bytes: &[u8]) -> Value {
serde_json::from_slice(bytes)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned()))
}
fn render_expectation(expectation: &Expectation) -> String {
match expectation {
Expectation::Equals(expected) => format!("equals {expected}"),
Expectation::Regex(pattern) => format!("matches regex `{pattern}`"),
Expectation::Contains(needle) => format!("contains `{needle}`"),
Expectation::StartsWith(prefix) => format!("startsWith `{prefix}`"),
Expectation::EndsWith(suffix) => format!("endsWith `{suffix}`"),
Expectation::Exists => "exists".to_string(),
Expectation::JsonSubset(pattern) => format!("is a superset of {pattern}"),
_ => "the expected value".to_string(),
}
}
async fn receive_action(
index: usize,
from: &EndpointRef,
deadline: Duration,
extract: Option<&BTreeMap<String, String>>,
router: &PartnerRouter,
vars: &mut ScenarioVars,
) -> Result<(), ScenarioFailure> {
let declared = from.endpoint.as_str();
let interpolated = resolve_placeholders(declared, vars)?;
let message = router
.receive(declared, &interpolated, deadline)
.await
.map_err(|source| {
let keys = router.secret_query_keys();
match source {
ReceiveError::Timeout(timeout) => ScenarioFailure::ReceiveTimeout {
endpoint: redact_wire_path(&timeout.endpoint, &keys),
deadline,
lanes: lanes_suffix(
&timeout
.lanes_recorded
.iter()
.map(|lane| redact_wire_path(lane, &keys))
.collect::<Vec<_>>(),
),
},
ReceiveError::Overflow(overflow) => ScenarioFailure::ArrivalLaneOverflow {
endpoint: redact_wire_path(&overflow.endpoint, &keys),
dropped: overflow.dropped,
},
ReceiveError::Transport(source) => ScenarioFailure::ActionTransport {
action: index,
source,
},
}
})?;
if let Some(extract) = extract {
for (name, selector) in extract {
let value = select_from(&message, selector).ok_or_else(|| {
ScenarioFailure::ValidationMismatch {
action: index,
detail: format!(
"extract of `{selector}` into variable `{name}` resolved to nothing"
),
}
})?;
vars.set(name.clone(), value);
}
}
vars.remember(from.endpoint.clone(), message);
Ok(())
}
async fn validate_action(
action: &ScenarioAction,
index: usize,
started_at: Instant,
router: &PartnerRouter,
vars: &ScenarioVars,
datasource_catalog: Option<&Arc<dyn DatasourceCatalog>>,
) -> Result<(), ScenarioFailure> {
let ScenarioAction::Validate {
target,
expectation,
deadline,
elapsed_at_least,
} = action
else {
return Err(unpaired_validate(index));
};
match (target, expectation) {
(ScenarioTarget::Partner(endpoint), ValidateExpectation::Partner(expected)) => {
partner_validate_action(index, &endpoint.endpoint, expected, *deadline, router).await
}
(ScenarioTarget::Sql(target), ValidateExpectation::Rows(expected)) => {
sql_validate_action(index, target, expected, *deadline, datasource_catalog).await
}
(_, ValidateExpectation::Message(expectation)) => {
let (value, subject) = match target {
ScenarioTarget::LastReceived(endpoint) => {
let redacted =
redact_wire_path(&endpoint.endpoint, &router.secret_query_keys());
let message = vars.last_received(&endpoint.endpoint).ok_or_else(|| {
ScenarioFailure::ValidationMismatch {
action: index,
detail: format!(
"no message has been received on {redacted} to validate"
),
}
})?;
if let Some(bound) = elapsed_at_least {
let actual = message
.arrival
.checked_duration_since(started_at)
.unwrap_or_default();
if actual < *bound {
return Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: format!(
"{redacted}: arrived {} after the scenario started; `elapsedAtLeast` requires {}",
humantime::format_duration(actual),
humantime::format_duration(*bound)
),
});
}
}
(
message.body.clone(),
format!("body last received on {redacted}"),
)
}
ScenarioTarget::Variable(name) => (
vars.get(name)
.cloned()
.ok_or_else(|| ScenarioFailure::VarUnresolved { name: name.clone() })?,
format!("variable `{name}`"),
),
ScenarioTarget::Partner(_) => return Err(unpaired_validate(index)),
ScenarioTarget::Sql(_) => return Err(unpaired_validate(index)),
};
match expectation {
Expectation::Equals(expected) => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: expected {expected}, got {value}"),
),
Expectation::Regex(pattern) => {
if let Err(error) = regex::Regex::new(pattern) {
return Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: format!("invalid regex `{pattern}`: {error}"),
});
}
check(
index,
expectation_matches(expectation, &value),
format!("{subject}: `{pattern}` did not match {value}"),
)
}
Expectation::Contains(needle) => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: did not contain `{needle}`: {value}"),
),
Expectation::StartsWith(prefix) => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: did not start with `{prefix}`: {value}"),
),
Expectation::EndsWith(suffix) => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: did not end with `{suffix}`: {value}"),
),
Expectation::Exists => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: expected a value, got null"),
),
Expectation::JsonSubset(pattern) => check(
index,
expectation_matches(expectation, &value),
format!("{subject}: not a superset of {pattern}: {value}"),
),
_ => Err(ScenarioFailure::ValidationMismatch {
action: index,
detail: "validate expectation kind is not supported by the message grammar"
.to_string(),
}),
}
}
_ => Err(unpaired_validate(index)),
}
}
fn unpaired_validate(index: usize) -> ScenarioFailure {
ScenarioFailure::ValidationMismatch {
action: index,
detail: "validate target kind does not pair with the expectation kind: `partner` pairs \
with the partner count grammar, `sql` with the rows grammar, and every other \
target with the message grammar"
.to_string(),
}
}
fn check(index: usize, passed: bool, detail: String) -> Result<(), ScenarioFailure> {
if passed {
Ok(())
} else {
Err(ScenarioFailure::ValidationMismatch {
action: index,
detail,
})
}
}
fn select_from(message: &IncomingMessage, selector: &str) -> Option<Value> {
let (head, rest) = match selector.split_once('.') {
Some((head, rest)) => (head, Some(rest)),
None => (selector, None),
};
match head {
"body" => match rest {
None => Some(message.body.clone()),
Some(path) => walk_path(&message.body, path),
},
"headers" => match rest {
None => Some(Value::Object(
message
.headers
.iter()
.map(|(name, value)| (name.clone(), value.clone()))
.collect(),
)),
Some(name) => lookup_header(&message.headers, name),
},
"status" if rest.is_none() => Some(
message
.status
.map_or(Value::Null, |code| Value::Number(code.into())),
),
"method" if rest.is_none() => {
Some(message.method.clone().map_or(Value::Null, Value::String))
}
"path" if rest.is_none() => Some(message.path.clone().map_or(Value::Null, Value::String)),
_ => None,
}
}
fn lookup_header(headers: &BTreeMap<String, Value>, name: &str) -> Option<Value> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.clone())
}
fn walk_path(value: &Value, path: &str) -> Option<Value> {
let mut current = value;
for key in path.split('.') {
current = current.as_object()?.get(key)?;
}
Some(current.clone())
}