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 Dec 2, 2024
1 parent 707752a commit c2dc73b
Show file tree
Hide file tree
Showing 8 changed files with 439 additions and 14 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
235 changes: 235 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,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));
}
}
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, 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)
}
}
}
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
)
}
}
Loading

0 comments on commit c2dc73b

Please sign in to comment.