Skip to content

Commit 70511f0

Browse files
authored
Fix #4771: Support OBJECT shape for QNAME serialization and deserialization. (#4968)
1 parent dd929e2 commit 70511f0

File tree

5 files changed

+216
-5
lines changed

5 files changed

+216
-5
lines changed

release-notes/VERSION-2.x

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ Project: jackson-databind
3030
Map object is ignored when Map key type not defined
3131
(reported by @devdanylo)
3232
(fix by Joo-Hyuk K)
33+
#4771: `QName` (de)serialization ignores prefix
34+
(reported by @jpraet)
35+
(fix contributed by @mcvayc)
3336
#4772: Serialization and deserialization issue of sub-types used with
3437
`JsonTypeInfo.Id.DEDUCTION` where sub-types are Object and Array
3538
(reported by Eduard G)

src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java

+36-2
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,50 @@ public Std(Class<?> raw, int kind) {
9292
public Object deserialize(JsonParser p, DeserializationContext ctxt)
9393
throws IOException
9494
{
95-
// For most types, use super impl; but GregorianCalendar also allows
96-
// integer value (timestamp), which needs separate handling
95+
// GregorianCalendar also allows integer value (timestamp),
96+
// which needs separate handling
9797
if (_kind == TYPE_G_CALENDAR) {
9898
if (p.hasToken(JsonToken.VALUE_NUMBER_INT)) {
9999
return _gregorianFromDate(ctxt, _parseDate(p, ctxt));
100100
}
101101
}
102+
// QName also allows object value, which needs separate handling
103+
if (_kind == TYPE_QNAME) {
104+
if (p.hasToken(JsonToken.START_OBJECT)) {
105+
return _parseQNameObject(p, ctxt);
106+
}
107+
}
102108
return super.deserialize(p, ctxt);
103109
}
104110

111+
private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt)
112+
throws IOException
113+
{
114+
JsonNode tree = ctxt.readTree(p);
115+
116+
JsonNode localPart = tree.get("localPart");
117+
if (localPart == null) {
118+
ctxt.reportInputMismatch(this,
119+
"Object value for `QName` is missing required property 'localPart'");
120+
}
121+
122+
if (!localPart.isTextual()) {
123+
ctxt.reportInputMismatch(this,
124+
"Object value property 'localPart' for `QName` must be of type STRING, not %s",
125+
localPart.getNodeType());
126+
}
127+
128+
JsonNode namespaceURI = tree.get("namespaceURI");
129+
if (namespaceURI != null) {
130+
if (tree.has("prefix")) {
131+
JsonNode prefix = tree.get("prefix");
132+
return new QName(namespaceURI.asText(), localPart.asText(), prefix.asText());
133+
}
134+
return new QName(namespaceURI.asText(), localPart.asText());
135+
}
136+
return new QName(localPart.asText());
137+
}
138+
105139
@Override
106140
protected Object _deserialize(String value, DeserializationContext ctxt)
107141
throws IOException

src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java

+74-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import javax.xml.datatype.XMLGregorianCalendar;
88
import javax.xml.namespace.QName;
99

