From 82a46180f13b775b573f0a3e1479049cb9faefb8 Mon Sep 17 00:00:00 2001 From: mcvayc Date: Fri, 31 Jan 2025 20:25:12 -0500 Subject: [PATCH 1/7] Support OBJECT shape for QNAME serialization and deserialization. This commit fixes #4771 by supporting the JsonFormat OBJECT shape during serialization and deserialization. --- .../databind/ext/CoreXMLDeserializers.java | 85 ++++++++++++------- .../databind/ext/CoreXMLSerializers.java | 61 ++++++++++++- .../ext/MiscJavaXMLTypesReadWriteTest.java | 27 ++++++ .../ext/QNameAsObjectReadWrite4771Test.java | 56 ++++++++++++ 4 files changed, 196 insertions(+), 33 deletions(-) create mode 100644 src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java index 76609a3914..8c434fcebe 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java @@ -16,8 +16,7 @@ * JDK 1.5. Types are directly needed by JAXB, but may be unavailable on some * limited platforms; hence separate out from basic deserializer factory. */ -public class CoreXMLDeserializers extends Deserializers.Base -{ +public class CoreXMLDeserializers extends Deserializers.Base { protected final static QName EMPTY_QNAME = QName.valueOf(""); /** @@ -26,6 +25,7 @@ public class CoreXMLDeserializers extends Deserializers.Base * introspection) can be expensive we better reuse the instance. */ final static DatatypeFactory _dataTypeFactory; + static { try { _dataTypeFactory = DatatypeFactory.newInstance(); @@ -36,8 +36,7 @@ public class CoreXMLDeserializers extends Deserializers.Base @Override public JsonDeserializer findBeanDeserializer(JavaType type, - DeserializationConfig config, BeanDescription beanDesc) - { + DeserializationConfig config, BeanDescription beanDesc) { Class raw = type.getRawClass(); if (raw == QName.class) { return new Std(raw, TYPE_QNAME); @@ -77,8 +76,7 @@ public boolean hasDeserializerFor(DeserializationConfig config, Class valueTy * * @since 2.4 */ - public static class Std extends FromStringDeserializer - { + public static class Std extends FromStringDeserializer { private static final long serialVersionUID = 1L; protected final int _kind; @@ -90,38 +88,67 @@ public Std(Class raw, int kind) { @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException - { - // For most types, use super impl; but GregorianCalendar also allows - // integer value (timestamp), which needs separate handling + throws IOException { + // GregorianCalendar also allows integer value (timestamp), + // which needs separate handling if (_kind == TYPE_G_CALENDAR) { if (p.hasToken(JsonToken.VALUE_NUMBER_INT)) { return _gregorianFromDate(ctxt, _parseDate(p, ctxt)); } } + // QName also allows object value, which needs separate handling + if (_kind == TYPE_QNAME) { + if (p.hasToken(JsonToken.START_OBJECT)) { + return _parseQNameObject(p, ctxt); + } + } return super.deserialize(p, ctxt); } + private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) throws IOException { + JsonNode tree = ctxt.readTree(p); + + if (!tree.has("localPart")) { + throw new JsonParseException("QName is missing required field: localPart"); + } + + JsonNode localPart = tree.get("localPart"); + if (!localPart.isTextual()) { + throw new JsonParseException("QName field \"localPart\" must be of type String."); + } + + if (tree.has("namespaceURI")) { + JsonNode namespaceURI = tree.get("namespaceURI"); + + if (tree.has("prefix")) { + JsonNode prefix = tree.get("prefix"); + return new QName(namespaceURI.asText(), localPart.asText(), prefix.asText()); + } + + return new QName(namespaceURI.asText(), localPart.asText()); + } else { + return new QName(localPart.asText()); + } + } + @Override protected Object _deserialize(String value, DeserializationContext ctxt) - throws IOException - { + throws IOException { switch (_kind) { - case TYPE_DURATION: - return _dataTypeFactory.newDuration(value); - case TYPE_QNAME: - return QName.valueOf(value); - case TYPE_G_CALENDAR: - Date d; - try { - d = _parseDate(value, ctxt); - } - catch (JsonMappingException e) { - // try to parse from native XML Schema 1.0 lexical representation String, - // which includes time-only formats not handled by parseXMLGregorianCalendarFromJacksonFormat(...) - return _dataTypeFactory.newXMLGregorianCalendar(value); - } - return _gregorianFromDate(ctxt, d); + case TYPE_DURATION: + return _dataTypeFactory.newDuration(value); + case TYPE_QNAME: + return QName.valueOf(value); + case TYPE_G_CALENDAR: + Date d; + try { + d = _parseDate(value, ctxt); + } catch (JsonMappingException e) { + // try to parse from native XML Schema 1.0 lexical representation String, + // which includes time-only formats not handled by parseXMLGregorianCalendarFromJacksonFormat(...) + return _dataTypeFactory.newXMLGregorianCalendar(value); + } + return _gregorianFromDate(ctxt, d); } throw new IllegalStateException(); } @@ -135,8 +162,7 @@ protected Object _deserializeFromEmptyString(DeserializationContext ctxt) throws } protected XMLGregorianCalendar _gregorianFromDate(DeserializationContext ctxt, - Date d) - { + Date d) { if (d == null) { return null; } @@ -148,5 +174,6 @@ protected XMLGregorianCalendar _gregorianFromDate(DeserializationContext ctxt, } return _dataTypeFactory.newXMLGregorianCalendar(calendar); } + } } diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java index 219d9a43db..0e8d63db76 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java @@ -1,22 +1,24 @@ package com.fasterxml.jackson.databind.ext; import java.io.IOException; +import java.lang.reflect.Type; import java.util.Calendar; import javax.xml.datatype.Duration; import javax.xml.datatype.XMLGregorianCalendar; import javax.xml.namespace.QName; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.*; import com.fasterxml.jackson.core.type.WritableTypeId; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.ser.BeanSerializer; import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.DefaultSerializerProvider; import com.fasterxml.jackson.databind.ser.Serializers; -import com.fasterxml.jackson.databind.ser.std.CalendarSerializer; -import com.fasterxml.jackson.databind.ser.std.StdSerializer; -import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import com.fasterxml.jackson.databind.ser.std.*; /** * Provider for serializers of XML types that are part of full JDK 1.5, but @@ -34,9 +36,12 @@ public JsonSerializer findSerializer(SerializationConfig config, JavaType type, BeanDescription beanDesc) { Class raw = type.getRawClass(); - if (Duration.class.isAssignableFrom(raw) || QName.class.isAssignableFrom(raw)) { + if (Duration.class.isAssignableFrom(raw)) { return ToStringSerializer.instance; } + if (QName.class.isAssignableFrom(raw)) { + return QNameSerializer.instance; + } if (XMLGregorianCalendar.class.isAssignableFrom(raw)) { return XMLGregorianCalendarSerializer.instance; } @@ -116,4 +121,52 @@ protected Calendar _convert(XMLGregorianCalendar input) { return (input == null) ? null : input.toGregorianCalendar(); } } + + public static class QNameSerializer + extends StdSerializer + implements ContextualSerializer + { + private static final long serialVersionUID = 1L; + + public static JsonSerializer instance = new QNameSerializer(); + + public QNameSerializer() { + super(QName.class); + } + + @Override + public JsonSerializer createContextual(SerializerProvider serializers, BeanProperty property) + throws JsonMappingException + { + JsonFormat.Value format = findFormatOverrides(serializers, property, handledType()); + if (format != null) { + JsonFormat.Shape shape = format.getShape(); + if (shape == JsonFormat.Shape.OBJECT) { + return this; + } + } + return ToStringSerializer.instance; + } + + @Override + public void serialize(QName value, JsonGenerator g, SerializerProvider provider) throws IOException { + g.writeStartObject(); + g.writeObjectField("localPart", value.getLocalPart()); + if(!value.getNamespaceURI().isEmpty()) g.writeObjectField("namespaceURI", value.getNamespaceURI()); + if(!value.getPrefix().isEmpty()) g.writeObjectField("prefix", value.getPrefix()); + g.writeEndObject(); + } + + @Override + public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider provider, + TypeSerializer typeSer) throws IOException + { + g.writeObject(value); + } + + @Override + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { + visitor.expectBooleanFormat(typeHint); + } + } } diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java index e4d1161482..1b7f5aa823 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java @@ -1,5 +1,6 @@ package com.fasterxml.jackson.databind.ext; +import com.fasterxml.jackson.annotation.JsonFormat; import javax.xml.datatype.*; import javax.xml.namespace.QName; import org.junit.jupiter.api.Test; @@ -40,6 +41,17 @@ public void testQNameSer() throws Exception assertEquals(q(qn.toString()), MAPPER.writeValueAsString(qn)); } + @Test + public void testQNameSerToObject() throws Exception { + QName qn = new QName("http://abc", "tag", "prefix"); + + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(QName.class, cfg -> cfg.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.OBJECT))) + .build(); + + assertEquals(a2q("{'localPart':'tag','namespaceURI':'http://abc','prefix':'prefix'}"), mapper.writeValueAsString(qn)); + } + @Test public void testDurationSer() throws Exception { @@ -121,6 +133,21 @@ public void testQNameDeser() throws Exception assertEquals("", qn.getLocalPart()); } + @Test + public void testQNameDeserFromObject() throws Exception + { + String qstr = a2q("{'namespaceURI':'http://abc','localPart':'tag','prefix':'prefix'}"); + ObjectMapper mapper = jsonMapperBuilder() + .withConfigOverride(QName.class, cfg -> cfg.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.OBJECT))) + .build(); + + QName qn = mapper.readValue(qstr, QName.class); + + assertEquals("http://abc", qn.getNamespaceURI()); + assertEquals("tag", qn.getLocalPart()); + assertEquals("prefix", qn.getPrefix()); + } + @Test public void testXMLGregorianCalendarDeser() throws Exception { diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java new file mode 100644 index 0000000000..654b5664d1 --- /dev/null +++ b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java @@ -0,0 +1,56 @@ +package com.fasterxml.jackson.databind.ext; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import javax.xml.namespace.QName; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class QNameAsObjectReadWrite4771Test extends DatabindTestUtil +{ + + private final ObjectMapper MAPPER = newJsonMapper(); + + static class BeanWithQName { + @JsonFormat(shape = JsonFormat.Shape.OBJECT) + public QName qname; + + public BeanWithQName() { + } + + public BeanWithQName(QName qName) { + this.qname = qName; + } + } + + + @ParameterizedTest + @MethodSource("provideAllPerumtationsOfQNameConstructor") + void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessingException { + BeanWithQName bean = new BeanWithQName(originalQName); + + String json = MAPPER.writeValueAsString(bean); + + QName deserializedQName = MAPPER.readValue(json, BeanWithQName.class).qname; + + assertEquals(originalQName.getLocalPart(), deserializedQName.getLocalPart()); + assertEquals(originalQName.getNamespaceURI(), deserializedQName.getNamespaceURI()); + assertEquals(originalQName.getPrefix(), deserializedQName.getPrefix()); + } + + static Stream provideAllPerumtationsOfQNameConstructor() { + return Stream.of( + Arguments.of(new QName("test-local-part")), + Arguments.of(new QName("test-namespace-uri", "test-local-part")), + Arguments.of(new QName("test-namespace-uri", "test-local-part", "test-prefix")) + ); + } + +} \ No newline at end of file From f0c2f60d946a825b24983eb36d16b23837c349f7 Mon Sep 17 00:00:00 2001 From: mcvayc Date: Fri, 14 Feb 2025 11:46:53 -0500 Subject: [PATCH 2/7] Fix whitespace formatting and import order. --- .../databind/ext/CoreXMLDeserializers.java | 53 +++++++++++-------- .../databind/ext/CoreXMLSerializers.java | 20 +++---- .../ext/MiscJavaXMLTypesReadWriteTest.java | 5 +- .../ext/QNameAsObjectReadWrite4771Test.java | 6 ++- 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java index 8c434fcebe..c69857328f 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java @@ -16,7 +16,8 @@ * JDK 1.5. Types are directly needed by JAXB, but may be unavailable on some * limited platforms; hence separate out from basic deserializer factory. */ -public class CoreXMLDeserializers extends Deserializers.Base { +public class CoreXMLDeserializers extends Deserializers.Base +{ protected final static QName EMPTY_QNAME = QName.valueOf(""); /** @@ -25,7 +26,6 @@ public class CoreXMLDeserializers extends Deserializers.Base { * introspection) can be expensive we better reuse the instance. */ final static DatatypeFactory _dataTypeFactory; - static { try { _dataTypeFactory = DatatypeFactory.newInstance(); @@ -36,7 +36,8 @@ public class CoreXMLDeserializers extends Deserializers.Base { @Override public JsonDeserializer findBeanDeserializer(JavaType type, - DeserializationConfig config, BeanDescription beanDesc) { + DeserializationConfig config, BeanDescription beanDesc) + { Class raw = type.getRawClass(); if (raw == QName.class) { return new Std(raw, TYPE_QNAME); @@ -76,7 +77,8 @@ public boolean hasDeserializerFor(DeserializationConfig config, Class valueTy * * @since 2.4 */ - public static class Std extends FromStringDeserializer { + public static class Std extends FromStringDeserializer + { private static final long serialVersionUID = 1L; protected final int _kind; @@ -88,7 +90,8 @@ public Std(Class raw, int kind) { @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException { + throws IOException + { // GregorianCalendar also allows integer value (timestamp), // which needs separate handling if (_kind == TYPE_G_CALENDAR) { @@ -105,7 +108,9 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) return super.deserialize(p, ctxt); } - private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) throws IOException { + private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) + throws IOException + { JsonNode tree = ctxt.readTree(p); if (!tree.has("localPart")) { @@ -133,22 +138,24 @@ private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) throw @Override protected Object _deserialize(String value, DeserializationContext ctxt) - throws IOException { + throws IOException + { switch (_kind) { - case TYPE_DURATION: - return _dataTypeFactory.newDuration(value); - case TYPE_QNAME: - return QName.valueOf(value); - case TYPE_G_CALENDAR: - Date d; - try { - d = _parseDate(value, ctxt); - } catch (JsonMappingException e) { - // try to parse from native XML Schema 1.0 lexical representation String, - // which includes time-only formats not handled by parseXMLGregorianCalendarFromJacksonFormat(...) - return _dataTypeFactory.newXMLGregorianCalendar(value); - } - return _gregorianFromDate(ctxt, d); + case TYPE_DURATION: + return _dataTypeFactory.newDuration(value); + case TYPE_QNAME: + return QName.valueOf(value); + case TYPE_G_CALENDAR: + Date d; + try { + d = _parseDate(value, ctxt); + } + catch (JsonMappingException e) { + // try to parse from native XML Schema 1.0 lexical representation String, + // which includes time-only formats not handled by parseXMLGregorianCalendarFromJacksonFormat(...) + return _dataTypeFactory.newXMLGregorianCalendar(value); + } + return _gregorianFromDate(ctxt, d); } throw new IllegalStateException(); } @@ -162,7 +169,8 @@ protected Object _deserializeFromEmptyString(DeserializationContext ctxt) throws } protected XMLGregorianCalendar _gregorianFromDate(DeserializationContext ctxt, - Date d) { + Date d) + { if (d == null) { return null; } @@ -174,6 +182,5 @@ protected XMLGregorianCalendar _gregorianFromDate(DeserializationContext ctxt, } return _dataTypeFactory.newXMLGregorianCalendar(calendar); } - } } diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java index 0e8d63db76..e8228eb6ee 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java @@ -1,7 +1,6 @@ package com.fasterxml.jackson.databind.ext; import java.io.IOException; -import java.lang.reflect.Type; import java.util.Calendar; import javax.xml.datatype.Duration; @@ -14,11 +13,11 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonFormatVisitorWrapper; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; -import com.fasterxml.jackson.databind.ser.BeanSerializer; import com.fasterxml.jackson.databind.ser.ContextualSerializer; -import com.fasterxml.jackson.databind.ser.DefaultSerializerProvider; import com.fasterxml.jackson.databind.ser.Serializers; -import com.fasterxml.jackson.databind.ser.std.*; +import com.fasterxml.jackson.databind.ser.std.CalendarSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; /** * Provider for serializers of XML types that are part of full JDK 1.5, but @@ -123,8 +122,8 @@ protected Calendar _convert(XMLGregorianCalendar input) { } public static class QNameSerializer - extends StdSerializer - implements ContextualSerializer + extends StdSerializer + implements ContextualSerializer { private static final long serialVersionUID = 1L; @@ -136,7 +135,7 @@ public QNameSerializer() { @Override public JsonSerializer createContextual(SerializerProvider serializers, BeanProperty property) - throws JsonMappingException + throws JsonMappingException { JsonFormat.Value format = findFormatOverrides(serializers, property, handledType()); if (format != null) { @@ -149,7 +148,9 @@ public JsonSerializer createContextual(SerializerProvider serializers, BeanPr } @Override - public void serialize(QName value, JsonGenerator g, SerializerProvider provider) throws IOException { + public void serialize(QName value, JsonGenerator g, SerializerProvider provider) + throws IOException + { g.writeStartObject(); g.writeObjectField("localPart", value.getLocalPart()); if(!value.getNamespaceURI().isEmpty()) g.writeObjectField("namespaceURI", value.getNamespaceURI()); @@ -159,7 +160,8 @@ public void serialize(QName value, JsonGenerator g, SerializerProvider provider) @Override public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider provider, - TypeSerializer typeSer) throws IOException + TypeSerializer typeSer) + throws IOException { g.writeObject(value); } diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java index 1b7f5aa823..52aef1ebee 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java @@ -1,10 +1,10 @@ package com.fasterxml.jackson.databind.ext; -import com.fasterxml.jackson.annotation.JsonFormat; import javax.xml.datatype.*; import javax.xml.namespace.QName; import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; import com.fasterxml.jackson.databind.testutil.NoCheckSubTypeValidator; @@ -42,7 +42,8 @@ public void testQNameSer() throws Exception } @Test - public void testQNameSerToObject() throws Exception { + public void testQNameSerToObject() throws Exception + { QName qn = new QName("http://abc", "tag", "prefix"); ObjectMapper mapper = jsonMapperBuilder() diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java index 654b5664d1..5255f55ce9 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java @@ -33,7 +33,8 @@ public BeanWithQName(QName qName) { @ParameterizedTest @MethodSource("provideAllPerumtationsOfQNameConstructor") - void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessingException { + void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessingException + { BeanWithQName bean = new BeanWithQName(originalQName); String json = MAPPER.writeValueAsString(bean); @@ -45,7 +46,8 @@ void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessing assertEquals(originalQName.getPrefix(), deserializedQName.getPrefix()); } - static Stream provideAllPerumtationsOfQNameConstructor() { + static Stream provideAllPerumtationsOfQNameConstructor() + { return Stream.of( Arguments.of(new QName("test-local-part")), Arguments.of(new QName("test-namespace-uri", "test-local-part")), From fa1f36dd7d67f8b7086d083d6f01d1dee7aa158e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 14 Feb 2025 13:40:14 -0800 Subject: [PATCH 3/7] Improve deser error reporting --- .../databind/ext/CoreXMLDeserializers.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java index c69857328f..654b456e8c 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java @@ -113,27 +113,25 @@ private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) { JsonNode tree = ctxt.readTree(p); - if (!tree.has("localPart")) { - throw new JsonParseException("QName is missing required field: localPart"); + JsonNode localPart = tree.get("localPart"); + if (localPart == null) { + ctxt.reportInputMismatch(this, "QName is missing required property: 'localPart'"); } - JsonNode localPart = tree.get("localPart"); if (!localPart.isTextual()) { - throw new JsonParseException("QName field \"localPart\" must be of type String."); + ctxt.reportInputMismatch(this, "QName property 'localPart' must be a STRING, not %s", + localPart.getNodeType()); } - if (tree.has("namespaceURI")) { - JsonNode namespaceURI = tree.get("namespaceURI"); - + JsonNode namespaceURI = tree.get("namespaceURI"); + if (namespaceURI != null) { if (tree.has("prefix")) { JsonNode prefix = tree.get("prefix"); return new QName(namespaceURI.asText(), localPart.asText(), prefix.asText()); } - return new QName(namespaceURI.asText(), localPart.asText()); - } else { - return new QName(localPart.asText()); } + return new QName(localPart.asText()); } @Override From 8678803bc355d8f935b9b7a927cb6559f7be0e24 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 14 Feb 2025 13:51:21 -0800 Subject: [PATCH 4/7] Update release notes, improve serialization --- release-notes/VERSION-2.x | 2 + .../databind/ext/CoreXMLSerializers.java | 38 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 6cb7d24f90..f9d6194363 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -30,6 +30,8 @@ Project: jackson-databind Map object is ignored when Map key type not defined (reported by @devdanylo) (fix by Joo-Hyuk K) +#4771: `QName` (de)serialization ignores prefix + (contributed by @mcvayc) #4772: Serialization and deserialization issue of sub-types used with `JsonTypeInfo.Id.DEDUCTION` where sub-types are Object and Array (reported by Eduard G) diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java index e8228eb6ee..fab10b79a2 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java @@ -121,6 +121,9 @@ protected Calendar _convert(XMLGregorianCalendar input) { } } + /** + * @since 2.19 + */ public static class QNameSerializer extends StdSerializer implements ContextualSerializer @@ -148,27 +151,42 @@ public JsonSerializer createContextual(SerializerProvider serializers, BeanPr } @Override - public void serialize(QName value, JsonGenerator g, SerializerProvider provider) + public void serialize(QName value, JsonGenerator g, SerializerProvider ctxt) throws IOException { - g.writeStartObject(); - g.writeObjectField("localPart", value.getLocalPart()); - if(!value.getNamespaceURI().isEmpty()) g.writeObjectField("namespaceURI", value.getNamespaceURI()); - if(!value.getPrefix().isEmpty()) g.writeObjectField("prefix", value.getPrefix()); + g.writeStartObject(value); + serializeProperties(value, g, ctxt); g.writeEndObject(); } @Override - public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider provider, - TypeSerializer typeSer) + public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider ctxt, + TypeSerializer typeSer) throws IOException { - g.writeObject(value); + WritableTypeId typeIdDef = typeSer.writeTypePrefix(g, + typeSer.typeId(value, JsonToken.START_OBJECT)); + serializeProperties(value, g, ctxt); + typeSer.writeTypeSuffix(g, typeIdDef); + } + + private void serializeProperties(QName value, JsonGenerator g, SerializerProvider ctxt) + throws IOException + { + g.writeStringField("localPart", value.getLocalPart()); + if (!value.getNamespaceURI().isEmpty()) { + g.writeStringField("namespaceURI", value.getNamespaceURI()); + } + if (!value.getPrefix().isEmpty()) { + g.writeStringField("prefix", value.getPrefix()); + } } @Override - public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) throws JsonMappingException { - visitor.expectBooleanFormat(typeHint); + public void acceptJsonFormatVisitor(JsonFormatVisitorWrapper visitor, JavaType typeHint) + throws JsonMappingException { + /*JsonObjectFormatVisitor v =*/ visitor.expectObjectFormat(typeHint); + // TODO: would need to visit properties too, see `BeanSerializerBase` } } } From a1ba6074c97785c3859d3e338a416f8854ff82d9 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 14 Feb 2025 14:03:00 -0800 Subject: [PATCH 5/7] Minor fixes, improve tests --- .../databind/ext/CoreXMLDeserializers.java | 6 ++-- .../databind/ext/CoreXMLSerializers.java | 2 +- .../ext/MiscJavaXMLTypesReadWriteTest.java | 29 +++++++++++++++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java index 654b456e8c..20aedbf907 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java @@ -115,11 +115,13 @@ private QName _parseQNameObject(JsonParser p, DeserializationContext ctxt) JsonNode localPart = tree.get("localPart"); if (localPart == null) { - ctxt.reportInputMismatch(this, "QName is missing required property: 'localPart'"); + ctxt.reportInputMismatch(this, + "Object value for `QName` is missing required property 'localPart'"); } if (!localPart.isTextual()) { - ctxt.reportInputMismatch(this, "QName property 'localPart' must be a STRING, not %s", + ctxt.reportInputMismatch(this, + "Object value property 'localPart' for `QName` must be of type STRING, not %s", localPart.getNodeType()); } diff --git a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java index fab10b79a2..c56eb368f6 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java @@ -130,7 +130,7 @@ public static class QNameSerializer { private static final long serialVersionUID = 1L; - public static JsonSerializer instance = new QNameSerializer(); + public final static JsonSerializer instance = new QNameSerializer(); public QNameSerializer() { super(QName.class); diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java index 52aef1ebee..f60dfb88fa 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; import com.fasterxml.jackson.databind.testutil.NoCheckSubTypeValidator; import com.fasterxml.jackson.databind.type.TypeFactory; @@ -35,7 +36,7 @@ public class MiscJavaXMLTypesReadWriteTest */ @Test - public void testQNameSer() throws Exception + public void testQNameSerDefault() throws Exception { QName qn = new QName("http://abc", "tag", "prefix"); assertEquals(q(qn.toString()), MAPPER.writeValueAsString(qn)); @@ -138,17 +139,33 @@ public void testQNameDeser() throws Exception public void testQNameDeserFromObject() throws Exception { String qstr = a2q("{'namespaceURI':'http://abc','localPart':'tag','prefix':'prefix'}"); - ObjectMapper mapper = jsonMapperBuilder() - .withConfigOverride(QName.class, cfg -> cfg.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.OBJECT))) - .build(); - - QName qn = mapper.readValue(qstr, QName.class); + // Ok to read with standard ObjectMapper, no `@JsonFormat` needed + QName qn = MAPPER.readValue(qstr, QName.class); assertEquals("http://abc", qn.getNamespaceURI()); assertEquals("tag", qn.getLocalPart()); assertEquals("prefix", qn.getPrefix()); } + @Test + public void testQNameDeserFail() throws Exception + { + try { + MAPPER.readValue("{}", QName.class); + fail("Should not pass"); + } catch (MismatchedInputException e) { + verifyException(e, "Object value for `QName` is missing required property 'localPart'"); + } + + try { + MAPPER.readValue(a2q("{'localPart': 123}"), QName.class); + fail("Should not pass"); + } catch (MismatchedInputException e) { + verifyException(e, "Object value property 'localPart'"); + verifyException(e, "must be of type STRING, not NUMBER"); + } + } + @Test public void testXMLGregorianCalendarDeser() throws Exception { From a785dc598d71ee13249832a8a58a9945bdda575e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 14 Feb 2025 14:04:38 -0800 Subject: [PATCH 6/7] Ok this will do. --- .../ext/MiscJavaXMLTypesReadWriteTest.java | 1 - .../ext/QNameAsObjectReadWrite4771Test.java | 23 +++++++++---------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java index f60dfb88fa..141591dd20 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java @@ -194,7 +194,6 @@ public void testDurationDeser() throws Exception /********************************************************************** */ - @Test public void testPolymorphicXMLGregorianCalendar() throws Exception { diff --git a/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java index 5255f55ce9..ac45ab5a47 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java @@ -1,36 +1,36 @@ package com.fasterxml.jackson.databind.ext; -import com.fasterxml.jackson.annotation.JsonFormat; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; +import java.util.stream.Stream; +import javax.xml.namespace.QName; + import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import javax.xml.namespace.QName; -import java.util.stream.Stream; +import com.fasterxml.jackson.annotation.JsonFormat; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; import static org.junit.jupiter.api.Assertions.assertEquals; class QNameAsObjectReadWrite4771Test extends DatabindTestUtil { - private final ObjectMapper MAPPER = newJsonMapper(); static class BeanWithQName { @JsonFormat(shape = JsonFormat.Shape.OBJECT) public QName qname; - public BeanWithQName() { - } + BeanWithQName() { } public BeanWithQName(QName qName) { this.qname = qName; } } - @ParameterizedTest @MethodSource("provideAllPerumtationsOfQNameConstructor") void testQNameWithObjectSerialization(QName originalQName) throws JsonProcessingException @@ -54,5 +54,4 @@ static Stream provideAllPerumtationsOfQNameConstructor() Arguments.of(new QName("test-namespace-uri", "test-local-part", "test-prefix")) ); } - -} \ No newline at end of file +} From b8cb0e8b80dcbd6cc6240c6317a03727fa1cd438 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Fri, 14 Feb 2025 14:05:31 -0800 Subject: [PATCH 7/7] Update release notes --- release-notes/VERSION-2.x | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index f9d6194363..d8bf3cec6b 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -31,7 +31,8 @@ Project: jackson-databind (reported by @devdanylo) (fix by Joo-Hyuk K) #4771: `QName` (de)serialization ignores prefix - (contributed by @mcvayc) + (reported by @jpraet) + (fix contributed by @mcvayc) #4772: Serialization and deserialization issue of sub-types used with `JsonTypeInfo.Id.DEDUCTION` where sub-types are Object and Array (reported by Eduard G)