Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Version: 3.x (for earlier see VERSION-2.x)
#455: Can't deserialize list in JsonSubtype when type property is visible
(reported by Jiri M)
(fix by @cowtowncoder, w/ Claude code)
#484: Add `FromXmlParser.Feature.WRAP_ROOT_ELEMENT_NAME` to allow
lossless round-trip via Tree Model
(fix by @cowtowncoder, w/ Claude code)
#496: Root name missing when root element has no attributes (add
`FromXmlParser.getRootElementName()`)
(reported by Sam K)
Expand Down
46 changes: 46 additions & 0 deletions src/main/java/tools/jackson/dataformat/xml/XmlReadFeature.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,52 @@ public enum XmlReadFeature implements FormatFeature
*/
SKIP_UNKNOWN_XSI_ATTRIBUTES(false),

/**
* Feature that, when enabled, exposes the XML root element as an extra
* outer Object wrapper whose single property is named after the root
* element's local name. This preserves the root name in the resulting
* token stream (and therefore in {@code JsonNode}, {@code Map}, etc.),
* which is otherwise discarded.
*<p>
* Example: with this feature enabled,
*<pre>
* &lt;root&gt;&lt;value&gt;3&lt;/value&gt;&lt;/root&gt;
*</pre>
* is exposed as token stream equivalent to
*<pre>
* { "root" : { "value" : "3" } }
*</pre>
* instead of the default
*<pre>
* { "value" : "3" }
*</pre>
* The wrapper is purely a token-stream-level addition; the body is exposed
* exactly as it would be without wrap. Roots that the parser would otherwise
* expose as {@code null} ({@code xsi:nil} or, with
* {@link #EMPTY_ELEMENT_AS_NULL} enabled, empty elements) become
* {@code { "root" : null }}.
*<p>
* Designed to pair with {@link XmlWriteFeature#UNWRAP_ROOT_OBJECT_NODE}
* to allow lossless round-tripping of root element name via the Tree
* Model ({@code JsonNode}) and {@code Map} bindings.
*<p>
* Notes:
*<ul>
* <li>The wrapper key uses the root element's <em>local name only</em>;
* namespace URI is not encoded into the key (consistent with how
* child element names are exposed throughout this parser). The full
* {@link javax.xml.namespace.QName} of the root remains accessible
* via {@code FromXmlParser.getRootElementName()}.</li>
* <li>This feature modifies the token stream, so it affects all
* bindings (POJO, {@code Map}, {@code JsonNode}), not just Tree Model.</li>
*</ul>
*<p>
* Default setting is {@code false} for backwards-compatibility.
*
* @since 3.2
*/
WRAP_ROOT_ELEMENT_NAME(false),

;

private final boolean _defaultState;
Expand Down
101 changes: 100 additions & 1 deletion src/main/java/tools/jackson/dataformat/xml/deser/FromXmlParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,37 @@ public class FromXmlParser
*/
protected boolean _nextIsNullXsiNil;

/*
/**********************************************************************
/* Parsing state, optional root element wrapping (3.2)
/**********************************************************************
*/

// [dataformat-xml#484] Root-element wrap state machine. When
// {@link XmlReadFeature#WRAP_ROOT_ELEMENT_NAME} is enabled, the parser
// synthesizes an extra outer Object whose single property is the root
// element local name. {@code _rootWrapStage} drives the synthetic tokens.

/** Wrap inactive: feature off, or wrapper already fully delivered. */
private static final int WRAP_INACTIVE = 0;
/** Next call emits the synthetic outer START_OBJECT. */
private static final int WRAP_PENDING_OUTER_START = 1;
/** Next call emits the synthetic PROPERTY_NAME (root local name). */
private static final int WRAP_PENDING_NAME = 2;
/** Wrapper open over an Object body: emit synthetic END_OBJECT when stream reaches XML_END. */
private static final int WRAP_OPEN_OBJECT = 3;
/** Wrapper open over a scalar/null body: queued in {@code _nextToken}; transition to PENDING_OUTER_END after delivery. */
private static final int WRAP_OPEN_SCALAR = 4;
/** Next call emits the synthetic outer END_OBJECT (scalar/null body case). */
private static final int WRAP_PENDING_OUTER_END = 5;

/**
* Stage of the root-element wrap state machine; see WRAP_* constants above.
*
* @since 3.2
*/
protected int _rootWrapStage = WRAP_INACTIVE;

