diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 6cb7d24f90..d8bf3cec6b 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -30,6 +30,9 @@ 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 + (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) 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..20aedbf907 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLDeserializers.java @@ -92,16 +92,50 @@ public Std(Class raw, int kind) { 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 + // 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); + + JsonNode localPart = tree.get("localPart"); + if (localPart == null) { + ctxt.reportInputMismatch(this, + "Object value for `QName` is missing required property 'localPart'"); + } + + if (!localPart.isTextual()) { + ctxt.reportInputMismatch(this, + "Object value property 'localPart' for `QName` must be of type STRING, not %s", + localPart.getNodeType()); + } + + 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()); + } + return new QName(localPart.asText()); + } + @Override protected Object _deserialize(String value, DeserializationContext ctxt) throws IOException 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..c56eb368f6 100644 --- a/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java +++ b/src/main/java/com/fasterxml/jackson/databind/ext/CoreXMLSerializers.java @@ -7,6 +7,7 @@ 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.*; @@ -34,9 +35,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 +120,73 @@ protected Calendar _convert(XMLGregorianCalendar input) { return (input == null) ? null : input.toGregorianCalendar(); } } + + /** + * @since 2.19 + */ + public static class QNameSerializer + extends StdSerializer + implements ContextualSerializer + { + private static final long serialVersionUID = 1L; + + public final 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 ctxt) + throws IOException + { + g.writeStartObject(value); + serializeProperties(value, g, ctxt); + g.writeEndObject(); + } + + @Override + public final void serializeWithType(QName value, JsonGenerator g, SerializerProvider ctxt, + TypeSerializer typeSer) + throws IOException + { + 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 { + /*JsonObjectFormatVisitor v =*/ visitor.expectObjectFormat(typeHint); + // TODO: would need to visit properties too, see `BeanSerializerBase` + } + } } 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..141591dd20 100644 --- a/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/ext/MiscJavaXMLTypesReadWriteTest.java @@ -4,7 +4,9 @@ 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.exc.MismatchedInputException; import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; import com.fasterxml.jackson.databind.testutil.NoCheckSubTypeValidator; import com.fasterxml.jackson.databind.type.TypeFactory; @@ -34,12 +36,24 @@ 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)); } + @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 +135,37 @@ public void testQNameDeser() throws Exception assertEquals("", qn.getLocalPart()); } + @Test + public void testQNameDeserFromObject() throws Exception + { + String qstr = a2q("{'namespaceURI':'http://abc','localPart':'tag','prefix':'prefix'}"); + // 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 { @@ -149,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 new file mode 100644 index 0000000000..ac45ab5a47 --- /dev/null +++ b/src/test/java/com/fasterxml/jackson/databind/ext/QNameAsObjectReadWrite4771Test.java @@ -0,0 +1,57 @@ +package com.fasterxml.jackson.databind.ext; + +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 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; + + 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")) + ); + } +}