Repository: wiremock/wiremock (language: java)

Issue:
xPath helper doesn't return result when XPath expression returns a single string item
### Proposal

Expressions that return a single string node, but the xPath helper expects a collection and currently treats anything else as an invalid expression.

### Reproduction steps

xPath expressions like `local-name(/*/*/*[1])` trigger this issue.

### References

_No response_


Discussion / hints:

Fix patch:
diff --git a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDocument.java b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDocument.java
index dbb573804f..3b4098589c 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDocument.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDocument.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2020-2021 Thomas Akehurst
+ * Copyright (C) 2020-2024 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -15,8 +15,6 @@
  */
 package com.github.tomakehurst.wiremock.common.xml;
 
-import static javax.xml.xpath.XPathConstants.NODESET;
-
 import com.github.tomakehurst.wiremock.common.ListOrSingle;
 import java.util.HashMap;
 import java.util.Map;
@@ -24,12 +22,12 @@
 import javax.xml.namespace.NamespaceContext;
 import javax.xml.transform.dom.DOMSource;
 import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathEvaluationResult;
 import javax.xml.xpath.XPathExpressionException;
 import org.w3c.dom.Document;
-import org.w3c.dom.NodeList;
 import org.xmlunit.util.Convert;
 
-public class XmlDocument extends XmlNode {
+public class XmlDocument extends XmlDomNode {
 
   private final Document document;
 
@@ -47,20 +45,19 @@ public ListOrSingle<XmlNode> findNodes(String xPathExpression, Map<String, Strin
       final XPath xPath = XPATH_CACHE.get();
       xPath.reset();
 
-      NodeList nodeSet;
+      XPathEvaluationResult<?> xPathEvaluationResult;
       if (namespaces != null) {
         Map<String, String> fullNamespaces = addStandardNamespaces(namespaces);
         NamespaceContext namespaceContext = Convert.toNamespaceContext(fullNamespaces);
         xPath.setNamespaceContext(namespaceContext);
-        nodeSet =
-            (NodeList)
-                xPath.evaluate(
-                    xPathExpression, Convert.toInputSource(new DOMSource(document)), NODESET);
+        xPathEvaluationResult =
+            xPath.evaluateExpression(
+                xPathExpression, Convert.toInputSource(new DOMSource(document)));
       } else {
-        nodeSet = (NodeList) xPath.evaluate(xPathExpression, document, NODESET);
+        xPathEvaluationResult = xPath.evaluateExpression(xPathExpression, document);
       }
 
-      return toListOrSingle(nodeSet);
+      return toListOrSingle(xPathEvaluationResult);
     } catch (XPathExpressionException e) {
       throw XPathException.fromXPathException(e);
     }
diff --git a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDomNode.java b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDomNode.java
new file mode 100644
index 0000000000..63098d210c
--- /dev/null
+++ b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlDomNode.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2024 Thomas Akehurst
+ *
+ * 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
+ *
+ * http://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 com.github.tomakehurst.wiremock.common.xml;
+
+import static com.github.tomakehurst.wiremock.common.Exceptions.throwUnchecked;
+
+import java.io.StringWriter;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import javax.xml.transform.Source;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.sax.SAXSource;
+import javax.xml.transform.stream.StreamResult;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.xml.sax.XMLReader;
+
+public class XmlDomNode extends XmlNode {
+
+  private final Node domNode;
+  private final Map<String, String> attributes;
+
+  public XmlDomNode(Node domNode) {
+    this.domNode = domNode;
+    attributes =
+        domNode.hasAttributes()
+            ? convertAttributeMap(domNode.getAttributes())
+            : Collections.emptyMap();
+  }
+
+  private static Map<String, String> convertAttributeMap(NamedNodeMap namedNodeMap) {
+    Map<String, String> map = new HashMap<>();
+    for (int i = 0; i < namedNodeMap.getLength(); i++) {
+      Node node = namedNodeMap.item(i);
+      map.put(node.getNodeName(), node.getNodeValue());
+    }
+
+    return Collections.unmodifiableMap(map);
+  }
+
+  public Map<String, String> getAttributes() {
+    return attributes;
+  }
+
+  public String getName() {
+    return domNode.getNodeName();
+  }
+
+  public String getText() {
+    return domNode.getTextContent();
+  }
+
+  @Override
+  public String toString() {
+    switch (domNode.getNodeType()) {
+      case Node.TEXT_NODE:
+      case Node.ATTRIBUTE_NODE:
+        return domNode.getTextContent();
+      case Node.DOCUMENT_NODE:
+      case Node.ELEMENT_NODE:
+        return render();
+      default:
+        return domNode.toString();
+    }
+  }
+
+  private String render() {
+    try {
+      Transformer transformer = TRANSFORMER_CACHE.get();
+      StreamResult result = new StreamResult(new StringWriter());
+      Source source = getSourceForTransform(domNode);
+      transformer.transform(source, result);
+      return result.getWriter().toString();
+    } catch (Exception e) {
+      return throwUnchecked(e, String.class);
+    }
+  }
+
+  private static final Class<XMLReader> DOM2SAX_XMLREADER_CLASS = getDom2SaxAvailability();
+
+  @SuppressWarnings("unchecked")
+  private static Class<XMLReader> getDom2SaxAvailability() {
+    try {
+      return (Class<XMLReader>)
+          Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.DOM2SAX");
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
+
+  // This nasty little hack attempts to ensure no exception is thrown when attempting to print an
+  // XML node with
+  // unbound namespace prefixes (which can happen when you've selected an element via XPath whose
+  // namespaces are declared in a parent element).
+  // For some reason Transformer is happy to do this with a SAX source, but not a DOM source.
+  private static Source getSourceForTransform(Node node) {
+    if (DOM2SAX_XMLREADER_CLASS != null) {
+      try {
+        Constructor<XMLReader> constructor = DOM2SAX_XMLREADER_CLASS.getConstructor(Node.class);
+        XMLReader dom2SAX = constructor.newInstance(node);
+        SAXSource saxSource = new SAXSource();
+        saxSource.setXMLReader(dom2SAX);
+        return saxSource;
+      } catch (NoSuchMethodException
+          | InstantiationException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        return new DOMSource(node);
+      }
+    }
+
+    return new DOMSource(node);
+  }
+}
diff --git a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlNode.java b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlNode.java
index e07d17eb79..7704cfcd42 100644
--- a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlNode.java
+++ b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlNode.java
@@ -20,24 +20,16 @@
 import static javax.xml.transform.OutputKeys.OMIT_XML_DECLARATION;
 
 import com.github.tomakehurst.wiremock.common.ListOrSingle;
-import java.io.StringWriter;
-import java.lang.reflect.Constructor;
-import java.lang.reflect.InvocationTargetException;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.Map;
-import javax.xml.transform.*;
-import javax.xml.transform.dom.DOMSource;
-import javax.xml.transform.sax.SAXSource;
-import javax.xml.transform.stream.StreamResult;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerConfigurationException;
+import javax.xml.transform.TransformerFactory;
 import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathEvaluationResult;
 import javax.xml.xpath.XPathFactory;
-import org.w3c.dom.NamedNodeMap;
 import org.w3c.dom.Node;
-import org.w3c.dom.NodeList;
-import org.xml.sax.XMLReader;
 
-public class XmlNode {
+public abstract class XmlNode {
 
   protected static final ThreadLocal<XPath> XPATH_CACHE =
       ThreadLocal.withInitial(
@@ -74,106 +66,25 @@ public class XmlNode {
             }
           });
 
-  private static final Class<XMLReader> DOM2SAX_XMLREADER_CLASS = getDom2SaxAvailability();
+  public abstract Map<String, String> getAttributes();
 
-  private static Class<XMLReader> getDom2SaxAvailability() {
-    try {
-      return (Class<XMLReader>)
-          Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.DOM2SAX");
-    } catch (ClassNotFoundException e) {
-      return null;
-    }
-  }
-
-  private final Node domNode;
-  private final Map<String, String> attributes;
-
-  public XmlNode(Node domNode) {
-    this.domNode = domNode;
-    attributes =
-        domNode.hasAttributes()
-            ? convertAttributeMap(domNode.getAttributes())
-            : Collections.emptyMap();
-  }
-
-  private static Map<String, String> convertAttributeMap(NamedNodeMap namedNodeMap) {
-    Map<String, String> map = new HashMap<>();
-    for (int i = 0; i < namedNodeMap.getLength(); i++) {
-      Node node = namedNodeMap.item(i);
-      map.put(node.getNodeName(), node.getNodeValue());
-    }
-
-    return Collections.unmodifiableMap(map);
-  }
-
-  public Map<String, String> getAttributes() {
-    return attributes;
-  }
-
-  protected static ListOrSingle<XmlNode> toListOrSingle(NodeList nodeList) {
-    ListOrSingle<XmlNode> nodes = new ListOrSingle<>();
-    for (int i = 0; i < nodeList.getLength(); i++) {
-      nodes.add(new XmlNode(nodeList.item(i)));
-    }
+  @SuppressWarnings("unchecked")
+  protected static ListOrSingle<XmlNode> toListOrSingle(XPathEvaluationResult<?> evaluationResult) {
+    ListOrSingle<XmlNode> xmlNodes = new ListOrSingle<>();
 
-    return nodes;
-  }
-
-  public String getName() {
-    return domNode.getNodeName();
-  }
-
-  public String getText() {
-    return domNode.getTextContent();
-  }
-
-  @Override
-  public String toString() {
-    switch (domNode.getNodeType()) {
-      case Node.TEXT_NODE:
-      case Node.ATTRIBUTE_NODE:
-        return domNode.getTextContent();
-      case Node.DOCUMENT_NODE:
-      case Node.ELEMENT_NODE:
-        return render();
+    switch (evaluationResult.type()) {
+      case NODESET:
+        Iterable<Node> nodes = (Iterable<Node>) evaluationResult.value();
+        nodes.forEach(node -> xmlNodes.add(new XmlDomNode(node)));
+        break;
+      case NODE:
+        xmlNodes.add(new XmlDomNode((Node) evaluationResult.value()));
+        break;
       default:
-        return domNode.toString();
-    }
-  }
-
-  private String render() {
-    try {
-      Transformer transformer = TRANSFORMER_CACHE.get();
-      StreamResult result = new StreamResult(new StringWriter());
-      Source source = getSourceForTransform(domNode);
-      transformer.transform(source, result);
-      return result.getWriter().toString();
-    } catch (Exception e) {
-      return throwUnchecked(e, String.class);
-    }
-  }
-
-  // This nasty little hack attempts to ensure no exception is thrown when attempting to print an
-  // XML node with
-  // unbound namespace prefixes (which can happen when you've selected an element via XPath whose
-  // namespaces are declared in a parent element).
-  // For some reason Transformer is happy to do this with a SAX source, but not a DOM source.
-  private static Source getSourceForTransform(Node node) {
-    if (DOM2SAX_XMLREADER_CLASS != null) {
-      try {
-        Constructor<XMLReader> constructor = DOM2SAX_XMLREADER_CLASS.getConstructor(Node.class);
-        XMLReader dom2SAX = constructor.newInstance(node);
-        SAXSource saxSource = new SAXSource();
-        saxSource.setXMLReader(dom2SAX);
-        return saxSource;
-      } catch (NoSuchMethodException
-          | InstantiationException
-          | IllegalAccessException
-          | InvocationTargetException e) {
-        return new DOMSource(node);
-      }
+        xmlNodes.add(new XmlPrimitiveNode<>(evaluationResult.value()));
+        break;
     }
 
-    return new DOMSource(node);
+    return xmlNodes;
   }
 }
diff --git a/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlPrimitiveNode.java b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlPrimitiveNode.java
new file mode 100644
index 0000000000..092dcb1de2
--- /dev/null
+++ b/src/main/java/com/github/tomakehurst/wiremock/common/xml/XmlPrimitiveNode.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright (C) 2024 Thomas Akehurst
+ *
+ * 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
+ *
+ * http://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 com.github.tomakehurst.wiremock.common.xml;
+
+import java.text.NumberFormat;
+import java.util.Collections;
+import java.util.Map;
+
+public class XmlPrimitiveNode<T> extends XmlNode {
+
+  private final T value;
+
+  public XmlPrimitiveNode(T value) {
+    this.value = value;
+  }
+
+  @Override
+  public String toString() {
+    return value instanceof Number ? NumberFormat.getInstance().format(value) : value.toString();
+  }
+
+  @Override
+  public Map<String, String> getAttributes() {
+    return Collections.emptyMap();
+  }
+}


Test patch:
diff --git a/src/test/java/com/github/tomakehurst/wiremock/extension/responsetemplating/helpers/HandlebarsXPathHelperTest.java b/src/test/java/com/github/tomakehurst/wiremock/extension/responsetemplating/helpers/HandlebarsXPathHelperTest.java
index dc93935268..ad91792fbf 100644
--- a/src/test/java/com/github/tomakehurst/wiremock/extension/responsetemplating/helpers/HandlebarsXPathHelperTest.java
+++ b/src/test/java/com/github/tomakehurst/wiremock/extension/responsetemplating/helpers/HandlebarsXPathHelperTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2017-2023 Thomas Akehurst
+ * Copyright (C) 2017-2024 Thomas Akehurst
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -251,4 +251,33 @@ public void rendersElementNames() {
 
     assertThat(responseDefinition.getBody(), is("one two three "));
   }
+
+  @Test
+  void rendersElementWhenXPathSelectorReturnsPrimitiveStringResult() {
+    ResponseDefinition responseDefinition =
+        transform(
+            transformer,
+            mockRequest().body("<one>\n" + "    <two>value</two>\n" + "</one>"),
+            aResponse().withBody("{{xPath request.body 'local-name(/*/*[1])'}}"));
+
+    assertThat(responseDefinition.getBody(), is("two"));
+  }
+
+  @Test
+  void rendersElementWhenXPathSelectorReturnsPrimitiveNumberResult() {
+    ResponseDefinition responseDefinition =
+        transform(
+            transformer,
+            mockRequest()
+                .body(
+                    "<wrap>\n"
+                        + "    <one>value</one>\n"
+                        + "    <two>value</two>\n"
+                        + "    <three>value</three>\n"
+                        + "    <four>value</four>\n"
+                        + "</wrap>"),
+            aResponse().withBody("{{xPath request.body 'count(/wrap/*)'}}"));
+
+    assertThat(responseDefinition.getBody(), is("4"));
+  }
 }