/*
/**********************************************************************
/* Parsing state, parsed values
Expand Down Expand Up @@ -207,6 +238,7 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,

// 04-Jan-2019, tatu: Root-level nulls need slightly specific handling;
// changed in 2.10.2
final boolean wrapRoot = isEnabled(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
if (_xmlTokens.hasXsiNil()) {
_nextToken = JsonToken.VALUE_NULL;
// 21-Apr-2025, tatu: [dataformat-xml#714] Must "flush" the stream
Expand All @@ -216,7 +248,12 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
case XmlTokenStream.XML_START_ELEMENT:
// Removed from 2.14:
// case XmlTokenStream.XML_DELAYED_START_ELEMENT:
_nextToken = JsonToken.START_OBJECT;
// [dataformat-xml#484]: in wrap mode, suppress queuing the
// (inner) START_OBJECT here; the wrap state machine queues it
// explicitly after delivering outer START + PROPERTY_NAME.
if (!wrapRoot) {
_nextToken = JsonToken.START_OBJECT;
}
break;
case XmlTokenStream.XML_ROOT_TEXT:
_currText = _xmlTokens.getText();
Expand All @@ -232,6 +269,10 @@ public FromXmlParser(ObjectReadContext readCtxt, IOContext ioCtxt,
_reportError("Internal problem: invalid starting state (%s)", _xmlTokens._currentStateDesc());
}
}
// [dataformat-xml#484]: activate wrap state machine if feature enabled
if (wrapRoot) {
_rootWrapStage = WRAP_PENDING_OUTER_START;
}
}

@Override
Expand Down Expand Up @@ -553,6 +594,14 @@ public JsonToken nextToken() throws JacksonException
_binaryValue = null;
_numTypesValid = NR_UNKNOWN;
//System.out.println("FromXmlParser.nextToken0: _nextToken = "+_nextToken);
// [dataformat-xml#484]: deliver synthetic root-wrap tokens before normal flow
if (_rootWrapStage != WRAP_INACTIVE) {
JsonToken wrap = _nextRootWrapToken();
if (wrap != null) {
return wrap;
}
// Otherwise: stage advanced to WRAP_OPEN_OBJECT/SCALAR; fall through to standard logic.
}
if (_nextToken != null) {
final JsonToken t = _updateToken(_nextToken);
_nextToken = null;
Expand Down Expand Up @@ -583,6 +632,10 @@ public JsonToken nextToken() throws JacksonException
// 13-May-2020, tatu: [dataformat-xml#397]: advance `index` anyway; not
// used for Object contexts, updated automatically by "createChildXxxContext"
_streamReadContext.valueStarted();
// [dataformat-xml#484]: scalar/null body delivered — queue closing END_OBJECT
if (_rootWrapStage == WRAP_OPEN_SCALAR) {
_rootWrapStage = WRAP_PENDING_OUTER_END;
}
}
return t;
}
Expand Down Expand Up @@ -774,13 +827,54 @@ public JsonToken nextToken() throws JacksonException
_nextToken = JsonToken.VALUE_STRING;
return _updateToken(JsonToken.PROPERTY_NAME);
case XmlTokenStream.XML_END:
// [dataformat-xml#484]: close the synthetic root wrapper before EOF
if (_rootWrapStage == WRAP_OPEN_OBJECT) {
_rootWrapStage = WRAP_INACTIVE;
_streamReadContext = _streamReadContext.getParent();
return _updateToken(JsonToken.END_OBJECT);
}
return _updateTokenToNull();
default:
return _internalErrorUnknownToken(token);
}
}
}

/**
* [dataformat-xml#484]: deliver the next synthetic root-wrap token, or
* return {@code null} when the wrap state machine has nothing pending and
* the caller should fall through to standard token logic.
*
* @since 3.2
*/
private JsonToken _nextRootWrapToken()
{
switch (_rootWrapStage) {
case WRAP_PENDING_OUTER_START:
_rootWrapStage = WRAP_PENDING_NAME;
_streamReadContext = _streamReadContext.createChildObjectContext(-1, -1);
return _updateToken(JsonToken.START_OBJECT);
case WRAP_PENDING_NAME:
// Object body case: queue inner START_OBJECT for normal flow to consume.
// Scalar/null body case: _nextToken already holds the value.
if (_nextToken == null) {
_nextToken = JsonToken.START_OBJECT;
_rootWrapStage = WRAP_OPEN_OBJECT;
} else {
_rootWrapStage = WRAP_OPEN_SCALAR;
}
_streamReadContext.setCurrentName(_xmlTokens.getRootName().getLocalPart());
return _updateToken(JsonToken.PROPERTY_NAME);
case WRAP_PENDING_OUTER_END:
_rootWrapStage = WRAP_INACTIVE;
_streamReadContext = _streamReadContext.getParent();
return _updateToken(JsonToken.END_OBJECT);
default:
// WRAP_OPEN_OBJECT / WRAP_OPEN_SCALAR — caller should fall through.
return null;
}
}

