From 90be2f0b50373b9687ec458116a02b6be7f33e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Alejandro=20Mi=C3=B1o?= <1666693+javiersvg@users.noreply.github.com> Date: Tue, 28 Jul 2020 06:40:30 -0500 Subject: [PATCH] bug: handle xml with namespaces for validations #1427 (#1446) fixes gh-1446 --- .../builder/XmlBodyVerificationBuilder.java | 4 +- .../util/xml/XmlToXPathsConverter.groovy | 8 +- .../verifier/util/ContractVerifierUtil.java | 12 +- .../util/xml/DOMNamespaceContext.java | 71 ++++++++ .../YamlContractConverterSpec.groovy | 36 ++++ .../util/xml/XmlToXPathsConverterSpec.groovy | 25 ++- .../XmlBodyVerificationBuilderTest.java | 34 ++++ .../util/ContractVerifierUtilTest.java | 164 ++++++++++++++++++ .../resources/yml/contract_rest_named_xml.yml | 17 ++ 9 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/xml/DOMNamespaceContext.java create mode 100644 spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java create mode 100644 spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtilTest.java create mode 100644 spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_named_xml.yml diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java index 692726487e..f8d41dfed1 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilder.java @@ -45,7 +45,9 @@ class XmlBodyVerificationBuilder implements BodyMethodGeneration { private void addXmlProcessingLines(final BlockBuilder blockBuilder, String responseString) { Arrays.asList( - "DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder()", + "DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance()", + "builderFactory.setNamespaceAware(true)", + "DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder()", "Document parsedXml = documentBuilder.parse(new InputSource(new StringReader(" + responseString + ")))") .forEach(it -> { diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverter.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverter.groovy index 6d2d42ff05..3f37a94f18 100644 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverter.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverter.groovy @@ -16,9 +16,6 @@ package org.springframework.cloud.contract.verifier.util.xml -import com.sun.org.apache.xml.internal.security.utils.DOMNamespaceContext - -import javax.xml.namespace.NamespaceContext import java.util.stream.IntStream import javax.xml.parsers.DocumentBuilder @@ -64,10 +61,13 @@ class XmlToXPathsConverter { static Object removeMatchingXPaths(Object body, BodyMatchers bodyMatchers) { XPath xPath = XPathFactory.newInstance().newXPath() - DocumentBuilder documentBuilder = DocumentBuilderFactory.newInstance() + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance() + builderFactory.setNamespaceAware(true) + DocumentBuilder documentBuilder = builderFactory .newDocumentBuilder() Document parsedXml = documentBuilder .parse(new InputSource(new StringReader(body as String))) + xPath.setNamespaceContext(new DOMNamespaceContext(parsedXml.documentElement)) bodyMatchers?.matchers()?.each({ Node node = xPath.evaluate(it.path(), parsedXml.documentElement, NODE) as Node removeNode(node) diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtil.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtil.java index 6ddc366c32..3af281e64f 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtil.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtil.java @@ -35,8 +35,11 @@ import javax.xml.xpath.XPathFactory; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.w3c.dom.Node; +import org.springframework.cloud.contract.verifier.util.xml.DOMNamespaceContext; + /** * Helper class for the generated tests. * @@ -81,8 +84,10 @@ public final class ContractVerifierUtil { */ public static String valueFromXPath(Document parsedXml, String path) { XPath xPath = XPathFactory.newInstance().newXPath(); + Element documentElement = parsedXml.getDocumentElement(); + xPath.setNamespaceContext(new DOMNamespaceContext(documentElement)); try { - return xPath.evaluate(path, parsedXml.getDocumentElement()); + return xPath.evaluate(path, documentElement); } catch (XPathExpressionException exception) { LOG.error("Incorrect xpath provided: " + path, exception); @@ -99,9 +104,10 @@ public final class ContractVerifierUtil { */ public static Node nodeFromXPath(Document parsedXml, String path) { XPath xPath = XPathFactory.newInstance().newXPath(); + Element documentElement = parsedXml.getDocumentElement(); + xPath.setNamespaceContext(new DOMNamespaceContext(documentElement)); try { - return (Node) xPath.evaluate(path, parsedXml.getDocumentElement(), - XPathConstants.NODE); + return (Node) xPath.evaluate(path, documentElement, XPathConstants.NODE); } catch (XPathExpressionException exception) { LOG.error("Incorrect xpath provided: " + path, exception); diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/xml/DOMNamespaceContext.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/xml/DOMNamespaceContext.java new file mode 100644 index 0000000000..e30e044ea8 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/util/xml/DOMNamespaceContext.java @@ -0,0 +1,71 @@ +/* + * Copyright 2013-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.contract.verifier.util.xml; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import javax.xml.namespace.NamespaceContext; + +import org.w3c.dom.Attr; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; +import org.w3c.dom.Node; + +public class DOMNamespaceContext implements NamespaceContext { + + private final Map namespaceMap = new HashMap<>(); + + public DOMNamespaceContext(Node contextNode) { + addNamespaces(contextNode); + } + + public String getNamespaceURI(String arg0) { + return namespaceMap.get(arg0); + } + + public String getPrefix(String arg0) { + for (Map.Entry entry : namespaceMap.entrySet()) { + if (entry.getValue().equals(arg0)) { + return entry.getKey(); + } + } + return null; + } + + public Iterator getPrefixes(String arg0) { + return namespaceMap.keySet().iterator(); + } + + private void addNamespaces(Node element) { + if (element.getParentNode() != null) { + addNamespaces(element.getParentNode()); + } + if (element instanceof Element) { + Element el = (Element) element; + NamedNodeMap map = el.getAttributes(); + for (int x = 0; x < map.getLength(); x++) { + Attr attr = (Attr) map.item(x); + if ("xmlns".equals(attr.getPrefix())) { + namespaceMap.put(attr.getLocalName(), attr.getValue()); + } + } + } + } + +} diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/converter/YamlContractConverterSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/converter/YamlContractConverterSpec.groovy index fde7728c63..502b18e0c8 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/converter/YamlContractConverterSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/converter/YamlContractConverterSpec.groovy @@ -89,6 +89,9 @@ class YamlContractConverterSpec extends Specification { URL ymlRestXmlFile = YamlContractConverterSpec. getResource("/yml/contract_rest_xml.yml") File ymlRestXml = new File(ymlRestXmlFile.toURI()) + URL ymlRestNamedXmlFile = YamlContractConverterSpec. + getResource("/yml/contract_rest_named_xml.yml") + File ymlRestNamedXml = new File(ymlRestNamedXmlFile.toURI()) URL oa3SpecUrl = YamlContractConverterSpec.getResource('/yml/oa3/openapi_petstore.yml') File oa3File = new File(oa3SpecUrl.toURI()) YamlContractConverter converter = new YamlContractConverter() @@ -110,6 +113,11 @@ class YamlContractConverterSpec extends Specification { string foo +''' + String xmlContractBodyWithNamespaces = ''' + +customer@test.com + ''' def "should convert YAML with Cookies to DSL"() { @@ -1331,6 +1339,34 @@ metadata: null .replaceAll("\n", "").replaceAll(' ', '') } + def "should convert REST YAML with XML with namespace in request and response to DSL"() { + given: + assert converter.isAccepted(ymlRestNamedXml) + when: + Collection contracts = converter.convertFrom(ymlRestNamedXml) + then: + contracts.size() == 1 + Contract contract = contracts.first() + contract.request.headers.entries.find({ + it.name == 'Content-Type' && it.clientValue == "application/xml" && it.serverValue == "application/xml" + }) + contract.request.bodyMatchers.matchers.isEmpty() + contract.request.body.clientValue.replaceAll("\n", ""). + replaceAll(' ', '') == xmlContractBodyWithNamespaces.replaceAll("\n", ""). + replaceAll(' ', '') + contract.request.body.serverValue.replaceAll("\n", ""). + replaceAll(' ', '') == xmlContractBodyWithNamespaces.replaceAll("\n", ""). + replaceAll(' ', '') + and: + contract.response.bodyMatchers.matchers.isEmpty() + contract.response.body.clientValue.replaceAll("\n", "") + .replaceAll(' ', '') == xmlContractBodyWithNamespaces + .replaceAll("\n", "").replaceAll(' ', '') + contract.response.body.serverValue.replaceAll("\n", "") + .replaceAll(' ', '') == xmlContractBodyWithNamespaces + .replaceAll("\n", "").replaceAll(' ', '') + } + def "should accept a yaml file that is a proper scc YAML contract"() { when: def accepted = converter.isAccepted(ymlWithRest3) diff --git a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverterSpec.groovy b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverterSpec.groovy index f3878b9642..14b94c6867 100644 --- a/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverterSpec.groovy +++ b/spring-cloud-contract-verifier/src/test/groovy/org/springframework/cloud/contract/verifier/util/xml/XmlToXPathsConverterSpec.groovy @@ -1,5 +1,6 @@ package org.springframework.cloud.contract.verifier.util.xml +import org.springframework.cloud.contract.spec.internal.BodyMatchers import spock.lang.Shared import spock.lang.Specification import spock.lang.Unroll @@ -23,18 +24,32 @@ class XmlToXPathsConverterSpec extends Specification { expect: value == expectedValue where: - value || expectedValue - XmlToXPathsConverter.retrieveValueFromBody("/ns1:customer/email/text()", namedXml) || '''customer@test.com''' - XmlToXPathsConverter.retrieveValueFromBody("/customer/email/text()", namedXml) || '''''' - XmlToXPathsConverter.retrieveValueFromBody("/customer/email/text()", unnamedXml) || '''customer@test.com''' + value || expectedValue + XmlToXPathsConverter.retrieveValueFromBody("/ns1:customer/email/text()", namedXml) || '''customer@test.com''' + XmlToXPathsConverter.retrieveValueFromBody("/customer/email/text()", namedXml) || '''''' + XmlToXPathsConverter.retrieveValueFromBody("/customer/email/text()", unnamedXml) || '''customer@test.com''' } @Unroll - def "should throw exception when searching for inexistent name space"() { + def "should throw exception when searching for in existent name space"() { when: XmlToXPathsConverter.retrieveValueFromBody("/ns1:customer/email/text()", unnamedXml) then: def e = thrown(XPathExpressionException) e.message.contains('Prefix must resolve to a namespace: ns1') } + + @Unroll + def "should remove elements to [#expectedValue] for xPath [#value]"() { + given: + BodyMatchers m = new BodyMatchers() + m.xPath(xpath, m.byEquality()); + expect: + result == XmlToXPathsConverter.removeMatchingXPaths(xml, m) + where: + xpath || xml || result + "/ns1:customer/email/text()" || namedXml || '''\n \n ''' + "/customer/email/text()" || namedXml || '''\n customer@test.com\n ''' + "/customer/email/text()" || unnamedXml || '''\n \n ''' + } } diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java new file mode 100644 index 0000000000..80f030da8d --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/builder/XmlBodyVerificationBuilderTest.java @@ -0,0 +1,34 @@ +package org.springframework.cloud.contract.verifier.builder; + +import org.junit.Test; +import org.springframework.cloud.contract.spec.Contract; +import org.springframework.cloud.contract.spec.internal.BodyMatchers; + +import java.util.Optional; + +import static com.toomuchcoding.jsonassert.JsonAssertion.assertThat; + +public class XmlBodyVerificationBuilderTest { + + private static final String xml = "\r\n" + + " customer@test.com\r\n" + " "; + + @Test + public void shouldAddXmlProcessingLines() { + // Given + XmlBodyVerificationBuilder builder = new XmlBodyVerificationBuilder( + new Contract(), Optional.of(";")); + BlockBuilder blockBuilder = new BlockBuilder(" "); + BodyMatchers matchers = new BodyMatchers(); + // When + builder.addXmlResponseBodyCheck(blockBuilder, xml, matchers, xml, true); + // Then + String test = blockBuilder.toString(); + assertThat(test).contains("DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();") + .contains("builderFactory.setNamespaceAware(true);") + .contains("DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder();") + .contains("Document parsedXml = documentBuilder.parse(new InputSource(new StringReader(") + .contains(xml); + } + +} \ No newline at end of file diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtilTest.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtilTest.java new file mode 100644 index 0000000000..10db124774 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/util/ContractVerifierUtilTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2020-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.contract.verifier.util; + +import java.io.IOException; +import java.io.StringReader; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; + +import org.junit.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Node; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ContractVerifierUtilTest { + + private final String unnamedXml = "\r\n" + + " customer@test.com\r\n" + " "; + + private final String namedXml = "\r\n" + + " customer@test.com\r\n" + " "; + + @Test + public void shouldGetValueFromXPath() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(unnamedXml))); + // When + String value = ContractVerifierUtil.valueFromXPath(parsedXml, + "/customer/email/text()"); + // Then + assertThat(value).isEqualTo("customer@test.com"); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowExceptionOnIllegalValueFromXPath() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(unnamedXml))); + // When + ContractVerifierUtil.valueFromXPath(parsedXml, "/ns1:customer/email/text()"); + } + + @Test + public void shouldGetValueFromXPathWithNamespace() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(namedXml))); + // When + String value = ContractVerifierUtil.valueFromXPath(parsedXml, + "/ns1:customer/email/text()"); + // Then + assertThat(value).isEqualTo("customer@test.com"); + } + + @Test + public void shouldGetEmptyValueFromXPathWithNamespace() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(namedXml))); + // When + String value = ContractVerifierUtil.valueFromXPath(parsedXml, + "/customer/email/text()"); + // Then + assertThat(value).isEqualTo(""); + } + + @Test + public void shouldGetNodeFromXPath() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(unnamedXml))); + // When + Node node = ContractVerifierUtil.nodeFromXPath(parsedXml, + "/customer/email/text()"); + // Then + assertThat(node.getTextContent()).isEqualTo("customer@test.com"); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldThrowExceptionOnIllegalNodeFromXPath() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(unnamedXml))); + // When + ContractVerifierUtil.nodeFromXPath(parsedXml, "/ns1:customer/email/text()"); + } + + @Test + public void shouldGetNodeFromXPathWithNamespace() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(namedXml))); + // When + Node node = ContractVerifierUtil.nodeFromXPath(parsedXml, + "/ns1:customer/email/text()"); + // Then + assertThat(node.getTextContent()).isEqualTo("customer@test.com"); + } + + @Test + public void shouldGetEmptyNodeFromXPathWithNamespace() + throws ParserConfigurationException, IOException, SAXException { + // Given + DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); + builderFactory.setNamespaceAware(true); + DocumentBuilder documentBuilder = builderFactory.newDocumentBuilder(); + Document parsedXml = documentBuilder + .parse(new InputSource(new StringReader(namedXml))); + // When + Node node = ContractVerifierUtil.nodeFromXPath(parsedXml, + "/customer/email/text()"); + // Then + assertThat(node).isNull(); + } + +} diff --git a/spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_named_xml.yml b/spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_named_xml.yml new file mode 100644 index 0000000000..959bda6c86 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/resources/yml/contract_rest_named_xml.yml @@ -0,0 +1,17 @@ +request: + method: GET + url: /getymlResponse + headers: + Content-Type: application/xml + body: | + + customer@test.com + +response: + status: 200 + headers: + Content-Type: application/xml + body: | + + customer@test.com + \ No newline at end of file