diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 364439bf..54697960 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2221,6 +2221,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 74958354..c7b83767 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..4b1fdb28 --- /dev/null +++ b/rust/pact_ffi/src/mock_server/form_urlencoded.rs @@ -0,0 +1,235 @@ +//! Form UrlEncoded matching support + +use std::collections::HashMap; +use serde_json::Value; +use tracing::{debug, error, trace}; + +use pact_models::generators::{GeneratorCategory, Generators}; +use pact_models::generators::form_urlencoded::QueryParams; +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, generators: &mut Generators) -> String { + trace!("process_form_urlencoded_json"); + let json = process_json(body, matching_rules, 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, generators); + debug!("form_urlencoded params: {:?}", params); + serde_urlencoded::to_string(params).expect("could not serialize body to form urlencoded string") +} + +fn convert_json_value_to_query_params(value: Value, matching_rules: &mut MatchingRuleCategory, generators: &mut Generators) -> QueryParams { + let mut params: QueryParams = vec![]; + match value { + Value::Object(map) => { + for (key, value) in map.iter() { + let path = DocPath::root().join(key); + match value { + Value::Number(value) => params.push((key.clone(), value.to_string())), + Value::String(value) => params.push((key.clone(), value.to_string())), + Value::Array(vec) => { + for (index, value) in vec.iter().enumerate() { + let path = DocPath::root().join(key).join_index(index); + match value { + Value::Number(value) => params.push((key.clone(), value.to_string())), + Value::String(value) => params.push((key.clone(), value.to_string())), + _ => handle_form_urlencoded_invalid_value(value, &path, matching_rules, generators), + } + } + }, + _ => handle_form_urlencoded_invalid_value(value, &path, matching_rules, generators), + } + } + }, + _ => () + } + params +} + +fn handle_form_urlencoded_invalid_value(value: &Value, path: &DocPath, matching_rules: &mut MatchingRuleCategory, generators: &mut Generators) { + for key in matching_rules.clone().rules.keys() { + if String::from(key).contains(&String::from(path)) { + matching_rules.rules.remove(&key); + generators.categories.entry(GeneratorCategory::BODY).or_insert(HashMap::new()).remove(&key); + } + } + error!("Value '{:?}' is not supported in form urlencoded. Matchers and generators (if defined) are removed", value); +} + +#[cfg(test)] +mod test { + use expectest::prelude::*; + use rstest::rstest; + use serde_json::json; + + use pact_models::generators; + use pact_models::generators::Generator; + 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" }), + "=empty+key", + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "": ["first", "second", "third"] }), + "=first&=second&=third", + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "": { "pact:matcher:type": "includes", "value": "empty" } }), + "", + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "number_value": -123.45 }), + "number_value=-123.45".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "string_value": "hello world" }), + "string_value=hello+world".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"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"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "null_value": null }), + "".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "null_value_with_matcher": { "pact:matcher:type": "null" } }), + "".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"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]}, + generators! {"BODY" => {}} + )] + #[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]}, + generators! {"BODY" => {"$.number_value_with_matcher_and_generator" => Generator::RandomInt(0, 10)}} + )] + // 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"; "$" => []}, + generators! {"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]}, + generators! {"BODY" => {"$.string_value_with_matcher_and_generator" => Generator::RandomString(15)}} + )] + #[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]}, + generators! {"BODY" => {}} + )] + #[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))]}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "array_values_with_matcher_and_generator": [ + { "pact:matcher:type": "regex", "value": "a1", "pact:generator:type": "Regex", "regex": "\\w\\d" }, + { "pact:matcher:type": "decimal", "pact:generator:type": "RandomDecimal", "digits": 3, "value": 12.3 } + ] }), + "array_values_with_matcher_and_generator=a1&array_values_with_matcher_and_generator=12.3".to_string(), + matchingrules_list!{ + "body"; + "$.array_values_with_matcher_and_generator[0]" => [MatchingRule::Regex("\\w\\d".to_string())], + "$.array_values_with_matcher_and_generator[1]" => [MatchingRule::Decimal] + }, + generators! {"BODY" => { + "$.array_values_with_matcher_and_generator[0]" => Generator::Regex("\\w\\d".to_string()), + "$.array_values_with_matcher_and_generator[1]" => Generator::RandomDecimal(3) + }} + )] + #[case( + json!({ "false": false }), + "".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "true": true }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "array_of_false": [false] }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "array_of_true": [true] }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "array_of_objects": [{ "key": "value" }] }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "array_of_arrays": [["value 1", "value 2"]] }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case( + json!({ "object_value": { "key": "value" } }), + "".to_string(), matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case(json!( + { "boolean_with_matcher_and_generator": { "pact:matcher:type": "boolean", "value": true, "pact:generator:type": "RandomBoolean" } }), + "".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + #[case(json!( + { "object_with_matcher_and_generator": { "pact:matcher:type": "type", "value": {"key": { "pact:matcher:type": "type", "value": "value", "pact:generator:type": "RandomString" }} } }), + "".to_string(), + matchingrules_list!{"body"; "$" => []}, + generators! {"BODY" => {}} + )] + fn process_form_urlencoded_json_test(#[case] json: Value, #[case] result: String, #[case] expected_matching_rules: MatchingRuleCategory, #[case] expected_generators: Generators) { + let mut matching_rules = MatchingRuleCategory::empty("body"); + let mut generators = Generators::default(); + expect!(process_form_urlencoded_json(json.to_string(), &mut matching_rules, &mut generators)).to(be_equal_to(result)); + expect!(matching_rules).to(be_equal_to(expected_matching_rules)); + expect!(generators).to(be_equal_to(expected_generators)); + } +} diff --git a/rust/pact_ffi/src/mock_server/handles.rs b/rust/pact_ffi/src/mock_server/handles.rs index be861492..393fda36 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; @@ -1700,6 +1701,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() @@ -1744,18 +1750,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, generators)), + 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) } } } @@ -3203,6 +3226,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::*; @@ -4337,14 +4361,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, @@ -4363,11 +4389,11 @@ 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)) ) } @@ -4423,4 +4449,35 @@ mod tests { expect!(result_1).to(be_false()); expect!(result_2).to(be_false()); } + + #[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..3f4a3722 100644 --- a/rust/pact_ffi/tests/tests.rs +++ b/rust/pact_ffi/tests/tests.rs @@ -1841,3 +1841,107 @@ 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!({ + "number": { + "pact:matcher:type": "number", + "value": 23.45 + }, + "string": { + "pact:matcher:type": "type", + "value": "example text" + }, + "array": { + "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!({ + "number": { + "pact:matcher:type": "number", + "value": 0, + "pact:generator:type": "RandomDecimal", + "digits": 2 + }, + "string": { + "pact:matcher:type": "type", + "value": "", + "pact:generator:type": "RandomString" + }, + "array": [ + { + "pact:matcher:type": "number", + "value": 0, + "pact:generator:type": "RandomInt", + "min": 0, + "max": 10 + }, + { + "pact:matcher:type": "type", + "value": "", + "pact:generator:type": "RandomString" + } + ] + }); + 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 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, content_type.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, content_type.as_ptr()); + pactffi_with_body(interaction.clone(), InteractionPart::Response, content_type.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/x-www-form-urlencoded") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("number=999.99&string=any+text&array=value2&array=value3") + .send(); + + match result { + Ok(res) => { + expect!(res.status()).to(be_eq(status)); + expect!(res.headers().get("Content-Type").unwrap()).to(be_eq("application/x-www-form-urlencoded")); + expect!(res.text().unwrap()).to_not(be_equal_to("number=0&string=&array=0&array=".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("[]")); +}