Skip to content
Open
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/main/java/com/voodoodyne/jackson/jsog/JSOGGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
*/
public class JSOGGenerator extends ObjectIdGenerator<JSOGRef> {

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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/voodoodyne/jackson/jsog/JSOGRef.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
37 changes: 33 additions & 4 deletions src/main/java/com/voodoodyne/jackson/jsog/JSOGRefDeserializer.java
Original file line number Diff line number Diff line change
@@ -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 <jeff@infohazard.org>
*/
public class JSOGRefDeserializer extends JsonDeserializer<JSOGRef>
{
@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());
Expand All @@ -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());
}
}
}


16 changes: 13 additions & 3 deletions src/main/java/com/voodoodyne/jackson/jsog/JSOGRefSerializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -16,9 +15,13 @@
public class JSOGRefSerializer extends JsonSerializer<JSOGRef>
{
@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 {
Expand All @@ -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);
// }
}
225 changes: 225 additions & 0 deletions src/test/java/com/voodoodyne/jackson/jsog/Issue26Test.java
Original file line number Diff line number Diff line change
@@ -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<Object[]> data() {
ArrayList<Object[]> objects = new ArrayList<Object[]>();
// 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<Object> 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<Object> source = Lists.newArrayList(outer, inner);
String json = mapper.writeValueAsString(source);

// also test this when jackson is upgraded...
// List<Object> 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<Object> 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<Object> 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;
}
}

}