diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0a838179..2268f1b0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1003,6 +1003,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "googletest" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e38fa267f4db1a2fa51795ea4234eaadc3617a97486a9f158de9256672260e" +dependencies = [ + "googletest_macro", + "num-traits", + "regex", + "rustversion", +] + +[[package]] +name = "googletest_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171deab504ad43a9ea80324a3686a0cbe9436220d9d0b48ae4d7f7bd303b48a9" +dependencies = [ + "quote 1.0.37", + "syn 2.0.85", +] + [[package]] name = "gregorian" version = "0.2.4" @@ -2159,6 +2181,7 @@ dependencies = [ "env_logger 0.11.5", "expectest", "futures", + "googletest", "hamcrest2", "hex", "http 1.1.0", @@ -2179,11 +2202,12 @@ dependencies = [ "quickcheck", "rand", "reqwest", - "rstest 0.22.0", + "rstest 0.23.0", "semver", "serde", "serde_json", "serde_urlencoded", + "snailquote", "sxd-document", "test-log", "tokio", @@ -2955,6 +2979,18 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros 0.23.0", + "rustc_version", +] + [[package]] name = "rstest_macros" version = "0.19.0" @@ -2990,6 +3026,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2 1.0.89", + "quote 1.0.37", + "regex", + "relative-path", + "rustc_version", + "syn 2.0.85", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3358,6 +3412,16 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "snailquote" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec62a949bda7f15800481a711909f946e1204f2460f89210eaf7f57730f88f86" +dependencies = [ + "thiserror", + "unicode_categories", +] + [[package]] name = "snapbox" version = "0.6.18" @@ -4047,6 +4111,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/rust/pact_matching/Cargo.toml b/rust/pact_matching/Cargo.toml index 82c6e611..2d92336a 100644 --- a/rust/pact_matching/Cargo.toml +++ b/rust/pact_matching/Cargo.toml @@ -49,6 +49,7 @@ semver = "1.0.22" serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0" serde_urlencoded = "0.7.1" +snailquote = "0.3.1" sxd-document = { version = "0.3.2", optional = true } tokio = { version = "1.37.0", features = ["full"] } tracing = "0.1.40" @@ -60,9 +61,10 @@ uuid = { version = "1.8.0", features = ["v4"] } quickcheck = "1" expectest = "0.12.0" env_logger = "0.11.3" +googletest = "0.12.0" hamcrest2 = "0.3.0" test-log = { version = "0.2.15", features = ["trace"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "tracing-log", "fmt"] } ntest = "0.9.0" pretty_assertions = "1.4.0" -rstest = "0.22.0" +rstest = "0.23.0" diff --git a/rust/pact_matching/src/engine/bodies.rs b/rust/pact_matching/src/engine/bodies.rs new file mode 100644 index 00000000..d3059a87 --- /dev/null +++ b/rust/pact_matching/src/engine/bodies.rs @@ -0,0 +1,60 @@ +//! Types for supporting building and executing plans for bodies + +use std::fmt::Debug; +use std::sync::{Arc, LazyLock, RwLock}; + +use bytes::Bytes; + +use pact_models::content_types::ContentType; +use pact_models::path_exp::DocPath; + +use crate::engine::{ExecutionPlanNode, PlanMatchingContext}; + +/// Trait for implementations of builders for different types of bodies +pub trait PlanBodyBuilder: Debug { + /// If this builder supports the given content type + fn supports_type(&self, content_type: &ContentType) -> bool; + + /// Build the plan for the expected body + fn build_plan(&self, content: &Bytes, context: &PlanMatchingContext) -> anyhow::Result; +} + +static BODY_PLAN_BUILDERS: LazyLock>>> = LazyLock::new(|| { + let mut builders = vec![]; + // TODO: Add default implementations here + RwLock::new(builders) +}); + +pub(crate) fn get_body_plan_builder(content_type: &ContentType) -> Option> { + let registered_builders = (*BODY_PLAN_BUILDERS).read().unwrap(); + registered_builders.iter().find(|builder| builder.supports_type(content_type)) + .cloned() +} + +/// Plan builder for plain text. This just sets up an equality matcher +#[derive(Clone, Debug)] +pub struct PlainTextBuilder; + +impl PlainTextBuilder { + /// Create a new instance + pub fn new() -> Self { + PlainTextBuilder{} + } +} + +impl PlanBodyBuilder for PlainTextBuilder { + fn supports_type(&self, content_type: &ContentType) -> bool { + content_type.is_text() + } + + fn build_plan(&self, content: &Bytes, _context: &PlanMatchingContext) -> anyhow::Result { + let bytes = content.to_vec(); + let text_content = String::from_utf8_lossy(&bytes); + let mut node = ExecutionPlanNode::action("match:equality"); + let mut child_node = ExecutionPlanNode::action("convert:UTF8"); + child_node.add(ExecutionPlanNode::resolve_value(DocPath::new_unwrap("$.body"))); + node.add(child_node); + node.add(ExecutionPlanNode::value_node(text_content.to_string())); + Ok(node) + } +} diff --git a/rust/pact_matching/src/engine/context.rs b/rust/pact_matching/src/engine/context.rs new file mode 100644 index 00000000..518ab8c3 --- /dev/null +++ b/rust/pact_matching/src/engine/context.rs @@ -0,0 +1,136 @@ +//! Traits and structs for dealing with the test context. + +use std::panic::RefUnwindSafe; + +use anyhow::anyhow; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use tracing::{instrument, trace, Level}; + +use pact_models::matchingrules::MatchingRule; +use pact_models::prelude::v4::{SynchronousHttp, V4Pact}; +use pact_models::v4::interaction::V4Interaction; + +use crate::engine::{ExecutionPlanNode, NodeResult, NodeValue}; +use crate::matchers::Matches; + +/// Context to store data for use in executing an execution plan. +#[derive(Clone, Debug)] +pub struct PlanMatchingContext { + /// Pact the plan is for + pub pact: V4Pact, + /// Interaction that the plan id for + pub interaction: Box +} + +impl PlanMatchingContext { + /// Execute the action + #[instrument(ret, skip(self), level = Level::TRACE)] + pub fn execute_action( + &self, + action: &str, + arguments: &Vec + ) -> anyhow::Result { + trace!(%action, ?arguments, "Executing action"); + match action { + "upper-case" => { + let value = validate_one_arg(arguments, action)?; + let result = value.as_string() + .unwrap_or_default(); + Ok(NodeResult::VALUE(NodeValue::STRING(result.to_uppercase()))) + } + "match:equality" => { + let (first, second) = validate_two_args(arguments, action)?; + let first = first.as_value().unwrap_or_default(); + let second = second.as_value().unwrap_or_default(); + first.matches_with(second, &MatchingRule::Equality, false)?; + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } + "expect:empty" => { + let arg = validate_one_arg(arguments, action)?; + let arg_value = arg.as_value(); + if let Some(value) = &arg_value { + match value { + NodeValue::NULL => Ok(NodeResult::VALUE(NodeValue::BOOL(true))), + NodeValue::STRING(s) => if s.is_empty() { + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } else { + Err(anyhow!("Expected {:?} to be empty", value)) + } + NodeValue::BOOL(b) => Ok(NodeResult::VALUE(NodeValue::BOOL(*b))), + NodeValue::MMAP(m) => if m.is_empty() { + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } else { + Err(anyhow!("Expected {:?} to be empty", value)) + } + NodeValue::SLIST(l) => if l.is_empty() { + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } else { + Err(anyhow!("Expected {:?} to be empty", value)) + }, + NodeValue::BARRAY(bytes) => if bytes.is_empty() { + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } else { + Err(anyhow!("Expected byte array ({} bytes) to be empty", bytes.len())) + } + } + } else { + // TODO: If the parameter value is an error, this should return an error? + Ok(NodeResult::VALUE(NodeValue::BOOL(true))) + } + } + "convert:UTF8" => { + let arg = validate_one_arg(arguments, action)?; + let arg_value = arg.as_value(); + if let Some(value) = &arg_value { + match value { + NodeValue::NULL => Ok(NodeResult::VALUE(NodeValue::STRING("".to_string()))), + NodeValue::STRING(s) => Ok(NodeResult::VALUE(NodeValue::STRING(s.clone()))), + NodeValue::BARRAY(b) => Ok(NodeResult::VALUE(NodeValue::STRING(String::from_utf8_lossy(b).to_string()))), + _ => Err(anyhow!("convert:UTF8 can not be used with {}", value.value_type())) + } + } else { + Ok(NodeResult::VALUE(NodeValue::STRING("".to_string()))) + } + } + "if" => { + let (first, second) = validate_two_args(arguments, action)?; + if first.is_truthy() { + Ok(second) + } else { + Ok(NodeResult::VALUE(NodeValue::BOOL(false))) + } + } + _ => Err(anyhow!("'{}' is not a valid action", action)) + } + } +} + +fn validate_two_args(arguments: &Vec, action: &str) -> anyhow::Result<(NodeResult, NodeResult)> { + if arguments.len() == 2 { + let first = arguments[0].value().unwrap_or_default(); + let second = arguments[1].value().unwrap_or_default(); + Ok((first, second)) + } else { + Err(anyhow!("{} requires two arguments, got {}", action, arguments.len())) + } +} + +fn validate_one_arg(arguments: &Vec, action: &str) -> anyhow::Result { + if arguments.len() > 1 { + Err(anyhow!("{} takes only one argument, got {}", action, arguments.len())) + } else if let Some(argument) = arguments.first() { + Ok(argument.value().unwrap_or_default()) + } else { + Err(anyhow!("{} requires one argument, got none", action)) + } +} + +impl Default for PlanMatchingContext { + fn default() -> Self { + PlanMatchingContext { + pact: Default::default(), + interaction: Box::new(SynchronousHttp::default()) + } + } +} diff --git a/rust/pact_matching/src/engine/mod.rs b/rust/pact_matching/src/engine/mod.rs index 5d25daf6..7c82b7c3 100644 --- a/rust/pact_matching/src/engine/mod.rs +++ b/rust/pact_matching/src/engine/mod.rs @@ -1,15 +1,36 @@ //! Structs and traits to support a general matching engine +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::{Debug, Display, Formatter}; use std::panic::RefUnwindSafe; +use anyhow::anyhow; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use itertools::Itertools; +use onig::EncodedChars; +use snailquote::escape; +use tracing::{instrument, Level, trace}; + use pact_models::bodies::OptionalBody; -use pact_models::content_types::{ContentType, TEXT}; +use pact_models::content_types::TEXT; +use pact_models::matchingrules::MatchingRule; use pact_models::path_exp::DocPath; use pact_models::v4::http_parts::HttpRequest; use pact_models::v4::interaction::V4Interaction; use pact_models::v4::pact::V4Pact; use pact_models::v4::synch_http::SynchronousHttp; +use crate::engine::bodies::{get_body_plan_builder, PlainTextBuilder, PlanBodyBuilder}; +use crate::engine::context::PlanMatchingContext; +use crate::engine::value_resolvers::{HttpRequestValueResolver, ValueResolver}; +use crate::matchers::Matches; + +mod bodies; +mod value_resolvers; +mod context; + /// Enum for the type of Plan Node #[derive(Clone, Debug, Default)] pub enum PlanNodeType { @@ -27,13 +48,21 @@ pub enum PlanNodeType { } /// Enum for the value stored in a leaf node -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub enum NodeValue { /// Default is no value #[default] NULL, /// A string value STRING(String), + /// Boolean value + BOOL(bool), + /// Multi-string map (String key to one or more string values) + MMAP(HashMap>), + /// List of String values + SLIST(Vec), + /// Byte Array + BARRAY(Vec) } impl NodeValue { @@ -41,7 +70,76 @@ impl NodeValue { pub fn str_form(&self) -> String { match self { NodeValue::NULL => "NULL".to_string(), - NodeValue::STRING(str) => format!("\"{}\"", str) + NodeValue::STRING(str) => { + Self::escape_string(str) + } + NodeValue::BOOL(b) => { + format!("BOOL({})", b) + } + NodeValue::MMAP(map) => { + let mut buffer = String::new(); + buffer.push('{'); + + let mut first = true; + for (key, values) in map { + if first { + first = false; + } else { + buffer.push_str(", "); + } + buffer.push_str(Self::escape_string(key).as_str()); + if values.is_empty() { + buffer.push_str(": []"); + } else if values.len() == 1 { + buffer.push_str(": "); + buffer.push_str(Self::escape_string(&values[0]).as_str()); + } else { + buffer.push_str(": ["); + buffer.push_str(values.iter().map(|v| Self::escape_string(v)).join(", ").as_str()); + buffer.push(']'); + } + } + + buffer.push('}'); + buffer + } + NodeValue::SLIST(list) => { + let mut buffer = String::new(); + buffer.push('['); + buffer.push_str(list.iter().map(|v| Self::escape_string(v)).join(", ").as_str()); + buffer.push(']'); + buffer + } + NodeValue::BARRAY(bytes) => { + let mut buffer = String::new(); + buffer.push_str("BYTES("); + buffer.push_str(bytes.len().to_string().as_str()); + buffer.push_str(", "); + buffer.push_str(BASE64.encode(bytes).as_str()); + buffer.push(')'); + buffer + } + } + } + + fn escape_string(str: &String) -> String { + let escaped_str = escape(str); + if let Cow::Borrowed(_) = &escaped_str { + format!("'{}'", escaped_str) + } else { + escaped_str.to_string() + } + } + + /// Returns the type of the value + pub fn value_type(&self) -> &str { + match self { + NodeValue::NULL => "NULL", + NodeValue::STRING(_) => "String", + NodeValue::BOOL(_) => "Boolean", + NodeValue::MMAP(_) => "Multi-Value String Map", + NodeValue::SLIST(_) => "String List", + NodeValue::BARRAY(_) => "Byte Array" } } } @@ -58,8 +156,42 @@ impl From<&str> for NodeValue { } } +impl Matches for NodeValue { + fn matches_with(&self, actual: NodeValue, matcher: &MatchingRule, cascaded: bool) -> anyhow::Result<()> { + match matcher { + MatchingRule::Equality => if self == &actual { + Ok(()) + } else { + Err(anyhow!("Expected {} to equal {}", self.str_form(), actual.str_form())) + } + MatchingRule::Regex(_) => todo!(), + MatchingRule::Type => todo!(), + MatchingRule::MinType(_) => todo!(), + MatchingRule::MaxType(_) => todo!(), + MatchingRule::MinMaxType(_, _) => todo!(), + MatchingRule::Timestamp(_) => todo!(), + MatchingRule::Time(_) => todo!(), + MatchingRule::Date(_) => todo!(), + MatchingRule::Include(_) => todo!(), + MatchingRule::Number => todo!(), + MatchingRule::Integer => todo!(), + MatchingRule::Decimal => todo!(), + MatchingRule::Null => todo!(), + MatchingRule::ContentType(_) => todo!(), + MatchingRule::ArrayContains(_) => todo!(), + MatchingRule::Values => todo!(), + MatchingRule::Boolean => todo!(), + MatchingRule::StatusCode(_) => todo!(), + MatchingRule::NotEmpty => todo!(), + MatchingRule::Semver => todo!(), + MatchingRule::EachKey(_) => todo!(), + MatchingRule::EachValue(_) => todo!() + } + } +} + /// Enum to store the result of executing a node -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] pub enum NodeResult { /// Default value to make a node as successfully executed #[default] @@ -70,6 +202,80 @@ pub enum NodeResult { ERROR(String) } +impl NodeResult { + /// Return the OR of this result with the given one + pub fn or(&self, option: &Option) -> NodeResult { + if let Some(result) = option { + match result { + NodeResult::OK => match self { + NodeResult::OK => NodeResult::OK, + NodeResult::VALUE(_) => NodeResult::OK, + NodeResult::ERROR(_) => NodeResult::ERROR("One or more children failed".to_string()) + }, + NodeResult::VALUE(_) => match self { + NodeResult::OK => NodeResult::OK, + NodeResult::VALUE(_) => NodeResult::OK, + NodeResult::ERROR(_) => NodeResult::ERROR("One or more children failed".to_string()) + } + NodeResult::ERROR(_) => NodeResult::ERROR("One or more children failed".to_string()) + } + } else { + self.clone() + } + } + + /// Converts the result value to a string + pub fn as_string(&self) -> Option { + match self { + NodeResult::OK => None, + NodeResult::VALUE(val) => match val { + NodeValue::NULL => Some("".to_string()), + NodeValue::STRING(s) => Some(s.clone()), + NodeValue::BOOL(b) => Some(b.to_string()), + NodeValue::MMAP(m) => Some(format!("{:?}", m)), + NodeValue::SLIST(list) => Some(format!("{:?}", list)), + NodeValue::BARRAY(bytes) => Some(BASE64.encode(bytes)) + } + NodeResult::ERROR(_) => None + } + } + + /// Returns the associated value if there is one + pub fn as_value(&self) -> Option { + match self { + NodeResult::OK => None, + NodeResult::VALUE(val) => Some(val.clone()), + NodeResult::ERROR(_) => None + } + } + + /// If this value represents a truthy value (not NULL, false ot empty) + pub fn is_truthy(&self) -> bool { + match self { + NodeResult::OK => true, + NodeResult::VALUE(v) => match v { + NodeValue::NULL => false, + NodeValue::STRING(s) => !s.is_empty(), + NodeValue::BOOL(b) => *b, + NodeValue::MMAP(m) => !m.is_empty(), + NodeValue::SLIST(l) => !l.is_empty(), + NodeValue::BARRAY(b) => !b.is_empty() + } + NodeResult::ERROR(_) => false + } + } +} + +impl Display for NodeResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + NodeResult::OK => write!(f, "OK"), + NodeResult::VALUE(val) => write!(f, "{}", val.str_form()), + NodeResult::ERROR(err) => write!(f, "ERROR({})", err), + } + } +} + /// Node in an executable plan tree #[derive(Clone, Debug, Default)] pub struct ExecutionPlanNode { @@ -117,14 +323,29 @@ impl ExecutionPlanNode { buffer.push_str(pad.as_str()); buffer.push(')'); } + + if let Some(result) = &self.result { + buffer.push_str(" ~ "); + buffer.push_str(result.to_string().as_str()); + } } PlanNodeType::VALUE(value) => { buffer.push_str(pad.as_str()); buffer.push_str(value.str_form().as_str()); + + if let Some(result) = &self.result { + buffer.push_str(" ~ "); + buffer.push_str(result.to_string().as_str()); + } } PlanNodeType::RESOLVE(str) => { buffer.push_str(pad.as_str()); buffer.push_str(str.to_string().as_str()); + + if let Some(result) = &self.result { + buffer.push_str(" ~ "); + buffer.push_str(result.to_string().as_str()); + } } } } @@ -164,12 +385,27 @@ impl ExecutionPlanNode { buffer.push('('); self.str_form_children(&mut buffer); buffer.push(')'); + + if let Some(result) = &self.result { + buffer.push('~'); + buffer.push_str(result.to_string().as_str()); + } } PlanNodeType::VALUE(value) => { buffer.push_str(value.str_form().as_str()); + + if let Some(result) = &self.result { + buffer.push('~'); + buffer.push_str(result.to_string().as_str()); + } } PlanNodeType::RESOLVE(str) => { buffer.push_str(str.to_string().as_str()); + + if let Some(result) = &self.result { + buffer.push('~'); + buffer.push_str(result.to_string().as_str()); + } } } @@ -206,7 +442,7 @@ impl ExecutionPlanNode { } /// Constructor for a value node - pub fn value>(value: T) -> ExecutionPlanNode { + pub fn value_node>(value: T) -> ExecutionPlanNode { ExecutionPlanNode { node_type: PlanNodeType::VALUE(value.into()), result: None, @@ -236,6 +472,11 @@ impl ExecutionPlanNode { _ => self.children.is_empty() } } + + /// Returns the value for the node + pub fn value(&self) -> Option { + self.result.clone() + } } impl From<&mut ExecutionPlanNode> for ExecutionPlanNode { @@ -247,6 +488,7 @@ impl From<&mut ExecutionPlanNode> for ExecutionPlanNode { /// An executable plan that contains a tree of execution nodes #[derive(Clone, Debug, Default)] pub struct ExecutionPlan { + /// Root node for the plan tree pub plan_root: ExecutionPlanNode } @@ -284,24 +526,6 @@ impl ExecutionPlan { } } -/// Context to store data for use in executing an execution plan. -#[derive(Clone, Debug)] -pub struct PlanMatchingContext { - /// Pact the plan is for - pub pact: V4Pact, - /// Interaction that the plan id for - pub interaction: Box -} - -impl Default for PlanMatchingContext { - fn default() -> Self { - PlanMatchingContext { - pact: Default::default(), - interaction: Box::new(SynchronousHttp::default()) - } - } -} - /// Constructs an execution plan for the HTTP request part. pub fn build_request_plan( expected: &HttpRequest, @@ -328,7 +552,7 @@ fn setup_method_plan( match_method .add(ExecutionPlanNode::action("upper-case") .add(ExecutionPlanNode::resolve_value(DocPath::new("$.method")?))) - .add(ExecutionPlanNode::value(expected.method.as_str())); + .add(ExecutionPlanNode::value_node(expected.method.as_str())); // TODO: Look at the matching rules and generators here method_container.add(match_method); @@ -346,7 +570,7 @@ fn setup_path_plan( .add( ExecutionPlanNode::action("match:equality") .add(ExecutionPlanNode::resolve_value(DocPath::new("$.path")?)) - .add(ExecutionPlanNode::value(expected.path.as_str())) + .add(ExecutionPlanNode::value_node(expected.path.as_str())) ); Ok(plan_node) } @@ -418,13 +642,14 @@ fn setup_body_plan( content_type_check_node .add( ExecutionPlanNode::action("match:equality") - .add(ExecutionPlanNode::action("content-type")) - .add(ExecutionPlanNode::value(content_type.to_string())) + .add(ExecutionPlanNode::resolve_value(DocPath::new("$.content-type")?)) + .add(ExecutionPlanNode::value_node(content_type.to_string())) ); - if content_type.is_json() { - + if let Some(plan_builder) = get_body_plan_builder(&content_type) { + content_type_check_node.add(plan_builder.build_plan(content, context)?); } else { - todo!() + let plan_builder = PlainTextBuilder::new(); + content_type_check_node.add(plan_builder.build_plan(content, context)?); } plan_node.add(content_type_check_node); } @@ -433,26 +658,259 @@ fn setup_body_plan( Ok(plan_node) } +/// Executes the request plan against the actual request. pub fn execute_request_plan( plan: &ExecutionPlan, actual: &HttpRequest, - context: &PlanMatchingContext + context: &mut PlanMatchingContext ) -> anyhow::Result { - Ok(ExecutionPlan::default()) + let value_resolver = HttpRequestValueResolver { + request: actual.clone() + }; + let path = vec![]; + let executed_tree = walk_tree(&path, &plan.plan_root, &value_resolver, context)?; + Ok(ExecutionPlan { + plan_root: executed_tree + }) +} + +fn walk_tree( + path: &[String], + node: &ExecutionPlanNode, + value_resolver: &dyn ValueResolver, + context: &mut PlanMatchingContext +) -> anyhow::Result { + match &node.node_type { + PlanNodeType::EMPTY => { + trace!(?path, "Empty node"); + Ok(node.clone()) + }, + PlanNodeType::CONTAINER(label) => { + trace!(?path, %label, "Container node"); + let mut result = vec![]; + + let mut child_path = path.to_vec(); + child_path.push(label.clone()); + let mut status = NodeResult::OK; + for child in &node.children { + let child_result = walk_tree(&child_path, child, value_resolver, context)?; + status = status.or(&child_result.result); + result.push(child_result); + } + + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(status), + children: result + }) + } + PlanNodeType::ACTION(action) => { + trace!(?path, %action, "Action node"); + + let mut child_path = path.to_vec(); + child_path.push(action.clone()); + let mut result = vec![]; + for child in &node.children { + let child_result = walk_tree(&child_path, child, value_resolver, context)?; + result.push(child_result); + } + match context.execute_action(action.as_str(), &result) { + Ok(val) => { + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(val.clone()), + children: result.clone() + }) + } + Err(err) => { + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(NodeResult::ERROR(err.to_string())), + children: result.clone() + }) + } + } + } + PlanNodeType::VALUE(val) => { + trace!(?path, ?val, "Value node"); + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(NodeResult::VALUE(val.clone())), + children: vec![] + }) + } + PlanNodeType::RESOLVE(resolve_path) => { + match value_resolver.resolve(resolve_path, context) { + Ok(val) => { + trace!(?path, %resolve_path, ?val, "Resolve node"); + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(NodeResult::VALUE(val.clone())), + children: vec![] + }) + } + Err(err) => { + trace!(?path, %resolve_path, %err, "Resolve node failed"); + Ok(ExecutionPlanNode { + node_type: node.node_type.clone(), + result: Some(NodeResult::ERROR(err.to_string())), + children: vec![] + }) + } + } + } + } } #[cfg(test)] mod tests { use expectest::prelude::*; + use pretty_assertions::assert_eq; + use rstest::rstest; use serde_json::json; + use pact_models::bodies::OptionalBody; + use pact_models::content_types::TEXT; use pact_models::v4::http_parts::HttpRequest; - use pretty_assertions::assert_eq; - use crate::engine::{build_request_plan, execute_request_plan, ExecutionPlan, PlanMatchingContext}; + use crate::engine::{build_request_plan, execute_request_plan, NodeResult, NodeValue, PlanMatchingContext}; + + #[rstest( + case("", "''"), + case("simple", "'simple'"), + case("simple sentence", "'simple sentence'"), + case("\"quoted sentence\"", "'\"quoted sentence\"'"), + case("'quoted sentence'", "\"'quoted sentence'\""), + case("new\nline", "\"new\\nline\""), + )] + fn node_value_str_form_escapes_strings(#[case] input: &str, #[case] expected: &str) { + let node = NodeValue::STRING(input.to_string()); + expect!(node.str_form()).to(be_equal_to(expected)); + } + + #[rstest( + case(NodeResult::OK, None, NodeResult::OK), + case(NodeResult::VALUE(NodeValue::NULL), None, NodeResult::VALUE(NodeValue::NULL)), + case(NodeResult::ERROR("".to_string()), None, NodeResult::ERROR("".to_string())), + case(NodeResult::OK, Some(NodeResult::OK), NodeResult::OK), + case(NodeResult::OK, Some(NodeResult::VALUE(NodeValue::NULL)), NodeResult::OK), + case(NodeResult::OK, Some(NodeResult::ERROR("".to_string())), NodeResult::ERROR("One or more children failed".to_string())), + case(NodeResult::VALUE(NodeValue::NULL), Some(NodeResult::OK), NodeResult::OK), + case(NodeResult::VALUE(NodeValue::NULL), Some(NodeResult::VALUE(NodeValue::NULL)), NodeResult::OK), + case(NodeResult::VALUE(NodeValue::NULL), Some(NodeResult::ERROR("".to_string())), NodeResult::ERROR("One or more children failed".to_string())), + case(NodeResult::ERROR("".to_string()), Some(NodeResult::OK), NodeResult::ERROR("One or more children failed".to_string())), + case(NodeResult::ERROR("".to_string()), Some(NodeResult::VALUE(NodeValue::NULL)), NodeResult::ERROR("One or more children failed".to_string())), + case(NodeResult::ERROR("".to_string()), Some(NodeResult::ERROR("".to_string())), NodeResult::ERROR("One or more children failed".to_string())), + )] + fn node_result_or(#[case] a: NodeResult, #[case] b: Option, #[case] result: NodeResult) { + expect!(a.or(&b)).to(be_equal_to(result)); + } - #[test] + #[test_log::test] fn simple_match_request_test() -> anyhow::Result<()> { + let request = HttpRequest { + method: "put".to_string(), + path: "/test".to_string(), + body: OptionalBody::Present("Some nice bit of text".into(), Some(TEXT.clone()), None), + .. Default::default() + }; + let expected_request = HttpRequest { + method: "POST".to_string(), + path: "/test".to_string(), + query: None, + headers: None, + body: OptionalBody::Present("Some nice bit of text".into(), Some(TEXT.clone()), None), + .. Default::default() + }; + let mut context = PlanMatchingContext::default(); + let plan = build_request_plan(&expected_request, &context)?; + + assert_eq!(plan.pretty_form(), +r#"( + :request ( + :method ( + %match:equality ( + %upper-case ( + $.method + ), + 'POST' + ) + ), + :path ( + %match:equality ( + $.path, + '/test' + ) + ), + :"query parameters" ( + %expect:empty ( + $.query + ) + ), + :body ( + %if ( + %match:equality ( + $.content-type, + 'text/plain' + ), + %match:equality ( + %convert:UTF8 ( + $.body + ), + 'Some nice bit of text' + ) + ) + ) + ) +) +"#); + + let executed_plan = execute_request_plan(&plan, &request, &mut context)?; + assert_eq!(executed_plan.pretty_form(), +r#"( + :request ( + :method ( + %match:equality ( + %upper-case ( + $.method ~ 'put' + ) ~ 'PUT', + 'POST' ~ 'POST' + ) ~ ERROR(Expected 'PUT' to equal 'POST') + ), + :path ( + %match:equality ( + $.path ~ '/test', + '/test' ~ '/test' + ) ~ BOOL(true) + ), + :"query parameters" ( + %expect:empty ( + $.query ~ {} + ) ~ BOOL(true) + ), + :body ( + %if ( + %match:equality ( + $.content-type ~ 'text/plain', + 'text/plain' ~ 'text/plain' + ) ~ BOOL(true), + %match:equality ( + %convert:UTF8 ( + $.body ~ BYTES(21, U29tZSBuaWNlIGJpdCBvZiB0ZXh0) + ) ~ 'Some nice bit of text', + 'Some nice bit of text' ~ 'Some nice bit of text' + ) ~ BOOL(true) + ) ~ BOOL(true) + ) + ) +) +"#); + + Ok(()) + } + + #[test_log::test] + fn simple_json_match_request_test() -> anyhow::Result<()> { let request = HttpRequest { method: "POST".to_string(), path: "/test".to_string(), @@ -476,7 +934,7 @@ mod tests { matching_rules: Default::default(), generators: Default::default(), }; - let context = PlanMatchingContext::default(); + let mut context = PlanMatchingContext::default(); let plan = build_request_plan(&expected_request, &context)?; assert_eq!(plan.pretty_form(), @@ -487,13 +945,13 @@ r#"( %upper-case ( $.method ), - "POST" + 'POST' ) ), :path ( %match:equality ( $.path, - "/test" + '/test' ) ), :"query parameters" ( @@ -505,7 +963,7 @@ r#"( %if ( %match:equality ( %content-type (), - "application/json;charset=utf-8" + 'application/json;charset=utf-8' ), :body:$ ( :body:$:a ( @@ -527,7 +985,7 @@ r#"( ) "#); - let executed_plan = execute_request_plan(&plan, &request, &context)?; + let executed_plan = execute_request_plan(&plan, &request, &mut context)?; assert_eq!(executed_plan.pretty_form(), r#"( :request ( :method ( diff --git a/rust/pact_matching/src/engine/value_resolvers.rs b/rust/pact_matching/src/engine/value_resolvers.rs new file mode 100644 index 00000000..0f767c4f --- /dev/null +++ b/rust/pact_matching/src/engine/value_resolvers.rs @@ -0,0 +1,113 @@ +//! Structs and traits to resolve values required while executing a plan + +use anyhow::anyhow; +use itertools::Itertools; +use pact_models::bodies::OptionalBody; +use pact_models::http_parts::HttpPart; +use pact_models::path_exp::DocPath; +use pact_models::v4::http_parts::HttpRequest; + +use crate::engine::{NodeValue, PlanMatchingContext}; + +/// Value resolver +pub trait ValueResolver { + /// Resolve the path value + fn resolve(&self, path: &DocPath, context: &PlanMatchingContext) -> anyhow::Result; +} + +/// Value resolver for an HTTP request +#[derive(Clone, Debug, Default)] +pub struct HttpRequestValueResolver { + /// Request to resolve values against + pub request: HttpRequest +} + +impl ValueResolver for HttpRequestValueResolver { + fn resolve(&self, path: &DocPath, context: &PlanMatchingContext) -> anyhow::Result { + if let Some(field) = path.first_field() { + match field { + "method" => Ok(NodeValue::STRING(self.request.method.clone())), + "path" => Ok(NodeValue::STRING(self.request.path.clone())), + "query" => if path.len() == 2 || (path.len() == 3 && path.is_wildcard()) { + let qp = self.request.query + .clone() + .unwrap_or_default() + .iter() + .map(|(k, v)| { + (k.clone(), v.iter().map(|val| val.clone().unwrap_or_default()).collect()) + }) + .collect(); + Ok(NodeValue::MMAP(qp)) + } else if path.len() == 3 { + let param_name = path.last_field().unwrap_or_default(); + let qp = self.request.query + .clone() + .unwrap_or_default(); + if let Some(val) = qp.get(param_name) { + let values = val.iter() + .map(|v| v.clone().unwrap_or_default()) + .collect_vec(); + if values.len() == 1 { + Ok(NodeValue::STRING(values[0].clone())) + } else { + Ok(NodeValue::SLIST(values)) + } + } else { + Ok(NodeValue::NULL) + } + } else { + Err(anyhow!("{} is not valid for a HTTP request query parameters", path)) + }, + "content-type" => { + Ok(self.request.content_type() + .map(|ct| NodeValue::STRING(ct.to_string())) + .unwrap_or(NodeValue::NULL)) + }, + "body" if path.len() == 2 => match &self.request.body { + OptionalBody::Present(bytes, _, _) => Ok(NodeValue::BARRAY(bytes.to_vec())), + _ => Ok(NodeValue::NULL) + } + _ => Err(anyhow!("{} is not valid for a HTTP request", path)) + } + } else { + Err(anyhow!("{} is not valid for a HTTP request", path)) + } + } +} + +#[cfg(test)] +mod tests { + use expectest::prelude::*; + use googletest::prelude::*; + use maplit::hashmap; + use rstest::rstest; + + use pact_models::path_exp::DocPath; + + use crate::engine::{NodeValue, PlanMatchingContext}; + use crate::engine::value_resolvers::{HttpRequestValueResolver, ValueResolver}; + + #[rstest( + case("$.method", NodeValue::STRING("GET".to_string())), + case("$.path", NodeValue::STRING("/".to_string())), + case("$.query", NodeValue::MMAP(hashmap!{})) + )] + fn http_request_resolve_values(#[case] path: &str, #[case] expected: NodeValue) { + let path = DocPath::new(path).unwrap(); + let resolver = HttpRequestValueResolver::default(); + let context = PlanMatchingContext::default(); + expect!(resolver.resolve(&path, &context).unwrap()).to(be_equal_to(expected)); + } + + #[googletest::test] + fn http_request_resolve_failures() { + let resolver = HttpRequestValueResolver::default(); + let context = PlanMatchingContext::default(); + + let path = DocPath::root(); + expect_that!(resolver.resolve(&path, &context), err(displays_as(eq("$ is not valid for a HTTP request")))); + + let path = DocPath::new_unwrap("$.blah"); + expect_that!(resolver.resolve(&path, &context), err(displays_as(eq("$.blah is not valid for a HTTP request")))); + } +}