Skip to content

Commit 1126c98

Browse files
authored
Fix #484: add XmlReadFeature.WRAP_ROOT_ELEMENT_NAME, handling (#860)
1 parent 3077080 commit 1126c98

4 files changed

Lines changed: 298 additions & 1 deletion

File tree

release-notes/VERSION

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ Version: 3.x (for earlier see VERSION-2.x)
6969
#455: Can't deserialize list in JsonSubtype when type property is visible
7070
(reported by Jiri M)
7171
(fix by @cowtowncoder, w/ Claude code)
72+
#484: Add `FromXmlParser.Feature.WRAP_ROOT_ELEMENT_NAME` to allow
73+
lossless round-trip via Tree Model
74+
(fix by @cowtowncoder, w/ Claude code)
7275
#496: Root name missing when root element has no attributes (add
7376
`FromXmlParser.getRootElementName()`)
7477
(reported by Sam K)

src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,52 @@ public enum XmlReadFeature implements FormatFeature
9191
*/
9292
SKIP_UNKNOWN_XSI_ATTRIBUTES(false),
9393

94+
/**
95+
* Feature that, when enabled, exposes the XML root element as an extra
96+
* outer Object wrapper whose single property is named after the root
97+
* element's local name. This preserves the root name in the resulting
98+
* token stream (and therefore in {@code JsonNode}, {@code Map}, etc.),
99+
* which is otherwise discarded.
100+
*<p>
101+
* Example: with this feature enabled,
102+
*<pre>
103+
* &lt;root&gt;&lt;value&gt;3&lt;/value&gt;&lt;/root&gt;
104+
*</pre>
105+
* is exposed as token stream equivalent to
106+
*<pre>
107+
* { "root" : { "value" : "3" } }
108+
*</pre>
109+
* instead of the default
110+
*<pre>
111+
* { "value" : "3" }
112+
*</pre>
113+
* The wrapper is purely a token-stream-level addition; the body is exposed
114+
* exactly as it would be without wrap. Roots that the parser would otherwise
115+
* expose as {@code null} ({@code xsi:nil} or, with
116+
* {@link #EMPTY_ELEMENT_AS_NULL} enabled, empty elements) become
117+
* {@code { "root" : null }}.
118+
*<p>
119+
* Designed to pair with {@link XmlWriteFeature#UNWRAP_ROOT_OBJECT_NODE}
120+
* to allow lossless round-tripping of root element name via the Tree
121+
* Model ({@code JsonNode}) and {@code Map} bindings.
122+
*<p>
123+
* Notes:
124+
*<ul>
125+
* <li>The wrapper key uses the root element's <em>local name only</em>;
126+
* namespace URI is not encoded into the key (consistent with how
127+
* child element names are exposed throughout this parser). The full
128+
* {@link javax.xml.namespace.QName} of the root remains accessible
129+
* via {@code FromXmlParser.getRootElementName()}.</li>
130+
* <li>This feature modifies the token stream, so it affects all
131+
* bindings (POJO, {@code Map}, {@code JsonNode}), not just Tree Model.</li>
132+
*</ul>
133+
*<p>
134+
* Default setting is {@code false} for backwards-compatibility.
135+
*
136+
* @since 3.2
137+
*/
138+
WRAP_ROOT_ELEMENT_NAME(false),
139+
94140
;
95141

96142
private final boolean _defaultState;

src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,37 @@ public class FromXmlParser
121121
*/
122122
protected boolean _nextIsNullXsiNil;
123123

