Custom polymorphic (de)serialisation avoiding type information on base type

664 views
Skip to first unread message

a...@caravelo.com

unread,
Jun 7, 2018, 5:18:59 PM6/7/18
to jackson-user
Hi there,

I have been investigating this issue for the past days and even though I feel like I'm close to achieving, I'm not 100% sure if it's doable.

In short what I'm trying to achieve is to make an advanced usage of the "Polymorphic Deserialisation" with the following requirements:
  1. Using annotations is not an option
  2. The type information of the base class must be omitted (in both serialisation and deserialisation) for backwards compatibility reasons
Let's say that I have the following entities, for instance:

public class Request
{
public String currency;
}

public class RequestA extends Request
{
public String fieldA;
}

public class RequestB extends Request
{
public String fieldB;
}

Also I have this custom type id resolver:

public class CustomTypeIdResolver extends TypeIdResolverBase
{
private JavaType superType;

@Override
public void init(JavaType baseType)
    {
superType = baseType;
}

@Override
public String idFromValue(Object value)
{
return idFromValueAndType(value, value.getClass());
}

@Override
public String idFromValueAndType(Object value, Class<?> suggestedType)
{
String typeId = null;
switch (suggestedType.getSimpleName()) {
case "RequestA":
typeId = "A";
break;
case "RequestB":
typeId = "B";
}
return typeId;
}

@Override
public JavaType typeFromId(DatabindContext context, String id) {
Class<?> subType = null;
switch (id) {
case "A":
subType = RequestA.class;
break;
case "B":
subType = RequestB.class;
}
return context.constructSpecializedType(superType, subType);
}

@Override
public JsonTypeInfo.Id getMechanism()
{
return JsonTypeInfo.Id.CUSTOM;
}
}

And this custom annotation introspector:

public class CustomAnnotationIntrospector extends JacksonAnnotationIntrospector
{
@Override
public TypeResolverBuilder<?> findTypeResolver(MapperConfig<?> config,
AnnotatedClass ac, JavaType baseType)
{
// Preserve default behaviour
TypeResolverBuilder<?> typeResolver = super.findTypeResolver(config, ac, baseType);
if (typeResolver != null ) {
return typeResolver;
}

if (ac.getAnnotated().equals(RequestA.class)) {
typeResolver = new StdTypeResolverBuilder();
typeResolver = typeResolver.init(JsonTypeInfo.Id.CUSTOM, new CustomTypeIdResolver());
typeResolver = typeResolver.inclusion(JsonTypeInfo.As.PROPERTY);
typeResolver.typeProperty("@type");
} else if (ac.getAnnotated().equals(RequestB.class)) {
typeResolver = new StdTypeResolverBuilder();
typeResolver = typeResolver.init(JsonTypeInfo.Id.CUSTOM, new CustomTypeIdResolver());
typeResolver = typeResolver.inclusion(JsonTypeInfo.As.PROPERTY);
typeResolver.typeProperty("@type");
}

return typeResolver;
}
}

When I run it like this:

@Test
public void test() throws Exception
{
Request r = new Request();
r.currency = "R";

RequestA a = new RequestA();
a.currency = "A";
a.fieldA = "field A";

RequestB b = new RequestB();
b.currency = "B";
b.fieldB = "field B";

ObjectMapper mapper = new ObjectMapper();
mapper.setAnnotationIntrospector(new CustomAnnotationIntrospector());

String contentR = write(mapper, r);
String contentA = write(mapper, a);
String contentB = write(mapper, b);

Request readR = read(mapper, contentR);
RequestA readA = (RequestA) read(mapper, contentA);
RequestB readB = (RequestB) read(mapper, contentB);

assertEquals(r, readR);
assertEquals(a, readA);
assertEquals(b, readB);
}

private static String write(ObjectMapper mapper, Request r) throws Exception
{
String content = mapper.writeValueAsString(r);
System.out.println(content);
return content;
}

