diff --git a/rust/pact_ffi/src/models/pact_specification.rs b/rust/pact_ffi/src/models/pact_specification.rs index a5f7dd448..55be57ea6 100644 --- a/rust/pact_ffi/src/models/pact_specification.rs +++ b/rust/pact_ffi/src/models/pact_specification.rs @@ -19,6 +19,8 @@ pub enum PactSpecification { V3, /// Version four of the pact specification () V4, + /// Version 4.1 of the pact specification () + V4_1, } impl From for PactSpecification { @@ -31,6 +33,7 @@ impl From for PactSpecification { NonCPactSpecification::V2 => PactSpecification::V2, NonCPactSpecification::V3 => PactSpecification::V3, NonCPactSpecification::V4 => PactSpecification::V4, + NonCPactSpecification::V4_1 => PactSpecification::V4_1, } } } @@ -45,6 +48,7 @@ impl From for NonCPactSpecification { PactSpecification::V2 => NonCPactSpecification::V2, PactSpecification::V3 => NonCPactSpecification::V3, PactSpecification::V4 => NonCPactSpecification::V4, + PactSpecification::V4_1 => NonCPactSpecification::V4_1, } } } diff --git a/rust/pact_matching/src/generator_tests.rs b/rust/pact_matching/src/generator_tests.rs index 411cc8816..53ef6ebaa 100644 --- a/rust/pact_matching/src/generator_tests.rs +++ b/rust/pact_matching/src/generator_tests.rs @@ -334,3 +334,347 @@ async fn applies_body_generator_to_the_copy_of_the_message() { expect!(&body["a"]).to_not(be_equal_to(&json!(100))); expect!(&body["b"]).to(be_equal_to(&json!("B"))); } + +#[tokio::test] +async fn applies_random_array_generator_to_request_body() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"name": "xxx", "price": 12, "count": 2}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$.items" => Generator::RandomArray(2, 4) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); + let first_item = &items[0]; + for item in items { + expect!(item).to(be_equal_to(first_item)); + } +} + +#[tokio::test] +async fn applies_random_array_generator_to_response_body() { + let response = HttpResponse { + body: OptionalBody::Present(r#"{"items": [{"name": "yyy", "price": 6, "count": 2}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$.items" => Generator::RandomArray(2, 4) } }, + .. HttpResponse::default() + }; + let generated_response = generate_response(&response, &GeneratorTestMode::Consumer, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_response.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); + let first_item = &items[0]; + for item in items { + expect!(item).to(be_equal_to(first_item)); + } +} + +#[tokio::test] +async fn applies_random_array_generator_with_nested_generators_to_request_body() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"name": "xxx", "price": 12, "count": 2}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { + "BODY" => { + "$.items" => Generator::RandomArray(2, 4), + "$.items[*].name" => Generator::RandomString(5), + "$.items[*].price" => Generator::RandomInt(1, 100), + "$.items[*].count" => Generator::RandomInt(1, 10) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); + for i in 1..items.len() { + expect!(&items[i]).not_to(be_equal_to(&items[i - 1])); + } +} + +#[tokio::test] +async fn applies_random_array_generator_with_nested_generators_to_response_body() { + let response = HttpResponse { + body: OptionalBody::Present(r#"{"items": [{"name": "yyy", "price": 6, "count": 2}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { + "BODY" => { + "$.items" => Generator::RandomArray(2, 4), + "$.items[*].name" => Generator::RandomString(5), + "$.items[*].price" => Generator::RandomInt(1, 100), + "$.items[*].count" => Generator::RandomInt(1, 10) + } + }, + .. HttpResponse::default() + }; + let generated_response = generate_response(&response, &GeneratorTestMode::Consumer, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_response.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); + for i in 1..items.len() { + expect!(&items[i]).not_to(be_equal_to(&items[i - 1])); + } +} + +#[tokio::test] +async fn applies_nested_random_array_generator() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"subitems": [{"value": 1}]}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { + "BODY" => { + "$.items" => Generator::RandomArray(2, 3), + "$.items[*].subitems" => Generator::RandomArray(2, 3), + "$.items[*].subitems[*].value" => Generator::RandomInt(1, 1000) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(3)); + for i in 1..items.len() { + expect!(&items[i]).not_to(be_equal_to(&items[i - 1])); + let subitems_curr = items[i]["subitems"].as_array().unwrap(); + let subitems_prev = items[i - 1]["subitems"].as_array().unwrap(); + expect!(subitems_curr).not_to(be_equal_to(subitems_prev)); + for j in 1..subitems_curr.len() { + expect!(&subitems_curr[j]).not_to(be_equal_to(&subitems_curr[j - 1])); + } + } +} + +#[tokio::test] +async fn applies_random_array_generator_with_same_min_max() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"value": 1}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$.items" => Generator::RandomArray(3, 3) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + expect!(items.len()).to(be_equal_to(3)); +} + +#[tokio::test] +async fn cant_applies_random_array_generator_with_min_max_zero() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"value": 1}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$.items" => Generator::RandomArray(0, 0) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + expect!(generated_request.body).to(be_equal_to(OptionalBody::Present(r#"{"items":[{"value":1}]}"#.into(), Some("application/json".into()), None))); +} + +#[tokio::test] +async fn applies_random_array_generator_to_root_level_array() { + let request = HttpRequest { + body: OptionalBody::Present(r#"[{"value": 1}]"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$" => Generator::RandomArray(2, 4) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body.as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); +} + +#[tokio::test] +async fn cant_applies_random_array_generator_to_empty_array() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": []}"#.into(), Some(JSON.clone()), None), + generators: generators! { "BODY" => { "$.items" => Generator::RandomArray(2, 4) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + expect!(generated_request.body).to(be_equal_to(OptionalBody::Present(r#"{"items":[]}"#.into(), Some("application/json".into()), None))); +} + +#[tokio::test] +async fn applies_multiple_independent_array_generators() { + let request = HttpRequest { + body: OptionalBody::Present(r#"{"items": [{"value": 1}], "other": [{"x": 2}]}"#.into(), Some(JSON.clone()), None), + generators: generators! { + "BODY" => { + "$.items" => Generator::RandomArray(2, 3), + "$.other" => Generator::RandomArray(3, 4) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body: Value = serde_json::from_str(generated_request.body.display_string().as_str()).unwrap(); + let items = body["items"].as_array().unwrap(); + let other = body["other"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(3)); + expect!(other.len()).to(be_ge(3)); + expect!(other.len()).to(be_le(4)); + let first_item = &items[0]; + let first_other = &other[0]; + for item in items { + expect!(item).to(be_equal_to(first_item)); + } + for item in other { + expect!(item).to(be_equal_to(first_other)); + } +} + +#[cfg(feature = "xml")] +mod xml_tests { + use super::*; + use pact_models::content_types::XML; + + fn count_xml_elements(body: &str, tag: &str) -> usize { + let open_tag_with_space = format!("<{} ", tag); + let open_tag_with_bracket = format!("<{}>", tag); + let self_close_tag = format!("<{}/", tag); + body.split(&open_tag_with_space).count() - 1 + + body.split(&open_tag_with_bracket).count() - 1 + + body.split(&self_close_tag).count() - 1 + } + + fn count_substring(body: &str, pattern: &str) -> usize { + body.split(pattern).count() - 1 + } + + #[tokio::test] + async fn applies_random_array_generator_to_xml_request_body() { + let request = HttpRequest { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { "BODY" => { "$.items.item" => Generator::RandomArray(2, 4) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + expect!(item_count).to(be_ge(2)); + expect!(item_count).to(be_le(4)); + } + + #[tokio::test] + async fn applies_random_array_generator_to_xml_response_body() { + let response = HttpResponse { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { "BODY" => { "$.items.item" => Generator::RandomArray(2, 4) } }, + .. HttpResponse::default() + }; + let generated_response = generate_response(&response, &GeneratorTestMode::Consumer, &hashmap!{}).await; + let body_str = generated_response.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + expect!(item_count).to(be_ge(2)); + expect!(item_count).to(be_le(4)); + } + + #[tokio::test] + async fn applies_random_array_generator_with_same_min_max_to_xml() { + let request = HttpRequest { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { "BODY" => { "$.items.item" => Generator::RandomArray(3, 3) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + expect!(item_count).to(be_equal_to(3)); + } + + #[tokio::test] + async fn applies_random_array_generator_with_nested_generators_to_xml() { + let request = HttpRequest { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { + "BODY" => { + "$.items.item" => Generator::RandomArray(2, 4), + "$.items.item['@name']" => Generator::RandomString(5) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + expect!(item_count).to(be_ge(2)); + expect!(item_count).to(be_le(4)); + + let name_count = count_substring(&body_str, "name='"); + expect!(name_count).to(be_ge(2)); + } + + #[tokio::test] + async fn applies_random_array_generator_with_nested_elements_to_xml() { + let request = HttpRequest { + body: OptionalBody::Present("
".into(), Some(XML.clone()), None), + generators: generators! { "BODY" => { "$.people.person" => Generator::RandomArray(2, 2) } }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let person_count = count_xml_elements(&body_str, "person"); + let address_count = count_xml_elements(&body_str, "address"); + + expect!(person_count).to(be_equal_to(2)); + expect!(address_count).to(be_equal_to(2)); + } + + #[tokio::test] + async fn applies_multiple_independent_array_generators_to_xml() { + let request = HttpRequest { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { + "BODY" => { + "$.root.items.item" => Generator::RandomArray(2, 3), + "$.root.other.entry" => Generator::RandomArray(3, 4) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + let entry_count = count_xml_elements(&body_str, "entry"); + + expect!(item_count).to(be_ge(2)); + expect!(item_count).to(be_le(3)); + expect!(entry_count).to(be_ge(3)); + expect!(entry_count).to(be_le(4)); + } + + #[tokio::test] + async fn applies_nested_random_array_generator_to_xml() { + let request = HttpRequest { + body: OptionalBody::Present("".into(), Some(XML.clone()), None), + generators: generators! { + "BODY" => { + "$.items.item" => Generator::RandomArray(2, 3), + "$.items.item.subitems.subitem" => Generator::RandomArray(2, 3), + "$.items.item.subitems.subitem['@value']" => Generator::RandomInt(1, 1000) + } + }, + .. HttpRequest::default() + }; + let generated_request = generate_request(&request, &GeneratorTestMode::Provider, &hashmap!{}).await; + let body_str = generated_request.body.display_string(); + + let item_count = count_xml_elements(&body_str, "item"); + expect!(item_count).to(be_ge(2)); + expect!(item_count).to(be_le(3)); + + let subitem_count = count_xml_elements(&body_str, "subitem"); + expect!(subitem_count).to(be_ge(4)); + expect!(subitem_count).to(be_le(9)); + } +} diff --git a/rust/pact_models/src/generators/form_urlencoded.rs b/rust/pact_models/src/generators/form_urlencoded.rs index 09ae0f2a3..f352d6eea 100644 --- a/rust/pact_models/src/generators/form_urlencoded.rs +++ b/rust/pact_models/src/generators/form_urlencoded.rs @@ -41,7 +41,7 @@ impl ContentTypeHandler for FormUrlEncodedHandler { fn apply_key( &mut self, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box, ) { @@ -49,7 +49,7 @@ impl ContentTypeHandler for FormUrlEncodedHandler { for (param_key, param_value) in self.params.iter_mut() { let index = map.entry(param_key.clone()).or_insert(0); if key.eq(&DocPath::root().join(param_key.clone())) || key.eq(&DocPath::root().join(param_key.clone()).join_index(*index)) { - return match generator.generate_value(¶m_value, context, matcher) { + return match generator.generate_value(&*param_value, context, matcher) { Ok(new_value) => *param_value = new_value, Err(_) => () } diff --git a/rust/pact_models/src/generators/mod.rs b/rust/pact_models/src/generators/mod.rs index 9cd0350a7..165506f08 100644 --- a/rust/pact_models/src/generators/mod.rs +++ b/rust/pact_models/src/generators/mod.rs @@ -116,6 +116,24 @@ impl FromStr for UuidFormat { } } +/// Processing category of generator that determines processing order +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GeneratorProcessingCategory { + /// Structure generators that modify the shape of data (e.g., Array) + Structure, + /// Data generators that generate values for existing fields + Data, +} + +impl GeneratorProcessingCategory { + pub fn priority(&self) -> u8 { + match self { + GeneratorProcessingCategory::Structure => 0, + GeneratorProcessingCategory::Data => 1, + } + } +} + /// Trait to represent a generator #[derive(Debug, Clone, Eq)] pub enum Generator { @@ -144,7 +162,9 @@ pub enum Generator { /// Generates a URL with the mock server as the base URL MockServerURL(String, String), /// List of variants which can have embedded generators - ArrayContains(Vec<(usize, MatchingRuleCategory, HashMap)>) + ArrayContains(Vec<(usize, MatchingRuleCategory, HashMap)>), + /// Generates an array with a random number of items between min and max + RandomArray(u16, u16) } impl Generator { @@ -194,6 +214,7 @@ impl Generator { } } Generator::MockServerURL(example, regex) => Some(json!({ "type": "MockServerURL", "example": example, "regex": regex })), + Generator::RandomArray(min, max) => Some(json!({ "type": "RandomArray", "min": min, "max": max })), _ => None } } @@ -224,6 +245,11 @@ impl Generator { .map(|dt| DataType::from(dt.clone())))), "MockServerURL" => Some(Generator::MockServerURL(get_field_as_string("example", map).unwrap_or_default(), get_field_as_string("regex", map).unwrap_or_default())), + "RandomArray" => { + let min = ::json_to_number(map, "min", 1); + let max = ::json_to_number(map, "max", 5); + Some(Generator::RandomArray(min, max)) + } _ => { warn!("'{}' is not a valid generator type", gen_type); None @@ -256,6 +282,7 @@ impl Generator { Generator::ProviderStateGenerator(_, _) => "ProviderState", Generator::MockServerURL(_, _) => "MockServerURL", Generator::ArrayContains(_) => "ArrayContains", + Generator::RandomArray(_, _) => "RandomArray", }.to_string() } @@ -312,7 +339,16 @@ impl Generator { (key.to_string(), g.to_json().unwrap()) }).collect())]) }).collect() - } + }, + Generator::RandomArray(min, max) => hashmap!{ "min" => json!(min), "max" => json!(max) } + } + } + + /// Returns the processing category of this generator (Structure or Data) + pub fn processing_category(&self) -> GeneratorProcessingCategory { + match self { + Generator::RandomArray(_, _) => GeneratorProcessingCategory::Structure, + _ => GeneratorProcessingCategory::Data, } } @@ -368,6 +404,10 @@ impl Hash for Generator { } } } + Generator::RandomArray(min, max) => { + min.hash(state); + max.hash(state); + } Generator::Uuid(format) => format.hash(state), _ => () } @@ -389,6 +429,7 @@ impl PartialEq for Generator { (Generator::MockServerURL(ex1, re1), Generator::MockServerURL(ex2, re2)) => ex1 == ex2 && re1 == re2, (Generator::ArrayContains(variants1), Generator::ArrayContains(variants2)) => variants1 == variants2, (Generator::Uuid(format), Generator::Uuid(format2)) => format == format2, + (Generator::RandomArray(min1, max1), Generator::RandomArray(min2, max2)) => min1 == min2 && max1 == max2, _ => mem::discriminant(self) == mem::discriminant(other) } } @@ -502,7 +543,7 @@ pub trait ContentTypeHandler { fn apply_key( &mut self, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box ); @@ -579,6 +620,13 @@ impl Generators { } fn to_json(&self) -> Value { + self.to_json_filtered(|_| true) + } + + fn to_json_filtered(&self, filter: F) -> Value + where + F: Fn(&Generator) -> bool, + { let json_attr = self.categories.iter() .fold(serde_json::Map::new(), |mut map, (name, category)| { let cat: String = name.clone().into(); @@ -586,9 +634,11 @@ impl Generators { GeneratorCategory::PATH | GeneratorCategory::METHOD | GeneratorCategory::STATUS => { match category.get(&DocPath::empty()).or_else(|| category.get(&DocPath::root())) { Some(generator) => { - let json = generator.to_json(); - if let Some(json) = json { - map.insert(cat.clone(), json); + if filter(generator) { + let json = generator.to_json(); + if let Some(json) = json { + map.insert(cat.clone(), json); + } } }, None => () @@ -597,24 +647,32 @@ impl Generators { GeneratorCategory::HEADER | GeneratorCategory::QUERY => { let mut generators = serde_json::Map::new(); for (key, val) in category { - let json = val.to_json(); - if let Some(json) = json { - let name = key.first_field().map(|v| v.to_string()) - .unwrap_or_else(|| key.to_string()); - generators.insert(name, json); + if filter(val) { + let json = val.to_json(); + if let Some(json) = json { + let name = key.first_field().map(|v| v.to_string()) + .unwrap_or_else(|| key.to_string()); + generators.insert(name, json); + } } } - map.insert(cat.clone(), Value::Object(generators)); + if !generators.is_empty() { + map.insert(cat.clone(), Value::Object(generators)); + } }, _ => { let mut generators = serde_json::Map::new(); for (key, val) in category { - let json = val.to_json(); - if let Some(json) = json { - generators.insert(String::from(key), json); + if filter(val) { + let json = val.to_json(); + if let Some(json) = json { + generators.insert(String::from(key), json); + } } } - map.insert(cat.clone(), Value::Object(generators)); + if !generators.is_empty() { + map.insert(cat.clone(), Value::Object(generators)); + } } } map @@ -718,7 +776,8 @@ pub fn generators_from_json(value: &Value) -> anyhow::Result { /// Generates a Value structure for the provided generators pub fn generators_to_json(generators: &Generators, spec_version: &PactSpecification) -> Value { match spec_version { - PactSpecification::V3 | PactSpecification::V4 => generators.to_json(), + PactSpecification::V3 | PactSpecification::V4 => generators.to_json_filtered(|g| !matches!(g, Generator::RandomArray(_, _))), + PactSpecification::V4_1 => generators.to_json(), _ => Value::Null } } @@ -1016,7 +1075,8 @@ impl GenerateValue for Generator { } else { Err(anyhow!("MockServerURL: can not generate a value as there is no mock server details in the test context")) }, - Generator::ArrayContains(_) => Err(anyhow!("can only use ArrayContains with lists")) + Generator::ArrayContains(_) => Err(anyhow!("can only use ArrayContains with lists")), + Generator::RandomArray(_, _) => Err(anyhow!("can only use Array generator with arrays")) }; debug!("Generator = {:?}, Generated value = {:?}", self, result); result @@ -1217,6 +1277,24 @@ impl GenerateValue for Generator { } _ => Err(anyhow!("can only use ArrayContains with lists")) } + Generator::RandomArray(min, max) => match value { + Value::Array(template) => { + if *min < 1 { + return Err(anyhow!("RandomArray: min ({}) must be >= 1", min)); + } + if *min > *max { + return Err(anyhow!("RandomArray: min ({}) must be <= max ({})", min, max)); + } + if template.is_empty() { + return Err(anyhow!("RandomArray: at least 1 item is required in the array to clone")); + } + let length = rand::rng().random_range(*min..max.saturating_add(1)); + let template_item = template.first().cloned().unwrap(); + let result = (0..length).map(|_| template_item.clone()).collect(); + Ok(Value::Array(result)) + } + _ => Err(anyhow!("can only use Array generator with arrays, got: {:?}", value)) + } }; debug!("Generated value = {:?}", result); result @@ -1237,19 +1315,21 @@ impl ContentTypeHandler for JsonHandler { context: &HashMap<&str, Value>, matcher: &Box ) -> Result { - for (key, generator) in generators { - if generator.corresponds_to_mode(mode) { - debug!("Applying generator {:?} to key {}", generator, key); - self.apply_key(key, generator, context, matcher); - } - }; + let mut filtered: Vec<_> = generators.iter() + .filter(|(_, g)| g.corresponds_to_mode(mode)) + .collect(); + filtered.sort_by_key(|(_, g)| g.processing_category().priority()); + for (key, generator) in filtered { + debug!("Applying generator {:?} (category: {:?}) to key {}", generator, generator.processing_category(), key); + self.apply_key(key, generator, context, matcher); + } Ok(OptionalBody::Present(self.value.to_string().into(), Some("application/json".into()), None)) } fn apply_key( &mut self, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box, ) { @@ -2375,7 +2455,8 @@ mod tests2 { use serde_json::{json, Value}; use crate::expression_parser::DataType; - use crate::generators::{generate_value_from_context, Generator}; + use crate::generators::{generate_value_from_context, Generator, GeneratorCategory, Generators, generators_to_json}; + use crate::PactSpecification; #[rstest] // expression, value, data_type, expected @@ -2421,7 +2502,146 @@ mod tests2 { #[case(Generator::ProviderStateGenerator("".to_string(), None), "ProviderState")] #[case(Generator::MockServerURL("".to_string(), "".to_string()), "MockServerURL")] #[case(Generator::ArrayContains(vec![]), "ArrayContains")] + #[case(Generator::RandomArray(2, 5), "RandomArray")] fn generator_name_test(#[case] generator: Generator, #[case] name: &str) { expect!(generator.name()).to(be_equal_to(name)); } + + use crate::generators::{ContentTypeHandler, GenerateValue, GeneratorTestMode, JsonHandler, NoopVariantMatcher, VariantMatcher}; + use crate::path_exp::DocPath; + + #[test] + fn random_array_generator_from_json_test() { + expect!(Generator::from_map("RandomArray", &serde_json::Map::new())).to(be_some().value(Generator::RandomArray(1, 5))); + expect!(Generator::from_map("RandomArray", &json!({ "min": 2 }).as_object().unwrap())).to(be_some().value(Generator::RandomArray(2, 5))); + expect!(Generator::from_map("RandomArray", &json!({ "max": 10 }).as_object().unwrap())).to(be_some().value(Generator::RandomArray(1, 10))); + expect!(Generator::from_map("RandomArray", &json!({ "min": 2, "max": 5 }).as_object().unwrap())).to(be_some().value(Generator::RandomArray(2, 5))); + } + + #[test] + fn random_array_generator_to_json_test() { + expect!(Generator::RandomArray(2, 5).to_json().unwrap()).to(be_equal_to(json!({ "type": "RandomArray", "min": 2, "max": 5 }))); + } + + #[test] + fn random_array_generator_values_test() { + expect!(Generator::RandomArray(2, 5).values()).to(be_equal_to(hashmap! { "min" => json!(2), "max" => json!(5) })); + } + + #[test] + fn random_array_generator_generates_array_with_random_length() { + let generator = Generator::RandomArray(2, 4); + let template = json!([{"name": "xxx", "price": 12}]); + let result = generator.generate_value(&template, &hashmap!{}, &NoopVariantMatcher.boxed()).unwrap(); + let array = result.as_array().unwrap(); + expect!(array.len()).to(be_ge(2)); + expect!(array.len()).to(be_le(4)); + } + + #[test] + fn random_array_generator_generates_unique_items() { + let generator = Generator::RandomArray(3, 3); + let template = json!([{"name": "xxx", "price": 12}]); + let result = generator.generate_value(&template, &hashmap!{}, &NoopVariantMatcher.boxed()).unwrap(); + let array = result.as_array().unwrap(); + expect!(array.len()).to(be_equal_to(3)); + for item in array { + expect!(item["name"].clone()).to(be_equal_to(json!("xxx"))); + expect!(item["price"].clone()).to(be_equal_to(json!(12))); + } + } + + #[test] + fn random_array_generator_with_nested_generators() { + let body = json!({ "items": [{"name": "xxx", "price": 12, "count": 2}] }); + let mut handler = JsonHandler { value: body }; + let generators = hashmap!{ + DocPath::new_unwrap("$.items") => Generator::RandomArray(2, 4), + DocPath::new_unwrap("$.*[*].name") => Generator::RandomString(5), + DocPath::new_unwrap("$.*[*].price") => Generator::RandomInt(1, 100), + DocPath::new_unwrap("$.*[*].count") => Generator::RandomInt(1, 10), + }; + handler.process_body(&generators, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()).unwrap(); + let items = handler.value["items"].as_array().unwrap(); + expect!(items.len()).to(be_ge(2)); + expect!(items.len()).to(be_le(4)); + for i in 1..items.len() { + expect!(&items[i]).not_to(be_equal_to(&items[i - 1])); + } + } + + #[test] + fn random_array_generator_error_on_empty_template() { + let generator = Generator::RandomArray(2, 3); + let template = json!([]); + expect!(generator.generate_value(&template, &hashmap!{}, &NoopVariantMatcher.boxed())).to(be_err()); + } + + #[test] + fn random_array_generator_error_on_invalid_bounds() { + let generator = Generator::RandomArray(5, 3); + let template = json!([{"value": 1}]); + expect!(generator.generate_value(&template, &hashmap!{}, &NoopVariantMatcher.boxed())).to(be_err()); + } + + #[test] + fn random_array_generator_error_on_min_less_than_one() { + let generator = Generator::RandomArray(0, 3); + let template = json!([{"value": 1}]); + expect!(generator.generate_value(&template, &hashmap!{}, &NoopVariantMatcher.boxed())).to(be_err()); + } + + #[rstest] + #[case(json!("string"), "string")] + #[case(json!(123), "number")] + #[case(json!({"name": "xxx"}), "object")] + #[case(json!(null), "null")] + #[case(json!(true), "bool")] + fn random_array_generator_error_on_non_array(#[case] value: Value, #[case] _type: &str) { + let generator = Generator::RandomArray(2, 3); + expect!(generator.generate_value(&value, &hashmap!{}, &NoopVariantMatcher.boxed())).to(be_err()); + } + + #[test] + fn generators_to_json_excludes_random_array_for_v3() { + let mut generators = Generators::default(); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.items"), Generator::RandomArray(2, 4)); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.name"), Generator::RandomString(5)); + + let json_v3 = generators_to_json(&generators, &PactSpecification::V3); + expect!(json_v3).to(be_equal_to(json!({ + "body": { + "$.name": {"size": 5, "type": "RandomString"} + } + }))); + } + + #[test] + fn generators_to_json_excludes_random_array_for_v4() { + let mut generators = Generators::default(); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.items"), Generator::RandomArray(2, 4)); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.name"), Generator::RandomString(5)); + + let json_v4 = generators_to_json(&generators, &PactSpecification::V4); + expect!(json_v4).to(be_equal_to(json!({ + "body": { + "$.name": {"size": 5, "type": "RandomString"} + } + }))); + } + + #[test] + fn generators_to_json_includes_random_array_for_v4_1() { + let mut generators = Generators::default(); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.items"), Generator::RandomArray(2, 4)); + generators.add_generator_with_subcategory(&GeneratorCategory::BODY, DocPath::new_unwrap("$.name"), Generator::RandomString(5)); + + let json_v4_1 = generators_to_json(&generators, &PactSpecification::V4_1); + expect!(json_v4_1).to(be_equal_to(json!({ + "body": { + "$.items": {"max": 4, "min": 2, "type": "RandomArray"}, + "$.name": {"size": 5, "type": "RandomString"} + } + }))); + } } diff --git a/rust/pact_models/src/generators/xml.rs b/rust/pact_models/src/generators/xml.rs index 6241df32d..963bcccf3 100644 --- a/rust/pact_models/src/generators/xml.rs +++ b/rust/pact_models/src/generators/xml.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; +use rand::Rng; use serde_json::Value; use sxd_document::dom::{Document, Element, Attribute, ChildOfRoot, ChildOfElement}; use sxd_document::writer::format_document; @@ -26,12 +27,15 @@ impl <'a> ContentTypeHandler for XmlHandler<'a> { context: &HashMap<&str, Value>, matcher: &Box ) -> Result { - for (key, generator) in generators { - if generator.corresponds_to_mode(mode) { - debug!("Applying generator {:?} to key {}", generator, key); - self.apply_key(key, generator, context, matcher); - } - }; + let mut filtered: Vec<_> = generators.iter() + .filter(|(_, g)| g.corresponds_to_mode(mode)) + .collect(); + filtered.sort_by_key(|(_, g)| g.processing_category().priority()); + + for (key, generator) in filtered { + debug!("Applying generator {:?} (category: {:?}) to key {}", generator, generator.processing_category(), key); + self.apply_key(key, generator, context, matcher); + } let mut w = Vec::new(); match format_document(&self.value, &mut w) { @@ -43,7 +47,7 @@ impl <'a> ContentTypeHandler for XmlHandler<'a> { fn apply_key( &mut self, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box ) { @@ -58,7 +62,7 @@ impl <'a> ContentTypeHandler for XmlHandler<'a> { fn generate_values_for_xml_element<'a>( el: &Element<'a>, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box, parent_path: Vec @@ -72,6 +76,13 @@ fn generate_values_for_xml_element<'a>( let mut path = parent_path.clone(); path.push(xml_element_name(el)); + if let Generator::RandomArray(min, max) = generator { + if key.len() == path.len() + 1 { + duplicate_elements(el, key, *min, *max); + return + } + } + if generate_values_for_xml_attribute(&el, key, generator, context, matcher, path.clone()) { return } @@ -94,7 +105,7 @@ fn generate_values_for_xml_element<'a>( fn generate_values_for_xml_attribute<'a>( el: &Element<'a>, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box, path: Vec @@ -130,7 +141,7 @@ fn generate_values_for_xml_attribute<'a>( fn generate_values_for_xml_text<'a>( el: &Element<'a>, key: &DocPath, - generator: &dyn GenerateValue, + generator: &Generator, context: &HashMap<&str, Value>, matcher: &Box, path: Vec @@ -192,6 +203,78 @@ fn xml_attribute_name(attr: Attribute) -> String { } } +fn duplicate_elements<'a>(el: &Element<'a>, key: &DocPath, min: u16, max: u16) { + if min < 1 { + error!("RandomArray: min ({}) must be >= 1", min); + return; + } + if min > max { + error!("RandomArray: min ({}) must be <= max ({})", min, max); + return; + } + + let length = rand::rng().random_range(min..max.saturating_add(1)); + let last_field = key.last_field().unwrap_or(""); + let element_name = last_field.trim_start_matches('@'); + + let children = el.children(); + let matching_children: Vec<_> = children.iter().filter_map(|c| { + if let ChildOfElement::Element(e) = c { + if e.name().local_part() == element_name { + Some(e) + } else { + None + } + } else { + None + } + }).collect(); + + if matching_children.is_empty() { + error!("RandomArray: at least 1 item is required in the array to clone"); + return; + } + + let template = matching_children[0]; + let items_to_add = length.saturating_sub(1); + + for _ in 0..items_to_add { + let cloned = clone_element(&el.document(), template); + el.append_child(cloned); + } +} + +fn clone_element<'a>(doc: &Document<'a>, el: &Element<'a>) -> Element<'a> { + let new_el = doc.create_element(el.name().local_part()); + + if let Some(prefix) = el.preferred_prefix() { + new_el.set_preferred_prefix(Some(prefix)); + } + + for attr in el.attributes() { + let new_attr = new_el.set_attribute_value(attr.name().local_part(), attr.value()); + if let Some(prefix) = attr.preferred_prefix() { + new_attr.set_preferred_prefix(Some(prefix)); + } + } + + for child in el.children() { + match child { + ChildOfElement::Element(child_el) => { + let cloned_child = clone_element(doc, &child_el); + new_el.append_child(cloned_child); + } + ChildOfElement::Text(txt) => { + let new_text = doc.create_text(txt.text()); + new_el.append_child(new_text); + } + _ => {} + } + } + + new_el +} + #[cfg(test)] mod tests { use expectest::expect; @@ -863,4 +946,203 @@ mod tests { expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("".into(), Some("application/xml".into()), None))); } + + #[test] + fn applies_random_array_generator_to_duplicate_elements() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("people"); + d.root().append_child(r); + + let person = d.create_element("person"); + person.set_attribute_value("id", "1"); + person.append_child(d.create_text("John")); + r.append_child(person); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.people.person") => Generator::RandomArray(2, 3) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + let xml_str = result.unwrap().value().unwrap(); + let xml_str = String::from_utf8_lossy(&xml_str); + + let person_count = xml_str.matches(" Generator::RandomArray(3, 3) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("".into(), Some("application/xml".into()), None))); + } + + #[test] + fn applies_random_array_generator_to_clone_element_with_text() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("root"); + d.root().append_child(r); + + let container = d.create_element("container"); + r.append_child(container); + + let element = d.create_element("element"); + element.append_child(d.create_text("value")); + container.append_child(element); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.root.container.element") => Generator::RandomArray(2, 2) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("valuevalue".into(), Some("application/xml".into()), None))); + } + + #[test] + fn applies_random_array_generator_to_clone_element_with_attribute() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("people"); + d.root().append_child(r); + + let person = d.create_element("person"); + person.set_attribute_value("id", "123"); + person.set_attribute_value("name", "John"); + r.append_child(person); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.people.person") => Generator::RandomArray(3, 3) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("".into(), Some("application/xml".into()), None))); + } + + #[test] + fn applies_random_array_generator_with_nested_generators() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("items"); + d.root().append_child(r); + + let item = d.create_element("item"); + item.set_attribute_value("name", "xxx"); + item.set_attribute_value("price", "12"); + r.append_child(item); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.items.item") => Generator::RandomArray(2, 4), + DocPath::new_unwrap("$.items.item['@name']") => Generator::RandomString(5), + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + let xml_str = result.unwrap().value().unwrap(); + let xml_str = String::from_utf8_lossy(&xml_str); + + let item_count = xml_str.matches(" = xml_str.split("name='").skip(1).map(|s| s.split('\'').next().unwrap().to_string()).collect(); + expect!(name_values.len()).to(be_equal_to(item_count)); + + for i in 1..name_values.len() { + expect!(&name_values[i]).not_to(be_equal_to(&name_values[i - 1])); + } + } + + #[test] + fn applies_random_array_generator_with_min_max_one() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("items"); + d.root().append_child(r); + + let item = d.create_element("item"); + item.set_attribute_value("value", "1"); + r.append_child(item); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.items.item") => Generator::RandomArray(1, 1) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("".into(), Some("application/xml".into()), None))); + } + + #[test] + fn applies_multiple_independent_array_generators() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("root"); + d.root().append_child(r); + + let items = d.create_element("items"); + let item = d.create_element("item"); + item.set_attribute_value("value", "1"); + items.append_child(item); + r.append_child(items); + + let other = d.create_element("other"); + let other_item = d.create_element("entry"); + other_item.set_attribute_value("x", "2"); + other.append_child(other_item); + r.append_child(other); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.root.items.item") => Generator::RandomArray(2, 2), + DocPath::new_unwrap("$.root.other.entry") => Generator::RandomArray(3, 3) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("".into(), Some("application/xml".into()), None))); + } + + #[test] + fn applies_random_array_generator_with_nested_elements_and_attributes() { + let p = Package::new(); + let d = p.as_document(); + let r = d.create_element("people"); + d.root().append_child(r); + + let person = d.create_element("person"); + person.set_attribute_value("id", "1"); + person.set_attribute_value("name", "John"); + + let address = d.create_element("address"); + address.set_attribute_value("city", "NYC"); + person.append_child(address); + + r.append_child(person); + + let mut xml_handler = XmlHandler { value: d }; + + let result = xml_handler.process_body(&hashmap!{ + DocPath::new_unwrap("$.people.person") => Generator::RandomArray(2, 2) + }, &GeneratorTestMode::Consumer, &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(result.unwrap()).to(be_equal_to(OptionalBody::Present("
".into(), Some("application/xml".into()), None))); + } } diff --git a/rust/pact_models/src/lib.rs b/rust/pact_models/src/lib.rs index e4efabe94..c0292cec0 100644 --- a/rust/pact_models/src/lib.rs +++ b/rust/pact_models/src/lib.rs @@ -92,7 +92,9 @@ pub enum PactSpecification { /// Version three of the pact specification (`https://github.com/pact-foundation/pact-specification/tree/version-3`) V3, /// Version four of the pact specification (`https://github.com/pact-foundation/pact-specification/tree/version-4`) - V4 + V4, + /// Version 4.1 of the pact specification (`https://github.com/pact-foundation/pact-specification/tree/version-4.1`) + V4_1 } impl Default for PactSpecification { @@ -110,6 +112,7 @@ impl PactSpecification { PactSpecification::V2 => "2.0.0", PactSpecification::V3 => "3.0.0", PactSpecification::V4 => "4.0", + PactSpecification::V4_1 => "4.1", _ => "unknown" }.into() } @@ -135,6 +138,7 @@ impl PactSpecification { }, 4 => match version.minor { 0 => Ok(PactSpecification::V4), + 1 => Ok(PactSpecification::V4_1), _ => Err(anyhow!("Unsupported specification version '{}'", str_version)) }, _ => Err(anyhow!("Invalid specification version '{}'", str_version)) @@ -150,6 +154,7 @@ impl From<&str> for PactSpecification { "V2" => PactSpecification::V2, "V3" => PactSpecification::V3, "V4" => PactSpecification::V4, + "V4.1" => PactSpecification::V4_1, _ => PactSpecification::Unknown } } @@ -175,6 +180,7 @@ impl Display for PactSpecification { PactSpecification::V2 => write!(f, "V2"), PactSpecification::V3 => write!(f, "V3"), PactSpecification::V4 => write!(f, "V4"), + PactSpecification::V4_1 => write!(f, "V4.1"), _ => write!(f, "unknown") } }