4

I have a standard polymorphic type Shape, and I can polymorphically deserialise it using the standard @JsonTypeInfo mechanism:

import static org.assertj.core.api.Assertions.assertThat;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;

public class DiscriminatorAliasTest {

  @JsonTypeInfo(use = Id.NAME, include = As.PROPERTY, property = "type")
  @JsonSubTypes({
      @JsonSubTypes.Type(value = Square.class, name = "square"),
      @JsonSubTypes.Type(value = Circle.class, name = "circle")
  })
  static abstract class Shape {
    abstract public String name();
  }

  static class Square extends Shape {
    @JsonProperty("A")
    public float width;

    @Override
    public String name() { return "square"; }
  }

  static class Circle extends Shape {
    @JsonProperty("A")
    public float diameter;

    @Override
    public String name() { return "circle"; }
  }

  @Test
  public void testDiscriminator() throws Exception {
    ObjectMapper mapper = new ObjectMapper();

    // This passes! 
    String squareJson = "{\"type\":\"square\", \"A\": 1.0 }";
    Shape square = mapper.readerFor(Shape.class).readValue(squareJson);
    assertThat(square.name()).isEqualTo("square");
  }
}

However, I would like to add ? as an alias to the discriminator property type such that the following JSON strings are deserialised by the Shape abstract class:

{ "type": "square", "A": 1.0 } AND { "?": "square", "A": 1.0 }

such that this test case would pass

  @Test
  public void testDiscriminator() throws Exception {
    ObjectMapper mapper = new ObjectMapper();

    // This passes!
    String squareJson = "{\"type\":\"square\", \"A\": 1.0 }";
    Shape square = mapper.readerFor(Shape.class).readValue(squareJson);
    assertThat(square.name()).isEqualTo("square");

    // and this should pass as well, with NO further modification
    // to this test case
    String squareJsonWithAlternativePropertyName = "{\"?\":\"square\", \"A\": 1.0 }";
    Shape alsoSquare = mapper.readerFor(Shape.class).readValue(squareJsonWithAlternativePropertyName);
    assertThat(alsoSquare.name()).isEqualTo("square");
  }

Why am I trying to do this?: I am trying to deserialise two different existing discriminated union encoding schemes onto the same class hierarchy - unfortunately there is no way in advance to know which scheme the class must deserialise.

Restrictions:

  • I cannot use @JsonTypeInfo(use = Id.DEDUCTION). There must be a discriminator property because the property key set cannot be used to disambiguate each class.
  • I cannot access or manipulate the ObjectMapper prior to mapping as this pattern exposes deserialisation implementation details to external modules. I assume this might rule out module.setDeserializerModifier / DelegatingDeserializer / BeanDeserializerModifier based solutions, such as the ones suggested in this question and this question.
  • I cannot manipulate the JSON, or equivalent JsonNode structure before it hits the ObjectMapper
  • I do not want to manually construct the Shape instance by hand by destructuring JsonNodes. I should reasonably expect to utilise the existing deserialisation machinery and annotations of Shape and its subtypes.

I am assuming these are reasonable restrictions - setting an alias for the discriminator property in Scala's Circe JSON library whilst complying with these restrictions was comparatively a single line change.

Thanks in advance!

Max Bo
  • 111
  • 1
  • 11
  • It is possible substitute the `?` property name with the `type` property name and after deserialize if there are no nested `?` properties, from what I know there are no builtin mechanisms to achieve your goal. – dariosicily Aug 15 '22 at 07:54
  • @dariosicily I assume that would be possible - what's the most reasonable way to achieve that with a custom `JsonDeserializer`? – Max Bo Aug 15 '22 at 08:09
  • No need of a `JsonDeserializer`, you can convert the json to a `JsonNode` and replace if it is present the `?` node with a `type` node and then deserialize the `JsonNode` object, I can submit a possible solution if you want. – dariosicily Aug 15 '22 at 08:39
  • @dariosicily Unfortunately I have no means of converting the JSON to a `JsonNode` before handing it to the `ObjectMapper`. Furthermore, I have no means of manipulating the `ObjectMapper` in any way. Is this still achievable? – Max Bo Aug 16 '22 at 04:43
  • @MaxBo Can you clarify what this means and why it is important? "...as this pattern exposes deserialisation implementation details to external modules." – Faron Aug 18 '22 at 20:56
  • Hi @Faron! Ideally other engineers would be able to use an "off the shelf" `ObjectMapper` in order to deserialise `Shape`. In the (non-working) code I have currently, deserialisation logic is encoded entirely in the annotations specified on `Shape`. However, the solutions suggested to those questions I linked above suggest constructing a `SimpleModule`, registering a `BeanDeserializerModifier`, and registering the module with the `ObjectMapper`. That would require an engineer to know that there is custom deserialisation associated with `Shape`, thus leaking deserialisation details. – Max Bo Aug 19 '22 at 04:11
  • @MaxBo One solution would be to use AOP to inject the behavior you desire. Otherwise, you will have to change something about how you acquire an ObjectMapper. If you allow developers to create an ObjectMapper at will, they can call `findAndRegisterModules` or you can provide a factory that does it. If you use Spring, you can simply create a bean for your custom module. – Faron Aug 20 '22 at 00:39