private static Request read(ObjectMapper mapper, String value) throws Exception
{
return mapper.readValue(value, Request.class);
}

I get this output:

{"currency":"R"}
{"@type":"A","currency":"A","fieldA":"field A"}
{"@type":"B","currency":"B","fieldB":"field B"}


com
.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "@type" (class cvo.experiments.Request), not marked as ignorable (one known property: "currency"])
 at
[Source: {"@type":"A","currency":"A","fieldA":"field A"}; line: 1, column: 11] (through reference chain: cvo.experiments.Request["@type"])


 at com
.fasterxml.jackson.databind.exc.UnrecognizedPropertyException.from(UnrecognizedPropertyException.java:62)
 at com
.fasterxml.jackson.databind.DeserializationContext.handleUnknownProperty(DeserializationContext.java:834)
 at com
.fasterxml.jackson.databind.deser.std.StdDeserializer.handleUnknownProperty(StdDeserializer.java:1094)
 at com
.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownProperty(BeanDeserializerBase.java:1470)
 at com
.fasterxml.jackson.databind.deser.BeanDeserializerBase.handleUnknownVanilla(BeanDeserializerBase.java:1448)
 at com
.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:282)
 at com
.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:140)
 at com
.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3798)
 at com
.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2842)
 at cvo
.experiments.MapperTest.read(MapperTest.java:200)
 at cvo
.experiments.MapperTest.without_annotations_best(MapperTest.java:183)
 at sun
.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
 at sun
.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
 at sun
.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
 at java
.lang.reflect.Method.invoke(Method.java:498)
 at org
.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
 at org
.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
 at org
.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
 at org
.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
 at org
.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271)
 at org
.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:70)
 at org
.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:50)
 at org
.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
 at org
.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
 at org
.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
 at org
.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
 at org
.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
 at org
.junit.runners.ParentRunner.run(ParentRunner.java:309)
 at org
.junit.runner.JUnitCore.run(JUnitCore.java:160)
 at com
.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
 at com
.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
 at com
.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
 at com
.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)

This is what has happened:
  • All the entities have been serialised as expected - the base type does not include type information and the subtypes do
  • The base type has been properly deserialised back to an object
  • It fails to deserialise the subtypes, even though that (as I understand) the custom annotation introspector is providing type field and the custom type id resolver that knows how to get the right type
I would like to know if what I'm trying to achieve is possible or not. If it is, what's wrong in my approach?

Many thanks and regards.

This message and its attachments are intended for the exclusive use of the addressee(s) stated above and contains privileged and confidential information. If you have received this message in error, you are on notice of its privileged and confidential status and bound to keep the information in the message and attachments confidential. Please notify the sender immediately and delete this message from your system, making no copy of it.

Tatu Saloranta

unread,
Jun 7, 2018, 5:24:38 PM6/7/18
to jackson-user
On Thu, Jun 7, 2018 at 4:49 AM, <a...@caravelo.com> wrote:
> Hi there,
>
> I have been investigating this issue for the past days and even though I
> feel like I'm close to achieving, I'm not 100% sure if it's doable.
>
> In short what I'm trying to achieve is to make an advanced usage of the
> "Polymorphic Deserialisation" with the following requirements:
>
> Using annotations is not an option
> The type information of the base class must be omitted (in both
> serialisation and deserialisation) for backwards compatibility reasons
>

Ok. Short answer is no, this is not the way system was designed to
work: assumption is that type id either always exists, or never does,
for given type (or, given property).

Having said that, there is one issue that I have been hoping to implement:

https://github.com/FasterXML/jackson-databind/issues/644

which might work the way you want; and if implemented, could possibly
be configurable to assume declared base type to be considered
`defaultImpl`.

Part about not using annotations could be doable by custom
`AnnotationIntrospector`.

But as things are, I don't think you can use Jackson's polymorphic
type handling to achieve what you are trying to do.

