diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 89e403ed..bf841d63 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2214,6 +2214,7 @@ dependencies = [ "rstest 0.22.0", "serde", "serde_json", + "serde_urlencoded", "sxd-document", "tempfile", "test-log", diff --git a/rust/pact_ffi/Cargo.toml b/rust/pact_ffi/Cargo.toml index 99799285..d2f7e52a 100644 --- a/rust/pact_ffi/Cargo.toml +++ b/rust/pact_ffi/Cargo.toml @@ -52,6 +52,7 @@ tracing-log = "0.2.0" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "tracing-log"] } uuid = { version = "1.10.0", features = ["v4"] } zeroize = "1.8.1" +serde_urlencoded = "0.7.1" [dev-dependencies] expectest = "0.12.0" diff --git a/rust/pact_ffi/src/mock_server/form_urlencoded.rs b/rust/pact_ffi/src/mock_server/form_urlencoded.rs new file mode 100644 index 00000000..d720c381 --- /dev/null +++ b/rust/pact_ffi/src/mock_server/form_urlencoded.rs @@ -0,0 +1,184 @@ +//! Form UrlEncoded matching support + +use serde_json::Value; +use tracing::{debug, error, trace}; + +use pact_models::generators::Generators; +use pact_models::matchingrules::MatchingRuleCategory; +use pact_models::path_exp::DocPath; + +use crate::mock_server::bodies::process_json; + +/// Process a JSON body with embedded matching rules and generators +pub fn process_form_urlencoded_json(body: String, matching_rules: &mut MatchingRuleCategory) -> String { + trace!("process_form_urlencoded_json"); + // @todo support generators in form_urlencoded_json, they are currently ignored due to the error 'Generators only support JSON and XML' + let mut generators = Generators::default(); + let json = process_json(body, matching_rules, &mut generators); + debug!("form_urlencoded json: {json}"); + let values: Value = serde_json::from_str(json.as_str()).unwrap(); + debug!("form_urlencoded values: {values}"); + let params = convert_json_value_to_query_params(values, matching_rules); + debug!("form_urlencoded params: {:?}", params); + serde_urlencoded::to_string(params).expect("could not serialize body to form urlencoded string") +} + +type QueryParams = Vec<(String, Option)>; + +fn convert_json_value_to_query_params(value: Value, matching_rules: &mut MatchingRuleCategory) -> QueryParams { + let mut params: QueryParams = vec![]; + match value { + Value::Object(map) => { + for (key, val) in map.iter() { + let path = &mut DocPath::root(); + path.push_field(key); + match val { + Value::Null => { + matching_rules.remove_rule(&path); + error!("Value '{}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + Value::Bool(val) => { + matching_rules.remove_rule(&path); + error!("Value '{}' is not supported in form urlencoded. Matcher (if defined) is removed", val) + }, + Value::Number(_) => params.push((key.clone(), Some(val.clone()))), + Value::String(_) => params.push((key.clone(), Some(val.clone()))), + Value::Array(vec) => { + for (index, val) in vec.iter().enumerate() { + let path = &mut path.clone(); + path.push_index(index); + match val { + Value::Null => { + matching_rules.remove_rule(&path); + error!("Value '{}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + Value::Bool(val) => { + matching_rules.remove_rule(&path); + error!("Value '{}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + Value::Number(_) => params.push((key.clone(), Some(val.clone()))), + Value::String(_) => params.push((key.clone(), Some(val.clone()))), + Value::Array(val) => { + matching_rules.remove_rule(&path); + error!("Value '{:?}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + Value::Object(val) => { + matching_rules.remove_rule(&path); + error!("Value '{:?}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + } + } + }, + Value::Object(val) => { + matching_rules.remove_rule(&path); + error!("Value '{:?}' is not supported in form urlencoded. Matcher (if defined) is removed", val); + }, + } + } + }, + _ => () + } + params +} + +#[cfg(test)] +mod test { + use expectest::prelude::*; + use rstest::rstest; + use serde_json::json; + + use pact_models::matchingrules_list; + use pact_models::matchingrules::{MatchingRule, MatchingRuleCategory}; + use pact_models::matchingrules::expressions::{MatchingRuleDefinition, ValueType}; + + use super::*; + + #[rstest] + #[case(json!({ "": "empty key" }), vec![("".to_string(), Some(json!("empty key")))])] + #[case(json!({ "": ["first", "second", "third"] }), vec![("".to_string(), Some(json!("first"))), ("".to_string(), Some(json!("second"))), ("".to_string(), Some(json!("third")))])] + #[case(json!({ "number_value": 123 }), vec![("number_value".to_string(), Some(json!(123)))])] + #[case(json!({ "string_value": "hello world" }), vec![("string_value".to_string(), Some(json!("hello world")))])] + #[case( + json!({ "array_values": [null, 234, "example text", {"key": "value"}, ["value 1", "value 2"]] }), + vec![ + ("array_values".to_string(), Some(json!(234))), + ("array_values".to_string(), Some(json!("example text"))), + ], + )] + #[case(json!({ "null_value": null }), vec![])] + #[case(json!({ "false": false }), vec![])] + #[case(json!({ "true": true }), vec![])] + #[case(json!({ "array_of_null": [null] }), vec![])] + #[case(json!({ "array_of_false": [false] }), vec![])] + #[case(json!({ "array_of_true": [true] }), vec![])] + #[case(json!({ "array_of_objects": [{ "key": "value" }] }), vec![])] + #[case(json!({ "array_of_arrays": [["value 1", "value 2"]] }), vec![])] + #[case(json!({ "object_value": { "key": "value" } }), vec![])] + fn convert_json_value_to_query_params_test(#[case] json: Value, #[case] result: QueryParams) { + let mut matching_rules = MatchingRuleCategory::empty("body"); + expect!(convert_json_value_to_query_params(json, &mut matching_rules)).to(be_equal_to(result)); + expect!(matching_rules).to(be_equal_to(matchingrules_list!{"body"; "$" => []})); + } + + #[rstest] + #[case(json!({ "": "empty key" }), "=empty+key", matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "": ["first", "second", "third"] }), "=first&=second&=third", matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "": { "pact:matcher:type": "includes", "value": "empty" } }), "", matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "number_value": -123.45 }), "number_value=-123.45".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "string_value": "hello world" }), "string_value=hello+world".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case( + json!({ "array_values": [null, 234, "example text", {"key": "value"}, ["value 1", "value 2"]] }), + "array_values=234&array_values=example+text".to_string(), + matchingrules_list!{"body"; "$" => []} + )] + #[case(json!({ "null_value": null }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "null_value_with_matcher": { "pact:matcher:type": "null" } }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case( + json!({ "number_value_with_matcher": { "pact:matcher:type": "number", "min": 0, "max": 10, "value": 123 } }), + "number_value_with_matcher=123".to_string(), + matchingrules_list!{"body"; "$.number_value_with_matcher" => [MatchingRule::Number]} + )] + #[case( + json!({ "number_value_with_matcher_and_generator": { "pact:matcher:type": "number", "pact:generator:type": "RandomInt", "min": 0, "max": 10, "value": 123 } }), + "number_value_with_matcher_and_generator=123".to_string(), + matchingrules_list!{"body"; "$.number_value_with_matcher_and_generator" => [MatchingRule::Number]} + )] + // Missing value => null will be used => but it is not supported, so matcher is removed. + #[case( + json!({ "number_matcher_only": { "pact:matcher:type": "number", "min": 0, "max": 10 } }), + "".to_string(), + matchingrules_list!{"body"; "$" => []} + )] + #[case( + json!({ "string_value_with_matcher_and_generator": { "pact:matcher:type": "type", "value": "some string", "pact:generator:type": "RandomString", "size": 15 } }), + "string_value_with_matcher_and_generator=some+string".to_string(), + matchingrules_list!{"body"; "$.string_value_with_matcher_and_generator" => [MatchingRule::Type]} + )] + #[case( + json!({ "string_value_with_matcher": { "pact:matcher:type": "type", "value": "some string", "size": 15 } }), + "string_value_with_matcher=some+string".to_string(), + matchingrules_list!{"body"; "$.string_value_with_matcher" => [MatchingRule::Type]} + )] + #[case( + json!({ "array_values_with_matcher": { "pact:matcher:type": "eachValue", "value": ["string value"], "rules": [{ "pact:matcher:type": "type", "value": "string" }] } }), + "array_values_with_matcher=string+value".to_string(), + matchingrules_list!{"body"; "$.array_values_with_matcher" => [MatchingRule::EachValue(MatchingRuleDefinition::new("[\"string value\"]".to_string(), ValueType::Unknown, MatchingRule::Type, None))]} + )] + #[case(json!({ "false": false }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "true": true }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "array_of_false": [false] }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "array_of_true": [true] }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "array_of_objects": [{ "key": "value" }] }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "array_of_arrays": [["value 1", "value 2"]] }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!({ "object_value": { "key": "value" } }), "".to_string(), matchingrules_list!{"body"; "$" => []})] + #[case(json!( + { "unsupported_value_with_matcher": { "pact:matcher:type": "boolean", "value": true } }), + "".to_string(), + matchingrules_list!{"body"; "$" => []} + )] + fn process_form_urlencoded_json_test(#[case] json: Value, #[case] result: String, #[case] expected_matching_rules: MatchingRuleCategory) { + let mut matching_rules = MatchingRuleCategory::empty("body"); + expect!(process_form_urlencoded_json(json.to_string(), &mut matching_rules)).to(be_equal_to(result)); + expect!(matching_rules).to(be_equal_to(expected_matching_rules)); + } +} diff --git a/rust/pact_ffi/src/mock_server/handles.rs b/rust/pact_ffi/src/mock_server/handles.rs index a252ff02..92f2fed6 100644 --- a/rust/pact_ffi/src/mock_server/handles.rs +++ b/rust/pact_ffi/src/mock_server/handles.rs @@ -160,6 +160,7 @@ use crate::mock_server::bodies::{ get_content_type_hint, part_body_replace_marker }; +use crate::mock_server::form_urlencoded::process_form_urlencoded_json; use crate::models::iterators::{PactAsyncMessageIterator, PactMessageIterator, PactSyncHttpIterator, PactSyncMessageIterator}; use crate::ptr; @@ -1679,6 +1680,11 @@ fn process_body( matching_rules, generators ); + + if body.is_empty() { + return OptionalBody::Empty; + } + let detected_type = detect_content_type_from_string(body); let content_type = content_type .clone() @@ -1723,18 +1729,35 @@ fn process_body( } _ => { trace!("Raw XML body left as is"); - OptionalBody::from(body) + OptionalBody::Present(Bytes::from(body.to_owned()), Some(ct), None) + } + } + } + Some(ct) if ct.is_form_urlencoded() => { + // The Form UrlEncoded payload may contain one of two cases: + // 1. A raw Form UrlEncoded payload + // 2. A JSON payload describing the Form UrlEncoded payload, including any + // embedded generators and matching rules. + match detected_type { + Some(detected_ct) if detected_ct.is_json() => { + trace!("Processing JSON description for Form UrlEncoded body"); + let category = matching_rules.add_category("body"); + OptionalBody::Present( + Bytes::from(process_form_urlencoded_json(body.to_string(), category)), + Some(ct), // Note to use the provided content type, not the detected one + None, + ) + } + _ => { + trace!("Raw Form UrlEncoded body left as is"); + OptionalBody::Present(Bytes::from(body.to_owned()), Some(ct), None) } } } _ => { // We either have no content type, or an unsupported content type. trace!("Raw body"); - if body.is_empty() { - OptionalBody::Empty - } else { - OptionalBody::Present(Bytes::from(body.to_owned()), content_type, None) - } + OptionalBody::Present(Bytes::from(body.to_owned()), content_type, None) } } } @@ -3182,6 +3205,7 @@ mod tests { use pact_models::path_exp::DocPath; use pact_models::prelude::{Generators, MatchingRules}; use pretty_assertions::assert_eq; + use rstest::rstest; use crate::mock_server::handles::*; @@ -4316,14 +4340,16 @@ mod tests { /// See https://github.com/pact-foundation/pact-php/pull/626 /// and https://github.com/pact-foundation/pact-reference/pull/461 - #[test] - fn annotate_raw_body_branch() { + #[rstest] + #[case("a=1&b=2&c=3", "application/x-www-form-urlencoded")] + #[case(r#"text"#, "application/xml")] + fn pactffi_with_raw_body_test(#[case] raw: String, #[case] ct: String) { let pact_handle = PactHandle::new("Consumer", "Provider"); let description = CString::new("Generator Test").unwrap(); let i_handle = pactffi_new_interaction(pact_handle, description.as_ptr()); - let body = CString::new("a=1&b=2&c=3").unwrap(); - let content_type = CString::new("application/x-www-form-urlencoded").unwrap(); + let body = CString::new(raw.clone()).unwrap(); + let content_type = CString::new(ct.clone()).unwrap(); let result = pactffi_with_body( i_handle, InteractionPart::Request, @@ -4342,11 +4368,42 @@ mod tests { .headers .expect("no headers found") .get("Content-Type"), - Some(&vec!["application/x-www-form-urlencoded".to_string()]) + Some(&vec![ct]) ); assert_eq!( interaction.request.body.value(), - Some(Bytes::from("a=1&b=2&c=3")) + Some(Bytes::from(raw)) ) } + + #[test] + fn pactffi_with_empty_body_test() { + let pact_handle = PactHandle::new("Consumer", "Provider"); + let description = CString::new("Generator Test").unwrap(); + let i_handle = pactffi_new_interaction(pact_handle, description.as_ptr()); + + let body = CString::new("").unwrap(); + let content_type = CString::new("text/plain").unwrap(); + let result = pactffi_with_body( + i_handle, + InteractionPart::Request, + content_type.as_ptr(), + body.as_ptr(), + ); + assert!(result); + + let interaction = i_handle + .with_interaction(&|_, _, inner| inner.as_v4_http().unwrap()) + .unwrap(); + + expect!( + interaction + .request + .headers + ).to(be_none()); + assert_eq!( + interaction.request.body.value(), + None + ) + } } diff --git a/rust/pact_ffi/src/mock_server/mod.rs b/rust/pact_ffi/src/mock_server/mod.rs index 2fd59b57..c8bc41e5 100644 --- a/rust/pact_ffi/src/mock_server/mod.rs +++ b/rust/pact_ffi/src/mock_server/mod.rs @@ -78,6 +78,7 @@ use crate::string::optional_str; pub mod handles; pub mod bodies; mod xml; +mod form_urlencoded; /// [DEPRECATED] External interface to create a HTTP mock server. A pointer to the pact JSON as a NULL-terminated C /// string is passed in, as well as the port for the mock server to run on. A value of 0 for the diff --git a/rust/pact_ffi/tests/tests.rs b/rust/pact_ffi/tests/tests.rs index 517f82c7..e097bdae 100644 --- a/rust/pact_ffi/tests/tests.rs +++ b/rust/pact_ffi/tests/tests.rs @@ -1841,3 +1841,89 @@ fn returns_mock_server_logs() { assert_ne!(logs,"", "logs are empty"); } + +#[test] +#[allow(deprecated)] +fn http_form_urlencoded_consumer_feature_test() { + let consumer_name = CString::new("http-consumer").unwrap(); + let provider_name = CString::new("http-provider").unwrap(); + let pact_handle = pactffi_new_pact(consumer_name.as_ptr(), provider_name.as_ptr()); + let description = CString::new("form_urlencoded_request_with_matchers").unwrap(); + let interaction = pactffi_new_interaction(pact_handle.clone(), description.as_ptr()); + let accept_header = CString::new("Accept").unwrap(); + let content_type_header = CString::new("Content-Type").unwrap(); + let json = json!({ + "null": null, + "number": 123, + "string": "example value", + "array": [null, -123.45, "inner text"], + "null_matching": { + "pact:matcher:type": "null" + }, + "number_matching": { + "pact:matcher:type": "number", + "value": 23.45 + }, + "string_matching": { + "pact:matcher:type": "type", + "value": "example text" + }, + "array_matching": { + "pact:matcher:type": "eachValue(matching(regex, 'value1|value2|value3|value4', 'value2'))", + "value": ["value1", "value4"] + } + }); + let body = CString::new(json.to_string()).unwrap(); + let response_json = json!({"id": 123}); + let response_body = CString::new(response_json.to_string()).unwrap(); + let address = CString::new("127.0.0.1:0").unwrap(); + let description = CString::new("a request to test the form urlencoded body").unwrap(); + let method = CString::new("POST").unwrap(); + let path = CString::new("/form-urlencoded").unwrap(); + let response_content_type = "application/json"; + let content_type = CString::new("application/x-www-form-urlencoded").unwrap(); + let status = 201; + + pactffi_upon_receiving(interaction.clone(), description.as_ptr()); + // with request... + pactffi_with_request(interaction.clone(), method.as_ptr(), path.as_ptr()); + pactffi_with_header(interaction.clone(), InteractionPart::Request, accept_header.as_ptr(), 0, CString::new(response_content_type).unwrap().as_ptr()); + pactffi_with_header(interaction.clone(), InteractionPart::Request, content_type_header.as_ptr(), 0, content_type.as_ptr()); + pactffi_with_body(interaction.clone(), InteractionPart::Request, content_type.as_ptr(), body.as_ptr()); + // will respond with... + pactffi_with_header(interaction.clone(), InteractionPart::Response, content_type_header.as_ptr(), 0, CString::new(response_content_type).unwrap().as_ptr()); + pactffi_with_body(interaction.clone(), InteractionPart::Response, CString::new(response_content_type).unwrap().as_ptr(), response_body.as_ptr()); + pactffi_response_status(interaction.clone(), status); + let port = pactffi_create_mock_server_for_pact(pact_handle.clone(), address.as_ptr(), false); + + expect!(port).to(be_greater_than(0)); + + // Mock server has started, we can't now modify the pact + expect!(pactffi_upon_receiving(interaction.clone(), description.as_ptr())).to(be_false()); + + let client = Client::default(); + let result = client.post(format!("http://127.0.0.1:{}/form-urlencoded", port).as_str()) + .header("Accept", "application/json") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("number=123&string=example+value&array=-123.45&array=inner+text&number_matching=999.99&string_matching=any+text&array_matching=value2&array_matching=value3") + .send(); + + match result { + Ok(res) => { + expect!(res.status()).to(be_eq(status)); + expect!(res.headers().get("Content-Type").unwrap()).to(be_eq(response_content_type)); + expect!(res.text().unwrap_or_default()).to(be_equal_to(response_json.to_string())); + }, + Err(_) => { + panic!("expected {} response but request failed", status); + } + }; + + let mismatches = unsafe { + CStr::from_ptr(pactffi_mock_server_mismatches(port)).to_string_lossy().into_owned() + }; + + pactffi_cleanup_mock_server(port); + + expect!(mismatches).to(be_equal_to("[]")); +} 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/matchingrules/mod.rs b/rust/pact_models/src/matchingrules/mod.rs index 21c0bed2..438b59b6 100644 --- a/rust/pact_models/src/matchingrules/mod.rs +++ b/rust/pact_models/src/matchingrules/mod.rs @@ -843,6 +843,11 @@ impl MatchingRuleCategory { rules.rules.push(matcher); } + /// Remove a rule from this category + pub fn remove_rule(&mut self, key: &DocPath) { + self.rules.remove(key); + } + /// Filters the matchers in the category by the predicate, and returns a new category pub fn filter(&self, predicate: F) -> MatchingRuleCategory where F : Fn(&(&DocPath, &RuleList)) -> bool {