Skip to content

Commit 29302a8

Browse files
rholshausenclaude
andcommitted
feat(pact_matching): Add V2 engine support for form-urlencoded body matching
Adds FormUrlencodedPlanBuilder for application/x-www-form-urlencoded content type, using a tee(form:parse($.body), ...) plan pattern. Also adds SLIST support to execute_match_each_value, MMAP navigation in resolve_stack_value, and the form:parse interpreter action. Fixes all 10 CI test failures under PACT_MATCHING_ENGINE=v2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb8c419 commit 29302a8

9 files changed

Lines changed: 458 additions & 60 deletions

File tree

rust/Cargo.lock

Lines changed: 5 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ resolver = "2"
1111
[patch.crates-io]
1212
# Issue 389 - Pull change into other crates
1313
pact_models = { version = "~1.3.10", path = "./pact_models" }
14+
pact_matching = { version = "~2.0.3", path = "./pact_matching" }
1415

1516
[profile.release]
1617
strip = true

rust/pact_ffi/tests/tests.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,11 @@ fn post_to_mock_server_with_mismatches() {
130130
pactffi_cleanup_mock_server(port);
131131
pactffi_free_pact_handle(pact_handle);
132132

133-
assert_eq!(
134-
"[{\"method\":\"POST\",\"mismatches\":[{\"actual\":\"\\\"no-very-bar\\\"\",\"expected\":\"\\\"bar\\\"\",\"mismatch\":\"Expected 'no-very-bar' (String) to be equal to 'bar' (String)\",\"path\":\"$.foo\",\"type\":\"BodyMismatch\"}],\"path\":\"/path\",\"type\":\"request-mismatch\"}]",
135-
mismatches
136-
);
133+
let mismatch_json: serde_json::Value = serde_json::from_str(&mismatches).unwrap();
134+
let body_mismatch = &mismatch_json[0]["mismatches"][0];
135+
assert_eq!(body_mismatch["type"], "BodyMismatch");
136+
assert_eq!(body_mismatch["path"], "$.foo");
137+
assert_eq!(body_mismatch["mismatch"], "Expected 'no-very-bar' (String) to be equal to 'bar' (String)");
137138
}
138139

139140
#[test]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use iai::{black_box, main};
2+
use pact_matching::engine::{build_request_plan, execute_request_plan};
3+
use pact_matching::engine::context::PlanMatchingContext;
4+
use pact_models::bodies::OptionalBody;
5+
use pact_models::content_types::TEXT;
6+
use pact_models::v4::http_parts::HttpRequest;
7+
8+
fn iai_benchmark_simple() {
9+
let request = HttpRequest {
10+
method: "put".to_string(),
11+
path: "/test".to_string(),
12+
body: OptionalBody::Present("Some nice bit of text".into(), Some(TEXT.clone()), None),
13+
.. Default::default()
14+
};
15+
let expected_request = HttpRequest {
16+
method: "POST".to_string(),
17+
path: "/test".to_string(),
18+
query: None,
19+
headers: None,
20+
body: OptionalBody::Present("Some nice bit of text".into(), Some(TEXT.clone()), None),
21+
.. Default::default()
22+
};
23+
let mut context = PlanMatchingContext::default();
24+
let plan = build_request_plan(&expected_request, &context).unwrap();
25+
let _executed_plan = execute_request_plan(&plan, &request, &mut context).unwrap();
26+
}
27+
28+
iai::main!(iai_benchmark_simple);
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//! Builder for application/x-www-form-urlencoded bodies
2+
3+
use bytes::Bytes;
4+
use itertools::Itertools;
5+
use pact_models::content_types::ContentType;
6+
use pact_models::path_exp::DocPath;
7+
8+
use crate::engine::{build_matching_rule_node, ExecutionPlanNode, NodeValue};
9+
use crate::engine::bodies::PlanBodyBuilder;
10+
use crate::engine::context::PlanMatchingContext;
11+
12+
/// Plan builder for application/x-www-form-urlencoded bodies
13+
#[derive(Clone, Debug)]
14+
pub struct FormUrlencodedPlanBuilder;
15+
16+
impl FormUrlencodedPlanBuilder {
17+
/// Create a new instance
18+
pub fn new() -> Self {
19+
FormUrlencodedPlanBuilder {}
20+
}
21+
}
22+
23+
impl PlanBodyBuilder for FormUrlencodedPlanBuilder {
24+
fn supports_type(&self, content_type: &ContentType) -> bool {
25+
content_type.base_type() == "application/x-www-form-urlencoded"
26+
}
27+
28+
fn build_plan(&self, content: &Bytes, context: &PlanMatchingContext) -> anyhow::Result<ExecutionPlanNode> {
29+
let expected_form: Vec<(String, String)> = serde_urlencoded::from_bytes(content)
30+
.map_err(|e| anyhow::anyhow!("Failed to parse form-urlencoded body: {}", e))?;
31+
32+
let mut params: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
33+
for (key, value) in expected_form {
34+
params.entry(key).or_default().push(value);
35+
}
36+
37+
let mut body_node = ExecutionPlanNode::action("tee");
38+
body_node.add(
39+
ExecutionPlanNode::action("form:parse")
40+
.add(ExecutionPlanNode::resolve_value(DocPath::new_unwrap("$.body")))
41+
);
42+
43+
let root_path = DocPath::root();
44+
let mut root_node = ExecutionPlanNode::container(&root_path);
45+
46+
let keys = params.keys().cloned().sorted().collect_vec();
47+
48+
if !params.is_empty() {
49+
for key in &keys {
50+
let values = params.get(key).unwrap();
51+
let key_path = root_path.join(key);
52+
53+
let expected_value = if values.len() == 1 {
54+
NodeValue::STRING(values[0].clone())
55+
} else {
56+
NodeValue::SLIST(values.clone())
57+
};
58+
59+
let mut item_node = ExecutionPlanNode::container(&key_path);
60+
let mut presence_check = ExecutionPlanNode::action("if");
61+
presence_check.add(
62+
ExecutionPlanNode::action("check:exists")
63+
.add(ExecutionPlanNode::resolve_current_value(&key_path))
64+
);
65+
66+
if context.matcher_is_defined(&key_path) {
67+
let matchers = context.select_best_matcher(&key_path);
68+
item_node.add(ExecutionPlanNode::annotation(
69+
format!("{} {}", key, matchers.generate_description(true))
70+
));
71+
presence_check.add(build_matching_rule_node(
72+
&ExecutionPlanNode::value_node(expected_value),
73+
&ExecutionPlanNode::resolve_current_value(&key_path),
74+
&matchers,
75+
true,
76+
context.config.show_types_in_errors
77+
));
78+
} else {
79+
item_node.add(ExecutionPlanNode::annotation(format!("{}={}", key, expected_value)));
80+
presence_check.add(
81+
ExecutionPlanNode::action("match:equality")
82+
.add(ExecutionPlanNode::value_node(expected_value))
83+
.add(ExecutionPlanNode::resolve_current_value(&key_path))
84+
.add(ExecutionPlanNode::value_node(NodeValue::NULL))
85+
.add(ExecutionPlanNode::value_node(context.config.show_types_in_errors))
86+
);
87+
}
88+
89+
item_node.add(presence_check);
90+
root_node.add(item_node);
91+
}
92+
93+
root_node.add(
94+
ExecutionPlanNode::action("expect:entries")
95+
.add(ExecutionPlanNode::value_node(NodeValue::SLIST(keys.clone())))
96+
.add(ExecutionPlanNode::resolve_current_value(&root_path))
97+
.add(
98+
ExecutionPlanNode::action("join")
99+
.add(ExecutionPlanNode::value_node("The following expected form parameters were missing: "))
100+
.add(
101+
ExecutionPlanNode::action("join-with")
102+
.add(ExecutionPlanNode::value_node(", "))
103+
.add(
104+
ExecutionPlanNode::splat()
105+
.add(ExecutionPlanNode::action("apply"))
106+
)
107+
)
108+
)
109+
);
110+
111+
if !context.config.allow_unexpected_entries {
112+
root_node.add(
113+
ExecutionPlanNode::action("expect:only-entries")
114+
.add(ExecutionPlanNode::value_node(NodeValue::SLIST(keys.clone())))
115+
.add(ExecutionPlanNode::resolve_current_value(&root_path))
116+
.add(
117+
ExecutionPlanNode::action("join")
118+
.add(ExecutionPlanNode::value_node("The following form parameters were not expected: "))
119+
.add(
120+
ExecutionPlanNode::action("join-with")
121+
.add(ExecutionPlanNode::value_node(", "))
122+
.add(
123+
ExecutionPlanNode::splat()
124+
.add(ExecutionPlanNode::action("apply"))
125+
)
126+
)
127+
)
128+
);
129+
}
130+
}
131+
132+
body_node.add(root_node);
133+
Ok(body_node)
134+
}
135+
}