-+ Tatu +-
> --
> You received this message because you are subscribed to the Google Groups
> "jackson-user" group.
> To unsubscribe from this group and stop receiving emails from it, send an
> email to jackson-user...@googlegroups.com.
> To post to this group, send email to jackso...@googlegroups.com.
> For more options, visit https://groups.google.com/d/optout.

a...@caravelo.com

unread,
Jun 8, 2018, 2:48:54 AM6/8/18
to jackson-user
OK fair enough, thanks a lot for your clarification :)

I think my use case makes sense, IMHO it's particularly useful if you have the requirement of backwards compatibility, be it related to a REST API or a NoSQL database.

If you provide more details an tips about the implementation of the issue you have pointed out, I can give it a try and send a PR if I was successful. Please bear in mind that I have just been few days going through Jackson's most intimate parts :)

Cheers.
Message has been deleted

a...@caravelo.com

unread,
Jun 8, 2018, 10:00:03 AM6/8/18
to jackson-user
I know I'm a pig headed, but my feeling of being close made me keep trying :)

I have managed to do it with a slight variations in my custom annotation introspector and type id resolver. I'll post the complete solution I currently have and I'd like to know if you envision any sort of risk. This is just a POC and I'd like to use it in production with several classes hierarchies.

The base entity and sub-entities:

public class Request
{
public String currency;

    @Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Request that = (Request) o;
return Objects.equals(currency, that.currency);
}

@Override
public int hashCode()
{
return Objects.hash(currency);
}

@Override
public String toString()
{
final StringBuffer sb = new StringBuffer("BaseRequest{");
sb.append("currency='").append(currency).append('\'');
sb.append('}');
return sb.toString();

}
}

public class RequestA extends Request
{
public String fieldA;

    @Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
RequestA requestA = (RequestA) o;
return Objects.equals(fieldA, requestA.fieldA);
}

@Override
public int hashCode()
{

return Objects.hash(super.hashCode(), fieldA);
}

@Override
public String toString()
{
final StringBuffer sb = new StringBuffer("RequestA{");

sb.append("fieldA='").append(fieldA).append('\'');
sb.append(", currency='").append(currency).append('\'');
sb.append('}');
return sb.toString();
}
}

public class RequestB extends Request
{
    @Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
RequestB requestB = (RequestB) o;
return Objects.equals(fieldB, requestB.fieldB);
}

@Override
public int hashCode()
{

return Objects.hash(super.hashCode(), fieldB);
}

@Override
public String toString()
{
final StringBuffer sb = new StringBuffer("RequestB{");
sb.append("fieldB='").append(fieldB).append('\'');
sb.append(", currency='").append(currency).append('\'');
sb.append('}');
return sb.toString();
}

public String fieldB;
}


The custom annotation introspector:

public class CustomAnnotationIntrospector extends JacksonAnnotationIntrospector
{
@Override
public TypeResolverBuilder<?> findTypeResolver(MapperConfig<?> config,
AnnotatedClass ac, JavaType baseType)
{
// Preserve default behaviour
TypeResolverBuilder<?> typeResolver = super.findTypeResolver(config, ac, baseType);
if (typeResolver != null ) {
return typeResolver;
}

        // Used to serialise RequestA
        if (ac.getAnnotated().equals(RequestA.class)) {
typeResolver = new StdTypeResolverBuilder();
typeResolver = typeResolver.init(JsonTypeInfo.Id.CUSTOM, new CustomTypeIdResolver());
typeResolver = typeResolver.inclusion(JsonTypeInfo.As.PROPERTY);
            typeResolver = typeResolver.typeProperty("@type");
typeResolver = typeResolver.defaultImpl(Request.class);
return typeResolver;
}

// Used to serialise RequestB
        if (ac.getAnnotated().equals(RequestB.class)) {
typeResolver = new StdTypeResolverBuilder();
typeResolver = typeResolver.init(JsonTypeInfo.Id.CUSTOM, new CustomTypeIdResolver());
typeResolver = typeResolver.inclusion(JsonTypeInfo.As.PROPERTY);
            typeResolver = typeResolver.typeProperty("@type");
typeResolver = typeResolver.defaultImpl(Request.class);
}

// Used to serialise Request (base type) and its subtypes
if (ac.getAnnotated().equals(Request.class)) {
typeResolver = new StdTypeResolverBuilder();
// Base type required upon de-serialisation of subtypes
typeResolver = typeResolver.init(JsonTypeInfo.Id.CUSTOM, new CustomTypeIdResolver(baseType));
typeResolver = typeResolver.inclusion(JsonTypeInfo.As.PROPERTY);
typeResolver = typeResolver.typeProperty("@type");
typeResolver = typeResolver.defaultImpl(Request.class);
}

return typeResolver;
}
}


