Skip to content

Commit f9d448e

Browse files
authored
Normalize xpath for json escaping (#2809)
* feat(core): add XML to JSON normalization utility and support NodeList conversion - Introduced `NormalizeXMLForJsonUtil` to normalize XML structures (e.g., `NodeList`) into JSON-compatible formats. - Enhanced `toJSON` method in `CommonBuiltInFunctions` to use the new utility for handling `NodeList` inputs. - Updated tests to verify XML to JSON normalization (`NormalizeXMLForJsonUtilTest`). - Refactored transformation tutorials to simplify and align with updated JSON handling. * refactor(core): switch to enhanced `switch` syntax in `NormalizeXMLForJsonUtil` - Updated `normalizeForJson` method to use Java's modern `switch` expression for improved readability and maintainability. * feat(core): enhance XML to JSON normalization with improved Node and NodeList handling - Updated `toJSON` to support `Node` normalization alongside `NodeList`. - Refined `normalizeForJson` to handle empty `NodeList` as empty JSON arrays and return `null` for empty nodes. - Improved test coverage for edge cases, including empty nodes and missing XPath results. * fix(core): handle null Node values in `normalizeForJson` and improve test coverage - Updated `normalizeForJson` to account for null `Node` values gracefully. - Enhanced test cases with additional validations for XPath evaluation and Jackson round-trips. - Replaced deprecated assertions with `assertInstanceOf` for improved clarity. * test(core): replace deprecated assertions in `CommonBuiltInFunctionsTest` - Updated test cases to use `assertInstanceOf` instead of `instanceof` checks for improved clarity. * test(core): replace deprecated `assertInstanceOf` with `assertTrue` in `NormalizeXMLForJsonUtilTest` - Updated test cases to use `assertTrue` for type checks instead of `assertInstanceOf`.
1 parent 54c7e3b commit f9d448e

6 files changed

Lines changed: 298 additions & 13 deletions

File tree

core/src/main/java/com/predic8/membrane/core/lang/CommonBuiltInFunctions.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.jetbrains.annotations.*;
2929
import org.slf4j.*;
3030
import org.slf4j.Logger;
31+
import org.w3c.dom.*;
3132

3233
import javax.xml.namespace.*;
3334
import javax.xml.xpath.*;
@@ -38,6 +39,7 @@
3839

3940
import static com.predic8.membrane.core.exchange.Exchange.*;
4041
import static com.predic8.membrane.core.http.Header.*;
42+
import static com.predic8.membrane.core.util.xml.NormalizeXMLForJsonUtil.normalizeForJson;
4143
import static java.lang.System.*;
4244
import static java.nio.charset.StandardCharsets.*;
4345
import static java.util.Collections.*;
@@ -65,6 +67,9 @@ public static Object jsonPath(String jsonPath, Message msg) {
6567

6668
public static String toJSON(Object o) {
6769
try {
70+
if (o instanceof NodeList || o instanceof Node) {
71+
o = normalizeForJson(o);
72+
}
6873
return objectMapper.writeValueAsString(o);
6974
} catch (Exception e) {
7075
log.info("Failed to convert object to JSON", e);

core/src/main/java/com/predic8/membrane/core/lang/ScriptingUtils.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ public static Map<String, Object> createParameterBindings(Router router, Exchang
113113
params.put("pathParam", new PathParametersMap(exc));
114114

115115
// "fn" is the special key used to set the Groovy Binding.
116-
// The Groovy Binding is allows function invocation in Groovy scripts with ${functionname()} .
116+
// The Groovy Binding allows function invocation in Groovy scripts with ${functionname()} .
117117
params.put(BINDING, new GroovyBuiltInFunctions(exc, flow, router, params) {
118118
@Override
119119
public Object escape(Object o) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/* Copyright 2026 predic8 GmbH, www.predic8.com
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License. */
14+
15+
package com.predic8.membrane.core.util.xml;
16+
17+
import com.fasterxml.jackson.databind.*;
18+
import org.w3c.dom.*;
19+
20+
import java.util.*;
21+
22+
import static org.w3c.dom.Node.*;
23+
24+
/**
25+
* Utility class to normalize XML structures for JSON compatibility.
26+
* The class provides methods to convert XML-based objects, such as
27+
* Node and NodeList, into JSON-friendly structures.
28+
* <p>
29+
* This involves processing XML nodes and attributes, and representing
30+
* them as JSON-compatible maps, arrays, and primitive types.
31+
* <p>
32+
* This class is stateless and thread-safe, and it cannot be instantiated.
33+
*/
34+
public class NormalizeXMLForJsonUtil {
35+
36+
private static final ObjectMapper om = new ObjectMapper();
37+
38+
private NormalizeXMLForJsonUtil() {
39+
}
40+
41+
/**
42+
* Normalizes an input object for JSON serialization. The method converts
43+
* objects such as {@link NodeList} or {@link Node} to JSON-compatible objects.
44+
* <p/>
45+
* NodeList with one item is not converted to an array!
46+
* <p/>
47+
*
48+
* @param o the XML input object to normalize, which could be a {@link NodeList},
49+
* a single {@link Node}, or any other object
50+
* `@return` a JSON-compatible object. If the input is a {`@link` NodeList}, it converts
51+
* to a list of normalized node values (or a single unwrapped value for single-item lists).
52+
* If it is a single {`@link` Node}, it converts to its normalized value.
53+
* For other objects, they are returned as-is.
54+
*/
55+
public static Object normalizeForJson(Object o) {
56+
switch (o) {
57+
case null -> {
58+
return null;
59+
}
60+
61+
// XPath often returns NodeList (e.g. DTMNodeList). Convert to JSON-friendly structure.
62+
case NodeList nl -> {
63+
var arr = new ArrayList<>(nl.getLength());
64+
for (int i = 0; i < nl.getLength(); i++) {
65+
arr.add(nodeToJsonValue(nl.item(i)));
66+
}
67+
if (arr.size() == 1) {
68+
return arr.getFirst();
69+
}
70+
return arr;
71+
}
72+
case Node n -> {
73+
return nodeToJsonValue(n);
74+
}
75+
default -> {
76+
}
77+
}
78+
79+
return o;
80+
}
81+
82+
private static Object nodeToJsonValue(Node n) {
83+
if (n == null) return null;
84+
85+
var value = switch (n.getNodeType()) {
86+
case ELEMENT_NODE -> elementToJsonValue(n);
87+
case ATTRIBUTE_NODE, TEXT_NODE, CDATA_SECTION_NODE -> n.getNodeValue() != null ? n.getNodeValue().trim() : "";
88+
default -> n.getTextContent();
89+
};
90+
var number = parseJsonNumber(value);
91+
if (number != null)
92+
return number;
93+
return value;
94+
}
95+
96+
private static Object elementToJsonValue(Node element) {
97+
var text = element.getTextContent();
98+
if (text != null) {
99+
return text.trim();
100+
}
101+
return "";
102+
}
103+
104+
private static Number parseJsonNumber(Object o) {
105+
if (!(o instanceof String s)) return null;
106+
try {
107+
var n = om.readTree(s);
108+
if (n == null || !n.isNumber())
109+
return null;
110+
return n.numberValue(); // returns Integer, Long, Double, BigDecimal, etc.
111+
} catch (Exception e) {
112+
return null;
113+
}
114+
}
115+
}

core/src/test/java/com/predic8/membrane/core/lang/CommonBuiltInFunctionsTest.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,21 @@
2020
import com.predic8.membrane.core.security.*;
2121
import org.junit.jupiter.api.*;
2222
import org.w3c.dom.*;
23-
import org.xml.sax.InputSource;
23+
import org.xml.sax.*;
2424

2525
import javax.xml.parsers.*;
26-
import java.net.*;
26+
import javax.xml.xpath.*;
2727
import java.io.*;
28+
import java.net.*;
2829
import java.util.*;
2930

3031
import static com.predic8.membrane.core.exchange.Exchange.*;
3132
import static com.predic8.membrane.core.http.Header.*;
3233
import static com.predic8.membrane.core.http.Request.*;
3334
import static com.predic8.membrane.core.lang.CommonBuiltInFunctions.*;
3435
import static com.predic8.membrane.core.security.ApiKeySecurityScheme.In.*;
35-
import static javax.xml.xpath.XPathConstants.*;
3636
import static java.util.List.*;
37+
import static javax.xml.xpath.XPathConstants.*;
3738
import static org.junit.jupiter.api.Assertions.*;
3839

3940
class CommonBuiltInFunctionsTest {
@@ -98,12 +99,12 @@ void xpathEvaluatesAgainstContextWithReturnTypeInference() throws Exception {
9899
""");
99100

100101
Object nodes = xpath("//fruit", document, null);
101-
assertTrue(nodes instanceof NodeList);
102+
assertInstanceOf(NodeList.class, nodes);
102103
assertEquals(2, ((NodeList) nodes).getLength());
103104

104-
Node firstFruit = ((NodeList) nodes).item(0);
105+
var firstFruit = ((NodeList) nodes).item(0);
105106
Object nameNode = xpath("./name", firstFruit, null);
106-
assertTrue(nameNode instanceof Node);
107+
assertInstanceOf(Node.class, nameNode);
107108
assertEquals("name", ((Node) nameNode).getNodeName());
108109

109110
assertEquals("Apricot", xpath("string(./name)", firstFruit, null));
@@ -231,7 +232,7 @@ void integer() {
231232
assertEquals("1", toJSON(1));
232233
}
233234

234-
@Test
235+
@Test
235236
void bool() {
236237
assertEquals("true", toJSON(true));
237238
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.predic8.membrane.core.util.xml;
2+
3+
import com.fasterxml.jackson.databind.*;
4+
import org.junit.jupiter.api.*;
5+
import org.w3c.dom.*;
6+
7+
import javax.xml.parsers.*;
8+
import javax.xml.xpath.*;
9+
import java.io.*;
10+
import java.util.*;
11+
12+
import static com.predic8.membrane.core.util.xml.NormalizeXMLForJsonUtil.*;
13+
import static java.nio.charset.StandardCharsets.*;
14+
import static java.util.Collections.emptyList;
15+
import static javax.xml.xpath.XPathConstants.*;
16+
import static org.junit.jupiter.api.Assertions.*;
17+
18+
class NormalizeXMLForJsonUtilTest {
19+
20+
private static final ObjectMapper om = new ObjectMapper();
21+
22+
private static Object evaluateXPath(Document doc, String xpath) throws XPathExpressionException {
23+
return XPathFactory.newInstance()
24+
.newXPath()
25+
.evaluate(xpath, doc, NODESET);
26+
}
27+
28+
@Nested
29+
class normalizeForJson_ {
30+
31+
@Test
32+
void nullValue() {
33+
assertNull(normalizeForJson(null));
34+
}
35+
36+
@Test
37+
void passthrough_nonXmlObject() {
38+
Object o = "foo";
39+
assertSame(o, normalizeForJson(o));
40+
}
41+
42+
@Test
43+
void nodeList_withTwoElements_becomesList() throws Exception {
44+
var doc = parseXml("""
45+
<root>
46+
<a>1</a>
47+
<a>2</a>
48+
</root>
49+
""");
50+
51+
var norm = normalizeForJson(evaluateXPath(doc, "/root/a"));
52+
53+
assertTrue(norm instanceof List<?>);
54+
List<?> list = (List<?>) norm;
55+
56+
assertEquals(2, list.size());
57+
assertEquals(1, list.get(0)); // integer
58+
assertEquals(2, list.get(1)); // integer
59+
}
60+
61+
@Test
62+
void nodeList_withSingleElement_isUnwrapped() throws Exception {
63+
var doc = parseXml("""
64+
<root>
65+
<a>1</a>
66+
</root>
67+
""");
68+
69+
var norm = normalizeForJson(evaluateXPath(doc, "/root/a"));
70+
71+
assertInstanceOf(Number.class, norm);
72+
assertEquals(1, ((Number) norm).intValue());
73+
}
74+
75+
@Test
76+
void singleNode_number_isParsed() throws Exception {
77+
var doc = parseXml("""
78+
<root>
79+
<a>1.0</a>
80+
</root>
81+
""");
82+
83+
var node = XPathFactory.newInstance()
84+
.newXPath()
85+
.evaluate("/root/a", doc, NODE);
86+
87+
var norm = normalizeForJson(node);
88+
89+
assertInstanceOf(Number.class, norm);
90+
// numberValue() may yield Double for 1.0
91+
assertEquals(1.0d, ((Number) norm).doubleValue(), 0.0d);
92+
}
93+
94+
@Test
95+
void singleNode_nonNumber_isTrimmedString() throws Exception {
96+
var doc = parseXml("""
97+
<root>
98+
<a>
99+
foo
100+
</a>
101+
</root>
102+
""");
103+
104+
var node = XPathFactory.newInstance()
105+
.newXPath()
106+
.evaluate("/root/a", doc, NODE);
107+
108+
assertEquals("foo", normalizeForJson(node));
109+
}
110+
111+
@Test
112+
void nodeList_nonNumber_values_areStrings() throws Exception {
113+
var doc = parseXml("""
114+
<root>
115+
<a>foo</a>
116+
<a>bar</a>
117+
</root>
118+
""");
119+
120+
var norm = normalizeForJson(evaluateXPath(doc, "/root/a"));
121+
122+
assertTrue(norm instanceof List<?>);
123+
List<?> list = (List<?>) norm;
124+
125+
assertEquals(List.of("foo", "bar"), list);
126+
}
127+
128+
@Test
129+
void normalizedValue_isJsonSerializable() throws Exception {
130+
var doc = parseXml("""
131+
<root>
132+
<a>1</a>
133+
<a>2.5</a>
134+
<a>foo</a>
135+
</root>
136+
""");
137+
138+
var norm = normalizeForJson(evaluateXPath(doc, "/root/a"));
139+
140+
var roundTrip = om.readValue(om.writeValueAsString(norm), Object.class);
141+
assertTrue(roundTrip instanceof List<?>);
142+
assertEquals(3, ((List<?>) roundTrip).size());
143+
}
144+
145+
@Test
146+
void emptyNodeList_isJsonSerializable() throws Exception {
147+
var doc = parseXml("""
148+
<root>
149+
<a></a>
150+
</root>
151+
""");
152+
var norm = normalizeForJson(evaluateXPath(doc, "/root/notThere"));
153+
assertEquals(emptyList(), norm);
154+
// verify it survives a Jackson round-trip
155+
var roundTrip = om.readValue(om.writeValueAsString(norm), Object.class);
156+
assertEquals(emptyList(), roundTrip);
157+
}
158+
159+
private Document parseXml(String xml) throws Exception {
160+
var dbf = DocumentBuilderFactory.newInstance();
161+
dbf.setNamespaceAware(true);
162+
return dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(UTF_8)));
163+
}
164+
}
165+
}

distribution/tutorials/transformation/40-REST-GET-to-SOAP.yaml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,12 @@ api:
2626
name: SOAPAction
2727
value: https://predic8.de/cities/get
2828
- response:
29-
- setBody:
30-
language: xpath
29+
- template:
3130
contentType: application/json
32-
value: |
31+
src: |
3332
{
34-
"country": "${//country}",
35-
"population": ${//population}
33+
"country": ${xpath('//country')},
34+
"population": ${xpath('//population')}
3635
}
3736
target:
3837
# Change method to POST

0 commit comments

Comments
 (0)