Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I add an alias to the discriminator property used to support polymorphic deserialisation in Jackson?

Tags:

java

json

jackson

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!

like image 338
Max Bo Avatar asked Oct 23 '25 18:10

Max Bo


2 Answers

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
like image 85
Olivier Avatar answered Oct 26 '25 08:10

Olivier


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.

like image 41
dariosicily Avatar answered Oct 26 '25 08:10

dariosicily



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!