Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support integration json for form urlencoded #462

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading