Skip to content

Commit 8f01ff9

Browse files
authored
Fix #150: allow writing DOCTYPE declarations (dtds) (#848)
1 parent 32d20fc commit 8f01ff9

7 files changed

Lines changed: 223 additions & 5 deletions

File tree

release-notes/CREDITS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ Christopher McVay (@mcvayc)
3939
* Fixed #306: Can not use `@JacksonXmlText` for Creator property (creator parameter)
4040
(3.2.0)
4141

42+
Stefan Walter (@marvin9000)
43+
* Requested #150: Allow specifying DOCTYPE declaration (`<!DOCTYPE root ...>`) to write
44+
(3.2.0)
45+
4246
Leonard Meyer (@LeonardMeyer)
4347
* Reported #247: `@JacksonXmlRootElement` does not enforce the local name during
4448
deserialization (add `XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME`)

release-notes/VERSION

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Version: 3.x (for earlier see VERSION-2.x)
1515
#149: `@JacksonXmlElementWrapper` as a `@JsonCreator parameter` not working
1616
(reported by Dai M)
1717
(fix by Christopher M))
18+
#150: Allow specifying DOCTYPE declaration (`<!DOCTYPE root ...>`) to write
19+
(requested by Stefan W)
20+
(fix by @cowtowncoder)
1821
#192: Two wrapped lists with items of same name conflict
1922
(reported by @TeemuStenhammar)
2023
(fix by @cowtowncoder, w/ Claude code)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package tools.jackson.dataformat.xml.ser;
2+
3+
/**
4+
* Value container to represent XML Document Type Declaration,
5+
* to be written using {@link XmlGeneratorInitializer}.
6+
*
7+
* @since 3.2
8+
*/
9+
public record DTD(String rootName,
10+
String systemId, String publicId,
11+
String internalSubset) {
12+
public DTD {
13+
rootName = _nonEmptyNonNull("rootName", rootName);
14+
systemId = _emptyToNull(systemId);
15+
publicId = _emptyToNull(publicId);
16+
internalSubset = _emptyToNull(internalSubset);
17+
}
18+
19+
static String _emptyToNull(String str) {
20+
return "".equals(str) ? null : str;
21+
}
22+
23+
static String _nonEmptyNonNull(String prop, String str) {
24+
if (str == null || str.isEmpty()) {
25+
throw new IllegalArgumentException("Illegal argument for '%s': must be non-empty String"
26+
.formatted(prop));
27+
}
28+
return str;
29+
}
30+
}

src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,19 @@ public class ToXmlGenerator
9393
*/
9494
protected final QName _textElementQName;
9595

96+
/*
97+
/**********************************************************************
98+
/* Initializer-injected configuration (3.2)
99+
/**********************************************************************
100+
*/
101+
102+
/**
103+
* Document Type Declaration to write, if any; {@code null} if none.
104+
*
105+
* @since 3.2
106+
*/
107+
protected DTD _dtd;
108+
96109
/*
97110
/**********************************************************************
98111
/* Logical output state
@@ -153,7 +166,7 @@ public class ToXmlGenerator
153166
* To support proper serialization of arrays it is necessary to keep
154167
* stack of element names, so that we can "revert" to earlier
155168
*/
156-
protected LinkedList<QName> _elementNameStack = new LinkedList<QName>();
169+
protected LinkedList<QName> _elementNameStack = new LinkedList<>();
157170

158171
/**
159172
* Reusable internal value object
@@ -162,7 +175,7 @@ public class ToXmlGenerator
162175

163176
/*
164177
/**********************************************************************
165-
/* Life-cycle
178+
/* Life-cycle, construction
166179
/**********************************************************************
167180
*/
168181

@@ -199,9 +212,16 @@ public ToXmlGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt,
199212
_textElementQName = new QName(nameForTextElement);
200213
}
201214