/*
/**********************************************************************
/* Overrides of specialized nextXxx() methods
Expand All @@ -804,6 +898,11 @@ public String nextName() throws JacksonException {
@Override
public String nextStringValue() throws JacksonException
{
// [dataformat-xml#484]: wrapper tokens are not String-valued; route
// through nextToken() to keep wrap-state advancement in one place.
if (_rootWrapStage != WRAP_INACTIVE) {
return (nextToken() == JsonToken.VALUE_STRING) ? _currText : null;
}
_binaryValue = null;
if (_nextToken != null) {
final JsonToken t = _updateToken(_nextToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package tools.jackson.dataformat.xml.node;

import java.util.Map;

import org.junit.jupiter.api.Test;

import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectReader;
import tools.jackson.databind.ObjectWriter;
import tools.jackson.databind.node.ObjectNode;

import tools.jackson.dataformat.xml.XmlMapper;
import tools.jackson.dataformat.xml.XmlReadFeature;
import tools.jackson.dataformat.xml.XmlTestUtil;
import tools.jackson.dataformat.xml.XmlWriteFeature;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

// [dataformat-xml#484]: WRAP_ROOT_ELEMENT_NAME exposes root element as outer wrapper
public class RootElementWrap484Test extends XmlTestUtil
{
private final XmlMapper MAPPER = newMapper();

private final ObjectReader WRAP_READER = MAPPER.reader()
.with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
private final ObjectReader PLAIN_READER = MAPPER.reader();

// Object root with single child element → {"root":{"value":"3"}}
@Test
public void testObjectRootWrapped() throws Exception
{
JsonNode tree = WRAP_READER.readTree("<root><value>3</value></root>");
assertTrue(tree.isObject(), "expected outer Object, got: " + tree);
assertEquals(1, tree.size());
JsonNode inner = tree.get("root");
assertTrue(inner.isObject(), "expected inner Object, got: " + inner);
assertEquals("3", inner.get("value").asString());
}

// Object root with multiple children + attributes
@Test
public void testObjectRootWithAttributesAndChildren() throws Exception
{
JsonNode tree = WRAP_READER.readTree(
"<root id=\"1\"><a>x</a><b>y</b></root>");
JsonNode inner = tree.get("root");
assertEquals("1", inner.get("id").asString());
assertEquals("x", inner.get("a").asString());
assertEquals("y", inner.get("b").asString());
}

// Text-only root: wrap is purely token-level, so body matches what you
// would get without wrap (text becomes an empty-name property).
// <root>3</root> unwrapped → {"":"3"}; wrapped → {"root":{"":"3"}}
@Test
public void testTextOnlyRootWrapped() throws Exception
{
JsonNode tree = WRAP_READER.readTree("<root>3</root>");
assertTrue(tree.isObject());
JsonNode inner = tree.get("root");
assertTrue(inner.isObject(), "inner expected to be Object, got: " + inner);
assertEquals("3", inner.get("").asString());
}

// xsi:nil root → {"root":null}
@Test
public void testXsiNilRootWrapped() throws Exception
{
JsonNode tree = WRAP_READER.readTree(
"<root xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\""
+ " xsi:nil=\"true\"/>");
assertTrue(tree.isObject());
assertTrue(tree.get("root").isNull());
}

// Empty element with EMPTY_ELEMENT_AS_NULL → {"root":null}
@Test
public void testEmptyElementAsNullRootWrapped() throws Exception
{
ObjectReader r = MAPPER.reader()
.with(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME)
.with(XmlReadFeature.EMPTY_ELEMENT_AS_NULL);
JsonNode tree = r.readTree("<root/>");
assertTrue(tree.isObject());
assertTrue(tree.get("root").isNull());
}

// Map binding works equivalently
@Test
public void testMapBindingWrapped() throws Exception
{
@SuppressWarnings("unchecked")
Map<String, Map<String, String>> result = WRAP_READER.forType(Map.class)
.readValue("<root><value>3</value></root>");
assertEquals(1, result.size());
assertEquals("3", result.get("root").get("value"));
}

// Round-trip: parse-with-wrap → serialize-with-unwrap returns to original XML
@Test
public void testRoundTrip() throws Exception
{
final String INPUT = "<root><value>3</value></root>";
JsonNode tree = WRAP_READER.readTree(INPUT);
// UNWRAP_ROOT_OBJECT_NODE is on by default in 3.x
ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE);
String xml = w.writeValueAsString(tree);
assertEquals(INPUT, xml);
}

// Default off: existing behavior unchanged
@Test
public void testDefaultOffUnchanged() throws Exception
{
JsonNode tree = PLAIN_READER.readTree("<root><value>3</value></root>");
// Without wrap, the root element name is dropped — body is exposed directly
assertTrue(tree.isObject());
assertFalse(tree.has("root"));
assertEquals("3", tree.get("value").asString());
}

// Sanity: explicitly disabling the feature behaves like default
@Test
public void testFeatureExplicitlyDisabled() throws Exception
{
ObjectReader r = MAPPER.reader().without(XmlReadFeature.WRAP_ROOT_ELEMENT_NAME);
JsonNode tree = r.readTree("<root><value>3</value></root>");
assertEquals("3", tree.get("value").asString());
assertFalse(tree.has("root"));
}

// ObjectNode round-trip starting from constructed tree
@Test
public void testObjectNodeRoundTrip() throws Exception
{
ObjectNode wrapper = MAPPER.createObjectNode();
ObjectNode inner = wrapper.putObject("root");
inner.put("a", "1");
inner.put("b", "2");
ObjectWriter w = MAPPER.writer().with(XmlWriteFeature.UNWRAP_ROOT_OBJECT_NODE);
String xml = w.writeValueAsString(wrapper);
assertEquals("<root><a>1</a><b>2</b></root>", xml);

JsonNode reparsed = WRAP_READER.readTree(xml);
assertEquals(wrapper, reparsed);
}
}