10+
import com.fasterxml.jackson.annotation.JsonFormat;
1011
import com.fasterxml.jackson.core.*;
1112
import com.fasterxml.jackson.core.type.WritableTypeId;
1213
import com.fasterxml.jackson.databind.*;
@@ -34,9 +35,12 @@ public JsonSerializer<?> findSerializer(SerializationConfig config,
3435
JavaType type, BeanDescription beanDesc)
3536
{
3637
Class<?> raw = type.getRawClass();
37-
if (Duration.class.isAssignableFrom(raw) || QName.class.isAssignableFrom(raw)) {
38+
if (Duration.class.isAssignableFrom(raw)) {
3839
return ToStringSerializer.instance;
3940
}
41+
if (QName.class.isAssignableFrom(raw)) {
42+
return QNameSerializer.instance;
43+
}
4044
if (XMLGregorianCalendar.class.isAssignableFrom(raw)) {
4145
return XMLGregorianCalendarSerializer.instance;
4246
}
@@ -116,4 +120,73 @@ protected Calendar _convert(XMLGregorianCalendar input) {
116120
return (input == null) ? null : input.toGregorianCalendar();
117121
}
118122
}
123+
124+
/**
125+
* @since 2.19
126+
*/
127+
public static class QNameSerializer
128+
extends StdSerializer<QName>
129+
implements ContextualSerializer
130+
{
131+
private static final long serialVersionUID = 1L;
132+
133+
public final static JsonSerializer<?> instance = new QNameSerializer();
134+
135+
public QNameSerializer() {
136+
super(QName.class);
137+
}
138+
139+
@Override
140+
public JsonSerializer<?> createContextual(SerializerProvider serializers, BeanProperty property)
141+
throws JsonMappingException
142+
{
143+
JsonFormat.Value format = findFormatOverrides(serializers, property, handledType());
144+
if (format != null) {
145+
JsonFormat.Shape shape = format.getShape();
146+
if (shape == JsonFormat.Shape.OBJECT) {
147+
return this;
148+
}
149+
}
150+
return ToStringSerializer.instance;
151+
}
152+
153+
@Override
154+
public void serialize(QName value, JsonGenerator g, SerializerProvider ctxt)
155+
throws IOException
156+
{
157+
g.writeStartObject(value);
158+
serializeProperties(value, g, ctxt);
159+
g.writeEndObject();
160+
}
161+
162+
@Override
163+
public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider ctxt,
164+
TypeSerializer typeSer)
165+
throws IOException
166+
{
167+
WritableTypeId typeIdDef = typeSer.writeTypePrefix(g,
168+
typeSer.typeId(value, JsonToken.START_OBJECT));
169+
serializeProperties(value, g, ctxt);
170+
typeSer.writeTypeSuffix(g, typeIdDef);
171+
}
172+
173+
private void serializeProperties(QName value, JsonGenerator g, SerializerProvider ctxt)
174+
throws IOException
175+
{
176+
g.writeStringField("localPart", value.getLocalPart());
177+
if (!value.getNamespaceURI().isEmpty()) {
178+
g.writeStringField("namespaceURI", value.getNamespaceURI());
179+
}
180+
if (!value.getPrefix().isEmpty()) {
181+
g.writeStringField("prefix", value.getPrefix());
182+
}
183+
}
184+
185+
@Override
186+
public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint)
187+
throws JsonMappingException {
188+
/*JsonObjectFormatVisitor v =*/ visitor.expectObjectFormat(typeHint);
189+
// TODO: would need to visit properties too, see `BeanSerializerBase`
190+
}
191+
}
119192
}

src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java

+46-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import javax.xml.namespace.QName;
55
import org.junit.jupiter.api.Test;
66

7+
import com.fasterxml.jackson.annotation.JsonFormat;
78
import com.fasterxml.jackson.databind.*;
9+
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
810
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
911
import com.fasterxml.jackson.databind.testutil.NoCheckSubTypeValidator;
1012
import com.fasterxml.jackson.databind.type.TypeFactory;
@@ -34,12 +36,24 @@ public class MiscJavaXMLTypesReadWriteTest
3436
*/
3537

3638
@Test
37-
public void testQNameSer() throws Exception
39+
public void testQNameSerDefault() throws Exception
3840
{
3941
QName qn = new QName("http://abc", "tag", "prefix");
4042
assertEquals(q(qn.toString()), MAPPER.writeValueAsString(qn));
4143
}
4244

45+
@Test
46+
public void testQNameSerToObject() throws Exception
47+
{
48+
QName qn = new QName("http://abc", "tag", "prefix");
49+
50+
ObjectMapper mapper = jsonMapperBuilder()
51+
.withConfigOverride(QName.class, cfg -> cfg.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.OBJECT)))
52+
.build();
53+
54+
assertEquals(a2q("{'localPart':'tag','namespaceURI':'http://abc','prefix':'prefix'}"), mapper.writeValueAsString(qn));
55+
}
56+
4357
@Test
4458
public void testDurationSer() throws Exception
4559
{
@@ -121,6 +135,37 @@ public void testQNameDeser() throws Exception
121135
assertEquals("", qn.getLocalPart());
122136
}
123137