rust/pact_matching/src/engine/bodies/json.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ impl JsonPlanBuilder {
103103
.add(ExecutionPlanNode::resolve_current_value(path))
104104
);
105105
}
106-
}
107106

108-
for (key, value) in entries {
109-
let mut item_path = path.clone();
110-
item_path.push_field(key);
111-
let mut item_node = ExecutionPlanNode::container(&item_path);
112-
Self::process_body_node(context, value, &item_path, &mut item_node);
113-
root_node.add(item_node);
107+
for (key, value) in entries {
108+
let mut item_path = path.clone();
109+
item_path.push_field(key);
110+
let mut item_node = ExecutionPlanNode::container(&item_path);
111+
Self::process_body_node(context, value, &item_path, &mut item_node);
112+
root_node.add(item_node);
113+
}
114114
}
115115
}
116116

rust/pact_matching/src/engine/bodies/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ use pact_models::matchingrules::{MatchingRule, RuleList};
1010
use pact_models::path_exp::{DocPath, PathToken};
1111

1212
use crate::engine::{ExecutionPlanNode, NodeValue, PlanMatchingContext};
13+
use crate::engine::bodies::form_urlencoded::FormUrlencodedPlanBuilder;
1314
use crate::engine::bodies::json::JsonPlanBuilder;
1415
#[cfg(feature = "xml")] use crate::engine::bodies::xml::XMLPlanBuilder;
1516

17+
pub mod form_urlencoded;
1618
pub mod json;
1719
#[cfg(feature = "xml")] pub mod xml;
1820

@@ -35,6 +37,7 @@ static BODY_PLAN_BUILDERS: LazyLock<RwLock<Vec<Arc<dyn PlanBodyBuilder + Send +
3537
let mut builders: Vec<Arc<dyn PlanBodyBuilder + Send + Sync>> = vec![];
3638

3739
// TODO: Add default implementations here
40+
builders.push(Arc::new(FormUrlencodedPlanBuilder::new()));
3841
builders.push(Arc::new(JsonPlanBuilder::new()));
3942
#[cfg(feature = "xml")]
4043
builders.push(Arc::new(XMLPlanBuilder::new()));

0 commit comments

Comments
 (0)