XML: Creating & Manipulating
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). It is the writing half of the XML story: building documents with builders, editing a parsed tree, printing it back out and, new in 6.0, converting it to maps and typed objects. Reading XML, the two parsers, GPath and the secure-by-default rules are on XML Parsing, GPath & Security; read that page first, since the editing examples assume it. The builder idea in general (closures with a delegate that turn method calls into nodes) is on Builders and, for the delegate mechanics, Closure Delegation: Owner, Delegate & This. Kotlin’s version of the same idea is described on Kotlin Type-Safe Builders & DSLs.
The examples use the seed catalogue of the greenhouse, the same data/catalogue.xml as the parsing page. The XML classes are in
groovy.xml, so each script starts with the imports it needs (import groovy.xml.MarkupBuilder and so on).
MarkupBuilder
groovy.xml.MarkupBuilder writes XML to a Writer as you call methods on it: a method call becomes an element, a map argument
becomes its attributes, a string argument its text, and a closure its child elements. Nothing is built in memory; the markup is
written as the closures run, which is why ordinary Groovy code (loops, conditions) can sit between the elements.
import groovy.xml.MarkupBuilder
import groovy.xml.XmlSlurper
def seeds = [
[id: 's1', name: 'Tomato', stock: 120, price: '2.50', tags: ['fruit', 'warm']],
[id: 's2', name: 'Basil', stock: 0, price: '1.80', tags: ['herb']]
]
def writer = new StringWriter()
def xml = new MarkupBuilder(writer)
xml.catalogue(season: 'spring') {
supplier(id: 'sup1', 'Green Seeds Ltd')
section(name: 'vegetables') {
seeds.each { s ->
seed(id: s.id, stock: s.stock) {
name(s.name)
price(currency: 'EUR', s.price)
tags { s.tags.each { tag(it) } }
if (s.stock == 0) soldOut()
}
}
}
}
println writer
def check = new XmlSlurper().parseText(writer.toString())
assert check.section.seed.size() == 2
assert check.section.seed*.@id*.toString() == ['s1', 's2']
assert check.section.seed[1].soldOut.size() == 1
<catalogue season='spring'>
<supplier id='sup1'>Green Seeds Ltd</supplier>
<section name='vegetables'>
<seed id='s1' stock='120'>
<name>Tomato</name>
<price currency='EUR'>2.50</price>
<tags>
<tag>fruit</tag>
<tag>warm</tag>
</tags>
</seed>
<seed id='s2' stock='0'>
<name>Basil</name>
<price currency='EUR'>1.80</price>
<tags>
<tag>herb</tag>
</tags>
<soldOut />
</seed>
</section>
</catalogue>
Notice the mixed form price(currency: 'EUR', s.price), where the map gives the attributes and the trailing value gives the
text, and that an element without content (soldOut()) is written as a self-closing tag. Attribute values can be any object; they
are converted with toString(). The trailing assert lines parse the generated text again with XmlSlurper and passed, which
is a cheap way to test a builder. The default quote character for attributes is the single quote, which is valid XML.
Options, escaping and namespaces
MarkupBuilder has properties for the details: doubleQuotes switches to double quotes, omitNullAttributes and
omitEmptyAttributes drop attributes with no value, and expandEmptyElements writes <tags></tags> instead of <tags />.
Special characters in text and attributes are escaped for you. A different indentation comes from giving the builder an
IndentPrinter, and a prefixed element name is quoted and the namespace declared with an xmlns: attribute.
import groovy.xml.MarkupBuilder
def w = new StringWriter()
def b = new MarkupBuilder(w)
b.doubleQuotes = true
b.omitNullAttributes = true
b.omitEmptyAttributes = true
b.expandEmptyElements = true
b.seed(id: 's9', origin: null, notes: '', stock: 5) { tags() }
println w
def w1 = new StringWriter()
new MarkupBuilder(w1).note(author: 'Tom & "Jerry"') { text('3 < 5 & 5 > 3'); empty() }
println w1
def w2 = new StringWriter()
new MarkupBuilder(new IndentPrinter(new PrintWriter(w2), ' ', true)).outer { inner { leaf('x') } }
println w2
def w3 = new StringWriter()
new MarkupBuilder(w3).'g:catalogue'('xmlns:g': 'urn:greenhouse:catalogue') { 'g:seed'(id: 's1') }
println w3
<seed id="s9" stock="5">
<tags></tags>
</seed>
<note author='Tom & "Jerry"'>
<text>3 < 5 & 5 > 3</text>
<empty />
</note>
<outer>
<inner>
<leaf>x</leaf>
</inner>
</outer>
<g:catalogue xmlns:g='urn:greenhouse:catalogue'>
<g:seed id='s1' />
</g:catalogue>
There is one trap to know about. A local variable or method with the same name as an element captures the call, because Groovy resolves the name in the surrounding scope before the call reaches the builder.
def w = new StringWriter()
def b = new MarkupBuilder(w)
b.a { b { c('x') } } // b is the local variable, not an element
println w
def w2 = new StringWriter()
def bld = new MarkupBuilder(w2)
bld.a { delegate.b { c('x') } } // go through the delegate explicitly
println w2
<a>
<call>
<c>x</c>
</call>
</a>
<a>
<b>
<c>x</c>
</b>
</a>
In the first script b { ... } is b.call { ... } on the builder variable, so the output has an element named call; inside a
builder closure use delegate.name(...), or give the builder a name that is not also an element name.
The mkp helper
Names inside a builder are elements, so the operations that are not elements, such as a comment, an XML declaration or a raw
fragment, live on a special property named mkp (a groovy.xml.MarkupBuilderHelper, reachable on both builders).
| Call | Effect |
|---|---|
|
Writes the XML declaration, with |
|
Writes a comment |
|
Writes a processing instruction |
|
Writes text with XML escaping, for example inside an element body |
|
Writes the text as it is, so you are responsible for it being well-formed markup |
import groovy.xml.MarkupBuilder
def w = new StringWriter()
def m = new MarkupBuilder(w)
m.mkp.xmlDeclaration(version: '1.0', encoding: 'UTF-8')
m.mkp.comment('generated, do not edit')
m.catalogue {
note('3 > n')
mkp.yield('a < b')
mkp.yieldUnescaped('<raw/>')
}
println w
<?xml version='1.0' encoding='UTF-8'?>
<!-- generated, do not edit --><catalogue>
<note>3 > n</note>a < b<raw/>
</catalogue>
The declaration and comment are written before the root element, the escaped text became a < b, and the unescaped fragment
was inserted verbatim. mkp.yieldUnescaped with input that you did not write yourself is an XML injection hole, since the text
is no longer escaped.
StreamingMarkupBuilder
groovy.xml.StreamingMarkupBuilder takes a different approach: bind does not write anything, it returns a Writable
(a closure that implements Writable) which produces the markup when it is converted to a string or written to a Writer. It is
the right choice when the same template is written several times, or streamed straight into a response. It writes compact
output without indentation, and the mkp helper is available inside the closure given to bind.
import groovy.xml.StreamingMarkupBuilder
def seeds = [[id: 's1', name: 'Tomato', stock: 120], [id: 's2', name: 'Basil', stock: 0]]
def built = new StreamingMarkupBuilder().bind {
mkp.xmlDeclaration()
mkp.comment('generated')
catalogue(season: 'spring') {
seeds.each { s ->
seed(id: s.id, stock: s.stock) { name(s.name) }
}
note { mkp.yield('3 < 5') }
raw { mkp.yieldUnescaped('<b>bold</b>') }
}
}
println built.getClass().name
println built
def sw = new StringWriter()
built.writeTo(sw)
println sw.toString().size()
def ns = new StreamingMarkupBuilder().bind {
mkp.declareNamespace(g: 'urn:greenhouse:catalogue')
g.catalogue { g.seed(id: 's1') }
}
println ns
def pit = new StreamingMarkupBuilder().bind { root { note(mkp.yield('3 < 5')) } }
println pit
groovy.lang.Closure$WritableClosure
<?xml version='1.0'?>
<!--generated--><catalogue season='spring'><seed id='s1' stock='120'><name>Tomato</name></seed><seed id='s2' stock='0'><name>Basil</name></seed><note>3 < 5</note><raw><b>bold</b></raw></catalogue>
221
<g:catalogue xmlns:g='urn:greenhouse:catalogue'><g:seed id='s1'/></g:catalogue>
<root>3 < 5<note>3 < 5</note></root>
Differences from MarkupBuilder that show up above: namespaces are declared with mkp.declareNamespace and used as a property
prefix (g.catalogue), and mkp.yield is meant to be called as a statement inside the element’s closure. The last script is
the trap: note(mkp.yield('3 < 5')) calls mkp.yield as an argument, so the text is written first, before the note element
starts, and the returned string becomes the content as well, giving the duplicated 3 < 5. Always write
note { mkp.yield(...) }. A builder passed to a helper closure, and a bind closure that loops over data, work the same as on
MarkupBuilder.
DomToGroovy
When you have a document and want builder code for it as a head start, org.apache.groovy.xml.tools.DomToGroovy converts a DOM
Document into MarkupBuilder source. It is an authoring aid: the output is a starting point for a script, not a finished
program.
import groovy.xml.DOMBuilder
import org.apache.groovy.xml.tools.DomToGroovy
def doc = DOMBuilder.parse(new StringReader('''
<section name="flowers">
<seed id="s3" stock="45">
<name>Marigold</name>
<tags><tag>companion</tag><tag>warm</tag></tags>
</seed>
</section>'''))
def out = new StringWriter()
new DomToGroovy(new PrintWriter(out)).print(doc)
println out
section(name:'flowers') {
seed(id:'s3', stock:'45') {
name('Marigold')
tags() {
tag('companion')
tag('warm')
}
}
}
The official page goes one step further and evaluates the generated text with a GroovyShell to show that the round trip
works. That is a demonstration only: evaluating generated source runs whatever the document produced, so do it only for
documents you control (see
Compilation Customizers & Secure Scripting
for running untrusted Groovy). Note also that DOMBuilder.parse applies the secure parsing defaults, so a document with a
DOCTYPE needs the relax knobs described on the parsing page.
Manipulating a parsed document
The two parsers differ most when you edit. A Node from XmlParser is an ordinary mutable tree: every change is visible at once.
A GPathResult from XmlSlurper is a lazy view: structural changes are recorded and only show up after the document is written
out and parsed again, while attribute changes are immediate. Both accept the same closure syntax as MarkupBuilder for the new
content.
Editing with XmlParser
import groovy.xml.XmlParser
import groovy.xml.XmlNodePrinter
import groovy.namespace.QName
def parser = new XmlParser(trimWhitespace: true)
def cat = parser.parseText(new File('data/catalogue.xml').text)
// add
def flowers = cat.section.find { it.@name == 'flowers' }
def nas = flowers.appendNode('seed', [id: 's4', stock: '10'])
nas.appendNode('name', 'Nasturtium')
nas.appendNode('price', [currency: 'EUR'], '1.00')
println flowers.seed*.@id
println flowers.seed[-1].name.text() // visible at once
def note = parser.createNode(cat, new QName('note'), [:])
note.value = 'hand-picked'
println cat.children().collect { it.name() }
// modify
cat.@updated = '2026-10-14'
def tomato = cat.section[0].seed[0]
tomato.price[0].value = '2.75'
tomato.price[0].@currency = 'USD'
println cat.@updated
println tomato.price.text() + ' ' + tomato.price.@currency
// replace
cat.section[1].seed[0].replaceNode {
seed(id: 's5', stock: '7') { name('Calendula') }
}
println cat.section[1].seed*.@id
println cat.section[1].seed[0].name.text()
// remove: ask the parent
def basil = cat.section[0].seed.find { it.@id == 's2' }
println cat.section[0].remove(basil)
println cat.section[0].seed*.@id
println cat.section[1].seed[0].attributes().remove('stock')
println cat.section[1].seed[0].attributes()
def sw = new StringWriter()
def printer = new XmlNodePrinter(new PrintWriter(sw))
printer.preserveWhitespace = true
printer.print(cat.section[1])
println sw
[s3, s4]
Nasturtium
[supplier, section, section, note]
2026-10-14
2.75 [USD]
[s5, s4]
Calendula
true
[s1]
7
[id:s5]
<section name="flowers">
<seed id="s5">
<name>Calendula</name>
</seed>
<seed id="s4" stock="10">
<name>Nasturtium</name>
<price currency="EUR">1.00</price>
</seed>
</section>
The operations, as the run shows them:
-
appendNode(name, attributes, value)adds a child to aNode(the attribute map and the value are optional) and returns the new node, so you can chainappendNodecalls on it.parser.createNode(parent, new QName('note'), [:])is the lower-level form that takes agroovy.namespace.QNameand a map of attributes; set the content withnode.value. -
An attribute is written with
node.@name = 'value', and text content is replaced by assigningnode.value. -
replaceNode { ... }swaps a node for whatever the closure builds, using theMarkupBuildersyntax. The newseedwas visible immediately because this is aNode. -
There is no
deleteon the node: removal is the parent’s job,parent.remove(node)(returnstruewhen the child was there), and an attribute is removed from the map returned byattributes(). -
appendNodeadds at the end, soseed[-1]is the last child, and the printed section shows the new seed s4 after the one thatreplaceNodeput in the place of s3.
Editing with XmlSlurper
import groovy.xml.StreamingMarkupBuilder
import groovy.xml.XmlSlurper
def cat = new XmlSlurper().parseText(new File('data/catalogue.xml').text)
def reparse = { new XmlSlurper().parseText(new StreamingMarkupBuilder().bind { mkp.yield cat }.toString()) }
cat.@season = 'summer' // attributes: visible at once
println cat.@season
cat.section[0].seed[0].replaceNode {
seed(id: 's9', stock: '3') { name('Courgette') }
}
println cat.section[0].seed[0].name.text() // the old node: change is queued
println reparse().section[0].seed[0].name.text() // after serialising and parsing again
cat.section[1].appendNode { seed(id: 's7') { name('Nigella') } }
println reparse().section[1].seed*.@id
cat.section[0].seed.find { it.@id == 's2' }.replaceNode { } // an empty replacement removes the node
println reparse().section[0].seed*.@id
cat.supplier.replaceBody('Blue Seeds Ltd')
println reparse().supplier.text()
println new StreamingMarkupBuilder().bind { mkp.yield cat }.toString().take(80)
summer
Tomato
Courgette
[s3, s7]
[s9]
Blue Seeds Ltd
<catalogue season='summer'><supplier id='sup1'>Blue Seeds Ltd</supplier><section
The same operations exist with different names. replaceNode with a closure replaces a node, an empty closure removes it,
appendNode and replaceBody add and rewrite content, and the attribute assignment works on the spot, but anything structural
is invisible until you evaluate the document again. The way to do that is the one in the official documentation: write the
GPathResult with StreamingMarkupBuilder (mkp.yield result) and parse that text, as the reparse closure does. The result
of the first println after the replaceNode still shows the old Tomato, and the re-parsed document shows Courgette. The
slurper has no createNode; that exists only on XmlParser. Choosing between them follows from this: if you read the tree
between edits use XmlParser, and if you make many edits and pass the document on, the slurper (a transformer) is a good fit.
The last line also shows that StreamingMarkupBuilder output has no XML declaration unless you add mkp.xmlDeclaration().
Printing XML
Three tools print a document, and they are not interchangeable. XmlUtil.serialize works for a String, a GPathResult, a
Node, a DOM element and more, and returns an indented string; a SerializeOptions controls the encoding, the indent and
the DOCTYPE and external-resource behaviour (the last two are the relax knobs of the parsing page).
groovy.xml.XmlNodePrinter prints a Node with its own indentation rules. StreamingMarkupBuilder with mkp.yield prints a
GPathResult, as above.
import groovy.xml.*
def text = '<seed id="s1"><name>Tomato</name><price currency="EUR">2.50</price></seed>'
// from a String and from a GPathResult: clean output
println XmlUtil.serialize(text)
def slurped = new XmlSlurper().parseText(text)
println XmlUtil.serialize(slurped) == XmlUtil.serialize(text)
println XmlUtil.serialize(slurped, new SerializeOptions(indent: 4))
println XmlUtil.serialize(slurped, new SerializeOptions(encoding: 'ISO-8859-1')).readLines().first()
// from a Node: use XmlNodePrinter
def node = new XmlParser().parseText(text)
def sw = new StringWriter()
new XmlNodePrinter(new PrintWriter(sw)).print(node)
println sw
def sw2 = new StringWriter()
def printer = new XmlNodePrinter(new PrintWriter(sw2), ' ')
printer.preserveWhitespace = true
printer.print(node)
println sw2
// XmlUtil.serialize on a Node containing nested elements adds whitespace-only lines on this JDK
println XmlUtil.serialize(node).readLines().collect { it.isBlank() ? '<blank>' : it }
<?xml version="1.0" encoding="UTF-8"?><seed id="s1">
<name>Tomato</name>
<price currency="EUR">2.50</price>
</seed>
true
<?xml version="1.0" encoding="UTF-8"?><seed id="s1">
<name>Tomato</name>
<price currency="EUR">2.50</price>
</seed>
<?xml version="1.0" encoding="ISO-8859-1"?><seed id="s1">
<seed id="s1">
<name>
Tomato
</name>
<price currency="EUR">
2.50
</price>
</seed>
<seed id="s1">
<name>Tomato</name>
<price currency="EUR">2.50</price>
</seed>
[<?xml version="1.0" encoding="UTF-8"?><seed id="s1">, <blank>, <name>Tomato</name>, <blank>, <price currency="EUR">2.50</price>, <blank>, </seed>]
Reading the output: XmlUtil.serialize writes the declaration on the same line as the root element, indent: 4 and the
encoding option take effect, and a slurped document prints the same as its source text. By default XmlNodePrinter puts element
text on its own indented lines; setting preserveWhitespace keeps <name>Tomato</name> on one line, and the second constructor
argument is the indent string. The last line is the surprise: XmlUtil.serialize of a Node that has nested elements printed
whitespace-only lines between them on this JDK, so prefer XmlNodePrinter for nodes. The official example serialises a
text-only Node (a single author element), which does not show this.
Every XmlUtil.serialize call in this run also printed two lines on standard error (not shown above), from
groovy.xml.FactorySupport warnHardeningNotApplied: WARNING: Unable to apply XML security setting 'http://apache.org/xml/features/disallow-doctype-decl'=true on com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl,
followed by the JDK’s reason and "the factory may be more permissive than intended". Groovy 6 tries to harden every XML factory
it creates (secure by default, see
XML Parsing, GPath & Security); the JDK 26
TransformerFactory does not support that one feature, and Groovy reports each setting it cannot apply instead of failing. The
warning does not change the result: the serialized text on standard output is the same with or without it. It is logged
through java.util.logging, so it can be silenced by raising the level of the groovy.xml.FactorySupport logger. Running the
same script with JAVA_OPTS=-Djava.util.logging.config.file=quiet.properties, where quiet.properties holds the lines
handlers=java.util.logging.ConsoleHandler, .level=INFO and groovy.xml.FactorySupport.level=SEVERE, printed nothing on
standard error. In code, Logger.getLogger('groovy.xml.FactorySupport').level = Level.SEVERE worked only when the logger was
kept in a variable; the one-line form, with no reference kept, still printed the warning. The Logger.getLogger API
documentation explains why: the logging framework may discard a logger that nothing references, and its level with it.
Typed XML conversion (Groovy 6.0)
Groovy 6.0 adds conversion from parsed XML to maps and typed objects, as a complement to dynamic GPath. It starts from a
Node, so it is a feature of XmlParser.
Nodes to maps
Node.toMap() turns a node into a nested Map. Attributes and child elements are merged into one map, repeated same-name
children become a list, and an element that has both attributes and text keeps the text under the key _text (the constant
Node.TEXT_KEY). If a child element and an attribute have the same name the child wins. All values are `String`s, and no extra
dependency is required.
import groovy.xml.XmlParser
def seedXml = '''<seed id="s1" stock="120">
<name>Tomato</name>
<price currency="EUR">2.50</price>
<tags><tag>fruit</tag><tag>warm</tag></tags>
</seed>'''
def node = new XmlParser().parseText(seedXml)
def map = node.toMap()
println map
println map.price
println map.tags
println Node.TEXT_KEY
println new XmlParser().parseText('<config><alias>web1</alias><alias>web2</alias></config>').toMap()
[id:s1, stock:120, name:Tomato, price:[currency:EUR, _text:2.50], tags:[tag:[fruit, warm]]]
[currency:EUR, _text:2.50]
[tag:[fruit, warm]]
_text
[alias:[web1, web2]]
The shape follows the XML, not what you would want in an object model: the tags are [tag: [...]], not a list, because the
tags element wraps the repeated tag elements, and the price is a map because it has an attribute.
Coercion with as
Because the result is a standard Map, Groovy’s as coercion can build an object from it through the named-parameter
constructor (see Type Coercion & Promotion), and as Map
returns the map. The properties must be `String`s for this to work without help, and a property of the XML that the class lacks is
an error.
class SeedHeader { String id; String stock; String name }
def flat = new XmlParser().parseText('<seed id="s1" stock="120"><name>Tomato</name></seed>')
def header = flat as SeedHeader
println header.id + ' ' + header.name + ' ' + header.stock.getClass().simpleName
Map asMap = flat as Map
println asMap.keySet()
try { node as SeedHeader } catch (MissingPropertyException e) { println e.message }
s1 Tomato String
[id, stock, name]
No such property: price for class: SeedHeader
The last line used the full seed from the previous example, which has a price and tags that SeedHeader does not declare.
For other types, conversion, or nested objects use parseTextAs.
Typed parsing with parseTextAs
XmlParser.parseTextAs(Type, text) (and parseAs for a Reader, InputStream, File or Path) parses the XML into the
map shown above and hands it to Jackson’s ObjectMapper.convertValue, so the types are converted (String to int,
BigDecimal, nested objects, lists) and Jackson annotations such as @JsonProperty and @JsonFormat apply. It needs
jackson-databind on the classpath, which the groovy command has; in a build add the dependency yourself, because a clear
error is raised when it is absent.
import groovy.xml.XmlParser
import com.fasterxml.jackson.annotation.JsonProperty
import groovy.transform.ToString
@ToString(includeNames = true) class Price { String currency; @JsonProperty('_text') BigDecimal amount }
@ToString(includeNames = true) class Tags { List<String> tag }
@ToString(includeNames = true) class Seed { String id; String name; int stock; Price price; Tags tags }
def seedXml = '''<seed id="s1" stock="120">
<name>Tomato</name>
<price currency="EUR">2.50</price>
<tags><tag>fruit</tag><tag>warm</tag></tags>
</seed>'''
def seed = new XmlParser().parseTextAs(Seed, seedXml)
println seed
println seed.stock.getClass().simpleName + ' ' + seed.price.amount.getClass().simpleName
println seed.tags.tag
class Bad { String id; int stock }
try { new XmlParser().parseTextAs(Bad, seedXml) } catch (Throwable e) {
def t = e; while (t.cause) t = t.cause
println t.class.simpleName + ': ' + t.message.readLines().first().take(130)
}
Seed(id:s1, name:Tomato, stock:120, price:Price(currency:EUR, amount:2.50), tags:Tags(tag:[fruit, warm]))
Integer BigDecimal
[fruit, warm]
UnrecognizedPropertyException: Unrecognized field "name" (class Bad), not marked as ignorable (3 known properties: "id", "stock", "metaClass")
stock arrived as an int and the price as a BigDecimal. Two details follow from the map shape: the text of an element that
also has attributes is reached through the _text key, mapped here with @JsonProperty('_text'), and a wrapper element such as
tags becomes its own class (Tags), because this path uses jackson-databind on the map, not an XML data format. The failure
is Jackson’s strictness: a property the class does not declare is an error unless the class is annotated
@JsonIgnoreProperties(ignoreUnknown = true); and the metaClass property that Jackson counts as "known" is the Groovy object’s
own accessor.
Full Jackson XML
parseTextAs does not use jackson-dataformat-xml, so the XML-specific annotations (@JacksonXmlText,
@JacksonXmlElementWrapper, @JacksonXmlProperty) have no effect there. For those, use the data format directly through
XmlMapper, which also writes XML. The example needs the library, so it uses @Grab (see
Grape Dependency Management).
@Grab('com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.22.2')
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper
import groovy.transform.ToString
import groovy.xml.XmlParser
import groovy.xml.XmlUtil
@ToString(includeNames = true) class Price { @JacksonXmlProperty(isAttribute = true) String currency; @JacksonXmlText BigDecimal amount }
@ToString(includeNames = true) class Seed {
@JacksonXmlProperty(isAttribute = true) String id
@JacksonXmlProperty(isAttribute = true) int stock
String name
Price price
@JacksonXmlElementWrapper(localName = 'tags') @JacksonXmlProperty(localName = 'tag') List<String> tags
}
def seedXml = '<seed id="s1" stock="120"><name>Tomato</name><price currency="EUR">2.50</price><tags><tag>fruit</tag><tag>warm</tag></tags></seed>'
def mapper = new XmlMapper()
def seed = mapper.readValue(seedXml, Seed)
println seed
println mapper.writeValueAsString(seed)
// inspect with XmlParser first, then hand the serialized text to Jackson
def node = new XmlParser().parseText(seedXml)
assert node.@stock == '120'
println mapper.readValue(XmlUtil.serialize(node), Seed).name
Seed(id:s1, stock:120, name:Tomato, price:Price(currency:EUR, amount:2.50), tags:[fruit, warm])
<Seed id="s1" stock="120"><name>Tomato</name><price currency="EUR">2.50</price><tags><tag>fruit</tag><tag>warm</tag></tags></Seed>
Tomato
The model is now closer to the XML: attributes, the element text and the list wrapper are declared with annotations, and the
same XmlMapper writes the object back (the root element is named after the class). The last lines show the other route the
documentation suggests: parse with XmlParser to inspect or validate, then serialize and give the text to Jackson. The run
printed the same two-line TransformerFactory hardening WARNING on standard error as the printing example above, from the
XmlUtil.serialize(node) call: Groovy 6 could not apply disallow-doctype-decl to the JDK 26 TransformerFactory and said so,
and the warning did not affect the result (the name Tomato was read as shown). The version 2.22.2 matches the Jackson jars of the Groovy
6.0.0 distribution that was used; pick the version your build already uses for other Jackson modules. The JSON side of this,
with JsonSlurper and parseTextAs, is on JSON.
Where the book is outdated
The Manning book Groovy in Action (publisher page in the references) shows MarkupBuilder, StreamingMarkupBuilder and
in-place editing of parsed trees, and those concepts are unchanged. Check these points against the current documentation:
-
The classes are in
groovy.xml(thegroovy-xmlmodule), notgroovy.util, so older listings need new imports. -
Typed conversion (
toMap,as,parseTextAs) is new in 6.0, andSerializeOptionsgivesXmlUtil.serializecontrol over encoding and indentation. -
Parsers and
XmlUtil.serializeare secure by default in 6.0, which affects documents with aDOCTYPE(see the parsing page). -
The SOAP and XML-RPC chapters are not covered; exchange XML over plain HTTP instead (see HTTP Client with HttpBuilder).
-
Baselines: Groovy 6.0 needs JDK 17 or later.