Skip to content

Commit

Permalink
feat: Match query with ArrayContains and EachValue matchers
Browse files Browse the repository at this point in the history
  • Loading branch information
tienvx committed Feb 28, 2024
1 parent e5eee74 commit 1c8a6f0
Show file tree
Hide file tree
Showing 4 changed files with 203 additions and 41 deletions.
12 changes: 3 additions & 9 deletions rust/pact_matching/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use tracing::debug;
use crate::{DiffConfig, MatchingContext, Mismatch, CommonMismatch, merge_result};
use crate::binary_utils::{convert_data, match_content_type};
use crate::matchers::*;
use crate::matchingrules::{compare_lists_with_matchingrule, compare_maps_with_matchingrule};
use crate::matchingrules::{compare_lists_with_matchingrules, compare_maps_with_matchingrule};

lazy_static! {
static ref DEC_REGEX: Regex = Regex::new(r"\d+\.\d+").unwrap();
Expand Down Expand Up @@ -476,15 +476,9 @@ fn compare_lists(
let spath = path.to_string();
if context.matcher_is_defined(path) {
debug!("compare_lists: matcher defined for path '{}'", path);
let mut result = Ok(());
let rule_list = context.select_best_matcher(path);
for matcher in rule_list.rules {
let values_result = compare_lists_with_matchingrule(&matcher, path, expected, actual, context, rule_list.cascaded, &mut |p, expected, actual, context| {
compare_lists_with_matchingrules(path, &context.select_best_matcher(path), expected, actual, context, &mut |p, expected, actual, context| {
compare_json(p, expected, actual, context)
});
result = merge_result(result, values_result);
}
result
})
} else if expected.is_empty() && !actual.is_empty() {
Err(vec![ CommonMismatch {
path: spath,
Expand Down
51 changes: 50 additions & 1 deletion rust/pact_matching/src/matchingrules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use onig::Regex;
use pact_models::matchingrules::{Category, MatchingRule, MatchingRuleCategory, RuleList, RuleLogic};
use pact_models::path_exp::DocPath;
use serde_json::{self, json, Value};
use tracing::debug;
use tracing::{debug, trace};

use crate::{Either, MatchingContext, merge_result, CommonMismatch};
use crate::binary_utils::match_content_type;
Expand Down Expand Up @@ -454,6 +454,55 @@ pub fn compare_lists_with_matchingrule<T: Display + Debug + PartialEq + Clone +
}
}

/// Compare the expected and actual lists using matching rules
pub fn compare_lists_with_matchingrules<T>(
path: &DocPath,
matching_rules: &RuleList,
expected: &[T],
actual: &[T],
context: &(dyn MatchingContext + Send + Sync),
callback: &mut dyn FnMut(&DocPath, &T, &T, &(dyn MatchingContext + Send + Sync)) -> Result<(), Vec<CommonMismatch>>
) -> Result<(), Vec<CommonMismatch>>
where T: Display + Debug + PartialEq + Clone + Sized {
trace!("compare_lists_with_matchingrules: {} -> {}", std::any::type_name::<T>(), std::any::type_name::<T>());
let mut mismatches = vec![];
if matching_rules.is_empty() {
mismatches.push(CommonMismatch {
path: path.to_string(),
expected: format!("{:?}", expected),
actual: format!("{:?}", actual),
description: format!("No matcher found for path '{}'", path)
})
} else {
let results = matching_rules.rules.iter().map(|rule| {
compare_lists_with_matchingrule(&rule, path, expected, actual, context, matching_rules.cascaded, callback)
}).collect::<Vec<Result<(), Vec<CommonMismatch>>>>();
match matching_rules.rule_logic {
RuleLogic::And => for result in results {
if let Err(err) = result {
mismatches.extend(err)
}
},
RuleLogic::Or => {
if results.iter().all(|result| result.is_err()) {
for result in results {
if let Err(err) = result {
mismatches.extend(err)
}
}
}
}
}
}
trace!(?mismatches, "compare_lists_with_matchingrules: {} -> {}", std::any::type_name::<T>(), std::any::type_name::<T>());

if mismatches.is_empty() {
Ok(())
} else {
Err(mismatches.clone())
}
}

fn match_list_contents<T: Display + Debug + PartialEq + Clone + Sized>(
path: &DocPath,
expected: &[T],
Expand Down
55 changes: 24 additions & 31 deletions rust/pact_matching/src/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use pact_models::matchingrules::MatchingRule;
use pact_models::path_exp::DocPath;
use tracing::debug;

use crate::{matchers, Matches, MatchingContext, merge_result, Mismatch};
use crate::{matchers, Matches, MatchingContext, merge_result, Mismatch, CommonMismatch};
use crate::matchingrules::compare_lists_with_matchingrules;

/// Match the query parameters as Maps
pub(crate) fn match_query_maps(
Expand All @@ -20,9 +21,10 @@ pub(crate) fn match_query_maps(
for (key, value) in &expected {
match actual.get(key) {
Some(actual_value) => {
let matches = match_query_values(key, value, actual_value, context);
let mismatches: Result<(), Vec<super::Mismatch>> = match_query_values(key, value, actual_value, context)
.map_err(|mismatches| mismatches.iter().map(|mismatch| mismatch.to_query_mismatch()).collect());
let v = result.entry(key.clone()).or_default();
v.extend(matches.err().unwrap_or_default());
v.extend(mismatches.err().unwrap_or_default());
},
None => result.entry(key.clone()).or_default().push(Mismatch::QueryMismatch {
parameter: key.clone(),
Expand Down Expand Up @@ -51,37 +53,28 @@ fn match_query_values(
expected: &[String],
actual: &[String],
context: &dyn MatchingContext
) -> Result<(), Vec<Mismatch>> {
) -> Result<(), Vec<CommonMismatch>> {
let path = DocPath::root().join(key);
if context.matcher_is_defined(&path) {
debug!("match_query_values: Matcher defined for query parameter '{}", key);
merge_result(
matchers::match_values(&path, &context.select_best_matcher(&path), expected, actual)
.map_err(|err| err.iter().map(|msg| {
Mismatch::QueryMismatch {
parameter: key.to_string(),
expected: format!("{:?}", expected),
actual: format!("{:?}", actual),
mismatch: msg.clone()
}
}).collect()),
compare_query_parameter_values(&path, expected, actual, context)
)
compare_lists_with_matchingrules(&path, &context.select_best_matcher(&path), expected, actual, context.clone_with(context.matchers()).as_ref(), &mut |p, expected, actual, context| {
compare_query_parameter_value(p, expected, actual, 0, context)
})
} else {
if expected.is_empty() && !actual.is_empty() {
Err(vec![ Mismatch::QueryMismatch {
parameter: key.to_string(),
Err(vec![ CommonMismatch {
path: key.to_string(),
expected: format!("{:?}", expected),
actual: format!("{:?}", actual),
mismatch: format!("Expected an empty parameter list for '{}' but received {:?}", key, actual)
description: format!("Expected an empty parameter list for '{}' but received {:?}", key, actual)
} ])
} else {
let mismatch = if expected.len() != actual.len() {
Err(vec![ Mismatch::QueryMismatch {
parameter: key.to_string(),
Err(vec![ CommonMismatch {
path: key.to_string(),
expected: format!("{:?}", expected),
actual: format!("{:?}", actual),
mismatch: format!(
description: format!(
"Expected query parameter '{}' with {} value(s) but received {} value(s)",
key, expected.len(), actual.len())
} ])
Expand All @@ -99,7 +92,7 @@ fn compare_query_parameter_value(
actual: &str,
index: usize,
context: &dyn MatchingContext
) -> Result<(), Vec<Mismatch>> {
) -> Result<(), Vec<CommonMismatch>> {
let index = index.to_string();
let index_path = path.join(index.as_str());
let matcher_result = if context.matcher_is_defined(&index_path) {
Expand All @@ -117,11 +110,11 @@ fn compare_query_parameter_value(
};
matcher_result.map_err(|messages| {
messages.iter().map(|message| {
Mismatch::QueryMismatch {
parameter: path.first_field().unwrap_or_default().to_string(),
CommonMismatch {
path: path.first_field().unwrap_or_default().to_string(),
expected: expected.to_string(),
actual: actual.to_string(),
mismatch: message.clone()
description: message.clone()
}
}).collect()
})
Expand All @@ -132,9 +125,9 @@ fn compare_query_parameter_values(
expected: &[String],
actual: &[String],
context: &dyn MatchingContext
) -> Result<(), Vec<Mismatch>> {
) -> Result<(), Vec<CommonMismatch>> {
let empty = String::new();
let result: Vec<Mismatch> = expected.iter()
let result: Vec<CommonMismatch> = expected.iter()
.pad_using(actual.len(), |_| &empty)
.enumerate()
.flat_map(|(index, val)| {
Expand All @@ -147,11 +140,11 @@ fn compare_query_parameter_values(
vec![]
} else {
let key = path.first_field().unwrap_or_default().to_string();
vec![ Mismatch::QueryMismatch {
parameter: key.clone(),
vec![ CommonMismatch {
path: key.clone(),
expected: format!("{:?}", expected),
actual: format!("{:?}", actual),
mismatch: format!("Expected query parameter '{}' value '{}' but was missing", key, val)
description: format!("Expected query parameter '{}' value '{}' but was missing", key, val)
} ]
}
})
Expand Down
126 changes: 126 additions & 0 deletions rust/pact_matching/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::collections::HashMap;
use expectest::prelude::*;

use pact_models::{matchingrules, matchingrules_list};
use pact_models::matchingrules::expressions::{MatchingRuleDefinition, ValueType};
use pact_models::bodies::OptionalBody;
use pact_models::content_types::{JSON, TEXT};
use pact_models::HttpStatus;
Expand Down Expand Up @@ -242,6 +243,35 @@ fn match_query_with_min_type_matching_rules() {
expect!(result.values().flatten()).to(be_empty());
}

#[test]
fn match_query_with_min_type_matching_rules_fails() {
let expected = hashmap! { "id".to_string() => vec![
"1".to_string(),
"2".to_string(),
"3".to_string(),
"4".to_string()
]};
let actual = hashmap! { "id".to_string() => vec!["1".to_string()] };
let rules = matchingrules! {
"query" => { "id" => [ MatchingRule::MinType(2) ] }
};
let context = CoreMatchingContext::new(
DiffConfig::AllowUnexpectedKeys,
&rules.rules_for_category("query").unwrap_or_default(),
&hashmap!{}
);

let result = match_query(Some(expected), Some(actual), &context);
expect!(result.get("id").unwrap().to_vec()).to(be_equal_to(vec![
Mismatch::QueryMismatch {
parameter: "$.id".to_string(),
expected: "[\"1\",\"2\",\"3\",\"4\"]".to_string(),
actual: "[\"1\"]".to_string(),
mismatch: "Expected [1] (size 1) to have minimum size of 2".to_string(),
}
]));
}

#[test]
fn match_query_returns_no_mismatch_if_the_values_are_not_the_same_but_match_by_a_matcher() {
let context = CoreMatchingContext::new(
Expand Down Expand Up @@ -319,6 +349,102 @@ fn match_query_with_query_parameters_with_brackets() {
expect!(result.get("Q[]").unwrap().iter()).to(be_empty());
}


#[test]
fn match_query_with_array_contains_matching_rules() {
let expected = hashmap! { "id".to_string() => vec!["1".to_string(), "3".to_string()] };
let actual = hashmap! { "id".to_string() => vec![
"1".to_string(),
"2".to_string(),
"3".to_string(),
"4".to_string()
]};
let rules = matchingrules! {
"query" => { "id" => [ MatchingRule::ArrayContains(vec![]) ] }
};
let context = CoreMatchingContext::new(
DiffConfig::AllowUnexpectedKeys,
&rules.rules_for_category("query").unwrap_or_default(),
&hashmap!{}
);

let result = match_query(Some(expected), Some(actual), &context);
expect!(result.values().flatten()).to(be_empty());
}

#[test]
fn match_query_with_array_contains_matching_rules_fails() {
let expected = hashmap! { "id".to_string() => vec!["1".to_string(), "3".to_string()] };
let actual = hashmap! { "id".to_string() => vec!["2".to_string(), "3".to_string(), "4".to_string()] };
let rules = matchingrules! {
"query" => { "id" => [ MatchingRule::ArrayContains(vec![]) ] }
};
let context = CoreMatchingContext::new(
DiffConfig::AllowUnexpectedKeys,
&rules.rules_for_category("query").unwrap_or_default(),
&hashmap!{}
);

let result = match_query(Some(expected), Some(actual), &context);
expect!(result.get("id").unwrap().to_vec()).to(be_equal_to(vec![
Mismatch::QueryMismatch {
parameter: "$.id".to_string(),
expected: "1".to_string(),
actual: "[\"2\",\"3\",\"4\"]".to_string(),
mismatch: "Variant at index 0 (1) was not found in the actual list".to_string(),
}
]));
}

#[test]
fn match_query_with_each_value_matching_rules() {
let expected = hashmap! { "id".to_string() => vec!["1".to_string(), "2".to_string()] };
let actual = hashmap! { "id".to_string() => vec!["3".to_string(), "4".to_string(), "567".to_string()] };
let rules = matchingrules! {
"query" => { "id" => [ MatchingRule::EachValue(MatchingRuleDefinition::new("100".to_string(), ValueType::String,
MatchingRule::Regex("\\d+".to_string()), None)) ] }
};
let context = CoreMatchingContext::new(
DiffConfig::AllowUnexpectedKeys,
&rules.rules_for_category("query").unwrap_or_default(),
&hashmap!{}
);

let result = match_query(Some(expected), Some(actual), &context);
expect!(result.values().flatten()).to(be_empty());
}

#[test]
fn match_query_with_each_value_matching_rules_fails() {
let expected = hashmap! { "id".to_string() => vec!["1".to_string(), "2".to_string()] };
let actual = hashmap! { "id".to_string() => vec!["3".to_string(), "abc123".to_string(), "test".to_string()] };
let rules = matchingrules! {
"query" => { "id" => [ MatchingRule::EachValue(MatchingRuleDefinition::new("100".to_string(), ValueType::String,
MatchingRule::Regex("\\d+".to_string()), None)) ] }
};
let context = CoreMatchingContext::new(
DiffConfig::AllowUnexpectedKeys,
&rules.rules_for_category("query").unwrap_or_default(),
&hashmap!{}
);

let result = match_query(Some(expected), Some(actual), &context);
expect!(result.get("id").unwrap().to_vec()).to(be_equal_to(vec![
Mismatch::QueryMismatch {
parameter: "id".to_string(),
expected: "2".to_string(),
actual: "abc123".to_string(),
mismatch: "Expected 'abc123' to match '\\d+'".to_string(),
},
Mismatch::QueryMismatch {
parameter: "id".to_string(),
expected: "1".to_string(),
actual: "test".to_string(),
mismatch: "Expected 'test' to match '\\d+'".to_string(),
}
]));
}

#[tokio::test]
async fn body_does_not_match_if_different_content_types() {
let expected = Request {
Expand Down

0 comments on commit 1c8a6f0

Please sign in to comment.