use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use camel_api::Value;
use camel_core::RouteDefinition;
use noyalib::compat::serde_yaml;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
pub use crate::partner_script::{PartnerFault, PartnerScript, PartnerScriptResponse};
pub use camel_matchers::RequestExpectation as PartnerExpectation;
pub use camel_matchers::{CountBound, Expectation, PathFilter};
#[derive(Debug)]
pub struct ScenarioDocument {
pub source_path: std::path::PathBuf,
pub route_source: RouteSource,
pub scenario: Vec<ScenarioAction>,
pub partners: Option<BTreeMap<String, Vec<PartnerScript>>>,
pub env: Option<BTreeMap<String, String>>,
pub env_passthrough: Option<Vec<String>>,
pub profile: Option<String>,
pub send_deadline: Option<Duration>,
pub inbound: Option<InboundListener>,
}
#[non_exhaustive]
pub enum RouteSource {
RouteFiles(Vec<PathBuf>),
RouteFilesFromRoot(Vec<PathBuf>),
Inline(Vec<RouteDefinition>),
}
impl std::fmt::Debug for RouteSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RouteFiles(files) => f.debug_tuple("RouteFiles").field(files).finish(),
Self::RouteFilesFromRoot(files) => {
f.debug_tuple("RouteFilesFromRoot").field(files).finish()
}
Self::Inline(routes) => f
.debug_tuple("Inline")
.field(&format_args!("{} route definitions", routes.len()))
.finish(),
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ScenarioAction {
Send {
to: EndpointRef,
body: Option<Value>,
headers: Option<BTreeMap<String, Value>>,
method: String,
expect_reply: Option<Expectation>,
},
Receive {
from: EndpointRef,
deadline: Duration,
extract: Option<BTreeMap<String, String>>,
},
Sleep {
duration: Duration,
},
Validate {
target: ScenarioTarget,
expectation: ValidateExpectation,
deadline: Option<Duration>,
elapsed_at_least: Option<Duration>,
},
}
impl ScenarioAction {
fn bindings(&self) -> Vec<(&str, &str)> {
fn endpoint_bindings(endpoint: &EndpointRef) -> Vec<(&str, &str)> {
endpoint.binding().into_iter().collect()
}
match self {
Self::Send { to, .. } => endpoint_bindings(to),
Self::Receive { from, .. } => endpoint_bindings(from),
Self::Validate { target, .. } => match target {
ScenarioTarget::LastReceived(endpoint) => endpoint_bindings(endpoint),
ScenarioTarget::Partner(_) => Vec::new(),
ScenarioTarget::Variable(_) => Vec::new(),
},
Self::Sleep { .. } => Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ScenarioTarget {
LastReceived(EndpointRef),
Variable(String),
Partner(EndpointRef),
}
#[derive(Debug, Clone, PartialEq)]
pub struct EndpointRef {
pub endpoint: String,
pub provisioning: Option<Provisioning>,
pub bind_var: Option<String>,
}
impl EndpointRef {
fn binding(&self) -> Option<(&str, &str)> {
self.bind_var
.as_deref()
.map(|bind_var| (bind_var, self.endpoint.as_str()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Provisioning {
Harness,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InboundListener {
pub bind_var: String,
}
#[cfg(feature = "http")]
pub fn partner_scripts_for(
doc: &ScenarioDocument,
endpoint: &str,
) -> Option<Vec<crate::adapters::http::ScriptedResponse>> {
use crate::adapters::http::ScriptedResponse;
let scripts = doc.partners.as_ref()?.get(endpoint)?;
Some(
scripts
.iter()
.map(|script| {
let (status, headers, body) = match script.response.as_ref() {
Some(response) => (
response.status.unwrap_or(200),
response.headers.clone().unwrap_or_default(),
response.body.as_ref().map_or_else(Vec::new, |value| {
crate::adapters::http::value_to_wire(value)
}),
),
None => (200, BTreeMap::new(), Vec::new()),
};
ScriptedResponse {
method: script.method.clone(),
path: script.path.clone(),
times: script.times.unwrap_or(1),
delay: script.delay,
fault: script.fault.clone(),
status,
headers,
body,
}
})
.collect(),
)
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ValidateExpectation {
Message(Expectation),
Partner(PartnerExpectation),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RawDocument {
route_files: Option<Vec<String>>,
route_files_from_root: Option<Vec<String>>,
routes: Option<serde_yaml::Value>,
scenario: Option<Vec<serde_yaml::Value>>,
env: Option<BTreeMap<String, String>>,
env_passthrough: Option<Vec<String>>,
profile: Option<String>,
partners: Option<BTreeMap<String, serde_yaml::Value>>,
send_deadline: Option<String>,
inbound: Option<serde_yaml::Value>,
inputs: Option<serde_yaml::Value>,
expects: Option<serde_yaml::Value>,
intercepts: Option<serde_yaml::Value>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RawSend {
to: RawEndpointRef,
body: Option<Value>,
headers: Option<BTreeMap<String, Value>>,
method: Option<String>,
expect_reply: Option<Value>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RawReceive {
from: RawEndpointRef,
deadline: Option<String>,
extract: Option<BTreeMap<String, String>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RawSleep {
duration: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct RawValidate {
target: serde_yaml::Value,
expectation: Value,
deadline: Option<String>,
elapsed_at_least: Option<String>,
}
#[derive(Debug, Clone)]
struct RawEndpointRef {
endpoint: String,
provisioning: Option<String>,
bind_var: Option<String>,
}
impl RawEndpointRef {
fn from_yaml_value(value: serde_yaml::Value) -> Result<Self, String> {
match value {
serde_yaml::Value::String(endpoint) => Ok(Self {
endpoint,
provisioning: None,
bind_var: None,
}),
serde_yaml::Value::Mapping(ref map) => {
let mut endpoint: Option<String> = None;
let mut provisioning: Option<String> = None;
let mut bind_var: Option<String> = None;
for (key, value) in map {
match key.as_str() {
"endpoint" | "provisioning" | "bindVar" => {
let text = value.as_str().ok_or_else(|| {
format!(
"endpoint reference `{key}` must be a string, got {value:?}"
)
})?;
match key.as_str() {
"endpoint" => endpoint = Some(text.to_string()),
"provisioning" => provisioning = Some(text.to_string()),
_ => bind_var = Some(text.to_string()),
}
}
other => {
return Err(format!("unknown field `{other}` in endpoint reference"));
}
}
}
let endpoint = endpoint
.ok_or_else(|| "endpoint reference requires the `endpoint` key".to_string())?;
Ok(Self {
endpoint,
provisioning,
bind_var,
})
}
other => Err(format!(
"endpoint reference must be a string or a map, got {other:?}"
)),
}
}
}
impl<'de> Deserialize<'de> for RawEndpointRef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_yaml::Value::deserialize(deserializer)?;
RawEndpointRef::from_yaml_value(value).map_err(D::Error::custom)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DocError {
#[error("failed to read test document {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error("invalid test document: {0}")]
Yaml(String),
#[error("unknown field in test document: {0}")]
UnknownField(String),
#[error(
"doc-validation: not a test document: {path} (reserved suffixes are `.test.yaml` and `.test.yml`)"
)]
NotTestDocument {
path: PathBuf,
},
#[error("doc-validation: scenario document must declare a `scenario:` section")]
MissingScenario,
#[error(
"doc-validation: mixed vocabulary: a document with `scenario:` must not declare unit-tier fields (found: {found})"
)]
MixedVocabulary {
found: String,
},
#[error(
"exactly one route source (`routeFiles`, `routeFilesFromRoot`, or `routes`) is required"
)]
RouteSourceMissing,
#[error("route sources {present} are mutually exclusive; exactly one route source is required")]
RouteSourceConflict {
present: String,
},
#[error("doc-validation: scenario[{index}]: {message}")]
Validation {
index: usize,
message: String,
},
#[error(
"doc-validation: unsupported provisioning `{value}` for endpoint `{endpoint}`: only `harness` is supported in v1 (infra-unavailable class)"
)]
UnsupportedProvisioning {
value: String,
endpoint: String,
},
#[error(
"doc-validation: endpoint `{endpoint}` declares `bindVar` but its `{ref_scheme}:` reference binds no harness partner, so the variable would never receive a bound authority (exit-2 doc-validation class)"
)]
ProvisioningWithoutAuthority {
endpoint: String,
ref_scheme: String,
},
#[error(
"doc-validation: env key `{key}` is reserved: it is the harness bind variable of endpoint `{endpoint}`"
)]
ReservedEnvKey {
key: String,
endpoint: String,
},
#[error("doc-validation: partners[{endpoint}]: {message}")]
Partners {
endpoint: String,
message: String,
},
#[error("doc-validation: inline routes: {0}")]
InlineRoutes(String),
#[error(
"doc-validation: inline `routes` are rejected at load: declare `routeFiles` instead (inline definitions cannot boot in the scenario tier; exit 2)"
)]
InlineRoutesRejected,
#[error(
"doc-validation: scenario[{index}]: `expectReply` is only valid on a `direct:` send, not `{scheme}` (exit 2)"
)]
ExpectReplyOnUnsupportedSend {
index: usize,
scheme: String,
},
}
fn classify_yaml_error(raw: &str) -> DocError {
if raw.contains("unknown field") {
return DocError::UnknownField(raw.to_string());
}
DocError::Yaml(raw.to_string())
}
pub fn parse_scenario_document(path: &Path) -> Result<ScenarioDocument, DocError> {
if !camel_dsl::discovery::is_test_document(path) {
return Err(DocError::NotTestDocument {
path: path.to_path_buf(),
});
}
let text = std::fs::read_to_string(path).map_err(|source| DocError::Io {
path: path.to_path_buf(),
source,
})?;
let raw = serde_yaml::from_str::<RawDocument>(&text)
.map_err(|e| classify_yaml_error(&e.to_string()))?;
let Some(raw_scenario) = raw.scenario else {
return Err(DocError::MissingScenario);
};
if raw_scenario.is_empty() {
return Err(DocError::Validation {
index: 0,
message: "`scenario` must declare at least one action".to_string(),
});
}
let mut unit_tier: Vec<&str> = Vec::new();
if raw.inputs.is_some() {
unit_tier.push("inputs");
}
if raw.expects.is_some() {
unit_tier.push("expects");
}
if raw.intercepts.is_some() {
unit_tier.push("intercepts");
}
if !unit_tier.is_empty() {
return Err(DocError::MixedVocabulary {
found: backticked(&unit_tier),
});
}
let mut present: Vec<&'static str> = Vec::new();
if raw.route_files.is_some() {
present.push("routeFiles");
}
if raw.route_files_from_root.is_some() {
present.push("routeFilesFromRoot");
}
if raw.routes.is_some() {
present.push("routes");
}
let route_source = match present.as_slice() {
["routeFiles"] => RouteSource::RouteFiles(
raw.route_files
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect(),
),
["routeFilesFromRoot"] => RouteSource::RouteFilesFromRoot(
raw.route_files_from_root
.unwrap_or_default()
.into_iter()
.map(PathBuf::from)
.collect(),
),
["routes"] => {
let value = raw.routes.unwrap_or(serde_yaml::Value::Null);
RouteSource::Inline(parse_inline_routes(&value)?)
}
[] => return Err(DocError::RouteSourceMissing),
_ => {
return Err(DocError::RouteSourceConflict {
present: backticked(&present),
});
}
};
if matches!(route_source, RouteSource::Inline(_)) {
return Err(DocError::InlineRoutesRejected);
}
let mut scenario = Vec::with_capacity(raw_scenario.len());
for (index, item) in raw_scenario.into_iter().enumerate() {
scenario.push(build_action(item, index)?);
}
let partners = crate::partner_script::partners_from_raw(raw.partners)?;
let inbound = raw.inbound.map(inbound_from_raw).transpose()?;
if let Some(env) = raw.env.as_ref() {
if let Some(inbound) = inbound.as_ref()
&& env.contains_key(&inbound.bind_var)
{
return Err(DocError::ReservedEnvKey {
key: inbound.bind_var.clone(),
endpoint: "inbound".to_string(),
});
}
for action in &scenario {
for (bind_var, endpoint) in action.bindings() {
if env.contains_key(bind_var) {
return Err(DocError::ReservedEnvKey {
key: bind_var.to_string(),
endpoint: endpoint.to_string(),
});
}
}
}
}
let mut harness_uris: Vec<&str> = Vec::new();
let mut partner_targets: Vec<(usize, &EndpointRef)> = Vec::new();
for (index, action) in scenario.iter().enumerate() {
match action {
ScenarioAction::Send { to, .. } => {
if to.provisioning == Some(Provisioning::Harness) {
harness_uris.push(to.endpoint.as_str());
}
}
ScenarioAction::Receive { from, .. } => {
if from.provisioning == Some(Provisioning::Harness) {
harness_uris.push(from.endpoint.as_str());
}
}
ScenarioAction::Validate {
target: ScenarioTarget::Partner(endpoint),
..
} => partner_targets.push((index, endpoint)),
_ => {}
}
}
for (index, endpoint) in partner_targets {
if !harness_uris.contains(&endpoint.endpoint.as_str()) {
return Err(DocError::Validation {
index,
message: format!(
"validate `partner` target `{}` does not match any harness endpoint reference declared by this scenario's `send`/`receive` actions",
endpoint.endpoint
),
});
}
}
let send_deadline = raw
.send_deadline
.as_deref()
.map(|raw_deadline| parse_duration(raw_deadline, 0, "sendDeadline"))
.transpose()?;
Ok(ScenarioDocument {
source_path: path.to_path_buf(),
route_source,
scenario,
partners,
env: raw.env,
env_passthrough: raw.env_passthrough,
profile: raw.profile,
send_deadline,
inbound,
})
}
fn inbound_from_raw(value: serde_yaml::Value) -> Result<InboundListener, DocError> {
let section_error = |message: String| DocError::Validation { index: 0, message };
let serde_yaml::Value::Mapping(ref map) = value else {
return Err(section_error(format!(
"`inbound` must be a map with a `bindVar` key, got {value:?}"
)));
};
let mut bind_var: Option<String> = None;
for (key, value) in map {
match key.as_str() {
"bindVar" => {
let text = value.as_str().ok_or_else(|| {
section_error(format!(
"`inbound`: `bindVar` must be a string, got {value:?}"
))
})?;
bind_var = Some(text.to_string());
}
other => {
return Err(section_error(format!(
"`inbound`: unknown field `{other}`; expected `bindVar`"
)));
}
}
}
let bind_var =
bind_var.ok_or_else(|| section_error("`inbound` requires a `bindVar` key".to_string()))?;
#[cfg(not(feature = "http"))]
{
let _ = bind_var;
Err(section_error(
"`inbound` requires the `http` feature, which this harness build does \
not enable: rebuild with `--features http` (demand-gated activation)"
.to_string(),
))
}
#[cfg(feature = "http")]
Ok(InboundListener { bind_var })
}
fn parse_inline_routes(value: &serde_yaml::Value) -> Result<Vec<RouteDefinition>, DocError> {
let mut mapping = serde_yaml::Mapping::new();
mapping.insert("routes", value.clone());
let text = serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping))
.map_err(|e| DocError::InlineRoutes(format!("failed to serialize inline routes: {e}")))?;
camel_dsl::parse_yaml(&text).map_err(|e| DocError::InlineRoutes(e.to_string()))
}
fn build_action(item: serde_yaml::Value, index: usize) -> Result<ScenarioAction, DocError> {
let action_error = |message: String| DocError::Validation { index, message };
let serde_yaml::Value::Mapping(ref map) = item else {
return Err(action_error(format!(
"action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got {item:?}"
)));
};
let Some((key, content)) = map.iter().next() else {
return Err(action_error(
"action must be a single-key map (`send`, `receive`, `sleep`, `validate`), got an empty map"
.to_string(),
));
};
if map.len() != 1 {
return Err(action_error(format!(
"action must declare exactly one key, got {}",
backticked(&map.keys().map(String::as_str).collect::<Vec<_>>())
)));
}
let action_error_from_serde = |e: serde_yaml::Error| action_error(e.to_string());
match key.as_str() {
"send" => {
let raw: RawSend =
serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
let method = match raw.method {
Some(method) => {
let upper = method.trim().to_ascii_uppercase();
if !is_http_token(&upper) {
return Err(action_error(format!(
"send action `method` must be a valid HTTP method name, got `{method}`"
)));
}
upper
}
None => {
if raw.body.is_some() {
"POST".to_string()
} else {
"GET".to_string()
}
}
};
let scheme = ref_scheme(&raw.to.endpoint);
if raw.expect_reply.is_some() && scheme != Some("direct") {
return Err(DocError::ExpectReplyOnUnsupportedSend {
index,
scheme: scheme.unwrap_or("no scheme").to_string(),
});
}
let expect_reply = raw
.expect_reply
.map(|value| expectation_from_value(&value, index, "expectReply"))
.transpose()?;
Ok(ScenarioAction::Send {
to: endpoint_from_raw(raw.to)?,
body: raw.body,
headers: raw.headers,
method,
expect_reply,
})
}
"receive" => {
let raw: RawReceive =
serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
let deadline = raw.deadline.ok_or_else(|| {
action_error(
"receive action requires a `deadline` (humantime string, e.g. `5s`)"
.to_string(),
)
})?;
Ok(ScenarioAction::Receive {
from: endpoint_from_raw(raw.from)?,
deadline: parse_duration(&deadline, index, "deadline")?,
extract: raw.extract,
})
}
"sleep" => {
let raw: RawSleep =
serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
Ok(ScenarioAction::Sleep {
duration: parse_duration(&raw.duration, index, "sleep duration")?,
})
}
"validate" => {
let raw: RawValidate =
serde_yaml::from_value(content.clone()).map_err(action_error_from_serde)?;
let target = build_target(&raw.target, index)?;
let deadline = match raw.deadline.as_deref() {
None => None,
Some(raw_deadline) if matches!(target, ScenarioTarget::Partner(_)) => {
Some(parse_duration(raw_deadline, index, "deadline")?)
}
Some(raw_deadline) => {
return Err(action_error(format!(
"`deadline` is only valid on a `partner` validate target, got `{raw_deadline}`"
)));
}
};
let elapsed_at_least = match raw.elapsed_at_least.as_deref() {
None => None,
Some(raw_bound) if matches!(target, ScenarioTarget::LastReceived(_)) => {
Some(parse_duration(raw_bound, index, "elapsedAtLeast")?)
}
Some(raw_bound) => {
return Err(action_error(format!(
"`elapsedAtLeast` is only valid on a `lastReceived` validate target, got `{raw_bound}`"
)));
}
};
let expectation = match &target {
ScenarioTarget::Partner(_) => ValidateExpectation::Partner(
partner_expectation_from_value(&raw.expectation, index)?,
),
_ => ValidateExpectation::Message(expectation_from_value(
&raw.expectation,
index,
"expectation",
)?),
};
Ok(ScenarioAction::Validate {
target,
expectation,
deadline,
elapsed_at_least,
})
}
other => Err(action_error(format!(
"unknown action `{other}`; expected `send`, `receive`, `sleep`, or `validate`"
))),
}
}
fn build_target(value: &serde_yaml::Value, index: usize) -> Result<ScenarioTarget, DocError> {
let action_error = |message: String| DocError::Validation { index, message };
let serde_yaml::Value::Mapping(map) = value else {
return Err(action_error(format!(
"validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got {value:?}"
)));
};
let Some((key, content)) = map.iter().next() else {
return Err(action_error(
"validate `target` must be a single-key map (`lastReceived`, `variable`, `partner`), got an empty map"
.to_string(),
));
};
match key.as_str() {
"lastReceived" => {
let raw: RawEndpointRef =
serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
Ok(ScenarioTarget::LastReceived(endpoint_from_raw(raw)?))
}
"variable" => match content.as_str() {
Some(name) => Ok(ScenarioTarget::Variable(name.to_string())),
None => Err(action_error(format!(
"validate `variable` target must be a string, got {content:?}"
))),
},
"partner" => {
let raw: RawEndpointRef =
serde_yaml::from_value(content.clone()).map_err(|e| action_error(e.to_string()))?;
Ok(ScenarioTarget::Partner(endpoint_from_raw(raw)?))
}
other => Err(action_error(format!(
"unknown validate target `{other}`; expected `lastReceived`, `variable`, or `partner`"
))),
}
}
fn endpoint_from_raw(raw: RawEndpointRef) -> Result<EndpointRef, DocError> {
let provisioning = match raw.provisioning.as_deref() {
None => None,
Some("harness") => Some(Provisioning::Harness),
Some(value) => {
return Err(DocError::UnsupportedProvisioning {
value: value.to_string(),
endpoint: raw.endpoint.clone(),
});
}
};
if provisioning == Some(Provisioning::Harness)
&& raw.bind_var.is_some()
&& let Some(scheme) = ref_scheme(&raw.endpoint)
&& (scheme == "direct" || scheme == "fake")
{
return Err(DocError::ProvisioningWithoutAuthority {
endpoint: raw.endpoint.clone(),
ref_scheme: scheme.to_string(),
});
}
Ok(EndpointRef {
endpoint: raw.endpoint,
provisioning,
bind_var: raw.bind_var,
})
}
fn ref_scheme(endpoint: &str) -> Option<&str> {
let (scheme, _) = endpoint.split_once(':')?;
(!scheme.is_empty()).then_some(scheme)
}
fn parse_duration(raw: &str, index: usize, field: &str) -> Result<Duration, DocError> {
humantime::parse_duration(raw).map_err(|e| DocError::Validation {
index,
message: format!("invalid {field} `{raw}`: {e}"),
})
}
pub(crate) fn is_http_token(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '.'
| '^'
| '_'
| '`'
| '|'
| '~'
)
})
}
fn is_matcher_key(key: &str) -> bool {
matches!(
key,
"equals" | "regex" | "contains" | "startsWith" | "endsWith" | "exists" | "jsonSubset"
)
}
fn expectation_from_value(
value: &Value,
index: usize,
field: &'static str,
) -> Result<Expectation, DocError> {
let invalid = |message: String| DocError::Validation { index, message };
if let Value::Object(map) = value
&& map.len() == 1
&& let Some((key, payload)) = map.iter().next()
&& is_matcher_key(key)
{
return match key.as_str() {
"equals" => Ok(Expectation::Equals(payload.clone())),
"regex" | "contains" | "startsWith" | "endsWith" => {
let Some(pattern) = payload.as_str() else {
return Err(invalid(format!(
"{field}: `{key}` requires a string payload"
)));
};
if key.as_str() == "regex"
&& let Err(e) = regex::Regex::new(pattern)
{
return Err(invalid(format!("{field}: invalid regex `{pattern}`: {e}")));
}
Ok(match key.as_str() {
"regex" => Expectation::Regex(pattern.to_string()),
"contains" => Expectation::Contains(pattern.to_string()),
"startsWith" => Expectation::StartsWith(pattern.to_string()),
_ => Expectation::EndsWith(pattern.to_string()),
})
}
"exists" => {
if payload.is_null() {
Ok(Expectation::Exists)
} else {
Err(invalid(format!("{field}: `exists` takes no argument")))
}
}
_ => {
if payload.is_object() {
Ok(Expectation::JsonSubset(payload.clone()))
} else {
Err(invalid(format!("{field}: `jsonSubset` must be an object")))
}
}
};
}
Ok(Expectation::Equals(value.clone()))
}
fn partner_expectation_from_value(
value: &Value,
index: usize,
) -> Result<PartnerExpectation, DocError> {
const FIELD: &str = "partner expectation";
const KEYS: &[&str] = &[
"count",
"atLeast",
"atMost",
"method",
"path",
"pathContains",
"pathMatches",
"query",
];
let invalid = |message: String| DocError::Validation { index, message };
let Value::Object(map) = value else {
return Err(invalid(format!(
"{FIELD} must be a map with a count bound, got {value:?}"
)));
};
let mut count: Option<u64> = None;
let mut at_least: Option<u64> = None;
let mut at_most: Option<u64> = None;
let mut method: Option<String> = None;
let mut path: Option<PathFilter> = None;
let mut path_key: Option<&str> = None;
let mut query: Option<BTreeMap<String, String>> = None;
for (key, payload) in map {
match key.as_str() {
"count" | "atLeast" | "atMost" => {
let bound = payload.as_u64().ok_or_else(|| {
invalid(format!(
"{FIELD}: `{key}` must be a non-negative integer, got {payload}"
))
})?;
match key.as_str() {
"count" => count = Some(bound),
"atLeast" => at_least = Some(bound),
_ => at_most = Some(bound),
}
}
"method" => {
let text = payload.as_str().ok_or_else(|| {
invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
})?;
method = Some(text.to_string());
}
"path" | "pathContains" | "pathMatches" => {
if let Some(first) = path_key {
return Err(invalid(format!(
"{FIELD}: `{first}` and `{key}` are exclusive: at most one path filter"
)));
}
let text = payload.as_str().ok_or_else(|| {
invalid(format!("{FIELD}: `{key}` must be a string, got {payload}"))
})?;
path = Some(match key.as_str() {
"path" => PathFilter::Exact(text.to_string()),
"pathContains" => PathFilter::Contains(text.to_string()),
_ => {
if let Err(e) = regex::Regex::new(text) {
return Err(invalid(format!("{FIELD}: invalid regex `{text}`: {e}")));
}
PathFilter::Matches(text.to_string())
}
});
path_key = Some(key.as_str());
}
"query" => {
let Value::Object(pairs) = payload else {
return Err(invalid(format!(
"{FIELD}: `query` must be a map of string keys to string values, got {payload}"
)));
};
let mut subset = BTreeMap::new();
for (name, pair) in pairs {
let Some(text) = pair.as_str() else {
return Err(invalid(format!(
"{FIELD}: `query` value for `{name}` must be a string, got {pair}"
)));
};
subset.insert(name.clone(), text.to_string());
}
query = Some(subset);
}
other => {
return Err(invalid(format!(
"{FIELD}: unknown field `{other}`; expected {}",
backticked(KEYS)
)));
}
}
}
if count.is_some() && (at_least.is_some() || at_most.is_some()) {
let mut others: Vec<&str> = Vec::new();
if at_least.is_some() {
others.push("atLeast");
}
if at_most.is_some() {
others.push("atMost");
}
return Err(invalid(format!(
"{FIELD}: `count` and {} are exclusive: declare exactly one bound form",
backticked(&others)
)));
}
let bound = if let Some(exact) = count {
CountBound::Exact(exact)
} else if let (Some(min), Some(max)) = (at_least, at_most) {
if min > max {
return Err(invalid(format!(
"{FIELD}: `atLeast` ({min}) must not exceed `atMost` ({max})"
)));
}
CountBound::Range(min, max)
} else if let Some(n) = at_least {
CountBound::AtLeast(n)
} else if let Some(n) = at_most {
CountBound::AtMost(n)
} else {
return Err(invalid(format!(
"{FIELD}: requires a count bound: `count`, `atLeast`, or `atMost`"
)));
};
Ok(PartnerExpectation {
bound,
method,
path,
query,
})
}
fn backticked(fields: &[&str]) -> String {
fields
.iter()
.map(|field| format!("`{field}`"))
.collect::<Vec<_>>()
.join(", ")
}