The custom type id resolver:

public class CustomTypeIdResolver extends TypeIdResolverBase
{
    private JavaType baseType;

public CustomTypeIdResolver()
{
baseType = null;
}

public CustomTypeIdResolver(JavaType baseType)
{
this.baseType = baseType;
    }

@Override
public void init(JavaType baseType)
{
        // Assumed it will never be called by Jackson
    }

@Override
public String idFromValue(Object value)
{
return idFromValueAndType(value, value.getClass());
}

@Override
public String idFromValueAndType(Object value, Class<?> suggestedType)
{
String typeId = null;
switch (suggestedType.getSimpleName()) {
case "RequestA":
typeId = "A";
break;
case "RequestB":
typeId = "B";
}
return typeId;
}

@Override
public JavaType typeFromId(DatabindContext context, String id) {
Class<?> subType = null;
switch (id) {
case "A":
subType = RequestA.class;
break;
case "B":
subType = RequestB.class;
}
        return context.constructSpecializedType(baseType, subType);
    }

@Override
public JsonTypeInfo.Id getMechanism()
{
return JsonTypeInfo.Id.CUSTOM;
}
}


The test:

    @Test
public void without_annotations_dream() throws Exception

{
Request r = new Request();
r.currency = "R";

RequestA a = new RequestA();
a.currency = "A";
        a.fieldA = "field A";

RequestB b = new RequestB();
b.currency = "B";
        b.fieldB = "field B";

ObjectMapper mapper = new ObjectMapper();
        mapper.setAnnotationIntrospector(new CustomAnnotationIntrospector());

String contentR = write(mapper, r);
        String contentA = write(mapper, a);
String contentB = write(mapper, b);

Request readR = read(mapper, contentR);
RequestA readA = (RequestA) read(mapper, contentA);
        RequestB readB = (RequestB) read(mapper, contentB);

assertEquals(r, readR);
        assertEquals(a, readA);
assertEquals(b, readB);
    }

private static String write(ObjectMapper mapper, Request r) throws Exception
{
String content = mapper.writeValueAsString(r);
        System.out.println(String.format("write >>> %s", content));
return content;
    }

private static Request read(ObjectMapper mapper, String value) throws Exception
{
        Request request = mapper.readValue(value, Request.class);
        System.out.println(String.format("read <<< %s", request));
return request;
    }


And the execution result:

write >>> {"currency":"R"}
write
>>> {"@type":"A","currency":"A","fieldA":"field A"}
write
>>> {"@type":"B","currency":"B","fieldB":"field B"}
read
<<< BaseRequest{currency='R'}
read
<<< RequestA{fieldA='field A', currency='A'}
read
<<< RequestB{fieldB='field B', currency='B'}


I would really appreciate your comments.

Finally, I would also appreciate if you can give me some tips to address the Github issue you pointed out, I would not mind giving it a try now that I have some Jackson internal concepts fresh.

Cheers.
Reply all
Reply to author
Forward
0 new messages