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
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));
}
}
Loading