From 21406631636a253786b294c55bc99be353da71dc Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Wed, 20 Mar 2024 11:59:46 +1100 Subject: [PATCH] feat(ffi): Update intermediate JSON to support matching rule expressions #399 --- rust/pact_ffi/src/mock_server/bodies.rs | 130 ++++++++++++++--------- rust/pact_ffi/src/mock_server/handles.rs | 54 +++++----- rust/pact_ffi/src/mock_server/mod.rs | 19 +++- rust/pact_ffi/src/mock_server/xml.rs | 31 ++++-- rust/pact_ffi/tests/tests.rs | 67 ++++++++++++ 5 files changed, 212 insertions(+), 89 deletions(-) diff --git a/rust/pact_ffi/src/mock_server/bodies.rs b/rust/pact_ffi/src/mock_server/bodies.rs index bb93a0d7e..98fd86db2 100644 --- a/rust/pact_ffi/src/mock_server/bodies.rs +++ b/rust/pact_ffi/src/mock_server/bodies.rs @@ -4,17 +4,22 @@ use std::path::Path; use anyhow::{anyhow, bail}; use bytes::{Bytes, BytesMut}; +use either::Either; use lazy_static::lazy_static; +use regex::Regex; +use serde_json::{Map, Value}; +use tracing::{debug, error, trace, warn}; + use pact_models::bodies::OptionalBody; use pact_models::content_types::ContentTypeHint; use pact_models::generators::{Generator, GeneratorCategory, Generators}; use pact_models::json_utils::json_to_string; -use pact_models::matchingrules::{Category, MatchingRule, MatchingRuleCategory, RuleLogic}; +use pact_models::matchingrules::{MatchingRule, MatchingRuleCategory, RuleLogic}; +use pact_models::matchingrules::expressions::{is_matcher_def, parse_matcher_def}; use pact_models::path_exp::DocPath; use pact_models::v4::http_parts::{HttpRequest, HttpResponse}; -use regex::Regex; -use serde_json::{Map, Value}; -use tracing::{debug, error, trace, warn}; + +use crate::mock_server::generator_category; const CONTENT_TYPE_HEADER: &str = "Content-Type"; @@ -41,8 +46,8 @@ pub fn process_array( item_path.push_index(index); } match val { - Value::Object(ref map) => process_object(map, matching_rules, generators, item_path, skip_matchers), - Value::Array(ref array) => process_array(array, matching_rules, generators, item_path, false, skip_matchers), + Value::Object(map) => process_object(map, matching_rules, generators, item_path, skip_matchers), + Value::Array(array) => process_array(array.as_slice(), matching_rules, generators, item_path, false, skip_matchers), _ => val.clone() } }).collect()) @@ -60,7 +65,7 @@ pub fn process_object( debug!("Path = {path}"); let result = if let Some(matcher_type) = obj.get("pact:matcher:type") { debug!("detected pact:matcher:type, will configure a matcher"); - process_matcher(obj, matching_rules, generators, &path, type_matcher, matcher_type) + process_matcher(obj, matching_rules, generators, &path, type_matcher, &matcher_type.clone()) } else { debug!("Configuring a normal object"); Value::Object(obj.iter() @@ -122,17 +127,23 @@ fn process_matcher( _ => Err(anyhow!("ArrayContains 'variants' attribute is missing or not an array")) } } else { - matchers_from_integration_json(obj).map(|rules| { + matchers_from_integration_json(obj).map(|(rules, generator)| { let has_values_matcher = rules.iter().any(MatchingRule::is_values_matcher); let json_value = match obj.get("value") { Some(inner) => match inner { - Value::Object(map) => process_object(map, matching_rules, generators, path.clone(), has_values_matcher), - Value::Array(array) => process_array(array, matching_rules, generators, path.clone(), true, skip_matchers), + Value::Object(ref map) => process_object(map, matching_rules, generators, path.clone(), has_values_matcher), + Value::Array(ref array) => process_array(array, matching_rules, generators, path.clone(), true, skip_matchers), _ => inner.clone() }, None => Value::Null }; + + if let Some(generator) = generator { + let category = generator_category(matching_rules); + generators.add_generator_with_subcategory(category, path.clone(), generator); + } + (rules, json_value) }) }; @@ -140,17 +151,7 @@ fn process_matcher( if let Some(gen) = obj.get("pact:generator:type") { debug!("detected pact:generator:type, will configure a generators"); if let Some(generator) = Generator::from_map(&json_to_string(gen), obj) { - let category = match matching_rules.name { - Category::BODY => &GeneratorCategory::BODY, - Category::HEADER => &GeneratorCategory::HEADER, - Category::PATH => &GeneratorCategory::PATH, - Category::QUERY => &GeneratorCategory::QUERY, - Category::METADATA => &GeneratorCategory::METADATA, - _ => { - warn!("invalid generator category {} provided, defaulting to body", matching_rules.name); - &GeneratorCategory::BODY - } - }; + let category = generator_category(matching_rules); generators.add_generator_with_subcategory(category, path.clone(), generator); } } @@ -185,41 +186,62 @@ pub fn matcher_from_integration_json(m: &Map) -> Option) -> anyhow::Result> { +pub fn matchers_from_integration_json(m: &Map) -> anyhow::Result<(Vec, Option)> { match m.get("pact:matcher:type") { - Some(value) => match value { - Value::Array(arr) => { - let mut rules = vec![]; - for v in arr { - match v.get("pact:matcher:type") { - Some(t) => { - let val = json_to_string(t); - let rule = MatchingRule::create(val.as_str(), &v) - .map_err(|err| { - error!("Failed to create matching rule from JSON '{:?}': {}", m, err); - err - })?; - rules.push(rule); + Some(value) => { + let json_str = value.to_string(); + match value { + Value::Array(arr) => { + let mut rules = vec![]; + for v in arr.clone() { + match v.get("pact:matcher:type") { + Some(t) => { + let val = json_to_string(t); + let rule = MatchingRule::create(val.as_str(), &v) + .map_err(|err| { + error!("Failed to create matching rule from JSON '{:?}': {}", m, err); + err + })?; + rules.push(rule); + } + None => { + error!("Failed to create matching rule from JSON '{:?}': there is no 'pact:matcher:type' attribute", json_str); + bail!("Failed to create matching rule from JSON '{:?}': there is no 'pact:matcher:type' attribute", json_str); + } } - None => { - error!("Failed to create matching rule from JSON '{:?}': there is no 'pact:matcher:type' attribute", v); - bail!("Failed to create matching rule from JSON '{:?}': there is no 'pact:matcher:type' attribute", v); + } + Ok((rules, None)) + } + _ => { + let val = json_to_string(value); + if val != "eachKey" && val != "eachValue" && val != "notEmpty" && is_matcher_def(val.as_str()) { + let mut rules = vec![]; + let def = parse_matcher_def(val.as_str())?; + for rule in def.rules { + match rule { + Either::Left(rule) => rules.push(rule), + Either::Right(reference) => if m.contains_key(reference.name.as_str()) { + rules.push(MatchingRule::Type); + // TODO: We need to somehow drop the reference otherwise the matching will try compare it + } else { + error!("Failed to create matching rules from JSON '{:?}': reference '{}' was not found", json_str, reference.name); + bail!("Failed to create matching rules from JSON '{:?}': reference '{}' was not found", json_str, reference.name); + } + } } + Ok((rules, def.generator)) + } else { + MatchingRule::create(val.as_str(), &Value::Object(m.clone())) + .map(|r| (vec![r], None)) + .map_err(|err| { + error!("Failed to create matching rule from JSON '{:?}': {}", json_str, err); + err + }) } } - Ok(rules) - } - _ => { - let val = json_to_string(value); - MatchingRule::create(val.as_str(), &Value::Object(m.clone())) - .map(|r| vec![r]) - .map_err(|err| { - error!("Failed to create matching rule from JSON '{:?}': {}", m, err); - err - }) } }, - _ => Ok(vec![]) + _ => Ok((vec![], None)) } } @@ -403,18 +425,20 @@ fn format_multipart_error(e: std::io::Error) -> String { mod test { use expectest::prelude::*; use maplit::hashmap; + use pretty_assertions::assert_eq; + use rstest::rstest; + use serde_json::json; + use pact_models::{generators, HttpStatus, matchingrules_list}; use pact_models::content_types::ContentType; use pact_models::generators::{Generator, Generators}; use pact_models::matchingrules::{MatchingRule, MatchingRuleCategory}; use pact_models::matchingrules::expressions::{MatchingRuleDefinition, ValueType}; use pact_models::path_exp::DocPath; - use serde_json::json; - use pretty_assertions::assert_eq; - use rstest::rstest; #[allow(deprecated)] use crate::mock_server::bodies::{matcher_from_integration_json, process_object}; + use super::*; #[test] @@ -875,7 +899,7 @@ mod test { { "pact:matcher:type": "include", "value": "[a-z]" } ] }), vec![MatchingRule::Regex("[a-z]".to_string()), MatchingRule::Equality, MatchingRule::Include("[a-z]".to_string())])] fn matchers_from_integration_json_ok_test(#[case] json: Value, #[case] value: Vec) { - expect!(matchers_from_integration_json(&json.as_object().unwrap())).to(be_ok().value(value)); + expect!(matchers_from_integration_json(&json.as_object().unwrap())).to(be_ok().value((value, None))); } #[rstest] diff --git a/rust/pact_ffi/src/mock_server/handles.rs b/rust/pact_ffi/src/mock_server/handles.rs index 5d202334b..e1a759cc7 100644 --- a/rust/pact_ffi/src/mock_server/handles.rs +++ b/rust/pact_ffi/src/mock_server/handles.rs @@ -120,7 +120,7 @@ use maplit::*; use pact_models::{Consumer, PactSpecification, Provider}; use pact_models::bodies::OptionalBody; use pact_models::content_types::{ContentType, detect_content_type_from_string, JSON, TEXT, XML}; -use pact_models::generators::{Generator, GeneratorCategory, Generators}; +use pact_models::generators::{Generator, Generators}; use pact_models::headers::parse_header; use pact_models::http_parts::HttpPart; use pact_models::interaction::Interaction; @@ -145,7 +145,7 @@ use futures::executor::block_on; use crate::{convert_cstr, ffi_fn, safe_str}; use crate::error::set_error_msg; -use crate::mock_server::{StringResult, xml}; +use crate::mock_server::{generator_category, StringResult, xml}; #[allow(deprecated)] use crate::mock_server::bodies::{ empty_multipart_body, @@ -939,8 +939,8 @@ fn from_integration_json( match serde_json::from_str(value) { Ok(json) => match json { - Value::Object(ref map) => { - let json: Value = process_object(map, category, generators, path, false); + Value::Object(map) => { + let json: Value = process_object(&map, category, generators, path, false); // These are simple JSON primitives (strings), so we must unescape them json_to_string(&json) }, @@ -970,17 +970,17 @@ fn from_integration_json_v2( let query_or_header = [Category::QUERY, Category::HEADER].contains(&matching_rules.name); match serde_json::from_str(value) { - Ok(json) => match json { - Value::Object(ref map) => { + Ok(mut json) => match &mut json { + Value::Object(map) => { let result = if map.contains_key("pact:matcher:type") { debug!("detected pact:matcher:type, will configure any matchers"); let rules = matchers_from_integration_json(map); trace!("matching_rules = {rules:?}"); - let (path, result_value) = match map.get("value") { + let (path, result_value) = match map.get_mut("value") { Some(val) => match val { Value::Array(array) => { - let array = process_array(&array, matching_rules, generators, path.clone(), true, false); + let array = process_array(array.as_mut_slice(), matching_rules, generators, path.clone(), true, false); (path.clone(), array) }, _ => (path.clone(), val.clone()) @@ -988,7 +988,7 @@ fn from_integration_json_v2( None => (path.clone(), Value::Null) }; - if let Ok(rules) = &rules { + if let Ok((rules, generator)) = &rules { let path = if path_or_status { path.parent().unwrap_or(DocPath::root()) } else { @@ -1012,22 +1012,22 @@ fn from_integration_json_v2( matching_rules.add_rule(path.clone(), rule.clone(), RuleLogic::And); } } + + if let Some(generator) = generator { + let category = generator_category(matching_rules); + let path = if path_or_status { + path.parent().unwrap_or(DocPath::root()) + } else { + path.clone() + }; + generators.add_generator_with_subcategory(category, path.clone(), generator.clone()); + } } if let Some(gen) = map.get("pact:generator:type") { debug!("detected pact:generator:type, will configure a generators"); if let Some(generator) = Generator::from_map(&json_to_string(gen), map) { - let category = match matching_rules.name { - Category::BODY => &GeneratorCategory::BODY, - Category::HEADER => &GeneratorCategory::HEADER, - Category::PATH => &GeneratorCategory::PATH, - Category::QUERY => &GeneratorCategory::QUERY, - Category::STATUS => &GeneratorCategory::STATUS, - _ => { - warn!("invalid generator category {} provided, defaulting to body", matching_rules.name); - &GeneratorCategory::BODY - } - }; + let category = generator_category(matching_rules); let path = if path_or_status { path.parent().unwrap_or(DocPath::root()) } else { @@ -1062,8 +1062,8 @@ fn from_integration_json_v2( pub(crate) fn process_xml(body: String, matching_rules: &mut MatchingRuleCategory, generators: &mut Generators) -> Result, String> { trace!("process_xml"); match serde_json::from_str(&body) { - Ok(json) => match json { - Value::Object(ref map) => xml::generate_xml_body(map, matching_rules, generators), + Ok(mut json) => match &mut json { + Value::Object(map) => xml::generate_xml_body(map, matching_rules, generators), _ => Err(format!("JSON document is invalid (expected an Object), have {}", json)) }, Err(err) => Err(format!("Failed to parse XML builder document: {}", err)) @@ -2543,12 +2543,10 @@ pub extern fn pactffi_message_with_metadata_v2(message_handle: MessageHandle, ke let generators = &mut message.contents.generators; let value = match serde_json::from_str(value) { Ok(json) => match json { - Value::Object(ref map) => process_object(map, matching_rules, generators, DocPath::new(key).unwrap(), false), - Value::Array(ref array) => process_array(array, matching_rules, generators, DocPath::new(key).unwrap(), false, false), + Value::Object(map) => process_object(&map, matching_rules, generators, DocPath::new(key).unwrap(), false), + Value::Array(array) => process_array(array.as_slice(), matching_rules, generators, DocPath::new(key).unwrap(), false, false), Value::Null => Value::Null, - Value::String(string) => Value::String(string), - Value::Bool(bool) => Value::Bool(bool), - Value::Number(number) => Value::Number(number), + _ => json }, Err(err) => { warn!("Failed to parse metadata value '{}' as JSON - {}. Will treat it as string", value, err); @@ -3748,7 +3746,7 @@ mod tests { })); } - #[test] + #[test_log::test] fn status_with_matcher_and_generator() { let pact_handle = PactHandle::new("TestPC3", "TestPP"); let description = CString::new("status_with_matcher_and_generator").unwrap(); diff --git a/rust/pact_ffi/src/mock_server/mod.rs b/rust/pact_ffi/src/mock_server/mod.rs index 18a8ad70d..f9ea7ae4d 100644 --- a/rust/pact_ffi/src/mock_server/mod.rs +++ b/rust/pact_ffi/src/mock_server/mod.rs @@ -60,7 +60,7 @@ use pact_models::time_utils::{parse_pattern, to_chrono_pattern}; use rand::prelude::*; use serde_json::Value; use tokio_rustls::rustls::ServerConfig; -use tracing::error; +use tracing::{error, warn}; use uuid::Uuid; use pact_matching::logging::fetch_buffer_contents; @@ -68,6 +68,8 @@ use pact_matching::metrics::{MetricEvent, send_metrics}; use pact_mock_server::{MANAGER, mock_server_mismatches, MockServerError, tls::TlsConfigBuilder, WritePactFileErr}; use pact_mock_server::mock_server::MockServerConfig; use pact_mock_server::server_manager::ServerManager; +use pact_models::generators::GeneratorCategory; +use pact_models::matchingrules::{Category, MatchingRuleCategory}; use crate::{convert_cstr, ffi_fn, safe_str}; use crate::mock_server::handles::{PactHandle, path_from_dir}; @@ -700,3 +702,18 @@ pub unsafe extern fn pactffi_free_string(s: *mut c_char) { } drop(CString::from_raw(s)); } + +pub(crate) fn generator_category(matching_rules: &mut MatchingRuleCategory) -> &GeneratorCategory { + match matching_rules.name { + Category::BODY => &GeneratorCategory::BODY, + Category::HEADER => &GeneratorCategory::HEADER, + Category::PATH => &GeneratorCategory::PATH, + Category::QUERY => &GeneratorCategory::QUERY, + Category::METADATA => &GeneratorCategory::METADATA, + Category::STATUS => &GeneratorCategory::STATUS, + _ => { + warn!("invalid generator category {} provided, defaulting to body", matching_rules.name); + &GeneratorCategory::BODY + } + } +} diff --git a/rust/pact_ffi/src/mock_server/xml.rs b/rust/pact_ffi/src/mock_server/xml.rs index 8524d4bd2..4b2f23f86 100644 --- a/rust/pact_ffi/src/mock_server/xml.rs +++ b/rust/pact_ffi/src/mock_server/xml.rs @@ -20,7 +20,11 @@ use pact_models::path_exp::DocPath; use crate::mock_server::bodies::matchers_from_integration_json; -pub fn generate_xml_body(attributes: &Map, matching_rules: &mut MatchingRuleCategory, generators: &mut Generators) -> Result, String> { +pub fn generate_xml_body( + attributes: &Map, + matching_rules: &mut MatchingRuleCategory, + generators: &mut Generators +) -> Result, String> { let package = Package::new(); let doc = package.as_document(); @@ -71,14 +75,19 @@ fn create_element_from_json<'a>( updated_path.push(&name); let doc_path = DocPath::new(updated_path.join(".").to_string()).unwrap_or(DocPath::root()); - if let Ok(rules) = matchers_from_integration_json(object) { + if let Ok((rules, generator)) = matchers_from_integration_json(object) { for rule in rules { matching_rules.add_rule(doc_path.clone(), rule, RuleLogic::And); } + + if let Some(generator) = generator { + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path.clone(), generator); + } } + if let Some(gen) = object.get("pact:generator:type") { match Generator::from_map(&json_to_string(gen), object) { - Some(generator) => generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path, generator), + Some(generator) => generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path.clone(), generator), _ => () }; } @@ -126,10 +135,14 @@ fn create_element_from_json<'a>( let doc_path = DocPath::new(&text_path.join(".")).unwrap_or(DocPath::root()); if let Value::Object(matcher) = matcher { - if let Ok(rules) = matchers_from_integration_json(matcher) { + if let Ok((rules, generator)) = matchers_from_integration_json(matcher) { for rule in rules { matching_rules.add_rule(doc_path.clone(), rule, RuleLogic::And); } + + if let Some(generator) = generator { + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path.clone(), generator); + } } } if let Some(gen) = object.get("pact:generator:type") { @@ -216,12 +229,16 @@ fn add_attributes( let value = match v { Value::Object(matcher_definition) => if matcher_definition.contains_key("pact:matcher:type") { let doc_path = DocPath::new(path).unwrap_or(DocPath::root()); - #[allow(deprecated)] - if let Ok(rules) = matchers_from_integration_json(matcher_definition) { + if let Ok((rules, generator)) = matchers_from_integration_json(matcher_definition) { for rule in rules { matching_rules.add_rule(doc_path.clone(), rule, RuleLogic::And); } + + if let Some(generator) = generator { + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path.clone(), generator); + } } + if let Some(gen) = matcher_definition.get("pact:generator:type") { match Generator::from_map(&json_to_string(gen), matcher_definition) { Some(generator) => generators.add_generator_with_subcategory(&GeneratorCategory::BODY, doc_path, generator), @@ -254,4 +271,4 @@ fn duplicate_element<'a>(doc: Document<'a>, el: &Element<'a>) -> Element<'a> { } } element -} \ No newline at end of file +} diff --git a/rust/pact_ffi/tests/tests.rs b/rust/pact_ffi/tests/tests.rs index 818ecfe83..018168496 100644 --- a/rust/pact_ffi/tests/tests.rs +++ b/rust/pact_ffi/tests/tests.rs @@ -1339,3 +1339,70 @@ fn combined_each_key_and_each_value_matcher() { } }; } + +// Issue #399 +#[test_log::test] +fn matching_definition_expressions_matcher() { + let consumer_name = CString::new("combined_matcher-consumer").unwrap(); + let provider_name = CString::new("combined_matcher-provider").unwrap(); + let pact_handle = pactffi_new_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + let description = CString::new("matching_definition_expressions").unwrap(); + let interaction = pactffi_new_interaction(pact_handle.clone(), description.as_ptr()); + + let content_type = CString::new("application/json").unwrap(); + let path = CString::new("/query").unwrap(); + let json = json!({ + "results": { + "pact:matcher:type": "eachKey(matching(regex, '\\w{3}-\\d+', 'AUK-155332')), eachValue(matching(type, ''))", + "AUK-155332": { + "title": "...", + "description": "...", + "link": "http://....", + "relatesTo": ["BAF-88654"] + } + } + }); + let body = CString::new(json.to_string()).unwrap(); + let address = CString::new("127.0.0.1:0").unwrap(); + let method = CString::new("PUT").unwrap(); + + pactffi_upon_receiving(interaction.clone(), description.as_ptr()); + pactffi_with_request(interaction.clone(), method.as_ptr(), path.as_ptr()); + pactffi_with_body(interaction.clone(), InteractionPart::Request, content_type.as_ptr(), body.as_ptr()); + pactffi_response_status(interaction.clone(), 200); + + let port = pactffi_create_mock_server_for_pact(pact_handle.clone(), address.as_ptr(), false); + + expect!(port).to(be_greater_than(0)); + + let client = Client::default(); + let json_body = json!({ + "results": { + "KGK-9954356": { + "title": "Some title", + "description": "Tells us what this is in more or less detail", + "link": "http://....", + "relatesTo": ["BAF-88654"] + } + } + }); + let result = client.put(format!("http://127.0.0.1:{}/query", port).as_str()) + .header("Content-Type", "application/json") + .body(json_body.to_string()) + .send(); + + let mismatches = pactffi_mock_server_mismatches(port); + println!("{}", unsafe { CStr::from_ptr(mismatches) }.to_string_lossy()); + + pactffi_cleanup_mock_server(port); + pactffi_free_pact_handle(pact_handle); + + match result { + Ok(res) => { + expect!(res.status()).to(be_eq(200)); + }, + Err(err) => { + panic!("expected 200 response but request failed: {}", err); + } + }; +}