jackson-databind
jackson-databind copied to clipboard
@JsonIdentityInfo doesn't handle references correctly in a generic list
Describe the bug I'm serializing and deserializing a generic list.
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
public static class Foo { }
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
public static class Bar { }
public static void main(String[] args) throws Exception {
// Turn on type info for generic list
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
ObjectMapper mapper = new ObjectMapper()
.activateDefaultTyping(ptv,
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY);
Foo obj = new Foo();
Bar bar = new Bar();
List<Object> source = Lists.newArrayList(obj, obj, bar);
String json = mapper.writeValueAsString(source);
System.out.println(json);
//See below
List<Object> target = mapper.readerForListOf(Object.class).readValue(json);
System.out.println(target);
//Got a Foo instance, a string and a Bar instance in target list
}
The second one losts its type info.
[
"java.util.ArrayList",
[
{
"@class": "com.foo.SomeTest$Foo",
"@id": "a7539e7b-4299-4be2-a2be-8b58af8acd9c"
},
"a7539e7b-4299-4be2-a2be-8b58af8acd9c",
{
"@class": "com.foo.SomeTest$Bar",
"@id": "c4c4cb46-95c9-4e33-b2ca-5ab70ff856db"
}
]
]
Version information Which Jackson version(s) was this for?
2.13.1
To Reproduce See above
Expected behavior Source list and target list should be equal.
Additional context I also tried JSOGGenerator by jackson-jsog. It does serialize well while it can't deserialize the property "@id".
Right, this does not and will not work: combination of "untyped" (java.lang.Object) root value, type info AND identity info cannot be supported.
You will need to untangle this combination: there are couple of things to try.
First, at the root level, you probably should use a wrapper object to avoid Java Type Erasure. Something like:
public class Wrapper {
public List<Object> values;
// getters, setters if you don't want to expose public field.
}
But there is also another potential problem with Object Id: identifiers for different types are in different scopes.
I guess most generally it'd be necessary to understand exactly what you are trying to achieve here. It is probably doable, but it is difficult to try to backtrack from not-working code into working one without knowing requirements.
@cowtowncoder
I'm trying to make a RPC,
- first serialize
MethodInvocation.getArguments()(which is anObject[]) and some other things including the method signature. - and then send the json to an http endpoint,
- finally deserialize the json, proceed the invocation, and send back the result vice versa.
Note that the arguments and result are very complex, including some ArrayList < Object >, and circular reference. So I have to turn on PolymorphicTypeValidator and JsonIdentityInfo.
Unfortunately, I do not think combination of Polymorphic typing (via Default Typing) and Object Identity can be made to work in the way you would want. So something will have to give.
What a pity.
I was wondering why Jackson does not put the Object Identity in a meta property like "@ref". Then it can be distinguished between a normal string and an object reference during deserializing.
BTW, I tried JSOGGenerator, and It does serialize well with the combination. I guess there's some bug during deserializing, either caused by jackson or the plugin.
Jackson uses whatever property is configured for property to be written, but configuration for Object Id (and references) depends on type of value -- and must be enabled for the type. But there is no universally agreed on property that would always signal Object Id, so there is no way to assume something is Object Id. Same goes for Type Ids.
And since java.lang.Object is effectively "untyped" base type, it is not possible to support both of these dimension (Type and Object Id), the way Jackson works.
If properties for both were hard-coded for all use cases this could work.
I figured out a workaround, which could pass the case above(when the reference stays in a generic list), especially for StringIdGenerator.
// register this module
// SimpleModule module = new SimpleModule().addDeserializer(Object.class, new WorkAroundUntypedObjectDeserializer());
// objectMapper.registerModule(module)
public class WorkAroundUntypedObjectDeserializer extends UntypedObjectDeserializer.Vanilla {
@Override
public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException {
boolean isStringToken = (p.currentTokenId() == JsonTokenId.ID_STRING);
Object obj = super.deserializeWithType(p, ctxt, typeDeserializer);
// Try resolve objectId
if (isStringToken) {
ReadableObjectId roid = ctxt.findObjectId(obj, new ObjectIdGenerators.StringIdGenerator(), new SimpleObjectIdResolver());
Object result = roid.resolve();
if (result != null) {
return result;
}
}
// Fallback to default behavior
return obj;
}
}
This is another similar workaround when the reference stays in an abstract class bean property.
// register this module
// objectMapper.addHandler(new RefWorkaroundDeserializationProblemHandler())
public class RefWorkaroundDeserializationProblemHandler extends DeserializationProblemHandler {
@Override
public JavaType handleMissingTypeId(DeserializationContext ctxt, JavaType baseType, TypeIdResolver idResolver, String failureMsg) throws IOException {
String ref = ctxt.getParser().getText();
ReadableObjectId roid = ctxt.findObjectId(ref, new ObjectIdGenerators.StringIdGenerator(), new SimpleObjectIdResolver());
Object result = roid.resolve();
if (result != null) {
return ctxt.getTypeFactory().constructFromCanonical(result.getClass().getName());
}
// as default
return null;
}
}
And two unit tests here
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
public static class Foo {
}
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
public static class Bar {
}
@Test
public void testRefWorkAroundUntypedObjectDeserializer() throws Exception {
ChunnelObjectMapperConfig config = new ChunnelObjectMapperConfig();
ObjectMapper mapper = config.chunnelObjectMapper();
Foo foo = new Foo();
Bar bar = new Bar();
UUID uuid = UUID.randomUUID();
List<Object> source = Lists.newArrayList(foo, foo, bar, uuid.toString());
String json = mapper.writeValueAsString(source);
System.out.println(json);
List<Object> target = mapper.readerForListOf(Object.class).readValue(json);
System.out.println(target);
Assert.assertTrue(target.get(1) instanceof Foo);
Assert.assertTrue(target.get(2) instanceof Bar);
Assert.assertTrue(target.get(3) instanceof String);
}
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
@AllArgsConstructor
@NoArgsConstructor
public static class BazOwner {
@Getter
private Baz hold;
}
public abstract static class Baz {
}
@JsonIdentityInfo(generator = ObjectIdGenerators.StringIdGenerator.class)
public static class SubBaz extends Baz {
}
@Test
public void testRefWorkaroundDeserializationProblemHandler() throws Exception {
SubBaz subBaz = new SubBaz();
BazOwner bazOwner = new BazOwner(subBaz);
List<Object> source = Lists.newArrayList(subBaz, bazOwner);
String json = mapper.writeValueAsString(source);
System.out.println(json);
List<Object> target = mapper.readerForListOf(Object.class).readValue(json);
System.out.println(target);
BazOwner bazOwnerTarget = (BazOwner) target.get(1);
Assert.assertEquals(SubBaz.class, bazOwnerTarget.getHold().getClass());
}
A simpler approach might be to create an empty RPCObject interface with @JsonTypeInfo annotation and just make all your RPC objects implement it (if you have control over objects).