2 Answers2

7

Here is a way to solve your problem. The idea is to define a custom deserializer and associate it with the base class Shape, but restore the default deserializer for the inherited classes (via an empty @JsonDeserialize annotation). That trick prevents an endless loop.

The deserializer works like this:

  • It converts the JSON to a tree structure
  • It extracts the type from the tree
  • It calls treeToValue() with the tree and the type
import java.io.IOException;
import java.util.HashMap;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.node.*;

public class Test
{
    @JsonDeserialize(using = ShapeDeserializer.class)
    public static abstract class Shape
    {
        abstract public String name();
    }

    @JsonDeserialize
    public static class Square extends Shape
    {
        @JsonProperty("A")
        public float width;

        @Override
        public String name() { return "square"; }
    }

    @JsonDeserialize
    public static class Circle extends Shape
    {
        @JsonProperty("A")
        public float diameter;

        @Override
        public String name() { return "circle"; }
    }

    static class ShapeDeserializer extends JsonDeserializer<Shape>
    {
        private HashMap<String, Class<? extends Shape>> types = new HashMap<>();

        public ShapeDeserializer()
        {
            types.put("square", Square.class);
            types.put("circle", Circle.class);
        }

        public Shape deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
        {
            // Get the tree and extract the type from it
            ObjectNode root = (ObjectNode)p.readValueAsTree();
            TextNode type = (TextNode)root.remove("type");
            if(type==null)
                type = (TextNode)root.remove("?");
            Class<? extends Shape> valueType = types.get(type.textValue());

            // Convert the tree to an instance of the specified class
            return p.getCodec().treeToValue(root, valueType);
        }
    }

    public static void main(String[] args) throws JsonProcessingException
    {
        String squareJson = "{\"type\":\"square\", \"A\": 1.0 }";
        String circleJson = "{\"?\":\"circle\", \"A\": 1.0 }";

        ObjectMapper mapper = new ObjectMapper();
        Shape square = mapper.readerFor(Shape.class).readValue(squareJson);
        Shape circle = mapper.readerFor(Shape.class).readValue(circleJson);

        System.out.println(square.name());
        System.out.println(circle.name());
    }
}

Output:

square
circle
Olivier
  • 13,283
  • 1
  • 8
  • 24
2

There is no jackson builtin mechanism that can help you to achieve your goal, a workaround is convert the json string to a ObjectNode, check if the "?" property is present in it, if so add the "type" property with the "?" associated value, delete the "?" property and use the ObjectMapper#treeToValue method like below:

String squareJsonWithAlternativePropertyName = "{\"?\":\"square\", \"A\": 1.0 }";
ObjectNode node = (ObjectNode) mapper.readTree(squareJsonWithAlternativePropertyName);
if (node.has("?")) {
   node.set("type", node.get("?"));
   node.remove("?");
}
Shape shape = mapper.treeToValue(node, Shape.class);

You can encapsulate this code in one function, in case of nested properties or another case the code has to be updated but substantially the basecode remains still the same.

dariosicily
  • 4,239
  • 2
  • 11
  • 17
  • Unfortunately I have no means of prior manipulation of the `ObjectNode` or JSON stream before passing it to the mapper, hence my inquiry about the custom `Json JsonDeserializer` / `@JsonDeserialise(using = ...)` annotation pattern. – Max Bo Aug 17 '22 at 00:16
  • @MaxBo You can use my code and insert it into a custom jsondeserializer, reading the string as a `JsonNode` inside the jsondeserializer deserialize method and then create the object. – dariosicily Aug 17 '22 at 05:49
  • This subverts the discriminated union machinery entirely by forcing me to construct the `Shape` manually by hand. Utilising an `ObjectMapper` in the context of a `JsonDeserializer::deserialise` triggers an infinite loop. Unfortunately your solution is not fit for purpose. – Max Bo Aug 17 '22 at 06:01