diff --git a/README.md b/README.md index 3416657..95e86fe 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ To use this plugin, annotate any classes which may contain references with *@Jso String name; Person secretSanta; } + +### Usage with Default Typing + +If you are using [default typing](https://github.com/FasterXML/jackson-docs/wiki/JacksonPolymorphicDeserialization) to handle polymorphism there is a little more setup you will need. +In short, you need to supply an attribute to the serializer. +This attribute will signal that default typing is in play and specify the attribute name to use for expressing types (Usually this is `@class`). +Examples and important warnings can be seen in [the unit test](blob/master/src/test/java/com/voodoodyne/jackson/jsog/Issue26Test.java) for DefaultTyping. Currently only NON_FINAL and EVERYTHING modes are supported. + +**WARNING:** Default typing modes other than NON_FINAL and EVERYTHING may appear to succeed when `.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)` is used **but actually cause buggy deserialization** in which objects may become duplicated. +This is why only these two modes are currently supported (see note at end of `testRoundTrip()` in the unit test for details, enhancements welcome) + + ## Author diff --git a/src/main/java/com/voodoodyne/jackson/jsog/JSOGGenerator.java b/src/main/java/com/voodoodyne/jackson/jsog/JSOGGenerator.java index da527bb..95f2865 100644 --- a/src/main/java/com/voodoodyne/jackson/jsog/JSOGGenerator.java +++ b/src/main/java/com/voodoodyne/jackson/jsog/JSOGGenerator.java @@ -11,7 +11,8 @@ */ public class JSOGGenerator extends ObjectIdGenerator { - private static final long serialVersionUID = 1L; + public static final String DEFAULT_TYPING_ATTRIBUTE = "JSOG_DEFAULT_TYPING_ATTRIBUTE"; + private static final long serialVersionUID = 1L; protected transient int _nextValue; protected final Class _scope; @@ -52,7 +53,7 @@ public com.fasterxml.jackson.annotation.ObjectIdGenerator.IdKey key(Object key) public JSOGRef generateId(Object forPojo) { int id = _nextValue; ++_nextValue; - return new JSOGRef(id); + return new JSOGRef(id, forPojo); } @Override diff --git a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRef.java b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRef.java index abff9c2..2b13b4f 100644 --- a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRef.java +++ b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRef.java @@ -22,6 +22,7 @@ public final class JSOGRef @JsonProperty(REF_KEY) public String ref; + transient Object refTo; /** * A flag we use to determine if this ref has already been serialized. Because jackson calls the same * code for serializing both ids and refs, we simply assume the first use is an id and all subsequent @@ -35,8 +36,9 @@ public JSOGRef(String val) { } /** */ - public JSOGRef(int val) { + public JSOGRef(int val, Object refTo) { this(Integer.toString(val)); + this.refTo = refTo; } @Override diff --git a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefDeserializer.java b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefDeserializer.java index fd320b9..4238413 100644 --- a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefDeserializer.java +++ b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefDeserializer.java @@ -1,22 +1,23 @@ package com.voodoodyne.jackson.jsog; +import java.io.IOException; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; -import java.io.IOException; +import com.fasterxml.jackson.databind.jsontype.TypeDeserializer; /** - * Knows how to take either form of a JSOGRef (string or {@ref:string} and convert it back into a JSOGRef. + * Knows how to take either form of a JSOGRef (string or {@code {@ref:string}}) and convert it back into a JSOGRef. * * @author Jeff Schnitzer */ public class JSOGRefDeserializer extends JsonDeserializer { @Override - public JSOGRef deserialize(JsonParser jp, DeserializationContext ctx) throws IOException, JsonProcessingException { + public JSOGRef deserialize(JsonParser jp, DeserializationContext ctx) throws IOException { JsonNode node = ctx.readValue(jp, JsonNode.class); if (node.isTextual()) { return new JSOGRef(node.asText()); @@ -25,4 +26,32 @@ public JSOGRef deserialize(JsonParser jp, DeserializationContext ctx) throws IOE } } + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException { + // in the event of default typing, this gets called instead of deserialize above, and it seems to get + // called for each @id (the first case) and for each {"@ref":"#"} (the second case) + if (p.currentToken() == JsonToken.VALUE_STRING) { + return new JSOGRef(p.getText()); + } + if (p.currentToken() == JsonToken.FIELD_NAME) { + // we are being called for { "@ref":"#" } + // and starting here ------^ + // Or in default typing we should get called for { "@class":"com.example.Thingy","@ref":"#" } + // and be starting here --------------------------------------------------------^ + // because default typing will have consumed the type attribute already. See the javadoc for + // com.fasterxml.jackson.databind.JsonDeserializer.deserialize(JsonParser, DeserializationContext) + // which talks about this (yes that does seem slightly out of place, I'd guess it predates this method) + p.nextToken(); + if (p.currentToken() != JsonToken.VALUE_STRING) { + throw new IllegalStateException("@ref attribute should be followed by a value?"); + } + String text = p.getText(); //grab our id + p.nextToken(); // consume END_OBJECT + return new JSOGRef(text); + } else { + throw new IllegalStateException("Unexpected Token Type:" + p.currentToken().name()); + } + } } + + diff --git a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefSerializer.java b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefSerializer.java index 0f7f23e..b6f8fdc 100644 --- a/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefSerializer.java +++ b/src/main/java/com/voodoodyne/jackson/jsog/JSOGRefSerializer.java @@ -2,11 +2,10 @@ import java.io.IOException; - import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; /** * Knows how to take a JSOGRef and print it as @id or @ref as appropriate. @@ -16,9 +15,13 @@ public class JSOGRefSerializer extends JsonSerializer { @Override - public void serialize(JSOGRef value, JsonGenerator jgen, SerializerProvider provider) throws IOException, JsonProcessingException { + public void serialize(JSOGRef value, JsonGenerator jgen, SerializerProvider provider) throws IOException { if (value.used) { jgen.writeStartObject(); + Object attribute = provider.getAttribute(JSOGGenerator.DEFAULT_TYPING_ATTRIBUTE); + if (attribute != null) { + jgen.writeObjectField(attribute.toString(), value.refTo.getClass().getName()); + } jgen.writeObjectField(JSOGRef.REF_KEY, value.ref); jgen.writeEndObject(); } else { @@ -27,4 +30,11 @@ public void serialize(JSOGRef value, JsonGenerator jgen, SerializerProvider prov } } +// Side Note: This never gets called, so we cant rely on it despite the fact we need its equivalent in the +// deserializer. I suspect ID serialization is not a first class citizen and jackson isn't expecting typing. +// +// @Override +// public void serializeWithType(JSOGRef value, JsonGenerator gen, SerializerProvider serializers, TypeSerializer typeSer) throws IOException { +// super.serializeWithType(value, gen, serializers, typeSer); +// } } diff --git a/src/test/java/com/voodoodyne/jackson/jsog/Issue26Test.java b/src/test/java/com/voodoodyne/jackson/jsog/Issue26Test.java new file mode 100644 index 0000000..d901eba --- /dev/null +++ b/src/test/java/com/voodoodyne/jackson/jsog/Issue26Test.java @@ -0,0 +1,225 @@ +package com.voodoodyne.jackson.jsog; + +import static com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping.EVERYTHING; +import static com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping.NON_FINAL; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectMapper.DefaultTyping; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.testng.collections.Lists; + +@RunWith(Parameterized.class) +public class Issue26Test { + + public static final String WITHOUT_DEFAULT_TYPING = "[{\"@id\":\"1\",\"inner\":{\"@id\":\"2\",\"outer\":{\"@ref\":\"1\"}}},{\"@ref\":\"2\"}]"; + + // @formatter:off + public static final String TEST_JSON= + "[\"java.util.ArrayList\",[" + + "{" + + "\"@class\":\"com.voodoodyne.jackson.jsog.Issue26Test$Outer\"," + + "\"@id\":\"1\"," + + "\"inner\":{" + + "\"@class\":\"com.voodoodyne.jackson.jsog.Issue26Test$Inner\"," + + "\"@id\":\"2\"," + + "\"outer\":{" + + "\"@class\":\"com.voodoodyne.jackson.jsog.Issue26Test$Outer\"," + // <-- added for issue 26 + "\"@ref\":\"1\"" + + "}" + + "}" + + "}," + + + "{\"" + + "@class\":\"com.voodoodyne.jackson.jsog.Issue26Test$Inner\"," + // <-- added for issue 26 + "\"@ref\":\"2\"}" + + "]]"; + // @formatter:on + + private final DefaultTyping defaultTyping; + + public Issue26Test(DefaultTyping defaultTyping) { + this.defaultTyping = defaultTyping; + } + + @Parameterized.Parameters + public static Collection data() { + ArrayList objects = new ArrayList(); + // sadly these don't work I tried adding a second attribute to let us know which + // mode we are in, but adding @class in these cases throws an exception because + // @class is not expected in one place and not adding it throws exceptions + // when @class isn't found in another. There's some tricky subtlety in these formats + // relating to typing I haven't figured out + // objects.add(new Object[]{DefaultTyping.JAVA_LANG_OBJECT}); + // objects.add(new Object[]{DefaultTyping.OBJECT_AND_NON_CONCRETE}); + // objects.add(new Object[]{DefaultTyping.NON_CONCRETE_AND_ARRAYS}); + + // These work + objects.add(new Object[]{NON_FINAL}); + objects.add(new Object[]{EVERYTHING}); + return objects; + } + + + @Test + public void testDeserializeWithDefaultTyping() throws JsonProcessingException { + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(); + ObjectMapper mapper = new ObjectMapper() + .activateDefaultTyping(ptv, EVERYTHING, + JsonTypeInfo.As.PROPERTY); + System.out.println(defaultTyping); + // also test this when jackson is upgraded... + // List target = mapper.readerForListOf(Object.class).readValue(json); + @SuppressWarnings("rawtypes") + ArrayList list = mapper.readValue(TEST_JSON, ArrayList.class); + assertEquals(2, list.size()); + assertEquals(Outer.class, list.get(0).getClass()); // prove it's not a list of map objects + assertEquals(Inner.class, list.get(1).getClass()); + assertSame(((Outer) list.get(0)).inner, list.get(1)); + assertSame(((Inner) list.get(1)).outer, list.get(0)); + } + + @Test + public void testRoundTrip() throws JsonProcessingException { + + Outer outer = new Outer(); + Inner inner = new Inner(outer); + // Turn on type info + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(); + ObjectMapper mapper = new ObjectMapper() + .activateDefaultTyping(ptv, defaultTyping, + JsonTypeInfo.As.PROPERTY); + // sadly the following throws a null pointer exception + // mapper.getSerializerProvider().setAttribute(JSOGGenerator.DEFAULT_TYPING, "@class"); + + // probably there is a better way to do this without double instantiating the mapper, + // but it wasn't easy to find. One can also set this directly when invoking the write + // operations with mapper.writer().withAttribute(JSOGGenerator.DEFAULT_TYPING, "@class") + // if single instantiation is important, but then you need to do it every time you write. + // + // Note: there IS a better way after https://github.com/FasterXML/jackson-databind/issues/3001 + // but that requires 2.13.0+ jackson dependency. + SerializationConfig config = mapper.getSerializationConfig() + // obviously this attribute must be coordinated with the actual value used in the JSON + // (could also be "@c" or a custom name depending on config) + .withAttribute(JSOGGenerator.DEFAULT_TYPING_ATTRIBUTE, "@class"); + + mapper = new ObjectMapper() + // FAIL_ON_UNKNOWN_PROPERTIES = false is dangerous if combined with any of + // JAVA_LANG_OBJECT, OBJECT_AND_NON_CONCRETE, NON_CONCRETE_AND_ARRAYS + // The final line of this test demonstrates that it can lead to duplication of objects. + // .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTyping(ptv, defaultTyping, + JsonTypeInfo.As.PROPERTY).setConfig(config); + List source = Lists.newArrayList(outer, inner); + String json = mapper.writeValueAsString(source); + + // also test this when jackson is upgraded... + // List target = mapper.readerForListOf(Object.class).readValue(json); + @SuppressWarnings("rawtypes") + ArrayList list = mapper.readValue(json, ArrayList.class); + assertEquals(2, list.size()); + assertEquals(Outer.class, list.get(0).getClass()); // prove it's not a list of map objects + assertEquals(Inner.class, list.get(1).getClass()); + assertSame(((Outer) list.get(0)).inner, list.get(1)); + + // This is critical. It fails when .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + // and any of JAVA_LANG_OBJECT, OBJECT_AND_NON_CONCRETE, NON_CONCRETE_AND_ARRAYS are used; + assertSame(((Inner) list.get(1)).outer, list.get(0)); + } + + @Test + public void testSerializeWithDefaultTyping() throws JsonProcessingException { + Outer outer = new Outer(); + Inner inner = new Inner(outer); + // Turn on type info + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(); + ObjectMapper mapper = new ObjectMapper() + .activateDefaultTyping(ptv, EVERYTHING, + JsonTypeInfo.As.PROPERTY); + // sadly the following throws a null pointer exception + // mapper.getSerializerProvider().setAttribute(JSOGGenerator.DEFAULT_TYPING, "@class"); + + // probably there is a better way to do this but it wasn't easy to find quickly. + SerializationConfig config = mapper.getSerializationConfig().withAttribute(JSOGGenerator.DEFAULT_TYPING_ATTRIBUTE, "@class"); + mapper = new ObjectMapper() + .activateDefaultTyping(ptv, EVERYTHING, + JsonTypeInfo.As.PROPERTY).setConfig(config); + List source = Lists.newArrayList(outer, inner); + String json = mapper.writeValueAsString(source); + assertEquals(TEST_JSON, json); + } + + // This will also be caught by various other tests, but for completeness of this issue + // we should also explicitly test it here with the same objects + @Test + public void testSerializeWithoutDefaultTypeing() throws JsonProcessingException { + Outer outer = new Outer(); + Inner inner = new Inner(outer); + // Turn on type info + ObjectMapper mapper = new ObjectMapper(); + + List source = Lists.newArrayList(outer, inner); + String json = mapper.writeValueAsString(source); + + // make sure our test json is truly valid - this round trips through a list of maps + @SuppressWarnings("rawtypes") + ArrayList l = mapper.readValue(WITHOUT_DEFAULT_TYPING, ArrayList.class); + assertEquals(WITHOUT_DEFAULT_TYPING, mapper.writeValueAsString(l)); + + // type info should not be written unless Default typing is on + assertEquals(WITHOUT_DEFAULT_TYPING, json); + } + + // classes used in this test + + @SuppressWarnings("unused") + @JsonIdentityInfo(generator = JSOGGenerator.class) + public static class Outer { + private Inner inner; + + public Inner getInner() { + return inner; + } + + public void setInner(Inner inner) { + this.inner = inner; + } + } + + @SuppressWarnings("unused") + @JsonIdentityInfo(generator = JSOGGenerator.class) + public static class Inner { + + private Outer outer; + + Inner() { + } + + public Inner(Outer outer) { + this.outer = outer; + outer.inner = this; + } + + public Outer getOuter() { + return outer; + } + + public void setOuter(Outer outer) { + this.outer = outer; + } + } + +}