215+
/*
216+
/**********************************************************************
217+
/* Life-cycle, initialization
218+
/**********************************************************************
219+
*/
220+
202221
/**
203-
* Method called before writing any other output, to optionally
204-
* output XML declaration.
222+
* Method called by {@link XmlSerializationContext} before writing any output,
223+
* to optionally output XML declaration and other before-root-element
224+
* nodes (DOCTYPE, processing instructions)
205225
*/
206226
public void initGenerator() throws JacksonException
207227
{
@@ -238,11 +258,32 @@ public void initGenerator() throws JacksonException
238258
if (XmlWriteFeature.AUTO_DETECT_XSI_TYPE.enabledIn(_formatFeatures)) {
239259
_xmlWriter.setPrefix("xsi", XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI);
240260
}
261+
262+
// 19-Apr-2026, tatu: [dataformat-xml#150] Allow outputting DTD
263+
if (_dtd != null) {
264+
_xmlWriter.writeDTD(_dtd.rootName(), _dtd.systemId(), _dtd.publicId(),
265+
_dtd.internalSubset());
266+
}
267+
241268
} catch (XMLStreamException e) {
242269
StaxUtil.throwAsWriteException(e, this);
243270
}
244271
}
245272

273+
/**
274+
* Method called by {@link XmlGeneratorInitializer} to inject
275+
* necessary configuration.
276+
*
277+
* @since 3.2
278+
*/
279+
public void initConfig(DTD dtd)
280+
{
281+
if (_initialized) { // sanity check
282+
_reportError("Internal error: cannot call `initConfig()` after generator already initialized");
283+
}
284+
_dtd = dtd;
285+
}
286+
246287
/*
247288
/**********************************************************************
248289
/* Versioned
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package tools.jackson.dataformat.xml.ser;
2+
3+
import tools.jackson.core.JacksonException;
4+
import tools.jackson.core.JsonGenerator;
5+
import tools.jackson.databind.*;
6+
import tools.jackson.databind.cfg.GeneratorInitializer;
7+
8+
/**
9+
* Default {@link GeneratorInitializer} implementation to use with
10+
* {@link ToXmlGenerator}, registered via
11+
* {@link ObjectWriter#with(GeneratorInitializer)}.
12+
* It allows output of various document-level things such as
13+
*<ul>
14+
* <li>Document Type Declarations (DTD); that is "&lt;!DOCTYPE>" directive
15+
* </li>
16+
* </ul>
17+
*<p>
18+
* NOTE: instances are mutable, not thread-safe.
19+
*
20+
* @since 3.2
21+
*/
22+
public class XmlGeneratorInitializer
23+
implements GeneratorInitializer
24+
{
25+
protected DTD _dtd;
26+
27+
@Override
28+
public void initialize(SerializationConfig config, JsonGenerator g) throws JacksonException {
29+
if (g instanceof ToXmlGenerator xg) {
30+
xg.initConfig(_dtd);
31+
}
32+
}
33+
34+
public XmlGeneratorInitializer setDTD(String rootName,
35+
String systemId, String publicId,
36+
String internalSubset) {
37+
return setDTD(new DTD(rootName, systemId, publicId, internalSubset));
38+
}
39+
40+
public XmlGeneratorInitializer setDTD(DTD dtd) {
41+
_dtd = dtd;
42+
return this;
43+
}
44+
}

