diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 857f9d06..f12fb09f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2390,6 +2390,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_urlencoded", "speculate", "sxd-document", "test-log", diff --git a/rust/pact_models/Cargo.toml b/rust/pact_models/Cargo.toml index 4bded3e3..ff6b422c 100644 --- a/rust/pact_models/Cargo.toml +++ b/rust/pact_models/Cargo.toml @@ -15,9 +15,10 @@ exclude = [ build = "build.rs" [features] -default = ["datetime", "xml"] +default = ["datetime", "xml", "form_urlencoded"] datetime = ["dep:chrono", "dep:chrono-tz", "dep:gregorian"] # Support for date/time matchers and expressions xml = ["dep:sxd-document"] # support for matching XML documents +form_urlencoded = ["dep:serde_urlencoded"] # suport for matching form urlencoded [dependencies] ariadne = "0.5.0" @@ -44,6 +45,7 @@ regex-syntax = "0.8.5" semver = "1.0.23" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.132" +serde_urlencoded = { version = "0.7.1", optional = true } sxd-document = { version = "0.3.2", optional = true } tracing = "0.1.40" # This needs to be the same version across all the libs (i.e. Pact FFI and plugin driver) diff --git a/rust/pact_models/src/content_types.rs b/rust/pact_models/src/content_types.rs index 624a9bd1..38442d36 100644 --- a/rust/pact_models/src/content_types.rs +++ b/rust/pact_models/src/content_types.rs @@ -160,6 +160,10 @@ impl ContentType { self.main_type == *t && self.sub_type == *st }).is_some() } + + pub fn is_form_urlencoded(&self) -> bool { + self.main_type == "application" && self.sub_type == "x-www-form-urlencoded" + } } impl Default for ContentType { @@ -348,6 +352,7 @@ impl TryFrom<&str> for ContentTypeHint { mod tests { use expectest::prelude::*; use maplit::btreemap; + use rstest::rstest; use super::ContentType; @@ -573,4 +578,13 @@ mod tests { expect!(content_type2.is_equivalent_to(&content_type3)).to(be_true()); expect!(content_type2.is_equivalent_to(&content_type4)).to(be_false()); } + + #[rstest] + #[case("text/plain", false)] + #[case("multipart/form-data", false)] + #[case("application/x-www-form-urlencoded", true)] + #[case("application/json", false)] + fn is_form_urlencoded_test(#[case] content_type: &str, #[case] result: bool) { + expect!(ContentType::parse(content_type).unwrap().is_form_urlencoded()).to(be_eq(result)); + } } diff --git a/rust/pact_models/src/generators/form_urlencoded.rs b/rust/pact_models/src/generators/form_urlencoded.rs new file mode 100644 index 00000000..09ae0f2a --- /dev/null +++ b/rust/pact_models/src/generators/form_urlencoded.rs @@ -0,0 +1,174 @@ + +use std::collections::HashMap; + +use serde_json::Value; +use tracing::debug; +use anyhow::{anyhow, Result}; + +use crate::generators::{ContentTypeHandler, Generator, GeneratorTestMode, VariantMatcher, GenerateValue}; +use crate::path_exp::DocPath; +use crate::bodies::OptionalBody; + +pub type QueryParams = Vec<(String, String)>; + +/// Implementation of a content type handler for FORM URLENCODED +pub struct FormUrlEncodedHandler { + /// Query params to apply the generators to. + pub params: QueryParams +} + +impl ContentTypeHandler for FormUrlEncodedHandler { + fn process_body( + &mut self, + generators: &HashMap, + mode: &GeneratorTestMode, + 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); + } + }; + debug!("Query Params {:?}", self.params); + match serde_urlencoded::to_string(self.params.clone()) { + Ok(query_string) => Ok(OptionalBody::Present(query_string.into(), Some("application/x-www-form-urlencoded".into()), None)), + Err(err) => Err(anyhow!("Failed to convert query params to query string: {}", err).to_string()) + } + } + + fn apply_key( + &mut self, + key: &DocPath, + generator: &dyn GenerateValue, + context: &HashMap<&str, Value>, + matcher: &Box, + ) { + let mut map: HashMap = HashMap::new(); + 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) { + Ok(new_value) => *param_value = new_value, + Err(_) => () + } + } + *index += 1; + } + } +} + + +#[cfg(test)] +mod tests { + use expectest::expect; + use expectest::prelude::*; + use test_log::test; + use maplit::hashmap; + + use crate::generators::NoopVariantMatcher; + + use super::*; + use super::Generator; + + #[test] + fn applies_the_generator_to_a_valid_param() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.b"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(&form_urlencoded_handler.params[1].1).to_not(be_equal_to("B")); + } + + #[test] + fn does_not_apply_the_generator_to_invalid_param() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.d"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn applies_the_generator_to_a_list_item() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B1".to_string()), ("b".to_string(), "B2".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.b[1]"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(&form_urlencoded_handler.params[2].1).to_not(be_equal_to("B2")); + } + + #[test] + fn does_not_apply_the_generator_when_index_is_not_in_list() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.b[3]"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn does_not_apply_the_generator_when_not_a_list() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.a[0]"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(&form_urlencoded_handler.params[0].1).to_not(be_equal_to("100")); + } + + #[test] + fn applies_the_generator_to_the_root() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::root(), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn does_not_apply_the_generator_to_long_path() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.a[1].b['2']"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn applies_the_generator_to_all_map_entries() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.*"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn applies_the_generator_to_all_list_items() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$[*]"), &Generator::RandomInt(0, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } + + #[test] + fn applies_the_generator_to_long_path_with_wildcard() { + let params = vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())); + let mut form_urlencoded_handler = FormUrlEncodedHandler { params }; + + form_urlencoded_handler.apply_key(&DocPath::new_unwrap("$.*[1].b[*]"), &Generator::RandomInt(3, 10), &hashmap!{}, &NoopVariantMatcher.boxed()); + + expect!(form_urlencoded_handler.params).to(be_equal_to(vec!(("a".to_string(), "100".to_string()), ("b".to_string(), "B".to_string()), ("c".to_string(), "C".to_string())))); + } +} diff --git a/rust/pact_models/src/generators/mod.rs b/rust/pact_models/src/generators/mod.rs index 21bfb0a1..61aa4e06 100644 --- a/rust/pact_models/src/generators/mod.rs +++ b/rust/pact_models/src/generators/mod.rs @@ -35,6 +35,7 @@ use crate::path_exp::{DocPath, PathToken}; #[cfg(feature = "datetime")] pub mod datetime_expressions; #[cfg(feature = "datetime")] mod date_expression_parser; #[cfg(feature = "datetime")] mod time_expression_parser; +#[cfg(feature = "form_urlencoded")] pub mod form_urlencoded; /// Trait to represent matching logic to find a matching variant for the Array Contains generator pub trait VariantMatcher: Debug {