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
4 changes: 4 additions & 0 deletions release-notes/CREDITS
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ Christopher McVay (@mcvayc)
* Fixed #306: Can not use `@JacksonXmlText` for Creator property (creator parameter)
(3.2.0)

Stefan Walter (@marvin9000)
* Requested #150: Allow specifying DOCTYPE declaration (`<!DOCTYPE root ...>`) to write
(3.2.0)

Leonard Meyer (@LeonardMeyer)
* Reported #247: `@JacksonXmlRootElement` does not enforce the local name during
deserialization (add `XmlReadFeature.ENFORCE_ROOT_ELEMENT_NAME`)
Expand Down
3 changes: 3 additions & 0 deletions release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Version: 3.x (for earlier see VERSION-2.x)
#149: `@JacksonXmlElementWrapper` as a `@JsonCreator parameter` not working
(reported by Dai M)
(fix by Christopher M))
#150: Allow specifying DOCTYPE declaration (`<!DOCTYPE root ...>`) to write
(requested by Stefan W)
(fix by @cowtowncoder)
#192: Two wrapped lists with items of same name conflict
(reported by @TeemuStenhammar)
(fix by @cowtowncoder, w/ Claude code)
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/tools/jackson/dataformat/xml/ser/DTD.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package tools.jackson.dataformat.xml.ser;

/**
* Value container to represent XML Document Type Declaration,
* to be written using {@link XmlGeneratorInitializer}.
*
* @since 3.2
*/
public record DTD(String rootName,
String systemId, String publicId,
String internalSubset) {
public DTD {
rootName = _nonEmptyNonNull("rootName", rootName);
systemId = _emptyToNull(systemId);
publicId = _emptyToNull(publicId);
internalSubset = _emptyToNull(internalSubset);
}

static String _emptyToNull(String str) {
return "".equals(str) ? null : str;
}

static String _nonEmptyNonNull(String prop, String str) {
if (str == null || str.isEmpty()) {
throw new IllegalArgumentException("Illegal argument for '%s': must be non-empty String"
.formatted(prop));
}
return str;
}
}
49 changes: 45 additions & 4 deletions src/main/java/tools/jackson/dataformat/xml/ser/ToXmlGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ public class ToXmlGenerator
*/
protected final QName _textElementQName;

/*
/**********************************************************************
/* Initializer-injected configuration (3.2)
/**********************************************************************
*/

/**
* Document Type Declaration to write, if any; {@code null} if none.
*
* @since 3.2
*/
protected DTD _dtd;

/*
/**********************************************************************
/* Logical output state
Expand Down Expand Up @@ -153,7 +166,7 @@ public class ToXmlGenerator
* To support proper serialization of arrays it is necessary to keep
* stack of element names, so that we can "revert" to earlier
*/
protected LinkedList<QName> _elementNameStack = new LinkedList<QName>();
protected LinkedList<QName> _elementNameStack = new LinkedList<>();

/**
* Reusable internal value object
Expand All @@ -162,7 +175,7 @@ public class ToXmlGenerator

/*
/**********************************************************************
/* Life-cycle
/* Life-cycle, construction
/**********************************************************************
*/

Expand Down Expand Up @@ -199,9 +212,16 @@ public ToXmlGenerator(ObjectWriteContext writeCtxt, IOContext ioCtxt,
_textElementQName = new QName(nameForTextElement);
}

/*
/**********************************************************************
/* Life-cycle, initialization
/**********************************************************************
*/

/**
* Method called before writing any other output, to optionally
* output XML declaration.
* Method called by {@link XmlSerializationContext} before writing any output,
* to optionally output XML declaration and other before-root-element
* nodes (DOCTYPE, processing instructions)
*/
public void initGenerator() throws JacksonException
{
Expand Down Expand Up @@ -238,11 +258,32 @@ public void initGenerator() throws JacksonException
if (XmlWriteFeature.AUTO_DETECT_XSI_TYPE.enabledIn(_formatFeatures)) {
_xmlWriter.setPrefix("xsi", XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI);
}

// 19-Apr-2026, tatu: [dataformat-xml#150] Allow outputting DTD
if (_dtd != null) {
_xmlWriter.writeDTD(_dtd.rootName(), _dtd.systemId(), _dtd.publicId(),
_dtd.internalSubset());
}

} catch (XMLStreamException e) {
StaxUtil.throwAsWriteException(e, this);
}
}

/**
* Method called by {@link XmlGeneratorInitializer} to inject
* necessary configuration.
*
* @since 3.2
*/
public void initConfig(DTD dtd)
{
if (_initialized) { // sanity check
_reportError("Internal error: cannot call `initConfig()` after generator already initialized");
}
_dtd = dtd;
}

