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 Sep 6, 2024
1 parent 1686185 commit 2cf002f
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 2 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
120 changes: 119 additions & 1 deletion rust/pact_ffi/src/mock_server/bodies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use bytes::{Bytes, BytesMut};
use either::Either;
use lazy_static::lazy_static;
use regex::Regex;
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use tracing::{debug, error, trace};

use pact_models::bodies::OptionalBody;
Expand Down Expand Up @@ -423,6 +423,51 @@ fn format_multipart_error(e: std::io::Error) -> String {
format!("convert_ptr_to_mime_part_body: Failed to generate multipart body: {}", e)
}

/// 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);
debug!("form_urlencoded params: {:?}", params);
serde_urlencoded::to_string(params).expect("could not serialize body to form urlencoded string")
}

type QueryParams = Vec<(String, Option<Value>)>;

fn convert_json_value_to_query_params(value: Value) -> QueryParams {
let mut params: QueryParams = vec![];
match value {
Value::Object(map) => {
for (key, val) in map.iter() {
match val {
Value::Null => params.push((key.clone(), None)),
Value::Bool(val) => panic!("Value '{}' of key '{}' is not supported: Bool is not supported in form urlencoded, use number or string instead", val, key),
Value::Number(val) => params.push((key.clone(), Some(json!(val)))),
Value::String(val) => params.push((key.clone(), Some(json!(val)))),
Value::Array(vec) => {
for val in vec.iter() {
match val {
Value::Null => params.push((key.clone(), None)),
Value::Bool(val) => panic!("Value '{}' of key '{}' is not supported: Bool is not supported in form urlencoded, use number or string instead", val, key),
Value::Number(val) => params.push((key.clone(), Some(json!(val)))),
Value::String(val) => params.push((key.clone(), Some(json!(val)))),
Value::Array(val) => panic!("Value '{:?}' of key '{}' is not supported: Array of arrays is not supported in form urlencoded", val, key),
Value::Object(val) => panic!("Value '{:?}' of key '{}' is not supported: Array of objects is not supported in form urlencoded", val, key),
}
}
},
Value::Object(val) => panic!("Value '{:?}' of key '{}' is not supported: Object is not supported in form urlencoded", val, key),
}
}
},
_ => ()
}
params
}

#[cfg(test)]
mod test {
use std::collections::HashMap;
Expand Down Expand Up @@ -1009,4 +1054,77 @@ Content-Type: application/json\r\n\r\n{}\r\n--ABCD\r\nContent-Disposition: form-
name=\"part-2\"; filename=\"2.txt\"\r\nContent-Type: text/plain\r\n\r\nTEXT\r\n--ABCD--\r\n",
response.body.value_as_string().unwrap());
}

#[rstest]
#[case(json!({ "null_value": null }), vec![("null_value".to_string(), None)])]
#[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"] }), vec![
("array_values".to_string(), None),
("array_values".to_string(), Some(json!(234))),
("array_values".to_string(), Some(json!("example text"))),
])]
#[should_panic]
#[case(json!({ "bool_value": false }), vec![])]
#[should_panic]
#[case(json!({ "bool_value": true }), vec![])]
#[should_panic]
#[case(json!({ "array_values": [false] }), vec![])]
#[should_panic]
#[case(json!({ "array_values": [true] }), vec![])]
#[should_panic]
#[case(json!({ "array_of_objects": [{ "key": "value" }] }), vec![])]
#[should_panic]
#[case(json!({ "array_of_arrays": [["value 1", "value 2"]] }), vec![])]
#[should_panic]
#[case(json!({ "object_value": { "key": "value" } }), vec![])]
fn convert_json_value_to_query_params_test(#[case] json: Value, #[case] result: QueryParams) {
expect!(convert_json_value_to_query_params(json)).to(be_equal_to(result));
}

#[rstest]
#[case(json!({ "null_value": null }), "".to_string(), 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"] }), "array_values=234&array_values=example+text".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[case(json!({ "null_value": { "pact:matcher:type": "null" } }), "".to_string(), matchingrules_list!{"body"; "$.null_value" => [MatchingRule::Null]}, generators!{"BODY" => {}})]
#[case(
json!({ "number_value": { "pact:matcher:type": "number", "pact:generator:type": "RandomInt", "min": 0, "max": 10 } }),
"".to_string(),
matchingrules_list!{"body"; "$.number_value" => [MatchingRule::Number]},
generators!{"BODY" => {"$.number_value" => Generator::RandomInt(0, 10)}}
)]
#[case(
json!({ "string_value": { "pact:matcher:type": "type", "value": "some string", "pact:generator:type": "RandomString", "size": 15 } }),
"string_value=some+string".to_string(),
matchingrules_list!{"body"; "$.string_value" => [MatchingRule::Type]},
generators!{"BODY" => {"$.string_value" => Generator::RandomString(15)}}
)]
#[case(
json!({ "array_values": { "pact:matcher:type": "eachValue", "value": ["string value"], "rules": [{ "pact:matcher:type": "type", "value": "string" }] } }),
"array_values=string+value".to_string(),
matchingrules_list!{"body"; "$.array_values" => [MatchingRule::EachValue(MatchingRuleDefinition::new("[\"string value\"]".to_string(), ValueType::Unknown, MatchingRule::Type, None))]},
generators!{"BODY" => {}}
)]
#[should_panic]
#[case(json!({ "bool_value": false }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "bool_value": true }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "array_values": [false] }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "array_values": [true] }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "array_of_objects": [{ "key": "value" }] }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "array_of_arrays": [["value 1", "value 2"]] }), "".to_string(), matchingrules_list!{"body"; "$" => []}, generators!{"BODY" => {}})]
#[should_panic]
#[case(json!({ "object_value": { "key": "value" } }), "".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));
}
}
24 changes: 23 additions & 1 deletion rust/pact_ffi/src/mock_server/handles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ use crate::mock_server::bodies::{
request_multipart,
response_multipart,
get_content_type_hint,
part_body_replace_marker
part_body_replace_marker,
process_form_urlencoded_json
};
use crate::models::iterators::{PactAsyncMessageIterator, PactMessageIterator, PactSyncHttpIterator, PactSyncMessageIterator};
use crate::ptr;
Expand Down Expand Up @@ -1727,6 +1728,27 @@ fn process_body(
}
}
}
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::from(body)
}
}
}
_ => {
// We either have no content type, or an unsupported content type.
trace!("Raw body");
Expand Down
86 changes: 86 additions & 0 deletions rust/pact_ffi/tests/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("[]"));
}
14 changes: 14 additions & 0 deletions rust/pact_models/src/content_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -348,6 +352,7 @@ impl TryFrom<&str> for ContentTypeHint {
mod tests {
use expectest::prelude::*;
use maplit::btreemap;
use rstest::rstest;

use super::ContentType;

Expand Down Expand Up @@ -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));
}
}

0 comments on commit 2cf002f

Please sign in to comment.