001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.test.util; 018 019import java.io.ByteArrayInputStream; 020import java.util.Collections; 021import java.util.Map; 022 023import javax.xml.namespace.QName; 024import javax.xml.parsers.DocumentBuilder; 025import javax.xml.parsers.DocumentBuilderFactory; 026import javax.xml.xpath.XPath; 027import javax.xml.xpath.XPathConstants; 028import javax.xml.xpath.XPathExpression; 029import javax.xml.xpath.XPathExpressionException; 030import javax.xml.xpath.XPathFactory; 031 032import org.hamcrest.Matcher; 033import org.hamcrest.MatcherAssert; 034import org.w3c.dom.Document; 035import org.w3c.dom.Node; 036import org.w3c.dom.NodeList; 037import org.xml.sax.InputSource; 038 039import org.springframework.lang.Nullable; 040import org.springframework.util.CollectionUtils; 041import org.springframework.util.StringUtils; 042import org.springframework.util.xml.SimpleNamespaceContext; 043 044/** 045 * A helper class for applying assertions via XPath expressions. 046 * 047 * @author Rossen Stoyanchev 048 * @since 3.2 049 */ 050public class XpathExpectationsHelper { 051 052 private final String expression; 053 054 private final XPathExpression xpathExpression; 055 056 private final boolean hasNamespaces; 057 058 059 /** 060 * XpathExpectationsHelper constructor. 061 * @param expression the XPath expression 062 * @param namespaces the XML namespaces referenced in the XPath expression, or {@code null} 063 * @param args arguments to parameterize the XPath expression with using the 064 * formatting specifiers defined in {@link String#format(String, Object...)} 065 * @throws XPathExpressionException if expression compilation failed 066 */ 067 public XpathExpectationsHelper(String expression, @Nullable Map<String, String> namespaces, Object... args) 068 throws XPathExpressionException { 069 070 this.expression = String.format(expression, args); 071 this.xpathExpression = compileXpathExpression(this.expression, namespaces); 072 this.hasNamespaces = !CollectionUtils.isEmpty(namespaces); 073 } 074 075 private static XPathExpression compileXpathExpression(String expression, 076 @Nullable Map<String, String> namespaces) throws XPathExpressionException { 077 078 SimpleNamespaceContext namespaceContext = new SimpleNamespaceContext(); 079 namespaceContext.setBindings(namespaces != null ? namespaces : Collections.emptyMap()); 080 XPath xpath = XPathFactory.newInstance().newXPath(); 081 xpath.setNamespaceContext(namespaceContext); 082 return xpath.compile(expression); 083 } 084 085 086 /** 087 * Return the compiled XPath expression. 088 */ 089 protected XPathExpression getXpathExpression() { 090 return this.xpathExpression; 091 } 092 093 094 /** 095 * Parse the content, evaluate the XPath expression as a {@link Node}, 096 * and assert it with the given {@code Matcher<Node>}. 097 */ 098 public void assertNode(byte[] content, @Nullable String encoding, Matcher<? super Node> matcher) 099 throws Exception { 100 101 Node node = evaluateXpath(content, encoding, Node.class); 102 MatcherAssert.assertThat("XPath " + this.expression, node, matcher); 103 } 104 105 /** 106 * Parse the content, evaluate the XPath expression as a {@link NodeList}, 107 * and assert it with the given {@code Matcher<NodeList>}. 108 * @since 5.2.2 109 */ 110 public void assertNodeList(byte[] content, @Nullable String encoding, Matcher<? super NodeList> matcher) 111 throws Exception { 112 113 Document document = parseXmlByteArray(content, encoding); 114 NodeList nodeList = evaluateXpath(document, XPathConstants.NODESET, NodeList.class); 115 MatcherAssert.assertThat("XPath " + this.getXpathExpression(), nodeList, matcher); 116 } 117 118 /** 119 * Apply the XPath expression and assert the resulting content exists. 120 * @throws Exception if content parsing or expression evaluation fails 121 */ 122 public void exists(byte[] content, @Nullable String encoding) throws Exception { 123 Node node = evaluateXpath(content, encoding, Node.class); 124 AssertionErrors.assertNotNull("XPath " + this.expression + " does not exist", node); 125 } 126 127 /** 128 * Apply the XPath expression and assert the resulting content does not exist. 129 * @throws Exception if content parsing or expression evaluation fails 130 */ 131 public void doesNotExist(byte[] content, @Nullable String encoding) throws Exception { 132 Node node = evaluateXpath(content, encoding, Node.class); 133 AssertionErrors.assertNull("XPath " + this.expression + " exists", node); 134 } 135 136 /** 137 * Apply the XPath expression and assert the resulting content with the 138 * given Hamcrest matcher. 139 * @throws Exception if content parsing or expression evaluation fails 140 */ 141 public void assertNodeCount(byte[] content, @Nullable String encoding, Matcher<Integer> matcher) 142 throws Exception { 143 144 NodeList nodeList = evaluateXpath(content, encoding, NodeList.class); 145 String reason = "nodeCount for XPath " + this.expression; 146 MatcherAssert.assertThat(reason, nodeList != null ? nodeList.getLength() : 0, matcher); 147 } 148 149 /** 150 * Apply the XPath expression and assert the resulting content as an integer. 151 * @throws Exception if content parsing or expression evaluation fails 152 */ 153 public void assertNodeCount(byte[] content, @Nullable String encoding, int expectedCount) throws Exception { 154 NodeList nodeList = evaluateXpath(content, encoding, NodeList.class); 155 AssertionErrors.assertEquals("nodeCount for XPath " + this.expression, expectedCount, 156 (nodeList != null ? nodeList.getLength() : 0)); 157 } 158 159 /** 160 * Apply the XPath expression and assert the resulting content with the 161 * given Hamcrest matcher. 162 * @throws Exception if content parsing or expression evaluation fails 163 */ 164 public void assertString(byte[] content, @Nullable String encoding, Matcher<? super String> matcher) 165 throws Exception { 166 167 String actual = evaluateXpath(content, encoding, String.class); 168 MatcherAssert.assertThat("XPath " + this.expression, actual, matcher); 169 } 170 171 /** 172 * Apply the XPath expression and assert the resulting content as a String. 173 * @throws Exception if content parsing or expression evaluation fails 174 */ 175 public void assertString(byte[] content, @Nullable String encoding, String expectedValue) throws Exception { 176 String actual = evaluateXpath(content, encoding, String.class); 177 AssertionErrors.assertEquals("XPath " + this.expression, expectedValue, actual); 178 } 179 180 /** 181 * Apply the XPath expression and assert the resulting content with the 182 * given Hamcrest matcher. 183 * @throws Exception if content parsing or expression evaluation fails 184 */ 185 public void assertNumber(byte[] content, @Nullable String encoding, Matcher<? super Double> matcher) throws Exception { 186 Double actual = evaluateXpath(content, encoding, Double.class); 187 MatcherAssert.assertThat("XPath " + this.expression, actual, matcher); 188 } 189 190 /** 191 * Apply the XPath expression and assert the resulting content as a Double. 192 * @throws Exception if content parsing or expression evaluation fails 193 */ 194 public void assertNumber(byte[] content, @Nullable String encoding, Double expectedValue) throws Exception { 195 Double actual = evaluateXpath(content, encoding, Double.class); 196 AssertionErrors.assertEquals("XPath " + this.expression, expectedValue, actual); 197 } 198 199 /** 200 * Apply the XPath expression and assert the resulting content as a Boolean. 201 * @throws Exception if content parsing or expression evaluation fails 202 */ 203 public void assertBoolean(byte[] content, @Nullable String encoding, boolean expectedValue) throws Exception { 204 String actual = evaluateXpath(content, encoding, String.class); 205 AssertionErrors.assertEquals("XPath " + this.expression, expectedValue, Boolean.parseBoolean(actual)); 206 } 207 208 /** 209 * Evaluate the XPath and return the resulting value. 210 * @param content the content to evaluate against 211 * @param encoding the encoding to use (optionally) 212 * @param targetClass the target class, one of Number, String, Boolean, 213 * org.w3c.Node, or NodeList 214 * @throws Exception if content parsing or expression evaluation fails 215 * @since 5.1 216 */ 217 @Nullable 218 public <T> T evaluateXpath(byte[] content, @Nullable String encoding, Class<T> targetClass) throws Exception { 219 Document document = parseXmlByteArray(content, encoding); 220 return evaluateXpath(document, toQName(targetClass), targetClass); 221 } 222 223 /** 224 * Parse the given XML content to a {@link Document}. 225 * @param xml the content to parse 226 * @param encoding optional content encoding, if provided as metadata (e.g. in HTTP headers) 227 * @return the parsed document 228 */ 229 protected Document parseXmlByteArray(byte[] xml, @Nullable String encoding) throws Exception { 230 DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 231 factory.setNamespaceAware(this.hasNamespaces); 232 DocumentBuilder documentBuilder = factory.newDocumentBuilder(); 233 InputSource inputSource = new InputSource(new ByteArrayInputStream(xml)); 234 if (StringUtils.hasText(encoding)) { 235 inputSource.setEncoding(encoding); 236 } 237 return documentBuilder.parse(inputSource); 238 } 239 240 /** 241 * Apply the XPath expression to given document. 242 * @throws XPathExpressionException if expression evaluation failed 243 */ 244 @SuppressWarnings("unchecked") 245 @Nullable 246 protected <T> T evaluateXpath(Document document, QName evaluationType, Class<T> expectedClass) 247 throws XPathExpressionException { 248 249 return (T) getXpathExpression().evaluate(document, evaluationType); 250 } 251 252 private <T> QName toQName(Class<T> expectedClass) { 253 QName evaluationType; 254 if (Number.class.isAssignableFrom(expectedClass)) { 255 evaluationType = XPathConstants.NUMBER; 256 } 257 else if (CharSequence.class.isAssignableFrom(expectedClass)) { 258 evaluationType = XPathConstants.STRING; 259 } 260 else if (Boolean.class.isAssignableFrom(expectedClass)) { 261 evaluationType = XPathConstants.BOOLEAN; 262 } 263 else if (Node.class.isAssignableFrom(expectedClass)) { 264 evaluationType = XPathConstants.NODE; 265 } 266 else if (NodeList.class.isAssignableFrom(expectedClass)) { 267 evaluationType = XPathConstants.NODESET; 268 } 269 else { 270 throw new IllegalArgumentException("Unexpected target class " + expectedClass + ". " + 271 "Supported: numbers, strings, boolean, and org.w3c.Node and NodeList"); 272 } 273 return evaluationType; 274 } 275 276}