This is an automated email from the ASF dual-hosted git repository.
paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 684f1900a9 GROOVY-11927: Add toMap() and typed parsing support to
XmlParser
684f1900a9 is described below
commit 684f1900a949fc9026309d354e9092cb7bb8f328
Author: Paul King <[email protected]>
AuthorDate: Mon Apr 13 10:36:43 2026 +1000
GROOVY-11927: Add toMap() and typed parsing support to XmlParser
---
src/main/java/groovy/util/Node.java | 85 +++++++++
src/test/groovy/groovy/util/NodeToMapTest.groovy | 119 +++++++++++++
subprojects/groovy-xml/build.gradle | 1 +
.../src/main/java/groovy/xml/XmlParser.java | 119 ++++++++++++-
.../main/java/groovy/xml/XmlRuntimeException.java | 44 +++++
.../groovy/xml/extensions/XmlExtensions.java | 24 +++
.../org/apache/groovy/xml/util/JacksonHelper.java | 93 ++++++++++
.../groovy-xml/src/spec/doc/xml-userguide.adoc | 137 ++++++++++++++
.../src/spec/test/UserGuideXmlTypedTest.groovy | 198 +++++++++++++++++++++
.../groovy/groovy/xml/XmlParserTypedTest.groovy | 147 +++++++++++++++
10 files changed, 959 insertions(+), 8 deletions(-)
diff --git a/src/main/java/groovy/util/Node.java
b/src/main/java/groovy/util/Node.java
index 9d465735a0..4b701099a6 100644
--- a/src/main/java/groovy/util/Node.java
+++ b/src/main/java/groovy/util/Node.java
@@ -38,6 +38,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -870,6 +871,90 @@ public class Node implements Serializable, Cloneable {
return StringGroovyMethods.toBigInteger((CharSequence)text());
}
+ /**
+ * The key used for text content when a node has both attributes and text.
+ *
+ * @since 6.0.0
+ */
+ public static final String TEXT_KEY = "_text";
+
+ /**
+ * Converts this Node tree into a nested {@code Map<String, Object>}.
+ * <p>
+ * Attributes and child elements are merged into a single map keyed by
name.
+ * If a child element name collides with an attribute name, the child
element wins.
+ * Leaf child elements (text only, no attributes) become String values.
+ * Non-leaf child elements and leaf elements with attributes become nested
Maps (recursive).
+ * Repeated same-name sibling elements become Lists.
+ * Elements with both attributes and text content use the {@value
#TEXT_KEY} key for the text.
+ *
+ * @return a Map representation of this Node
+ * @since 6.0.0
+ */
+ @SuppressWarnings("unchecked")
+ public Map<String, Object> toMap() {
+ Map<String, Object> map = new LinkedHashMap<>();
+
+ // copy attributes, tracking which keys came from attributes
+ java.util.Set<String> attributeKeys = new java.util.HashSet<>();
+ for (Map.Entry<?, ?> entry : ((Map<?, ?>) attributes()).entrySet()) {
+ String key = nameAsString(entry.getKey());
+ map.put(key, entry.getValue());
+ attributeKeys.add(key);
+ }
+
+ // process child nodes
+ boolean hasChildElements = false;
+ for (Object child : children()) {
+ if (child instanceof Node childNode) {
+ hasChildElements = true;
+ String key = nameAsString(childNode.name());
+ Object childValue;
+ if (childNode.isLeaf() && childNode.attributes().isEmpty()) {
+ childValue = childNode.text();
+ } else {
+ childValue = childNode.toMap();
+ }
+ Object existing = map.get(key);
+ if (existing == null || attributeKeys.remove(key)) {
+ // first child element with this name (or replacing an
attribute)
+ map.put(key, childValue);
+ } else if (existing instanceof List) {
+ ((List<Object>) existing).add(childValue);
+ } else {
+ List<Object> list = new ArrayList<>();
+ list.add(existing);
+ list.add(childValue);
+ map.put(key, list);
+ }
+ }
+ }
+
+ // if this node has attributes AND direct text, add _text entry
+ if (!attributes().isEmpty() && !hasChildElements) {
+ String text = text();
+ if (!text.isEmpty()) {
+ map.put(TEXT_KEY, text);
+ }
+ }
+
+ return map;
+ }
+
+ private static String nameAsString(Object name) {
+ if (name instanceof QName qn) {
+ return qn.getLocalPart();
+ }
+ return name.toString();
+ }
+
+ private boolean isLeaf() {
+ for (Object child : children()) {
+ if (child instanceof Node) return false;
+ }
+ return true;
+ }
+
private boolean textIsEmptyOrNull() {
String t = text();
return null == t || t.isEmpty();
diff --git a/src/test/groovy/groovy/util/NodeToMapTest.groovy
b/src/test/groovy/groovy/util/NodeToMapTest.groovy
new file mode 100644
index 0000000000..40f4ca0238
--- /dev/null
+++ b/src/test/groovy/groovy/util/NodeToMapTest.groovy
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 groovy.util
+
+import org.junit.jupiter.api.Test
+
+class NodeToMapTest {
+
+ @Test
+ void testFlatElements() {
+ def root = new Node(null, 'server', [
+ new Node(null, 'host', 'localhost'),
+ new Node(null, 'port', '8080')
+ ])
+ assert root.toMap() == [host: 'localhost', port: '8080']
+ }
+
+ @Test
+ void testAttributesOnly() {
+ def root = new Node(null, 'server', [host: 'localhost', port: '8080'])
+ assert root.toMap() == [host: 'localhost', port: '8080']
+ }
+
+ @Test
+ void testAttributesAndChildElements() {
+ def root = new Node(null, 'server', [env: 'prod'])
+ new Node(root, 'host', 'localhost')
+ new Node(root, 'port', '8080')
+ assert root.toMap() == [env: 'prod', host: 'localhost', port: '8080']
+ }
+
+ @Test
+ void testChildElementWinsOnCollision() {
+ def root = new Node(null, 'server', [host: 'from-attr'])
+ new Node(root, 'host', 'from-element')
+ def map = root.toMap()
+ assert map.host == 'from-element'
+ }
+
+ @Test
+ void testNestedElements() {
+ def root = new Node(null, 'server')
+ def db = new Node(root, 'database')
+ new Node(db, 'host', 'dbhost')
+ new Node(db, 'port', '5432')
+ assert root.toMap() == [database: [host: 'dbhost', port: '5432']]
+ }
+
+ @Test
+ void testRepeatedElements() {
+ def root = new Node(null, 'server')
+ new Node(root, 'alias', 'a1')
+ new Node(root, 'alias', 'a2')
+ new Node(root, 'alias', 'a3')
+ assert root.toMap() == [alias: ['a1', 'a2', 'a3']]
+ }
+
+ @Test
+ void testRepeatedNestedElements() {
+ def root = new Node(null, 'config')
+ def s1 = new Node(root, 'server', [name: 'web'])
+ new Node(s1, 'port', '80')
+ def s2 = new Node(root, 'server', [name: 'api'])
+ new Node(s2, 'port', '8080')
+ assert root.toMap() == [
+ server: [
+ [name: 'web', port: '80'],
+ [name: 'api', port: '8080']
+ ]
+ ]
+ }
+
+ @Test
+ void testAttributesAndTextContent() {
+ def root = new Node(null, 'price', [currency: 'USD'], '9.99')
+ assert root.toMap() == [currency: 'USD', (Node.TEXT_KEY): '9.99']
+ }
+
+ @Test
+ void testLeafWithAttributesAsChildValue() {
+ def root = new Node(null, 'order')
+ def price = new Node(root, 'price', [currency: 'USD'], '9.99')
+ def map = root.toMap()
+ assert map.price == [currency: 'USD', (Node.TEXT_KEY): '9.99']
+ }
+
+ @Test
+ void testEmptyNode() {
+ def root = new Node(null, 'empty')
+ assert root.toMap() == [:]
+ }
+
+ @Test
+ void testEmptyNodeWithAttributes() {
+ def root = new Node(null, 'empty', [id: '1'])
+ assert root.toMap() == [id: '1']
+ }
+
+ @Test
+ void testTextKeyConstant() {
+ assert Node.TEXT_KEY == '_text'
+ }
+}
diff --git a/subprojects/groovy-xml/build.gradle
b/subprojects/groovy-xml/build.gradle
index b7a96b8a5b..8b05b0c2c5 100644
--- a/subprojects/groovy-xml/build.gradle
+++ b/subprojects/groovy-xml/build.gradle
@@ -25,6 +25,7 @@ dependencies {
testImplementation projects.groovyTest
testImplementation "xmlunit:xmlunit:${versions.xmlunit}"
+ testRuntimeOnly
"com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
testImplementation("org.spockframework:spock-core:${versions.spock}") {
exclude group: 'org.apache.groovy'
}
diff --git a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
index 6937addc59..047735dd1b 100644
--- a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlParser.java
@@ -20,6 +20,7 @@ package groovy.xml;
import groovy.namespace.QName;
import groovy.util.Node;
+import org.apache.groovy.xml.util.JacksonHelper;
import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.DTDHandler;
@@ -209,10 +210,12 @@ public class XmlParser implements ContentHandler {
* supplied by the application.
*/
public Node parse(File file) throws IOException, SAXException {
- InputSource input = new
InputSource(Files.newInputStream(file.toPath()));
- input.setSystemId("file://" + file.getAbsolutePath());
- getXMLReader().parse(input);
- return parent;
+ try (InputStream stream = Files.newInputStream(file.toPath())) {
+ InputSource input = new InputSource(stream);
+ input.setSystemId("file://" + file.getAbsolutePath());
+ getXMLReader().parse(input);
+ return parent;
+ }
}
/**
@@ -228,10 +231,12 @@ public class XmlParser implements ContentHandler {
* supplied by the application.
*/
public Node parse(Path path) throws IOException, SAXException {
- InputSource input = new InputSource(Files.newInputStream(path));
- input.setSystemId("file://" + path.toAbsolutePath());
- getXMLReader().parse(input);
- return parent;
+ try (InputStream stream = Files.newInputStream(path)) {
+ InputSource input = new InputSource(stream);
+ input.setSystemId("file://" + path.toAbsolutePath());
+ getXMLReader().parse(input);
+ return parent;
+ }
}
/**
@@ -322,6 +327,104 @@ public class XmlParser implements ContentHandler {
return parse(new StringReader(text));
}
+ /**
+ * Parse the content of the specified XML text into a typed object.
+ * Requires jackson-databind on the classpath for type conversion.
+ * Supports {@code @JsonProperty} and {@code @JsonFormat} annotations.
+ *
+ * @param type the target type
+ * @param text the XML text to parse
+ * @param <T> the target type
+ * @return a typed object
+ * @throws XmlRuntimeException if parsing or conversion fails, or
jackson-databind is absent
+ * @since 6.0.0
+ */
+ public <T> T parseTextAs(Class<T> type, String text) {
+ return parseAs(type, new StringReader(text));
+ }
+
+ /**
+ * Parse XML from a reader into a typed object.
+ * Requires jackson-databind on the classpath for type conversion.
+ *
+ * @param type the target type
+ * @param reader the reader of XML
+ * @param <T> the target type
+ * @return a typed object
+ * @throws XmlRuntimeException if parsing or conversion fails, or
jackson-databind is absent
+ * @since 6.0.0
+ */
+ public <T> T parseAs(Class<T> type, Reader reader) {
+ try {
+ Node root = parse(reader);
+ return JacksonHelper.convertMapToType(root.toMap(), type);
+ } catch (IOException | SAXException e) {
+ throw new XmlRuntimeException(e);
+ }
+ }
+
+ /**
+ * Parse XML from an input stream into a typed object.
+ * Requires jackson-databind on the classpath for type conversion.
+ *
+ * @param type the target type
+ * @param stream the input stream of XML
+ * @param <T> the target type
+ * @return a typed object
+ * @throws XmlRuntimeException if parsing or conversion fails, or
jackson-databind is absent
+ * @since 6.0.0
+ */
+ public <T> T parseAs(Class<T> type, InputStream stream) {
+ try {
+ Node root = parse(stream);
+ return JacksonHelper.convertMapToType(root.toMap(), type);
+ } catch (IOException | SAXException e) {
+ throw new XmlRuntimeException(e);
+ }
+ }
+
+ /**
+ * Parse XML from a file into a typed object.
+ * Requires jackson-databind on the classpath for type conversion.
+ *
+ * @param type the target type
+ * @param file the XML file
+ * @param <T> the target type
+ * @return a typed object
+ * @throws IOException if the file cannot be read
+ * @throws XmlRuntimeException if parsing or conversion fails, or
jackson-databind is absent
+ * @since 6.0.0
+ */
+ public <T> T parseAs(Class<T> type, File file) throws IOException {
+ try {
+ Node root = parse(file);
+ return JacksonHelper.convertMapToType(root.toMap(), type);
+ } catch (SAXException e) {
+ throw new XmlRuntimeException(e);
+ }
+ }
+
+ /**
+ * Parse XML from a path into a typed object.
+ * Requires jackson-databind on the classpath for type conversion.
+ *
+ * @param type the target type
+ * @param path the path to the XML file
+ * @param <T> the target type
+ * @return a typed object
+ * @throws IOException if the file cannot be read
+ * @throws XmlRuntimeException if parsing or conversion fails, or
jackson-databind is absent
+ * @since 6.0.0
+ */
+ public <T> T parseAs(Class<T> type, Path path) throws IOException {
+ try {
+ Node root = parse(path);
+ return JacksonHelper.convertMapToType(root.toMap(), type);
+ } catch (SAXException e) {
+ throw new XmlRuntimeException(e);
+ }
+ }
+
/**
* Determine if namespace handling is enabled.
*
diff --git
a/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java
b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java
new file mode 100644
index 0000000000..2ece5b7d01
--- /dev/null
+++ b/subprojects/groovy-xml/src/main/java/groovy/xml/XmlRuntimeException.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 groovy.xml;
+
+import groovy.lang.GroovyRuntimeException;
+import org.apache.groovy.lang.annotation.Incubating;
+
+/**
+ * Represents a runtime exception that occurred when parsing or building XML.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public class XmlRuntimeException extends GroovyRuntimeException {
+ private static final long serialVersionUID = 7858720024082487492L;
+
+ public XmlRuntimeException(String msg) {
+ super(msg);
+ }
+
+ public XmlRuntimeException(Throwable cause) {
+ super(cause);
+ }
+
+ public XmlRuntimeException(String msg, Throwable cause) {
+ super(msg, cause);
+ }
+}
diff --git
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
index 393be90731..e01470eaa2 100644
---
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
+++
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/extensions/XmlExtensions.java
@@ -19,11 +19,14 @@
package org.apache.groovy.xml.extensions;
import groovy.xml.XmlUtil;
+import org.codehaus.groovy.runtime.InvokerHelper;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
/**
* This class defines all the new XML-related groovy methods which enhance
@@ -72,4 +75,25 @@ public class XmlExtensions {
public static String serialize(Element element) {
return XmlUtil.serialize(element).replaceFirst("<\\?xml
version=\"1.0\".*\\?>", "");
}
+
+ /**
+ * Enables {@code node as Type} coercion for XML Nodes.
+ * Converts the Node to a Map via {@link groovy.util.Node#toMap()} and then
+ * uses Groovy's standard Map coercion to produce the typed object.
+ * Does not require Jackson on the classpath.
+ *
+ * @param self the Node to convert
+ * @param type the target type
+ * @param <T> the target type
+ * @return a typed object
+ * @since 6.0.0
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> T asType(groovy.util.Node self, Class<T> type) {
+ if (type == Map.class || type == LinkedHashMap.class) {
+ return (T) self.toMap();
+ }
+ Map<String, Object> map = self.toMap();
+ return (T) InvokerHelper.invokeConstructorOf(type, new Object[]{map});
+ }
}
\ No newline at end of file
diff --git
a/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
new file mode 100644
index 0000000000..10ce503fd0
--- /dev/null
+++
b/subprojects/groovy-xml/src/main/java/org/apache/groovy/xml/util/JacksonHelper.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.groovy.xml.util;
+
+import groovy.xml.XmlRuntimeException;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+/**
+ * Internal helper for optional Jackson databinding support.
+ * Uses reflection to avoid a compile-time dependency on jackson-databind.
+ */
+public class JacksonHelper {
+
+ private static final String OBJECT_MAPPER_CLASS =
"com.fasterxml.jackson.databind.ObjectMapper";
+
+ // Lazily cached mapper and method — initialized on first use, thread-safe
via volatile + holder
+ private static volatile Object cachedMapper;
+ private static volatile Method cachedConvertValue;
+
+ /**
+ * Converts a Map to a typed object using Jackson's
ObjectMapper.convertValue.
+ * Requires jackson-databind on the classpath.
+ *
+ * @param map the source map
+ * @param type the target type
+ * @param <T> the target type
+ * @return the converted object
+ * @throws XmlRuntimeException if jackson-databind is not available or
conversion fails
+ */
+ public static <T> T convertMapToType(Map<String, Object> map, Class<T>
type) {
+ try {
+ if (cachedMapper == null) {
+ initMapper();
+ }
+ return type.cast(cachedConvertValue.invoke(cachedMapper, map,
type));
+ } catch (XmlRuntimeException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new XmlRuntimeException("Failed to convert XML map to " +
type.getName(), e);
+ }
+ }
+
+ private static synchronized void initMapper() {
+ if (cachedMapper != null) return;
+ Class<?> omClass = loadOptionalClass(OBJECT_MAPPER_CLASS);
+ if (omClass == null) {
+ throw new XmlRuntimeException(
+ "Typed XML parsing requires jackson-databind on the
classpath. "
+ + "Add com.fasterxml.jackson.core:jackson-databind
to your dependencies.");
+ }
+ try {
+ cachedMapper = omClass.getDeclaredConstructor().newInstance();
+ cachedConvertValue = omClass.getMethod("convertValue",
Object.class, Class.class);
+ } catch (Exception e) {
+ throw new XmlRuntimeException("Failed to initialize Jackson
ObjectMapper", e);
+ }
+ }
+
+ private static Class<?> loadOptionalClass(String className) {
+ ClassLoader[] loaders = {
+ Thread.currentThread().getContextClassLoader(),
+ JacksonHelper.class.getClassLoader(),
+ ClassLoader.getSystemClassLoader()
+ };
+ for (ClassLoader loader : loaders) {
+ if (loader == null) continue;
+ try {
+ return Class.forName(className, false, loader);
+ } catch (ClassNotFoundException ignore) {
+ // try next class loader
+ }
+ }
+ return null;
+ }
+}
diff --git a/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
b/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
index 23b596e63a..10b2fa1f5f 100644
--- a/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
+++ b/subprojects/groovy-xml/src/spec/doc/xml-userguide.adoc
@@ -650,3 +650,140 @@ encoding, indentation, and DOCTYPE handling:
include::../test/UserGuideXmlUtilTest.groovy[tags=testSerializeOptions,indent=0]
----
+== Typed XML Conversion
+
+Groovy 6 adds support for converting XML nodes into typed objects,
+complementing the dynamic GPath-style navigation that `XmlParser` and
+`XmlSlurper` already provide.
+
+=== Converting Nodes to Maps
+
+The `toMap()` method on `Node` converts an XML node tree into a nested
+`Map<String, Object>`. This requires no additional dependencies.
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap,indent=0]
+----
+
+Attributes and child elements are merged into a single map.
+Nested elements become nested maps:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_nested,indent=0]
+----
+
+Attributes are also included:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_attributes,indent=0]
+----
+
+Repeated same-name elements become lists:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_repeated,indent=0]
+----
+
+If a child element name collides with an attribute name, the child element
wins.
+
+When an element has both attributes and text content, the text is stored
+under the `_text` key (available as the `Node.TEXT_KEY` constant):
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=toMap_text_key,indent=0]
+----
+
+=== Type coercion with `as`
+
+Because `toMap()` produces a standard Groovy `Map`, you can use Groovy's
+built-in `as` coercion to convert a node directly to a typed object.
+No additional dependencies are required:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=simple_config_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=as_coercion,indent=0]
+----
+
+You can also coerce to `Map` directly:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=as_map,indent=0]
+----
+
+NOTE: The `as` coercion uses Groovy's standard named-parameter constructor,
+which works well when target properties are `String`-typed. For richer
+type conversion (e.g. `String` to `int`, `boolean`, `BigDecimal`), use
+`parseTextAs` described below.
+
+=== Typed parsing with `parseTextAs`
+
+The `parseTextAs` and `parseAs` methods on `XmlParser` parse XML directly
+into typed objects using Jackson's `ObjectMapper.convertValue`, which
+handles type conversion automatically. This requires `jackson-databind`
+on the classpath -- a clear error is thrown if it is absent.
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=server_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=parseTextAs,indent=0]
+----
+
+Nested objects are supported:
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=app_class,indent=0]
+----
+
+[source,groovy]
+----
+include::../test/UserGuideXmlTypedTest.groovy[tags=parseTextAs_nested,indent=0]
+----
+
+Standard Jackson annotations such as `@JsonProperty` and `@JsonFormat`
+are supported for property name mapping and type conversion.
+
+Overloaded variants of `parseAs` accept a `Reader`, `InputStream`,
+`File`, or `Path` in addition to the `String`-based `parseTextAs`.
+
+=== Full Jackson XML support
+
+The `parseTextAs` method uses `jackson-databind` (not
`jackson-dataformat-xml`),
+so XML-specific Jackson annotations like `@JacksonXmlText` and
+`@JacksonXmlElementWrapper` are not available through this path.
+
+If you need the full Jackson XML experience, use `jackson-dataformat-xml`
+directly:
+
+[source,groovy]
+----
+@Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml')
+import com.fasterxml.jackson.dataformat.xml.XmlMapper
+
+def config = new XmlMapper().readValue(xmlString, ServerConfig)
+----
+
+Or parse first with `XmlParser` for inspection or validation,
+then re-serialize and hand off to Jackson:
+
+[source,groovy]
+----
+def node = new XmlParser().parseText(xmlString)
+// ... inspect or validate node ...
+def config = new XmlMapper().readValue(XmlUtil.serialize(node), ServerConfig)
+----
diff --git a/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy
b/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy
new file mode 100644
index 0000000000..910e338e89
--- /dev/null
+++ b/subprojects/groovy-xml/src/spec/test/UserGuideXmlTypedTest.groovy
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 groovy.xml
+
+import groovy.util.Node
+import org.junit.jupiter.api.Test
+
+/**
+ * Tests for the Groovy Xml user guide related to typed XML parsing.
+ */
+class UserGuideXmlTypedTest {
+
+ // tag::server_class[]
+ static class ServerConfig {
+ String host
+ int port
+ boolean debug
+ }
+ // end::server_class[]
+
+ // tag::app_class[]
+ static class AppConfig {
+ String name
+ ServerConfig server
+ }
+ // end::app_class[]
+
+ // tag::simple_config_class[]
+ static class SimpleConfig {
+ String host
+ String port
+ }
+ // end::simple_config_class[]
+
+ @Test
+ void testToMap() {
+ // tag::toMap[]
+ def node = new XmlParser().parseText('''
+ <server>
+ <host>localhost</host>
+ <port>8080</port>
+ <debug>true</debug>
+ </server>'''.stripIndent())
+
+ def map = node.toMap()
+ assert map == [host: 'localhost', port: '8080', debug: 'true']
+ // end::toMap[]
+ }
+
+ @Test
+ void testToMapNested() {
+ // tag::toMap_nested[]
+ def node = new XmlParser().parseText('''
+ <app>
+ <name>myapp</name>
+ <server>
+ <host>localhost</host>
+ <port>8080</port>
+ </server>
+ </app>'''.stripIndent())
+
+ def map = node.toMap()
+ assert map == [name: 'myapp', server: [host: 'localhost', port:
'8080']]
+ // end::toMap_nested[]
+ }
+
+ @Test
+ void testToMapAttributes() {
+ // tag::toMap_attributes[]
+ def node = new XmlParser().parseText('<server host="localhost"
port="8080"/>')
+
+ def map = node.toMap()
+ assert map == [host: 'localhost', port: '8080']
+ // end::toMap_attributes[]
+ }
+
+ @Test
+ void testToMapRepeatedElements() {
+ // tag::toMap_repeated[]
+ def node = new XmlParser().parseText('''
+ <config>
+ <alias>web1</alias>
+ <alias>web2</alias>
+ <alias>web3</alias>
+ </config>'''.stripIndent())
+
+ def map = node.toMap()
+ assert map == [alias: ['web1', 'web2', 'web3']]
+ // end::toMap_repeated[]
+ }
+
+ @Test
+ void testToMapTextKey() {
+ // tag::toMap_text_key[]
+ def node = new XmlParser().parseText('<price
currency="USD">9.99</price>')
+
+ def map = node.toMap()
+ assert map == [currency: 'USD', _text: '9.99']
+ assert map[Node.TEXT_KEY] == '9.99'
+ // end::toMap_text_key[]
+ }
+
+ @Test
+ void testAsCoercion() {
+ // tag::as_coercion[]
+ def node = new XmlParser().parseText('''
+ <server>
+ <host>localhost</host>
+ <port>8080</port>
+ </server>'''.stripIndent())
+
+ def config = node as SimpleConfig
+ assert config.host == 'localhost'
+ assert config.port == '8080'
+ // end::as_coercion[]
+ }
+
+ @Test
+ void testAsMap() {
+ // tag::as_map[]
+ def node = new
XmlParser().parseText('<server><host>localhost</host></server>')
+
+ Map map = node as Map
+ assert map.host == 'localhost'
+ // end::as_map[]
+ }
+
+ @Test
+ void testParseTextAs() {
+ // tag::parseTextAs[]
+ def config = new XmlParser().parseTextAs(ServerConfig, '''
+ <server>
+ <host>localhost</host>
+ <port>8080</port>
+ <debug>true</debug>
+ </server>'''.stripIndent())
+
+ assert config instanceof ServerConfig
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ assert config.debug == true
+ // end::parseTextAs[]
+ }
+
+ @Test
+ void testParseTextAsNested() {
+ // tag::parseTextAs_nested[]
+ def config = new XmlParser().parseTextAs(AppConfig, '''
+ <app>
+ <name>myapp</name>
+ <server>
+ <host>localhost</host>
+ <port>9090</port>
+ <debug>false</debug>
+ </server>
+ </app>'''.stripIndent())
+
+ assert config.name == 'myapp'
+ assert config.server.host == 'localhost'
+ assert config.server.port == 9090
+ // end::parseTextAs_nested[]
+ }
+
+ @Test
+ void testRollYourOwnJacksonXml() {
+ // tag::roll_your_own[]
+ // If you need full Jackson XML support (e.g. @JacksonXmlText,
+ // @JacksonXmlElementWrapper), use jackson-dataformat-xml directly:
+ //
+ // @Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml')
+ // import com.fasterxml.jackson.dataformat.xml.XmlMapper
+ //
+ // def config = new XmlMapper().readValue(xmlString, ServerConfig)
+ //
+ // Or parse first with XmlParser for validation, then re-serialize:
+ //
+ // def node = new XmlParser().parseText(xmlString)
+ // // ... inspect or validate ...
+ // def config = new XmlMapper().readValue(XmlUtil.serialize(node),
ServerConfig)
+ // end::roll_your_own[]
+ }
+}
diff --git
a/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy
b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy
new file mode 100644
index 0000000000..3952c7e6ca
--- /dev/null
+++
b/subprojects/groovy-xml/src/test/groovy/groovy/xml/XmlParserTypedTest.groovy
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 groovy.xml
+
+import org.junit.jupiter.api.Test
+
+class XmlParserTypedTest {
+
+ static class ServerConfig {
+ String host
+ int port
+ boolean debug
+ }
+
+ static class AppConfig {
+ String name
+ ServerConfig server
+ }
+
+ static class SimpleConfig {
+ String host
+ String port
+ }
+
+ @Test
+ void testParseTextAsSimple() {
+ def config = new XmlParser().parseTextAs(ServerConfig, '''\
+ <server>
+ <host>localhost</host>
+ <port>8080</port>
+ <debug>true</debug>
+ </server>'''.stripIndent())
+ assert config instanceof ServerConfig
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ assert config.debug == true
+ }
+
+ @Test
+ void testParseTextAsNested() {
+ def config = new XmlParser().parseTextAs(AppConfig, '''\
+ <app>
+ <name>myapp</name>
+ <server>
+ <host>localhost</host>
+ <port>9090</port>
+ <debug>false</debug>
+ </server>
+ </app>'''.stripIndent())
+ assert config instanceof AppConfig
+ assert config.name == 'myapp'
+ assert config.server instanceof ServerConfig
+ assert config.server.host == 'localhost'
+ assert config.server.port == 9090
+ }
+
+ @Test
+ void testParseAsFromReader() {
+ def xml =
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+ def config = new XmlParser().parseAs(ServerConfig, new
StringReader(xml))
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ }
+
+ @Test
+ void testParseAsFromInputStream() {
+ def xml =
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+ def config = new XmlParser().parseAs(ServerConfig, new
ByteArrayInputStream(xml.bytes))
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ }
+
+ @Test
+ void testParseAsFromFile() {
+ def xml =
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+ def file = File.createTempFile('xmltest', '.xml')
+ file.deleteOnExit()
+ file.text = xml
+ def config = new XmlParser().parseAs(ServerConfig, file)
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ }
+
+ @Test
+ void testParseAsFromPath() {
+ def xml =
'<server><host>localhost</host><port>8080</port><debug>false</debug></server>'
+ def file = File.createTempFile('xmltest', '.xml')
+ file.deleteOnExit()
+ file.text = xml
+ def config = new XmlParser().parseAs(ServerConfig, file.toPath())
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ }
+
+ @Test
+ void testParseTextAsWithAttributes() {
+ def config = new XmlParser().parseTextAs(ServerConfig, '<server
host="localhost" port="8080" debug="true"/>')
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ assert config.debug == true
+ }
+
+ @Test
+ void testNodeAsMap() {
+ def node = new
XmlParser().parseText('<server><host>localhost</host><port>8080</port></server>')
+ def map = node as Map
+ assert map instanceof Map
+ assert map.host == 'localhost'
+ assert map.port == '8080'
+ }
+
+ @Test
+ void testNodeAsTypedObject() {
+ // as coercion works for String-typed properties without Jackson
+ def node = new
XmlParser().parseText('<server><host>localhost</host><port>8080</port></server>')
+ def config = node as SimpleConfig
+ assert config instanceof SimpleConfig
+ assert config.host == 'localhost'
+ assert config.port == '8080'
+ }
+
+ @Test
+ void testNodeAsTypedObjectWithTypeConversion() {
+ // parseAs uses Jackson for full type conversion (String -> int,
boolean, etc.)
+ def config = new XmlParser().parseTextAs(ServerConfig,
'<server><host>localhost</host><port>8080</port><debug>true</debug></server>')
+ assert config instanceof ServerConfig
+ assert config.host == 'localhost'
+ assert config.port == 8080
+ assert config.debug == true
+ }
+}