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}