/*
/**********************************************************************
/* Versioned
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package tools.jackson.dataformat.xml.ser;

import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.*;
import tools.jackson.databind.cfg.GeneratorInitializer;

/**
* Default {@link GeneratorInitializer} implementation to use with
* {@link ToXmlGenerator}, registered via
* {@link ObjectWriter#with(GeneratorInitializer)}.
* It allows output of various document-level things such as
*<ul>
* <li>Document Type Declarations (DTD); that is "&lt;!DOCTYPE>" directive
* </li>
* </ul>
*<p>
* NOTE: instances are mutable, not thread-safe.
*
* @since 3.2
*/
public class XmlGeneratorInitializer
implements GeneratorInitializer
{
protected DTD _dtd;

@Override
public void initialize(SerializationConfig config, JsonGenerator g) throws JacksonException {
if (g instanceof ToXmlGenerator xg) {
xg.initConfig(_dtd);
}
}

public XmlGeneratorInitializer setDTD(String rootName,
String systemId, String publicId,
String internalSubset) {
return setDTD(new DTD(rootName, systemId, publicId, internalSubset));
}

public XmlGeneratorInitializer setDTD(DTD dtd) {
_dtd = dtd;
return this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// Test to ensure that XML module supports
// [https://github.com/FasterXML/jackson-databind/issues/5860] (GeneratorInitializer
// abstraction)
public class GeneratorInitializerTest extends XmlTestUtil
public class GeneralGeneratorInitializerTest extends XmlTestUtil
{
private final GeneratorInitializer ISO_8859_INITIALIZER = (config, gen) -> {
ToXmlGenerator xmlGen = (ToXmlGenerator) gen;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package tools.jackson.dataformat.xml.ser;

import org.junit.jupiter.api.Test;

import tools.jackson.databind.ObjectWriter;
import tools.jackson.dataformat.xml.XmlMapper;
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.fail;

public class XmlGeneratorInitializerTest extends XmlTestUtil
{
private final XmlMapper MAPPER = newMapper();

// [dataformat-xml#150]: DTD writing -- ok cases
@Test
public void testDTDWithOnlyRootElement() throws Exception
{
ObjectWriter w = MAPPER.writer().with(
new XmlGeneratorInitializer()
.setDTD("StringBean", null, null, null));
assertEquals(a2q("<!DOCTYPE StringBean>"
+"<StringBean><text>test</text></StringBean>"),
w.writeValueAsString(new StringBean("test")));
}

@Test
public void testDTDWithPublicId() throws Exception
{
ObjectWriter w = MAPPER.writer().with(
new XmlGeneratorInitializer()
.setDTD("StringBean", "system", "http://foo.bar", ""));
assertEquals(a2q("<!DOCTYPE StringBean PUBLIC 'http://foo.bar' 'system'>"
+"<StringBean><text>test</text></StringBean>"),
w.writeValueAsString(new StringBean("test")));
}

@Test
public void testDTDWithSystemIdOnly() throws Exception
{
ObjectWriter w = MAPPER.writer().with(
new XmlGeneratorInitializer()
.setDTD("StringBean", "system", "", null));
assertEquals(a2q("<!DOCTYPE StringBean SYSTEM 'system'>"
+"<StringBean><text>test</text></StringBean>"),
w.writeValueAsString(new StringBean("test")));
}

@Test
public void testDTDWithInternalSubset() throws Exception
{
ObjectWriter w = MAPPER.writer().with(
new XmlGeneratorInitializer()
.setDTD("StringBean", "system", "http://foo.bar", "<!ELEMENT root (#PCDATA)>"));
assertEquals(a2q("<!DOCTYPE StringBean PUBLIC 'http://foo.bar' 'system' "
+"[<!ELEMENT root (#PCDATA)>]>"
+"<StringBean><text>test</text></StringBean>"),
w.writeValueAsString(new StringBean("test")));
}

// Verify prolog ordering: XML declaration must come before DOCTYPE
@Test
public void testDTDWithXmlDeclaration() throws Exception
{
XmlMapper mapper = XmlMapper.builder()
.configure(XmlWriteFeature.WRITE_XML_DECLARATION, true)
.build();
ObjectWriter w = mapper.writer().with(
new XmlGeneratorInitializer()
.setDTD("StringBean", "system", "http://foo.bar", null));
// XML declaration is emitted with single quotes, DOCTYPE with double quotes,
// so cannot use a2q() on the whole string here.
assertEquals("<?xml version='1.0' encoding='UTF-8'?>"
+"<!DOCTYPE StringBean PUBLIC \"http://foo.bar\" \"system\">"
+"<StringBean><text>test</text></StringBean>",
w.writeValueAsString(new StringBean("test")));
}

// [dataformat-xml#150]: DTD writing -- failing cases
@Test
public void testDTDInvalidNoRoot() throws Exception
{
try {
/*ObjectWriter w =*/ MAPPER.writer().with(
new XmlGeneratorInitializer()
.setDTD("", null, null, null));
fail("Should not pass");
} catch (IllegalArgumentException e) {
verifyException(e, "Illegal argument for 'rootName': must be");
}
}

// Other tests
}