Skip to content

Commit

Permalink
feat: Support integration json for form urlencoded
Browse files Browse the repository at this point in the history
  • Loading branch information
tienvx committed Nov 27, 2024
1 parent b55407e commit e60fd32
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 12 deletions.
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions rust/pact_ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
184 changes: 184 additions & 0 deletions rust/pact_ffi/src/mock_server/form_urlencoded.rs
Original file line number Diff line number Diff line change
@@ -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, String)>;

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(val) => params.push((key.clone(), val.to_string())),
Value::String(val) => params.push((key.clone(), val.to_string())),
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(val) => params.push((key.clone(), val.to_string())),
Value::String(val) => params.push((key.clone(), val.to_string())),
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(), "empty key".to_string())])]
#[case(json!({ "": ["first", "second", "third"] }), vec![("".to_string(), "first".to_string()), ("".to_string(), "second".to_string()), ("".to_string(), "third".to_string())])]
#[case(json!({ "number_value": 123 }), vec![("number_value".to_string(), "123".to_string())])]
#[case(json!({ "string_value": "hello world" }), vec![("string_value".to_string(), "hello world".to_string())])]
#[case(
json!({ "array_values": [null, 234, "example text", {"key": "value"}, ["value 1", "value 2"]] }),
vec![
("array_values".to_string(), "234".to_string()),
("array_values".to_string(), "example text".to_string()),
],
)]
#[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));
}
}
81 changes: 69 additions & 12 deletions rust/pact_ffi/src/mock_server/handles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)),
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)
}
}
}
Expand Down Expand Up @@ -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::*;

Expand Down Expand Up @@ -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#"<?xml version="1.0" encoding="UTF-8"?><items><item>text</item></items>"#, "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,
Expand All @@ -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))
)
}

Expand Down Expand Up @@ -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
)
}
}
1 change: 1 addition & 0 deletions rust/pact_ffi/src/mock_server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit e60fd32

Please sign in to comment.