124+
/*
125+
/**********************************************************************
126+
/* Parsing state, optional root element wrapping (3.2)
127+
/**********************************************************************
128+
*/
129+
130+
// [dataformat-xml#484] Root-element wrap state machine. When
131+
// {@link XmlReadFeature#WRAP_ROOT_ELEMENT_NAME} is enabled, the parser
132+
// synthesizes an extra outer Object whose single property is the root
133+
// element local name. {@code _rootWrapStage} drives the synthetic tokens.
134+
135+
/** Wrap inactive: feature off, or wrapper already fully delivered. */
136+
private static final int WRAP_INACTIVE = 0;
137+
/** Next call emits the synthetic outer START_OBJECT. */
138+
private static final int WRAP_PENDING_OUTER_START = 1;
139+
/** Next call emits the synthetic PROPERTY_NAME (root local name). */
140+
private static final int WRAP_PENDING_NAME = 2;
141+
/** Wrapper open over an Object body: emit synthetic END_OBJECT when stream reaches XML_END. */
142+
private static final int WRAP_OPEN_OBJECT = 3;
143+
/** Wrapper open over a scalar/null body: queued in {@code _nextToken}; transition to PENDING_OUTER_END after delivery. */
144+
private static final int WRAP_OPEN_SCALAR = 4;
145+
/** Next call emits the synthetic outer END_OBJECT (scalar/null body case). */
146+
private static final int WRAP_PENDING_OUTER_END = 5;
147+
148+
/**
149+
* Stage of the root-element wrap state machine; see WRAP_* constants above.
150+
*
151+
* @since 3.2
152+
*/
153+
protected int _rootWrapStage = WRAP_INACTIVE;
154+
124155
/*
125156
/**********************************************************************
126157
/* Parsing state, parsed values
@@ -207,6 +238,7 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
207238

208239
// 04-Jan-2019, tatu: Root-level nulls need slightly specific handling;
209240
// changed in 2.10.2
241+
final boolean wrapRoot = isEnabled(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
210242
if (_xmlTokens.hasXsiNil()) {
211243
_nextToken = JsonToken.VALUE_NULL;
212244
// 21-Apr-2025, tatu: [dataformat-xml#714] Must "flush" the stream
@@ -216,7 +248,12 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
216248
case XmlTokenStream.XML_START_ELEMENT:
217249
// Removed from 2.14:
218250
// case XmlTokenStream.XML_DELAYED_START_ELEMENT:
219-
_nextToken = JsonToken.START_OBJECT;
251+
// [dataformat-xml#484]: in wrap mode, suppress queuing the
252+
// (inner) START_OBJECT here; the wrap state machine queues it
253+
// explicitly after delivering outer START + PROPERTY_NAME.
254+
if (!wrapRoot) {
255+
_nextToken = JsonToken.START_OBJECT;
256+
}
220257
break;
221258
case XmlTokenStream.XML_ROOT_TEXT:
222259
_currText = _xmlTokens.getText();
@@ -232,6 +269,10 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
232269
_reportError("Internal problem: invalid starting state (%s)", _xmlTokens._currentStateDesc());
233270
}
234271
}
272+
// [dataformat-xml#484]: activate wrap state machine if feature enabled
273+
if (wrapRoot) {
274+
_rootWrapStage = WRAP_PENDING_OUTER_START;
275+
}
235276
}
236277

237278
@Override
@@ -553,6 +594,14 @@ public JsonToken nextToken() throws JacksonException
553594
_binaryValue = null;
554595
_numTypesValid = NR_UNKNOWN;
555596
//System.out.println("FromXmlParser.nextToken0: _nextToken = "+_nextToken);
597+
// [dataformat-xml#484]: deliver synthetic root-wrap tokens before normal flow
598+
if (_rootWrapStage != WRAP_INACTIVE) {
599+
JsonToken wrap = _nextRootWrapToken();
600+
if (wrap != null) {
601+
return wrap;
602+
}
603+
// Otherwise: stage advanced to WRAP_OPEN_OBJECT/SCALAR; fall through to standard logic.
604+
}
556605
if (_nextToken != null) {
557606
final JsonToken t = _updateToken(_nextToken);
558607
_nextToken = null;
@@ -583,6 +632,10 @@ public JsonToken nextToken() throws JacksonException
583632
// 13-May-2020, tatu: [dataformat-xml#397]: advance `index` anyway; not
584633
// used for Object contexts, updated automatically by "createChildXxxContext"
585634
_streamReadContext.valueStarted();
635+
// [dataformat-xml#484]: scalar/null body delivered — queue closing END_OBJECT
636+
if (_rootWrapStage == WRAP_OPEN_SCALAR) {
637+
_rootWrapStage = WRAP_PENDING_OUTER_END;
638+
}
586639
}
587640
return t;
588641
}
@@ -774,13 +827,54 @@ public JsonToken nextToken() throws JacksonException
774827
_nextToken = JsonToken.VALUE_STRING;
775828
return _updateToken(JsonToken.PROPERTY_NAME);
776829
case XmlTokenStream.XML_END:
830+
// [dataformat-xml#484]: close the synthetic root wrapper before EOF
831+
if (_rootWrapStage == WRAP_OPEN_OBJECT) {
832+
_rootWrapStage = WRAP_INACTIVE;
833+
_streamReadContext = _streamReadContext.getParent();
834+
return _updateToken(JsonToken.END_OBJECT);
835+
}
777836
return _updateTokenToNull();
778837
default:
779838
return _internalErrorUnknownToken(token);
780839
}
781840
}
782841
}
783842

843+
/**
844+
* [dataformat-xml#484]: deliver the next synthetic root-wrap token, or
845+
* return {@code null} when the wrap state machine has nothing pending and
846+
* the caller should fall through to standard token logic.
847+
*
848+
* @since 3.2
849+
*/
850+
private JsonToken _nextRootWrapToken()
851+
{
852+
switch (_rootWrapStage) {
853+
case WRAP_PENDING_OUTER_START:
854+
_rootWrapStage = WRAP_PENDING_NAME;
855+
_streamReadContext = _streamReadContext.createChildObjectContext(-1, -1);
856+
return _updateToken(JsonToken.START_OBJECT);
857+
case WRAP_PENDING_NAME:
858+
// Object body case: queue inner START_OBJECT for normal flow to consume.
859+
// Scalar/null body case: _nextToken already holds the value.
860+
if (_nextToken == null) {
861+
_nextToken = JsonToken.START_OBJECT;
862+
_rootWrapStage = WRAP_OPEN_OBJECT;
863+
} else {
864+
_rootWrapStage = WRAP_OPEN_SCALAR;
865+
}
866+
_streamReadContext.setCurrentName(_xmlTokens.getRootName().getLocalPart());
867+
return _updateToken(JsonToken.PROPERTY_NAME);
868+
case WRAP_PENDING_OUTER_END:
869+
_rootWrapStage = WRAP_INACTIVE;
870+
_streamReadContext = _streamReadContext.getParent();
871+
return _updateToken(JsonToken.END_OBJECT);
872+
default:
873+
// WRAP_OPEN_OBJECT / WRAP_OPEN_SCALAR — caller should fall through.
874+
return null;
875+
}
876+
}
877+
784878
/*
785879
/**********************************************************************
786880
/* Overrides of specialized nextXxx() methods
@@ -804,6 +898,11 @@ public String nextName() throws JacksonException {
804898
@Override
805899
public String nextStringValue() throws JacksonException
806900
{
901+
// [dataformat-xml#484]: wrapper tokens are not String-valued; route
902+
// through nextToken() to keep wrap-state advancement in one place.
903+
if (_rootWrapStage != WRAP_INACTIVE) {
904+
return (nextToken() == JsonToken.VALUE_STRING) ? _currText : null;
905+
}
807906
_binaryValue = null;
808907
if (_nextToken != null) {
809908
final JsonToken t = _updateToken(_nextToken);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package tools.jackson.dataformat.xml.node;
2+
3+
import java.util.Map;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import tools.jackson.databind.JsonNode;
8+
import tools.jackson.databind.ObjectReader;
9+
import tools.jackson.databind.ObjectWriter;
10+
import tools.jackson.databind.node.ObjectNode;
11+
12+
import tools.jackson.dataformat.xml.XmlMapper;
13+
import tools.jackson.dataformat.xml.XmlReadFeature;
14+
import tools.jackson.dataformat.xml.XmlTestUtil;
15+
import tools.jackson.dataformat.xml.XmlWriteFeature;
16+
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
import static org.junit.jupiter.api.Assertions.assertFalse;
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
// [dataformat-xml#484]: WRAP_ROOT_ELEMENT_NAME exposes root element as outer wrapper
22+
public class RootElementWrap484Test extends XmlTestUtil
23+
{
24+
private final XmlMapper MAPPER = newMapper();
25+
26+
private final ObjectReader WRAP_READER = MAPPER.reader()
27+
.with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
28+
private final ObjectReader PLAIN_READER = MAPPER.reader();
29+
30+
// Object root with single child element → {"root":{"value":"3"}}
31+
@Test
32+
public void testObjectRootWrapped() throws Exception
33+
{
34+
JsonNode tree = WRAP_READER.readTree("<root><value>3</value></root>");
35+
assertTrue(tree.isObject(), "expected outer Object, got: " + tree);
36+
assertEquals(1, tree.size());
37+
JsonNode inner = tree.get("root");
38+
assertTrue(inner.isObject(), "expected inner Object, got: " + inner);
39+
assertEquals("3", inner.get("value").asString());
40+
}
41+
42+
// Object root with multiple children + attributes
43+
@Test
44+
public void testObjectRootWithAttributesAndChildren() throws Exception
45+
{
46+
JsonNode tree = WRAP_READER.readTree(
47+
"<root id=\"1\"><a>x</a><b>y</b></root>");
48+
JsonNode inner = tree.get("root");
49+
assertEquals("1", inner.get("id").asString());
50+
assertEquals("x", inner.get("a").asString());
51+
assertEquals("y", inner.get("b").asString());
52+
}
53+
54+
// Text-only root: wrap is purely token-level, so body matches what you
55+
// would get without wrap (text becomes an empty-name property).
56+
// <root>3</root> unwrapped → {"":"3"}; wrapped → {"root":{"":"3"}}
57+
@Test
58+
public void testTextOnlyRootWrapped() throws Exception
59+
{
60+
JsonNode tree = WRAP_READER.readTree("<root>3</root>");
61+
assertTrue(tree.isObject());
62+
JsonNode inner = tree.get("root");
63+
assertTrue(inner.isObject(), "inner expected to be Object, got: " + inner);
64+
assertEquals("3", inner.get("").asString());
65+
}
66+
67+
// xsi:nil root → {"root":null}
68+
@Test
69+
public void testXsiNilRootWrapped() throws Exception
70+
{
71+
JsonNode tree = WRAP_READER.readTree(
72+
"<root xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
73+
+ " xsi:nil=\"true\"/>");
74+
assertTrue(tree.isObject());
75+
assertTrue(tree.get("root").isNull());
76+
}
77+
78+
// Empty element with EMPTY_ELEMENT_AS_NULL → {"root":null}
79+
@Test
80+
public void testEmptyElementAsNullRootWrapped() throws Exception
81+
{
82+
ObjectReader r = MAPPER.reader()
83+
.with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME)
84+
.with(XmlReadFeature.EMPTY_ELEMENT_AS_NULL);
85+
JsonNode tree = r.readTree("<root/>");
86+
assertTrue(tree.isObject());
87+
assertTrue(tree.get("root").isNull());
88+
}
89+
90+
// Map binding works equivalently
91+
@Test
92+
public void testMapBindingWrapped() throws Exception
93+
{
94+
@SuppressWarnings("unchecked")
95+
Map<String, Map<String, String>> result = WRAP_READER.forType(Map.class)
96+
.readValue("<root><value>3</value></root>");
97+
assertEquals(1, result.size());
98+
assertEquals("3", result.get("root").get("value"));
99+
}
100+
101+
// Round-trip: parse-with-wrap → serialize-with-unwrap returns to original XML
102+
@Test
103+
public void testRoundTrip() throws Exception
104+
{
105+
final String INPUT = "<root><value>3</value></root>";
106+
JsonNode tree = WRAP_READER.readTree(INPUT);
107+
// UNWRAP_ROOT_OBJECT_NODE is on by default in 3.x
108+
ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE);
109+
String xml = w.writeValueAsString(tree);
110+
assertEquals(INPUT, xml);
111+
}
112+
113+
// Default off: existing behavior unchanged
114+
@Test
115+
public void testDefaultOffUnchanged() throws Exception
116+
{
117+
JsonNode tree = PLAIN_READER.readTree("<root><value>3</value></root>");
118+
// Without wrap, the root element name is dropped — body is exposed directly
119+
assertTrue(tree.isObject());
120+
assertFalse(tree.has("root"));
121+
assertEquals("3", tree.get("value").asString());
122+
}
123+
124+
// Sanity: explicitly disabling the feature behaves like default
125+
@Test
126+
public void testFeatureExplicitlyDisabled() throws Exception
127+
{
128+
ObjectReader r = MAPPER.reader().without(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
129+
JsonNode tree = r.readTree("<root><value>3</value></root>");
130+
assertEquals("3", tree.get("value").asString());
131+
assertFalse(tree.has("root"));
132+
}
133+
134+
// ObjectNode round-trip starting from constructed tree
135+
@Test
136+
public void testObjectNodeRoundTrip() throws Exception
137+
{
138+
ObjectNode wrapper = MAPPER.createObjectNode();
139+
ObjectNode inner = wrapper.putObject("root");
140+
inner.put("a", "1");
141+
inner.put("b", "2");
142+
ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE);
143+
String xml = w.writeValueAsString(wrapper);
144+
assertEquals("<root><a>1</a><b>2</b></root>", xml);
145+
146+
JsonNode reparsed = WRAP_READER.readTree(xml);
147+
assertEquals(wrapper, reparsed);
148+
}
149+
}

0 commit comments

Comments
 (0)