use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use camel_api::Body;
use camel_component_mock::BodyMatcher;
use camel_component_mock::HeaderMatcher;
use camel_core::intercept::{InterceptAction, InterceptRule, InterceptRules};
use noyalib::compat::serde_yaml;
use regex::Regex;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer};
const MOCK_SCHEME_PREFIX: &str = "mock:";
const DIRECT_SCHEME_PREFIX: &str = "direct:";
const BODY_SCALAR_SENTINEL: &str = "unsupported body scalar: ";
const MATCHER_SENTINEL: &str = "invalid matcher: ";
const SETTLE_MAX: Duration = Duration::from_secs(5);
pub(crate) const SUPPORTED_REGISTRY_KINDS: [&str; 3] = ["cache", "idempotent", "claimCheck"];
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TestDocument {
pub route_files: Option<Vec<String>>,
pub route_files_from_root: Option<Vec<String>>,
pub routes: Option<serde_yaml::Value>,
#[serde(default)]
pub inputs: Vec<TestInput>,
#[serde(default)]
pub expects: BTreeMap<String, ExpectSet>,
pub settle: Option<String>,
#[serde(skip)]
pub settle_parsed: Option<Duration>,
pub intercepts: Option<BTreeMap<String, InterceptActionDoc>>,
#[serde(skip)]
intercept_rules_parsed: Option<InterceptRules>,
pub beans: Option<BTreeMap<String, BeanDeclDoc>>,
pub repositories: Option<RepositoriesDoc>,
}
impl TestDocument {
pub fn settle_duration(&self) -> Option<Duration> {
self.settle_parsed
}
pub fn intercept_rules(&self) -> Option<InterceptRules> {
self.intercept_rules_parsed.clone()
}
pub fn bean_decls(&self) -> Option<&BTreeMap<String, BeanDeclDoc>> {
self.beans.as_ref()
}
pub fn repository_stubs(&self) -> Option<&RepositoriesDoc> {
self.repositories.as_ref()
}
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct BeanDeclDoc {
pub kind: BeanKindDoc,
pub methods: Option<Vec<String>>,
pub config: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum BeanKindDoc {
Echo,
SetBody,
Fail,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RepositoriesDoc {
pub cache: Option<BTreeMap<String, String>>,
pub idempotent: Option<BTreeMap<String, String>>,
pub claim_check: Option<BTreeMap<String, String>>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml::Value>,
}
impl RepositoriesDoc {
pub(crate) fn stub_pairs(&self) -> Vec<(&'static str, &str)> {
let mut out = Vec::new();
if let Some(cache) = &self.cache {
for name in cache.keys() {
out.push((SUPPORTED_REGISTRY_KINDS[0], name.as_str()));
}
}
if let Some(idempotent) = &self.idempotent {
for name in idempotent.keys() {
out.push((SUPPORTED_REGISTRY_KINDS[1], name.as_str()));
}
}
if let Some(claim_check) = &self.claim_check {
for name in claim_check.keys() {
out.push((SUPPORTED_REGISTRY_KINDS[2], name.as_str()));
}
}
out
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InterceptActionDoc {
pub skip_to: Option<String>,
pub divert_copy_to: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ExpectReply {
#[serde(default, deserialize_with = "deserialize_reply_body")]
pub body: Option<BodyMatcher>,
#[serde(default, deserialize_with = "deserialize_reply_headers")]
pub headers: Option<HashMap<String, HeaderMatcher>>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TestInput {
pub to: String,
#[serde(default, deserialize_with = "deserialize_option_input_body")]
pub body: Option<InputBody>,
pub headers: Option<HashMap<String, serde_json::Value>>,
pub expect_reply: Option<ExpectReply>,
}
#[derive(Debug)]
pub enum InputBody {
Text(String),
Json(serde_json::Value),
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ExpectSet {
pub count: Option<usize>,
pub min_count: Option<usize>,
#[serde(default, deserialize_with = "deserialize_bodies")]
pub bodies: Option<Vec<BodyMatcher>>,
#[serde(default, deserialize_with = "deserialize_expect_headers")]
pub headers: Option<HashMap<String, HeaderMatcher>>,
}
fn input_body_from_value(value: serde_json::Value) -> Result<Option<InputBody>, String> {
match &value {
serde_json::Value::String(s) => Ok(Some(InputBody::Text(s.clone()))),
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
Ok(Some(InputBody::Json(value)))
}
scalar => Err(format!("{BODY_SCALAR_SENTINEL}{scalar}")),
}
}
fn deserialize_option_input_body<'de, D>(deserializer: D) -> Result<Option<InputBody>, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
input_body_from_value(value).map_err(D::Error::custom)
}
fn is_body_matcher_key(key: &str) -> bool {
matches!(
key,
"equals" | "regex" | "contains" | "startsWith" | "endsWith" | "exists" | "jsonSubset"
)
}
fn predicate_error(field: &str) -> TestDocError {
TestDocError::InvalidMatcher(format!("{field}: predicate matchers are not supported"))
}
fn body_from_json(value: &serde_json::Value) -> Body {
match value {
serde_json::Value::String(s) => Body::Text(s.clone()),
other => Body::Json(other.clone()),
}
}
fn body_matcher_from_map(
key: &str,
payload: &serde_json::Value,
field: &str,
) -> Result<BodyMatcher, TestDocError> {
match key {
"equals" => Ok(BodyMatcher::Equals(body_from_json(payload))),
"regex" | "contains" | "startsWith" | "endsWith" => {
let Some(pattern) = payload.as_str() else {
return Err(TestDocError::InvalidMatcher(format!(
"{field}: `{key}` requires a string payload"
)));
};
if key == "regex"
&& let Err(e) = Regex::new(pattern)
{
return Err(TestDocError::InvalidMatcher(format!(
"{field}: invalid regex in `{key}` `{pattern}`: {e}"
)));
}
Ok(match key {
"regex" => BodyMatcher::Regex(pattern.to_string()),
"contains" => BodyMatcher::Contains(pattern.to_string()),
"startsWith" => BodyMatcher::StartsWith(pattern.to_string()),
_ => BodyMatcher::EndsWith(pattern.to_string()),
})
}
"exists" => {
if payload.is_null() {
Ok(BodyMatcher::Exists)
} else {
Err(TestDocError::InvalidMatcher(format!(
"{field}: `exists` takes no argument"
)))
}
}
"jsonSubset" => match payload {
serde_json::Value::Object(map) => Ok(BodyMatcher::JsonSubset(
serde_json::Value::Object(map.clone()),
)),
_ => Err(TestDocError::InvalidMatcher(format!(
"{field}: `jsonSubset` must be an object"
))),
},
_ => Err(TestDocError::InvalidMatcher(format!(
"{field}: unknown matcher key `{key}`"
))),
}
}
fn body_entry_shape_error(
map: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> TestDocError {
let mut keys = map.iter();
let detail = match (map.len(), keys.next()) {
(0, None) => "a matcher map must have exactly one key (empty map)".to_string(),
(1, Some((key, _))) => format!("unknown matcher key `{key}`"),
_ => format!(
"a matcher map must have exactly one key (got {})",
map.keys()
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>()
.join(", ")
),
};
TestDocError::InvalidMatcher(format!(
"{field} entries must be strings or matcher maps: {detail}"
))
}
fn parse_body_entry(value: &serde_json::Value, field: &str) -> Result<BodyMatcher, TestDocError> {
match value {
serde_json::Value::String(s) => Ok(BodyMatcher::Equals(Body::Text(s.clone()))),
serde_json::Value::Object(map) => {
let mut keys = map.iter();
let sole = if map.len() == 1 { keys.next() } else { None };
if let Some((key, payload)) = sole {
if key == "predicate" {
return Err(predicate_error(field));
}
if is_body_matcher_key(key) {
return body_matcher_from_map(key, payload, field);
}
}
Err(body_entry_shape_error(map, field))
}
_ => Err(TestDocError::InvalidMatcher(format!(
"{field} entries must be strings or matcher maps: bare scalars and \
arrays are not body expectations"
))),
}
}
fn parse_header_value(
value: &serde_json::Value,
field: &str,
) -> Result<HeaderMatcher, TestDocError> {
let serde_json::Value::Object(map) = value else {
return Ok(HeaderMatcher::Equals(value.clone()));
};
let mut keys = map.iter();
let sole = if map.len() == 1 { keys.next() } else { None };
if let Some((key, payload)) = sole {
match key.as_str() {
"equals" => Ok(HeaderMatcher::Equals(payload.clone())),
"regex" => {
let Some(pattern) = payload.as_str() else {
return Err(TestDocError::InvalidMatcher(format!(
"{field}: `regex` requires a string payload"
)));
};
Regex::new(pattern).map_err(|e| {
TestDocError::InvalidMatcher(format!(
"{field}: invalid regex in `regex` `{pattern}`: {e}"
))
})?;
Ok(HeaderMatcher::Regex(pattern.to_string()))
}
"exists" => {
if payload.is_null() {
Ok(HeaderMatcher::Exists)
} else {
Err(TestDocError::InvalidMatcher(format!(
"{field}: `exists` takes no argument"
)))
}
}
"jsonSubset" => Err(TestDocError::InvalidMatcher(format!(
"{field}: `jsonSubset` applies to bodies only"
))),
"predicate" => Err(predicate_error(field)),
_ => Ok(HeaderMatcher::Equals(value.clone())),
}
} else {
Ok(HeaderMatcher::Equals(value.clone()))
}
}
fn parse_reply_body(value: &serde_json::Value, field: &str) -> Result<BodyMatcher, TestDocError> {
match value {
serde_json::Value::Object(map) => {
let mut keys = map.iter();
let sole = if map.len() == 1 { keys.next() } else { None };
if let Some((key, payload)) = sole {
if key == "predicate" {
return Err(predicate_error(field));
}
if is_body_matcher_key(key) {
return body_matcher_from_map(key, payload, field);
}
}
Ok(BodyMatcher::Equals(Body::Json(value.clone())))
}
serde_json::Value::String(s) => Ok(BodyMatcher::Equals(Body::Text(s.clone()))),
scalar_or_array => Ok(BodyMatcher::Equals(Body::Json(scalar_or_array.clone()))),
}
}
fn deserialize_bodies<'de, D>(deserializer: D) -> Result<Option<Vec<BodyMatcher>>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<Vec<serde_json::Value>>::deserialize(deserializer)?;
let Some(entries) = raw else {
return Ok(None);
};
let mut matchers = Vec::with_capacity(entries.len());
for entry in &entries {
let matcher = parse_body_entry(entry, "bodies")
.map_err(|e| D::Error::custom(format!("{MATCHER_SENTINEL}{e}")))?;
matchers.push(matcher);
}
Ok(Some(matchers))
}
fn deserialize_header_map<'de, D>(
deserializer: D,
field_prefix: &str,
) -> Result<Option<HashMap<String, HeaderMatcher>>, D::Error>
where
D: Deserializer<'de>,
{
let raw = Option::<HashMap<String, serde_json::Value>>::deserialize(deserializer)?;
let Some(headers) = raw else {
return Ok(None);
};
let mut matchers = HashMap::with_capacity(headers.len());
for (key, value) in &headers {
let field = format!("{field_prefix}[{key}]");
let matcher = parse_header_value(value, &field)
.map_err(|e| D::Error::custom(format!("{MATCHER_SENTINEL}{e}")))?;
matchers.insert(key.clone(), matcher);
}
Ok(Some(matchers))
}
fn deserialize_expect_headers<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<String, HeaderMatcher>>, D::Error>
where
D: Deserializer<'de>,
{
deserialize_header_map(deserializer, "headers")
}
fn deserialize_reply_headers<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<String, HeaderMatcher>>, D::Error>
where
D: Deserializer<'de>,
{
deserialize_header_map(deserializer, "expectReply.headers")
}
fn deserialize_reply_body<'de, D>(deserializer: D) -> Result<Option<BodyMatcher>, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
parse_reply_body(&value, "expectReply.body")
.map(Some)
.map_err(|e| D::Error::custom(format!("{MATCHER_SENTINEL}{e}")))
}
#[derive(Debug)]
pub enum TestDocError {
Yaml(String),
UnknownField(String),
RouteSourceConflict { present: Vec<&'static str> },
NoProjectRoot { doc_dir: String },
ExpectsEmpty,
ExpectKeyMissingScheme { key: String },
CountAndMinCount(String),
SettleOutOfRange(String),
UnsupportedInputScheme { target: String },
UnsupportedBodyScalar(String),
InterceptEmptySource,
InterceptMockSource { key: String },
InterceptActionKeys { key: String, problem: &'static str },
InterceptEmptyTargetPath { key: String },
InterceptInvalid(String),
InvalidBeans(String),
InvalidRepositories(String),
InvalidReply(String),
InvalidMatcher(String),
}
impl fmt::Display for TestDocError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Yaml(raw) => write!(f, "invalid test document: {raw}"),
Self::UnknownField(raw) => write!(f, "unknown field in test document: {raw}"),
Self::RouteSourceConflict { present } => {
if present.is_empty() {
write!(
f,
"exactly one route source (`routeFiles`, `routeFilesFromRoot`, \
or `routes`) is required"
)
} else {
write!(
f,
"route sources {} are mutually exclusive; exactly one route \
source is required",
present
.iter()
.map(|key| format!("`{key}`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
Self::NoProjectRoot { doc_dir } => write!(
f,
"NoProjectRoot: routeFilesFromRoot requires a Camel.toml in an ancestor \
directory of {doc_dir}; none was found."
),
Self::ExpectsEmpty => write!(
f,
"expects must declare at least one mock: endpoint unless an input declares expectReply"
),
Self::ExpectKeyMissingScheme { key } => {
write!(f, "expects key `{key}` must start with `mock:`")
}
Self::CountAndMinCount(endpoint) => write!(
f,
"expects entry `{endpoint}` must not set both count and minCount"
),
Self::SettleOutOfRange(raw) => {
write!(
f,
"settle `{raw}` out of range: must satisfy 0 < settle <= 5s"
)
}
Self::UnsupportedInputScheme { target } => {
write!(f, "input target `{target}` must start with `direct:`")
}
Self::UnsupportedBodyScalar(raw) => write!(
f,
"unsupported body scalar `{raw}`: only string, object, and array bodies are supported"
),
Self::InterceptEmptySource => write!(f, "intercept source URI must not be empty"),
Self::InterceptMockSource { key } => {
write!(f, "intercept source `{key}` must not start with `mock:`")
}
Self::InterceptActionKeys { key, problem } => write!(
f,
"intercept action for `{key}`: exactly one of `skipTo` or `divertCopyTo` is required (got {problem})"
),
Self::InterceptEmptyTargetPath { key } => write!(
f,
"intercept target for `{key}` needs a mock endpoint name: `mock:` requires a non-empty endpoint path"
),
Self::InterceptInvalid(msg) => write!(f, "invalid intercept: {msg}"),
Self::InvalidBeans(msg) => write!(f, "{msg}"),
Self::InvalidRepositories(msg) => write!(f, "{msg}"),
Self::InvalidReply(msg) => write!(f, "{msg}"),
Self::InvalidMatcher(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for TestDocError {}
fn classify_yaml_error(raw: &str) -> TestDocError {
if let Some((_, after)) = raw.split_once(BODY_SCALAR_SENTINEL) {
let scalar = after.split_whitespace().next().unwrap_or_default();
return TestDocError::UnsupportedBodyScalar(scalar.to_string());
}
if let Some((_, after)) = raw.split_once(MATCHER_SENTINEL) {
let msg = after.split_once(" at line ").map_or(after, |(msg, _)| msg);
return TestDocError::InvalidMatcher(msg.to_string());
}
if raw.contains("unknown field") {
return TestDocError::UnknownField(raw.to_string());
}
TestDocError::Yaml(raw.to_string())
}
pub fn parse_test_document(text: &str) -> Result<TestDocument, TestDocError> {
let mut doc = serde_yaml::from_str::<TestDocument>(text)
.map_err(|e| classify_yaml_error(&e.to_string()))?;
let mut present: Vec<&'static str> = Vec::new();
if doc.route_files.is_some() {
present.push("routeFiles");
}
if doc.route_files_from_root.is_some() {
present.push("routeFilesFromRoot");
}
if doc.routes.is_some() {
present.push("routes");
}
if present.len() != 1 {
return Err(TestDocError::RouteSourceConflict { present });
}
let any_expect_reply = doc.inputs.iter().any(|i| i.expect_reply.is_some());
if doc.expects.is_empty() && !any_expect_reply {
return Err(TestDocError::ExpectsEmpty);
}
for key in doc.expects.keys() {
if !key.starts_with(MOCK_SCHEME_PREFIX) {
return Err(TestDocError::ExpectKeyMissingScheme { key: key.clone() });
}
}
let raw_expects = std::mem::take(&mut doc.expects);
for (key, set) in raw_expects {
let bare = key[MOCK_SCHEME_PREFIX.len()..].to_string();
if set.count.is_some() && set.min_count.is_some() {
return Err(TestDocError::CountAndMinCount(bare));
}
doc.expects.insert(bare, set);
}
if let Some(raw) = doc.settle.clone() {
let parsed = humantime::parse_duration(&raw)
.map_err(|_| TestDocError::SettleOutOfRange(raw.clone()))?;
if parsed.is_zero() || parsed > SETTLE_MAX {
return Err(TestDocError::SettleOutOfRange(raw));
}
doc.settle_parsed = Some(parsed);
}
for input in &doc.inputs {
if !input.to.starts_with(DIRECT_SCHEME_PREFIX) {
return Err(TestDocError::UnsupportedInputScheme {
target: input.to.clone(),
});
}
if let Some(reply) = input.expect_reply.as_ref()
&& reply.body.is_none()
&& reply.headers.is_none()
{
return Err(TestDocError::InvalidReply(
"expectReply must declare body or headers".to_string(),
));
}
}
if let Some(intercepts) = doc.intercepts.as_ref() {
let mut rules: Vec<InterceptRule> = Vec::new();
for (source, action) in intercepts {
if source.is_empty() {
return Err(TestDocError::InterceptEmptySource);
}
if source.starts_with(MOCK_SCHEME_PREFIX) {
return Err(TestDocError::InterceptMockSource {
key: source.clone(),
});
}
let (target, rule_action) = match (&action.skip_to, &action.divert_copy_to) {
(Some(t), None) => (t.as_str(), InterceptAction::SkipTo { uri: t.clone() }),
(None, Some(t)) => (t.as_str(), InterceptAction::DivertCopyTo { uri: t.clone() }),
(Some(_), Some(_)) => {
return Err(TestDocError::InterceptActionKeys {
key: source.clone(),
problem: "both",
});
}
(None, None) => {
return Err(TestDocError::InterceptActionKeys {
key: source.clone(),
problem: "neither",
});
}
};
if target == "mock:" || target.starts_with("mock:?") {
return Err(TestDocError::InterceptEmptyTargetPath {
key: source.clone(),
});
}
rules.push(InterceptRule {
uri: source.clone(),
action: rule_action,
});
}
match InterceptRules::new(rules) {
Ok(parsed) => doc.intercept_rules_parsed = Some(parsed),
Err(e) => {
let msg = match e {
camel_api::CamelError::Config(inner) => inner,
other => other.to_string(),
};
return Err(TestDocError::InterceptInvalid(msg));
}
}
}
if let Some(beans) = doc.beans.as_ref() {
for (name, decl) in beans {
if name.trim().is_empty() {
return Err(TestDocError::InvalidBeans(
"bean names must be non-blank".to_string(),
));
}
if decl.methods == Some(vec![]) {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: methods must be non-empty or omitted"
)));
}
if let Some(methods) = decl.methods.as_ref() {
for entry in methods {
if entry.trim().is_empty() {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: method names must be non-blank"
)));
}
}
}
match decl.kind {
BeanKindDoc::Echo => {
if let Some(config) = decl.config.as_ref()
&& let Some(key) = config.keys().next()
{
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: config key {key} is not valid for kind echo"
)));
}
}
BeanKindDoc::SetBody => {
let Some(config) = decl.config.as_ref().filter(|c| c.contains_key("body"))
else {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: kind setBody requires config key body"
)));
};
for key in config.keys() {
if key != "body" {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: config key {key} is not valid for kind setBody"
)));
}
}
}
BeanKindDoc::Fail => {
if let Some(config) = decl.config.as_ref() {
for key in config.keys() {
if key != "message" {
return Err(TestDocError::InvalidBeans(format!(
"bean {name}: config key {key} is not valid for kind fail"
)));
}
}
}
}
}
}
}
validate_repositories(&doc)?;
Ok(doc)
}
fn validate_repositories(doc: &TestDocument) -> Result<(), TestDocError> {
let Some(repos) = doc.repositories.as_ref() else {
return Ok(());
};
if !repos.extra.is_empty() {
let kinds = repos
.extra
.keys()
.map(|kind| format!("`{kind}`"))
.collect::<Vec<_>>()
.join(", ");
let noun = if repos.extra.len() == 1 {
"unknown registry kind"
} else {
"unknown registry kinds"
};
return Err(TestDocError::InvalidRepositories(format!(
"{noun} {kinds}; supported kinds: {}",
SUPPORTED_REGISTRY_KINDS.join(", ")
)));
}
for (registry, map) in [
(SUPPORTED_REGISTRY_KINDS[0], &repos.cache),
(SUPPORTED_REGISTRY_KINDS[1], &repos.idempotent),
(SUPPORTED_REGISTRY_KINDS[2], &repos.claim_check),
] {
let Some(map) = map.as_ref() else {
continue;
};
for (name, target) in map {
if name.trim().is_empty() {
return Err(TestDocError::InvalidRepositories(
"repository names must be non-blank".to_string(),
));
}
if name == "memory" {
return Err(TestDocError::InvalidRepositories(format!(
"repository {name}: `memory` is a built-in repository name and cannot be stubbed"
)));
}
if target != "memory" {
return Err(TestDocError::InvalidRepositories(format!(
"repository {registry} `{name}`: unsupported stub target `{target}`; \
only `memory` is supported"
)));
}
}
}
Ok(())
}