Skip to content

Commit e3d23be

Browse files
committed
chore(v2-matching-engine): Support XML namespaces with attributes
1 parent 8a32316 commit e3d23be

8 files changed

Lines changed: 160 additions & 29 deletions

File tree

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::engine::{build_matching_rule_node, ExecutionPlanNode, NodeValue};
1111
use crate::engine::bodies::{drop_indices, PlanBodyBuilder, remove_marker};
1212
use crate::engine::context::PlanMatchingContext;
1313
use crate::engine::xml::name;
14+
use crate::xml::resolve_attr_namespaces;
1415

1516
/// Plan builder for XML bodies
1617
#[derive(Clone, Debug)]
@@ -107,19 +108,21 @@ impl XMLPlanBuilder {
107108
.filter(|matcher| matcher.is_type_matcher())
108109
.remove_duplicates();
109110
if matchers.is_empty() {
110-
parent_node.add(
111-
ExecutionPlanNode::action("expect:count")
112-
.add(ExecutionPlanNode::value_node(NodeValue::UINT(elements.len() as u64)))
113-
.add(ExecutionPlanNode::resolve_current_value(p.clone()))
114-
.add(
115-
ExecutionPlanNode::action("join")
116-
.add(ExecutionPlanNode::value_node(
117-
format!("Expected {} <{}> child element{} but there were ",
118-
elements.len(), child_name.as_str(), if elements.len() > 1 { "s" } else { "" })))
119-
.add(ExecutionPlanNode::action("length")
120-
.add(ExecutionPlanNode::resolve_current_value(p.clone())))
121-
)
122-
);
111+
if !context.config.allow_unexpected_entries {
112+
parent_node.add(
113+
ExecutionPlanNode::action("expect:count")
114+
.add(ExecutionPlanNode::value_node(NodeValue::UINT(elements.len() as u64)))
115+
.add(ExecutionPlanNode::resolve_current_value(p.clone()))
116+
.add(
117+
ExecutionPlanNode::action("join")
118+
.add(ExecutionPlanNode::value_node(
119+
format!("Expected {} <{}> child element{} but there were ",
120+
elements.len(), child_name.as_str(), if elements.len() > 1 { "s" } else { "" })))
121+
.add(ExecutionPlanNode::action("length")
122+
.add(ExecutionPlanNode::resolve_current_value(p.clone())))
123+
)
124+
);
125+
}
123126

124127
if elements.len() == 1 {
125128
self.process_element(context, elements[0], Some(0), path, parent_node);
@@ -198,8 +201,12 @@ impl XMLPlanBuilder {
198201
node: &mut ExecutionPlanNode,
199202
context: &PlanMatchingContext
200203
) {
201-
let attributes = element.attributes();
202-
let keys = attributes.keys().cloned().sorted().collect_vec();
204+
let attributes = resolve_attr_namespaces(element);
205+
let keys = attributes.keys()
206+
.filter(|key| key.as_str() != "xmlns" && !key.starts_with("xmlns:"))
207+
.cloned()
208+
.sorted()
209+
.collect_vec();
203210
for key in &keys {
204211
let p = path.join_field(format!("@{}", key));
205212
let value = attributes.get(key).unwrap();

rust/pact_matching/src/engine/interpreter.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! This module provides the interpreter that can execute a matching plan AST
22
3-
use std::collections::{HashSet, VecDeque};
3+
use std::collections::{HashMap, HashSet, VecDeque};
44
use std::iter::once;
55

66
use anyhow::anyhow;
@@ -19,6 +19,7 @@ use crate::engine::value_resolvers::ValueResolver;
1919
use crate::headers::{parse_charset_parameters, strip_whitespace};
2020
use crate::json::type_of;
2121
use crate::matchers::Matches;
22+
use crate::xml::resolve_attr_namespaces;
2223

2324
/// Main interpreter for the matching plan AST
2425
#[derive(Debug)]
@@ -769,9 +770,12 @@ impl ExecutionPlanInterpreter {
769770
match value {
770771
NodeValue::XML(xml) => match xml {
771772
XmlValue::Attribute(name, value) => Ok(NodeResult::VALUE(NodeValue::ENTRY(name.clone(), Box::new(NodeValue::STRING(value.clone()))))),
772-
XmlValue::Element(element) => Ok(NodeResult::VALUE(NodeValue::MMAP(element.attributes().iter()
773-
.map(|(k, v)| (k.clone(), vec![v.clone()]))
774-
.collect()))),
773+
XmlValue::Element(element) => {
774+
let attributes = resolve_attr_namespaces(element);
775+
Ok(NodeResult::VALUE(NodeValue::MMAP(attributes.iter()
776+
.map(|(k, v)| (k.clone(), vec![v.clone()]))
777+
.collect())))
778+
},
775779
_ => Err(anyhow!("xml:attributes can not be used with {}", xml))
776780
}
777781
_ => Err(anyhow!("xml:attributes can not be used with {}", value.value_type()))

rust/pact_matching/src/xml.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use std::collections::btree_map::{BTreeMap, Entry};
2-
2+
use std::collections::HashMap;
33
use anyhow::anyhow;
44
use bytes::Bytes;
55
use itertools::{EitherOrBoth, Itertools};
66
use maplit::*;
77
use onig::Regex;
8-
use sxd_document::dom::*;
8+
use sxd_document::dom::Element;
99
use sxd_document::QName;
1010

1111
use pact_models::bodies::OptionalBody;
@@ -396,6 +396,34 @@ fn compare_value(
396396
})
397397
}
398398

399+
/// Returns all the attributes with the namespaces resolved
400+
pub fn resolve_attr_namespaces(element: &kiss_xml::dom::Element) -> HashMap<String, String> {
401+
let namespaces: HashMap<_, _> = element.attributes().iter()
402+
.filter_map(|(key, value)| if key.starts_with("xmlns:") {
403+
Some((key.strip_prefix("xmlns:").unwrap(), value.as_str()))
404+
} else {
405+
None
406+
}).collect();
407+
if namespaces.is_empty() {
408+
element.attributes().iter()
409+
.map(|(k, v)| (k.clone(), v.clone()))
410+
.collect()
411+
} else {
412+
element.attributes().iter()
413+
.map(|(k, v)| {
414+
if let Some((ns, attr)) = k.split_once(':') {
415+
if let Some(name) = namespaces.get(ns) {
416+
(format!("{}:{}", *name, attr), v.clone())
417+
} else {
418+
(k.clone(), v.clone())
419+
}
420+
} else {
421+
(k.clone(), v.clone())
422+
}
423+
}).collect()
424+
}
425+
}
426+
399427
#[cfg(test)]
400428
mod tests {
401429
use bytes::Bytes;

rust/pact_matching/tests/spec_testcases/v3/response/body/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2682,7 +2682,7 @@ async fn unexpected_xml_namespace() {
26822682
#[allow(unused_mut)]
26832683
let mut pact: serde_json::Value = serde_json::from_str(r#"
26842684
{
2685-
"match": false,
2685+
"match": true,
26862686
"comment": "XML namespaces not expected",
26872687
"expected" : {
26882688
"headers": {"Content-Type": "application/xml"},

rust/pact_matching/tests/spec_testcases/v3/response/body/unexpected xml namespace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"match": false,
2+
"match": true,
33
"comment": "XML namespaces not expected",
44
"expected" : {
55
"headers": {"Content-Type": "application/xml"},

rust/pact_matching/tests/spec_testcases/v4/response/body/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3151,7 +3151,7 @@ async fn unexpected_xml_namespace() {
31513151
#[allow(unused_mut)]
31523152
let mut pact: serde_json::Value = serde_json::from_str(r#"
31533153
{
3154-
"match": false,
3154+
"match": true,
31553155
"comment": "XML namespaces not expected",
31563156
"expected" : {
31573157
"headers": {"Content-Type": "application/xml"},

rust/pact_matching/tests/spec_testcases/v4/response/body/unexpected xml namespace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"match": false,
2+
"match": true,
33
"comment": "XML namespaces not expected",
44
"expected" : {
55
"headers": {"Content-Type": "application/xml"},

rust/pact_models/src/xml_utils.rs

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Collection of utilities for working with XML
22
3-
use std::collections::BTreeMap;
3+
use std::collections::{BTreeMap, HashMap};
44
use std::ops::Index;
55
use std::str;
66
use anyhow::anyhow;
@@ -66,10 +66,24 @@ fn query_graph(
6666
trace!(?token, "next token");
6767
match token {
6868
PathToken::Field(name) => {
69-
if element.name() == name.as_str() {
69+
let matches = if element.name() == name.as_str() {
7070
trace!(name, %parent_id, "Field name matches element");
71-
let node_id = parent_id.append_value(format!("{}[{}]", name, index), tree);
71+
Some(parent_id.append_value(format!("{}[{}]", name, index), tree))
72+
} else {
73+
if let Some(ns) = element.namespace() {
74+
let name_with_ns = format!("{}:{}", ns, element.name());
75+
if name_with_ns == name.as_str() {
76+
trace!(name, %parent_id, "Field name matches element including namespace");
77+
Some(parent_id.append_value(format!("{}[{}]", name_with_ns, index), tree))
78+
} else {
79+
None
80+
}
81+
} else {
82+
None
83+
}
84+
};
7285

86+
if let Some(node_id) = matches {
7387
let remaining_tokens = &path_iter[1..];
7488
if !remaining_tokens.is_empty() {
7589
query_attributes(remaining_tokens, tree, node_id, element, index);
@@ -157,7 +171,8 @@ fn query_attributes(
157171
if let PathToken::Field(name) = token {
158172
if name.starts_with('@') {
159173
let attribute_name = &name[1..];
160-
if element.attributes().contains_key(attribute_name) {
174+
let attributes = resolve_namespaces(element.attributes());
175+
if attributes.contains_key(attribute_name) {
161176
trace!(name, "Field name matches element attribute");
162177
parent_id.append_value(name.clone(), tree);
163178
}
@@ -166,6 +181,31 @@ fn query_attributes(
166181
}
167182
}
168183

184+
fn resolve_namespaces(attributes: &HashMap<String, String>) -> HashMap<String, String> {
185+
let namespaces: HashMap<_, _> = attributes.iter()
186+
.filter_map(|(key, value)| if key.starts_with("xmlns:") {
187+
Some((key.strip_prefix("xmlns:").unwrap(), value.as_str()))
188+
} else {
189+
None
190+
}).collect();
191+
if namespaces.is_empty() {
192+
attributes.clone()
193+
} else {
194+
attributes.iter()
195+
.flat_map(|(k, v)| {
196+
if let Some((ns, attr)) = k.split_once(':') {
197+
if let Some(name) = namespaces.get(ns) {
198+
vec![(k.clone(), v.clone()), (format!("{}:{}", *name, attr), v.clone())]
199+
} else {
200+
vec![(k.clone(), v.clone())]
201+
}
202+
} else {
203+
vec![(k.clone(), v.clone())]
204+
}
205+
}).collect()
206+
}
207+
}
208+
169209
fn query_text(
170210
path_iter: &[PathToken],
171211
tree: &mut Arena<String>,
@@ -285,6 +325,7 @@ fn match_next(element: &Element, paths: &[&str]) -> Option<XmlResult> {
285325
#[cfg(test)]
286326
mod tests {
287327
use expectest::prelude::*;
328+
use maplit::hashmap;
288329

289330
use crate::path_exp::DocPath;
290331

@@ -369,6 +410,34 @@ mod tests {
369410
expect!(resolve_path(root, &path).is_empty()).to(be_true());
370411
}
371412

413+
#[test_log::test]
414+
fn resolve_path_with_xml_namespaces_test() {
415+
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
416+
<a:alligator xmlns:a="urn:alligators" xmlns:n="urn:names" n:name="Mary">
417+
<a:favouriteNumbers>
418+
<favouriteNumber xmlns="urn:favourite:numbers">1</favouriteNumber>
419+
</a:favouriteNumbers>
420+
</a:alligator>
421+
"#;
422+
let dom = kiss_xml::parse_str(xml).unwrap();
423+
let root = dom.root_element();
424+
425+
let path = DocPath::root();
426+
expect!(resolve_path(root, &path).is_empty()).to(be_true());
427+
428+
let path = DocPath::new_unwrap("$.alligator");
429+
expect!(resolve_path(root, &path)).to(be_equal_to(vec!["/alligator[0]"]));
430+
431+
let path = DocPath::new_unwrap("$['urn:alligators:alligator']");
432+
expect!(resolve_path(root, &path)).to(be_equal_to(vec!["/urn:alligators:alligator[0]"]));
433+
434+
let path = DocPath::new_unwrap("$['urn:alligators:alligator']['@n:name']");
435+
expect!(resolve_path(root, &path)).to(be_equal_to(vec!["/urn:alligators:alligator[0]/@n:name"]));
436+
437+
let path = DocPath::new_unwrap("$['urn:alligators:alligator']['@urn:names:name']");
438+
expect!(resolve_path(root, &path)).to(be_equal_to(vec!["/urn:alligators:alligator[0]/@urn:names:name"]));
439+
}
440+
372441
#[test_log::test]
373442
fn resolve_matching_node_test() {
374443
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
@@ -408,4 +477,27 @@ mod tests {
408477
.value(XmlResult::TextNode("My Settings".to_string())));
409478
expect!(resolve_matching_node(root, "/config[0]/sound[0]/property[0]/#text")).to(be_none());
410479
}
480+
481+
#[test_log::test]
482+
fn resolve_namespaces_test() {
483+
expect!(resolve_namespaces(&hashmap!{})).to(be_equal_to(hashmap!{}));
484+
485+
let attributes = hashmap!{
486+
"a".to_string() => "b".to_string(),
487+
"c".to_string() => "d".to_string()
488+
};
489+
expect!(resolve_namespaces(&attributes)).to(be_equal_to(attributes));
490+
491+
let attributes = hashmap!{
492+
"n:name".to_string() => "Mary".to_string(),
493+
"xmlns:a".to_string() => "urn:alligators".to_string(),
494+
"xmlns:n".to_string() => "urn:names".to_string()
495+
};
496+
expect!(resolve_namespaces(&attributes)).to(be_equal_to(hashmap!{
497+
"n:name".to_string() => "Mary".to_string(),
498+
"urn:names:name".to_string() => "Mary".to_string(),
499+
"xmlns:a".to_string() => "urn:alligators".to_string(),
500+
"xmlns:n".to_string() => "urn:names".to_string()
501+
}));
502+
}
411503
}

0 commit comments

Comments
 (0)