From f2c68a1645edd107ab308049c217992e556a3208 Mon Sep 17 00:00:00 2001 From: "tien.xuan.vo" Date: Thu, 5 Sep 2024 01:30:55 +0700 Subject: [PATCH] feat: Support integration json for form urlencoded --- rust/Cargo.lock | 1 + rust/pact_ffi/Cargo.toml | 1 + rust/pact_ffi/src/mock_server/bodies.rs | 120 ++++++++++++++++++++++- rust/pact_ffi/src/mock_server/handles.rs | 24 ++++- rust/pact_ffi/tests/tests.rs | 86 ++++++++++++++++ rust/pact_models/src/content_types.rs | 14 +++ 6 files changed, 244 insertions(+), 2 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b6d0de28..9d18674a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2107,6 +2107,7 @@ dependencies = [ "rstest 0.22.0", "serde", "serde_json", + "serde_urlencoded", "sxd-document", "tempfile", "test-log", diff --git a/rust/pact_ffi/Cargo.toml b/rust/pact_ffi/Cargo.toml index 865df2c2..89e2bc97 100644 --- a/rust/pact_ffi/Cargo.toml +++ b/rust/pact_ffi/Cargo.toml @@ -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" diff --git a/rust/pact_ffi/src/mock_server/bodies.rs b/rust/pact_ffi/src/mock_server/bodies.rs index 71f80664..f20c2cce 100644 --- a/rust/pact_ffi/src/mock_server/bodies.rs +++ b/rust/pact_ffi/src/mock_server/bodies.rs @@ -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; @@ -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)>; + +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; @@ -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)); + } } diff --git a/rust/pact_ffi/src/mock_server/handles.rs b/rust/pact_ffi/src/mock_server/handles.rs index 56da1796..a60d4ac2 100644 --- a/rust/pact_ffi/src/mock_server/handles.rs +++ b/rust/pact_ffi/src/mock_server/handles.rs @@ -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; @@ -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"); diff --git a/rust/pact_ffi/tests/tests.rs b/rust/pact_ffi/tests/tests.rs index 517f82c7..e097bdae 100644 --- a/rust/pact_ffi/tests/tests.rs +++ b/rust/pact_ffi/tests/tests.rs @@ -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("[]")); +} diff --git a/rust/pact_models/src/content_types.rs b/rust/pact_models/src/content_types.rs index 624a9bd1..38442d36 100644 --- a/rust/pact_models/src/content_types.rs +++ b/rust/pact_models/src/content_types.rs @@ -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 { @@ -348,6 +352,7 @@ impl TryFrom<&str> for ContentTypeHint { mod tests { use expectest::prelude::*; use maplit::btreemap; + use rstest::rstest; use super::ContentType; @@ -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)); + } }