138+
@Test
139+
public void testQNameDeserFromObject() throws Exception
140+
{
141+
String qstr = a2q("{'namespaceURI':'http://abc','localPart':'tag','prefix':'prefix'}");
142+
// Ok to read with standard ObjectMapper, no `@JsonFormat` needed
143+
QName qn = MAPPER.readValue(qstr, QName.class);
144+
145+
assertEquals("http://abc", qn.getNamespaceURI());
146+
assertEquals("tag", qn.getLocalPart());
147+
assertEquals("prefix", qn.getPrefix());
148+
}
149+
150+
@Test
151+
public void testQNameDeserFail() throws Exception
152+
{
153+
try {
154+
MAPPER.readValue("{}", QName.class);
155+
fail("Should not pass");
156+
} catch (MismatchedInputException e) {
157+
verifyException(e, "Object value for `QName` is missing required property 'localPart'");
158+
}
159+
160+
try {
161+
MAPPER.readValue(a2q("{'localPart': 123}"), QName.class);
162+
fail("Should not pass");
163+
} catch (MismatchedInputException e) {
164+
verifyException(e, "Object value property 'localPart'");
165+
verifyException(e, "must be of type STRING, not NUMBER");
166+
}
167+
}
168+
124169
@Test
125170
public void testXMLGregorianCalendarDeser() throws Exception
126171
{
@@ -149,7 +194,6 @@ public void testDurationDeser() throws Exception
149194
/**********************************************************************
150195
*/
151196

152-
153197
@Test
154198
public void testPolymorphicXMLGregorianCalendar() throws Exception
155199
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.fasterxml.jackson.databind.ext;
2+
3+
import java.util.stream.Stream;
4+
import javax.xml.namespace.QName;
5+
6+
import org.junit.jupiter.params.ParameterizedTest;
7+
import org.junit.jupiter.params.provider.Arguments;
8+
import org.junit.jupiter.params.provider.MethodSource;
9+
10+
import com.fasterxml.jackson.annotation.JsonFormat;
11+
12+
import com.fasterxml.jackson.core.JsonProcessingException;
13+
14+
import com.fasterxml.jackson.databind.ObjectMapper;
15+
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
16+
17+
import static org.junit.jupiter.api.Assertions.assertEquals;
18+
19+
class QNameAsObjectReadWrite4771Test extends DatabindTestUtil
20+
{
21+
private final ObjectMapper MAPPER = newJsonMapper();
22+
23+
static class BeanWithQName {
24+
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
25+
public QName qname;
26+
27+
BeanWithQName() { }
28+
29+
public BeanWithQName(QName qName) {
30+
this.qname = qName;
31+
}
32+
}
33+
34+
@ParameterizedTest
35+
@MethodSource("provideAllPerumtationsOfQNameConstructor")
36+
void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessingException
37+
{
38+
BeanWithQName bean = new BeanWithQName(originalQName);
39+
40+
String json = MAPPER.writeValueAsString(bean);
41+
42+
QName deserializedQName = MAPPER.readValue(json, BeanWithQName.class).qname;
43+
44+
assertEquals(originalQName.getLocalPart(), deserializedQName.getLocalPart());
45+
assertEquals(originalQName.getNamespaceURI(), deserializedQName.getNamespaceURI());
46+
assertEquals(originalQName.getPrefix(), deserializedQName.getPrefix());
47+
}
48+
49+
static Stream<Arguments> provideAllPerumtationsOfQNameConstructor()
50+
{
51+
return Stream.of(
52+
Arguments.of(new QName("test-local-part")),
53+
Arguments.of(new QName("test-namespace-uri", "test-local-part")),
54+
Arguments.of(new QName("test-namespace-uri", "test-local-part", "test-prefix"))
55+
);
56+
}
57+
}

0 commit comments

Comments
 (0)