src/test/java/tools/jackson/dataformat/xml/ser/GeneratorInitializerTest.java renamed to src/test/java/tools/jackson/dataformat/xml/ser/GeneralGeneratorInitializerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
// Test to ensure that XML module supports
1818
// [https://github.com/FasterXML/jackson-databind/issues/5860] (GeneratorInitializer
1919
// abstraction)
20-
public class GeneratorInitializerTest extends XmlTestUtil
20+
public class GeneralGeneratorInitializerTest extends XmlTestUtil
2121
{
2222
private final GeneratorInitializer ISO_8859_INITIALIZER = (config, gen) -> {
2323
ToXmlGenerator xmlGen = (ToXmlGenerator) gen;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package tools.jackson.dataformat.xml.ser;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import tools.jackson.databind.ObjectWriter;
6+
import tools.jackson.dataformat.xml.XmlMapper;
7+
import tools.jackson.dataformat.xml.XmlTestUtil;
8+
import tools.jackson.dataformat.xml.XmlWriteFeature;
9+
10+
import static org.junit.jupiter.api.Assertions.assertEquals;
11+
import static org.junit.jupiter.api.Assertions.fail;
12+
13+
public class XmlGeneratorInitializerTest extends XmlTestUtil
14+
{
15+
private final XmlMapper MAPPER = newMapper();
16+
17+
// [dataformat-xml#150]: DTD writing -- ok cases
18+
@Test
19+
public void testDTDWithOnlyRootElement() throws Exception
20+
{
21+
ObjectWriter w = MAPPER.writer().with(
22+
new XmlGeneratorInitializer()
23+
.setDTD("StringBean", null, null, null));
24+
assertEquals(a2q("<!DOCTYPE StringBean>"
25+
+"<StringBean><text>test</text></StringBean>"),
26+
w.writeValueAsString(new StringBean("test")));
27+
}
28+
29+
@Test
30+
public void testDTDWithPublicId() throws Exception
31+
{
32+
ObjectWriter w = MAPPER.writer().with(
33+
new XmlGeneratorInitializer()
34+
.setDTD("StringBean", "system", "http://foo.bar", ""));
35+
assertEquals(a2q("<!DOCTYPE StringBean PUBLIC 'http://foo.bar' 'system'>"
36+
+"<StringBean><text>test</text></StringBean>"),
37+
w.writeValueAsString(new StringBean("test")));
38+
}
39+
40+
@Test
41+
public void testDTDWithSystemIdOnly() throws Exception
42+
{
43+
ObjectWriter w = MAPPER.writer().with(
44+
new XmlGeneratorInitializer()
45+
.setDTD("StringBean", "system", "", null));
46+
assertEquals(a2q("<!DOCTYPE StringBean SYSTEM 'system'>"
47+
+"<StringBean><text>test</text></StringBean>"),
48+
w.writeValueAsString(new StringBean("test")));
49+
}
50+
51+
@Test
52+
public void testDTDWithInternalSubset() throws Exception
53+
{
54+
ObjectWriter w = MAPPER.writer().with(
55+
new XmlGeneratorInitializer()
56+
.setDTD("StringBean", "system", "http://foo.bar", "<!ELEMENT root (#PCDATA)>"));
57+
assertEquals(a2q("<!DOCTYPE StringBean PUBLIC 'http://foo.bar' 'system' "
58+
+"[<!ELEMENT root (#PCDATA)>]>"
59+
+"<StringBean><text>test</text></StringBean>"),
60+
w.writeValueAsString(new StringBean("test")));
61+
}
62+
63+
// Verify prolog ordering: XML declaration must come before DOCTYPE
64+
@Test
65+
public void testDTDWithXmlDeclaration() throws Exception
66+
{
67+
XmlMapper mapper = XmlMapper.builder()
68+
.configure(XmlWriteFeature.WRITE_XML_DECLARATION, true)
69+
.build();
70+
ObjectWriter w = mapper.writer().with(
71+
new XmlGeneratorInitializer()
72+
.setDTD("StringBean", "system", "http://foo.bar", null));
73+
// XML declaration is emitted with single quotes, DOCTYPE with double quotes,
74+
// so cannot use a2q() on the whole string here.
75+
assertEquals("<?xml version='1.0' encoding='UTF-8'?>"
76+
+"<!DOCTYPE StringBean PUBLIC \"http://foo.bar\" \"system\">"
77+
+"<StringBean><text>test</text></StringBean>",
78+
w.writeValueAsString(new StringBean("test")));
79+
}
80+
81+
// [dataformat-xml#150]: DTD writing -- failing cases
82+
@Test
83+
public void testDTDInvalidNoRoot() throws Exception
84+
{
85+
try {
86+
/*ObjectWriter w =*/ MAPPER.writer().with(
87+
new XmlGeneratorInitializer()
88+
.setDTD("", null, null, null));
89+
fail("Should not pass");
90+
} catch (IllegalArgumentException e) {
91+
verifyException(e, "Illegal argument for 'rootName': must be");
92+
}
93+
}
94+
95+
// Other tests
96+
}

0 commit comments

Comments
 (0)