Skip to content

Commit c7d47c2

Browse files
author
Ronald Holshausen
authoredApr 18, 2020
Merge pull request #1071 from mitre/namespace-aware
feat: add namespace-aware XML matching
2 parents b7a3ce7 + a52f7e5 commit c7d47c2

File tree

8 files changed

+309
-50
lines changed

8 files changed

+309
-50
lines changed
 

‎core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/MatcherExecutor.kt

+3-3
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ fun valueOf(value: Any?): String {
3434
return when (value) {
3535
null -> "null"
3636
is String -> "'$value'"
37-
is Element -> "<${value.tagName}>"
37+
is Element -> "<${QualifiedName(value)}>"
3838
is Text -> "'${value.wholeText}'"
3939
else -> value.toString()
4040
}
@@ -131,7 +131,7 @@ fun <M : Mismatch> matchEquality(
131131
): List<M> {
132132
val matches = when {
133133
(actual == null || actual is JsonNull) && (expected == null || expected is JsonNull) -> true
134-
actual is Element && expected is Element -> actual.tagName == expected.tagName
134+
actual is Element && expected is Element -> QualifiedName(actual) == QualifiedName(expected)
135135
else -> actual != null && actual == expected
136136
}
137137
logger.debug { "comparing ${valueOf(actual)} to ${valueOf(expected)} at $path -> $matches" }
@@ -176,7 +176,7 @@ fun <M : Mismatch> matchType(
176176
expected is JsonArray && actual is JsonArray ||
177177
expected is Map<*, *> && actual is Map<*, *> ||
178178
expected is JsonObject && actual is JsonObject ||
179-
expected is Element && actual is Element && actual.tagName == expected.tagName
179+
expected is Element && actual is Element && QualifiedName(actual) == QualifiedName(expected)
180180
) {
181181
emptyList()
182182
} else if (expected is JsonPrimitive && actual is JsonPrimitive &&
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package au.com.dius.pact.core.matchers
2+
3+
import org.w3c.dom.Node
4+
5+
/**
6+
* Namespace-aware XML node qualified names.
7+
*
8+
* Used for comparison and display purposes. Uses the XML namespace and local name if present in the [Node].
9+
* Falls back to using the default node name if a namespace isn't present.
10+
*/
11+
class QualifiedName(node: Node) {
12+
val namespaceUri: String? = node.namespaceURI
13+
val localName: String? = node.localName
14+
val nodeName: String = node.nodeName
15+
16+
/**
17+
* When both do not have a namespace, check equality using node name.
18+
* Otherwise, check equality using both namespace and local name.
19+
*/
20+
override fun equals(other: Any?): Boolean = when (other) {
21+
is QualifiedName -> {
22+
when {
23+
this.namespaceUri == null && other.namespaceUri == null -> other.nodeName == nodeName
24+
else -> other.namespaceUri == namespaceUri && other.localName == localName
25+
}
26+
}
27+
else -> false
28+
}
29+
30+
/**
31+
* When a namespace isn't present, return the hash of the node name.
32+
* Otherwise, return the hash of the namespace and local name.
33+
*/
34+
override fun hashCode(): Int = when (namespaceUri) {
35+
null -> nodeName.hashCode()
36+
else -> 31 * (31 + namespaceUri.hashCode()) + localName.hashCode()
37+
}
38+
39+
/**
40+
* Returns the qualified name using Clark-notation if
41+
* a namespace is present, otherwise returns the node name.
42+
*
43+
* Clark-notation uses the format `{namespace}localname`
44+
* per https://sabre.io/xml/clark-notation/.
45+
*
46+
* @see Node.getNodeName
47+
*/
48+
override fun toString(): String {
49+
return when (namespaceUri) {
50+
null -> nodeName
51+
else -> "{$namespaceUri}$localName"
52+
}
53+
}
54+
}

‎core/matchers/src/main/kotlin/au/com/dius/pact/core/matchers/XmlBodyMatcher.kt

+28-25
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import org.w3c.dom.Node.TEXT_NODE
1414
import org.w3c.dom.NodeList
1515
import org.xml.sax.InputSource
1616
import java.io.StringReader
17+
import javax.xml.XMLConstants
1718
import javax.xml.parsers.DocumentBuilderFactory
1819

1920
object XmlBodyMatcher : BodyMatcher, KLogging() {
@@ -46,15 +47,18 @@ object XmlBodyMatcher : BodyMatcher, KLogging() {
4647
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-dtd-grammar", false)
4748
dbFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
4849
}
50+
if (System.getProperty("pact.matching.xml.namespace-aware") != "false") {
51+
dbFactory.isNamespaceAware = true
52+
}
4953
val dBuilder = dbFactory.newDocumentBuilder()
5054
val xmlInput = InputSource(StringReader(xmlData))
5155
val doc = dBuilder.parse(xmlInput)
5256
doc.documentElement
5357
}
5458
}
5559

56-
private fun appendAttribute(path: List<String>, attribute: String): List<String> {
57-
return path + "@$attribute"
60+
private fun appendAttribute(path: List<String>, attribute: QualifiedName): List<String> {
61+
return path + "@${attribute.nodeName}"
5862
}
5963

6064
fun compareText(
@@ -107,10 +111,17 @@ object XmlBodyMatcher : BodyMatcher, KLogging() {
107111
logger.debug { "compareNode: Matcher defined for path $nodePath" }
108112
Matchers.domatch(matchers, "body", nodePath, expected, actual, BodyMismatchFactory)
109113
}
110-
actual.nodeName != expected.nodeName -> listOf(BodyMismatch(expected, actual,
111-
"Expected element ${expected.nodeName} but received ${actual.nodeName}",
112-
nodePath.joinToString(".")))
113-
else -> emptyList()
114+
else -> {
115+
val actualName = QualifiedName(actual)
116+
val expectedName = QualifiedName(expected)
117+
118+
when {
119+
actualName != expectedName -> listOf(BodyMismatch(expected, actual,
120+
"Expected element $expectedName but received $actualName",
121+
nodePath.joinToString(".")))
122+
else -> emptyList()
123+
}
124+
}
114125
}
115126

116127
return if (mismatches.isEmpty()) {
@@ -142,18 +153,18 @@ object XmlBodyMatcher : BodyMatcher, KLogging() {
142153
emptyList()
143154
}
144155

145-
val actualChildrenByTag = actualChildren.groupBy { it.nodeName }
156+
val actualChildrenByQName = actualChildren.groupBy { QualifiedName(it) }
146157
return mismatches + expectedChildren
147-
.groupBy { it.nodeName }
158+
.groupBy { QualifiedName(it) }
148159
.flatMap { e ->
149-
if (actualChildrenByTag.contains(e.key)) {
150-
e.value.zipAll(actualChildrenByTag.getValue(e.key)).mapIndexed { index, comp ->
160+
if (actualChildrenByQName.contains(e.key)) {
161+
e.value.zipAll(actualChildrenByQName.getValue(e.key)).mapIndexed { index, comp ->
151162
val expectedNode = comp.first
152163
val actualNode = comp.second
153164
when {
154165
expectedNode == null -> emptyList()
155166
actualNode == null -> listOf(BodyMismatch(expected, actual,
156-
"Expected child ${describe(expectedNode)} but was missing",
167+
"Expected child <${e.key}/> but was missing",
157168
(path + expectedNode.nodeName + index.toString()).joinToString(".")))
158169
else -> compareNode(path, expectedNode, actualNode, allowUnexpectedKeys, matchers)
159170
}
@@ -165,13 +176,6 @@ object XmlBodyMatcher : BodyMatcher, KLogging() {
165176
}
166177
}
167178

168-
private fun describe(node: Node) =
169-
if (node.nodeType == ELEMENT_NODE) {
170-
"<${node.nodeName}/>"
171-
} else {
172-
node.toString()
173-
}
174-
175179
private fun compareAttributes(
176180
path: List<String>,
177181
expected: Node,
@@ -219,16 +223,15 @@ object XmlBodyMatcher : BodyMatcher, KLogging() {
219223
}
220224
}
221225

222-
private fun attributesToMap(attributes: NamedNodeMap?): Map<String, String> {
226+
private fun attributesToMap(attributes: NamedNodeMap?): Map<QualifiedName, String> {
223227
return if (attributes == null) {
224228
emptyMap()
225229
} else {
226-
val map = mutableMapOf<String, String>()
227-
for (i in 0 until attributes.length) {
228-
val item = attributes.item(i)
229-
map[item.nodeName] = item.nodeValue
230-
}
231-
map
230+
(0 until attributes.length)
231+
.map { attributes.item(it) }
232+
.filter { it.namespaceURI != XMLConstants.XMLNS_ATTRIBUTE_NS_URI }
233+
.map { QualifiedName(it) to it.nodeValue }
234+
.toMap()
232235
}
233236
}
234237
}

‎core/matchers/src/test/groovy/au/com/dius/pact/core/matchers/MatcherExecutorSpec.groovy

+53-22
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class MatcherExecutorSpec extends Specification {
2525
def mismatchFactory
2626
def path
2727

28+
static xml(String xml) {
29+
XmlBodyMatcher.INSTANCE.parse(xml)
30+
}
31+
2832
def setup() {
2933
mismatchFactory = [create: { p0, p1, p2, p3 -> new StatusMismatch(100, 100) } ] as MismatchFactory
3034
path = ['/']
@@ -36,16 +40,21 @@ class MatcherExecutorSpec extends Specification {
3640
MatcherExecutorKt.domatch(EqualsMatcher.INSTANCE, path, expected, actual, mismatchFactory).empty == mustBeEmpty
3741

3842
where:
39-
expected | actual || mustBeEmpty
40-
'100' | '100' || true
41-
100 | '100' || false
42-
100 | 100 || true
43-
null | null || true
44-
'100' | null || false
45-
null | 100 || false
46-
JsonNull.INSTANCE | null || true
47-
null | JsonNull.INSTANCE || true
48-
JsonNull.INSTANCE | JsonNull.INSTANCE || true
43+
expected | actual || mustBeEmpty
44+
'100' | '100' || true
45+
100 | '100' || false
46+
100 | 100 || true
47+
null | null || true
48+
'100' | null || false
49+
null | 100 || false
50+
JsonNull.INSTANCE | null || true
51+
null | JsonNull.INSTANCE || true
52+
JsonNull.INSTANCE | JsonNull.INSTANCE || true
53+
xml('<a/>') | xml('<a/>') || true
54+
xml('<a/>') | xml('<b/>') || false
55+
xml('<e xmlns="a"/>') | xml('<a:e xmlns:a="a"/>') || true
56+
xml('<a:e xmlns:a="a"/>') | xml('<b:e xmlns:b="a"/>') || true
57+
xml('<e xmlns="a"/>') | xml('<e xmlns="b"/>') || false
4958
}
5059

5160
@Unroll
@@ -66,18 +75,23 @@ class MatcherExecutorSpec extends Specification {
6675
MatcherExecutorKt.domatch(TypeMatcher.INSTANCE, path, expected, actual, mismatchFactory).empty == mustBeEmpty
6776

6877
where:
69-
expected | actual || mustBeEmpty
70-
'Harry' | 'Some other string' || true
71-
100 | 200.3 || true
72-
true | false || true
73-
null | null || true
74-
'200' | 200 || false
75-
200 | null || false
76-
[100, 200, 300] | [200.3] || true
77-
[a: 100] | [a: 200.3, b: 200, c: 300] || true
78-
JsonNull.INSTANCE | null || true
79-
null | JsonNull.INSTANCE || true
80-
JsonNull.INSTANCE | JsonNull.INSTANCE || true
78+
expected | actual || mustBeEmpty
79+
'Harry' | 'Some other string' || true
80+
100 | 200.3 || true
81+
true | false || true
82+
null | null || true
83+
'200' | 200 || false
84+
200 | null || false
85+
[100, 200, 300] | [200.3] || true
86+
[a: 100] | [a: 200.3, b: 200, c: 300] || true
87+
JsonNull.INSTANCE | null || true
88+
null | JsonNull.INSTANCE || true
89+
JsonNull.INSTANCE | JsonNull.INSTANCE || true
90+
xml('<a/>') | xml('<a/>') || true
91+
xml('<a/>') | xml('<b/>') || false
92+
xml('<e xmlns="a"/>') | xml('<a:e xmlns:a="a"/>') || true
93+
xml('<a:e xmlns:a="a"/>') | xml('<b:e xmlns:b="a"/>') || true
94+
xml('<e xmlns="a"/>') | xml('<e xmlns="b"/>') || false
8195
}
8296

8397
@Unroll
@@ -245,4 +259,21 @@ class MatcherExecutorSpec extends Specification {
245259
BigDecimal.ZERO | true
246260
}
247261

262+
@Unroll
263+
def 'display #value as #display'() {
264+
expect:
265+
MatcherExecutorKt.valueOf(value) == display
266+
267+
where:
268+
269+
value || display
270+
null || 'null'
271+
'foo' || "'foo'"
272+
55 || '55'
273+
xml('<foo/>') || '<foo>'
274+
xml('<foo><bar/></foo>') || '<foo>'
275+
xml('<foo xmlns="a"/>') || '<{a}foo>'
276+
xml('<a>text</a>').firstChild || "'text'"
277+
}
278+
248279
}

‎core/matchers/src/test/groovy/au/com/dius/pact/core/matchers/XmlBodyMatcherSpec.groovy

+135
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import au.com.dius.pact.core.model.matchingrules.MatchingRulesImpl
55
import au.com.dius.pact.core.model.matchingrules.RegexMatcher
66
import spock.lang.Issue
77
import spock.lang.Specification
8+
import spock.lang.Unroll
9+
import spock.util.environment.RestoreSystemProperties
810

911
@SuppressWarnings(['LineLength', 'PrivateFieldCouldBeFinal'])
1012
class XmlBodyMatcherSpec extends Specification {
@@ -14,6 +16,8 @@ class XmlBodyMatcherSpec extends Specification {
1416
private XmlBodyMatcher matcher
1517

1618
def setup() {
19+
System.clearProperty('pact.matching.xml.namespace-aware')
20+
1721
matcher = new XmlBodyMatcher()
1822
matchers = new MatchingRulesImpl()
1923
expectedBody = OptionalBody.missing()
@@ -340,4 +344,135 @@ class XmlBodyMatcherSpec extends Specification {
340344
matcher.matchBody(expectedBody, actualBody, false, matchers).empty
341345
}
342346

347+
@Unroll
348+
def 'matching XML bodies - with different namespace declarations'() {
349+
given:
350+
actualBody = OptionalBody.body(actual.bytes)
351+
expectedBody = OptionalBody.body(expected.bytes)
352+
353+
expect:
354+
matcher.matchBody(expectedBody, actualBody, false, matchers).empty
355+
356+
where:
357+
actual | expected
358+
'<blah xmlns="urn:ns"/>' | '<blah xmlns="urn:ns"/>'
359+
'<blah xmlns="urn:ns"/>' | '<b:blah xmlns:b="urn:ns"/>'
360+
'<a:blah xmlns:a="urn:ns"/>' | '<blah xmlns="urn:ns"/>'
361+
'<a:blah xmlns:a="urn:ns"/>' | '<b:blah xmlns:b="urn:ns"/>'
362+
}
363+
364+
@Unroll
365+
def 'matching XML bodies - with different namespace declarations - and have child elements'() {
366+
given:
367+
actualBody = OptionalBody.body(actual.bytes)
368+
expectedBody = OptionalBody.body(expected.bytes)
369+
370+
expect:
371+
matcher.matchBody(expectedBody, actualBody, false, matchers).empty
372+
373+
where:
374+
actual | expected
375+
'<foo xmlns="urn:ns"><item/></foo>' | '<foo xmlns="urn:ns"><item/></foo>'
376+
'<foo xmlns="urn:ns"><item/></foo>' | '<b:foo xmlns:b="urn:ns"><b:item/></b:foo>'
377+
'<a:foo xmlns:a="urn:ns"><a:item/></a:foo>' | '<foo xmlns="urn:ns"><item/></foo>'
378+
'<a:foo xmlns:a="urn:ns"><a:item/></a:foo>' | '<b:foo xmlns:b="urn:ns"><b:item/></b:foo>'
379+
'<a:foo xmlns:a="urn:ns"><a2:item xmlns:a2="urn:ns"/></a:foo>' | '<b:foo xmlns:b="urn:ns"><b:item/></b:foo>'
380+
}
381+
382+
def 'matching XML bodies - returns a mismatch - when different namespaces are used'() {
383+
given:
384+
actualBody = OptionalBody.body('<blah xmlns="urn:other"/>'.bytes)
385+
expectedBody = OptionalBody.body('<blah xmlns="urn:ns"/>'.bytes)
386+
387+
when:
388+
def mismatches = matcher.matchBody(expectedBody, actualBody, false, matchers)
389+
390+
then:
391+
!mismatches.empty
392+
mismatches*.mismatch == ['Expected element {urn:ns}blah but received {urn:other}blah']
393+
mismatches*.path == ['$.blah']
394+
}
395+
396+
def 'matching XML bodies - returns a mismatch - when expected namespace is not used'() {
397+
given:
398+
actualBody = OptionalBody.body('<blah/>'.bytes)
399+
expectedBody = OptionalBody.body('<blah xmlns="urn:ns"/>'.bytes)
400+
401+
when:
402+
def mismatches = matcher.matchBody(expectedBody, actualBody, false, matchers)
403+
404+
then:
405+
!mismatches.empty
406+
mismatches*.mismatch == ['Expected element {urn:ns}blah but received blah']
407+
mismatches*.path == ['$.blah']
408+
}
409+
410+
def 'matching XML bodies - returns a mismatch - when allowUnexpectedKeys is true - and no namespace is expected'() {
411+
given:
412+
actualBody = OptionalBody.body('<blah xmlns="urn:ns"/>'.bytes)
413+
expectedBody = OptionalBody.body('<blah/>'.bytes)
414+
415+
when:
416+
def mismatches = matcher.matchBody(expectedBody, actualBody, true, matchers)
417+
418+
then:
419+
!mismatches.empty
420+
mismatches*.mismatch == ['Expected element blah but received {urn:ns}blah']
421+
mismatches*.path == ['$.blah']
422+
}
423+
424+
@RestoreSystemProperties
425+
def 'matching XML bodies - when allowUnexpectedKeys is true - and namespace-aware matching disabled - and no namespace is expected'() {
426+
given:
427+
System.setProperty('pact.matching.xml.namespace-aware', 'false')
428+
actualBody = OptionalBody.body('<blah xmlns="urn:ns"/>'.bytes)
429+
expectedBody = OptionalBody.body('<blah/>'.bytes)
430+
431+
expect:
432+
matcher.matchBody(expectedBody, actualBody, true, matchers).empty
433+
}
434+
435+
def 'matching XML bodies - when attribute uses different prefix'() {
436+
given:
437+
actualBody = OptionalBody.body('<foo xmlns:a="urn:ns" a:something="100"/>'.bytes)
438+
expectedBody = OptionalBody.body('<foo xmlns:b="urn:ns" b:something="100"/>'.bytes)
439+
440+
expect:
441+
matcher.matchBody(expectedBody, actualBody, true, matchers).empty
442+
}
443+
444+
def 'matching XML bodies - returns a mismatch - when attribute uses different namespace'() {
445+
given:
446+
actualBody = OptionalBody.body('<foo xmlns:ns="urn:a" ns:something="100"/>'.bytes)
447+
expectedBody = OptionalBody.body('<foo xmlns:ns="urn:b" ns:something="100"/>'.bytes)
448+
449+
when:
450+
def mismatches = matcher.matchBody(expectedBody, actualBody, false, matchers)
451+
452+
then:
453+
!mismatches.empty
454+
mismatches*.mismatch == ['Expected {urn:b}something=\'100\' but was missing']
455+
mismatches*.path == ['$.foo.@ns:something']
456+
}
457+
458+
def 'matching XML bodies - with namespaces and a matcher defined - delegate to matcher for attribute'() {
459+
given:
460+
actualBody = OptionalBody.body('<foo xmlns:a="urn:ns" a:something="100"/>'.bytes)
461+
expectedBody = OptionalBody.body('<foo xmlns:b="urn:ns" b:something="101"/>'.bytes)
462+
matchers.addCategory('body').addRule("\$.foo['@b:something']", new RegexMatcher('\\d+'))
463+
464+
expect:
465+
matcher.matchBody(expectedBody, actualBody, false, matchers).empty
466+
}
467+
468+
def 'matching XML bodies - with namespaces and a matcher defined - delegate to the matcher'() {
469+
given:
470+
actualBody = OptionalBody.body('<ns:foo xmlns:ns="urn:ns"><ns:something>100</ns:something></ns:foo>'.bytes)
471+
expectedBody = OptionalBody.body('<foo xmlns="urn:ns"><something>101</something></foo>'.bytes)
472+
matchers.addCategory('body').addRule("\$.foo.something", new RegexMatcher('\\d+'))
473+
474+
expect:
475+
matcher.matchBody(expectedBody, actualBody, false, matchers).empty
476+
}
477+
343478
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"match": true,
3+
"comment": "different XML namespace declarations/prefixes",
4+
"expected" : {
5+
"headers": {"Content-Type": "application/xml"},
6+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><alligator xmlns=\"urn:alligators\" xmlns:names=\"urn:names\" names:name=\"Mary\"><favouriteNumbers xmlns:fn=\"urn:favourite:numbers\"><fn:favouriteNumber>1</fn:favouriteNumber></favouriteNumbers></alligator>"
7+
},
8+
"actual": {
9+
"headers": {"Content-Type": "application/xml"},
10+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><a:alligator xmlns:a=\"urn:alligators\" xmlns:n=\"urn:names\" n:name=\"Mary\"><a:favouriteNumbers><favouriteNumber xmlns=\"urn:favourite:numbers\">1</favouriteNumber></a:favouriteNumbers></a:alligator>"
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"match": false,
3+
"comment": "XML namespaces do not match",
4+
"expected" : {
5+
"headers": {"Content-Type": "application/xml"},
6+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><a:alligator xmlns:a=\"urn:alligators\"/>"
7+
},
8+
"actual": {
9+
"headers": {"Content-Type": "application/xml"},
10+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><a:alligator xmlns:a=\"urn:crocodiles\"/>"
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"match": false,
3+
"comment": "XML namespaces not expected",
4+
"expected" : {
5+
"headers": {"Content-Type": "application/xml"},
6+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><alligator/>"
7+
},
8+
"actual": {
9+
"headers": {"Content-Type": "application/xml"},
10+
"body": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><alligator xmlns=\"urn:alligators\"/>"
11+
}
12+
}

0 commit comments

Comments
 (0)
Please sign in to comment.