Search code examples
javajsonjacksonjackson-databindlottie

How to parse a mix of simple and complex JSON types to a list of Java objects with Jackson


I'm working on a Java library to be able to read/write Lottie data (animations defined as JSON). I try to achieve this with minimal code and Records, but it's a bit challenging to achieve this for all possible use cases as defined in the Lottie format. I've been able to handle almost everything, but I still need to find a suitable solution for the keyframes.

Given the following unit tests, how should the Java objects be defined to be able to parse the example JSONs? Is this possible with "pure Jackson" or will a helper class be needed? I'm using Jackson 2.14.1.

At this point, only testTimed succeeds.

public class KeyframeTest {

    private static final ObjectMapper mapper = new ObjectMapper();

    @Test
    void testInteger() throws JsonProcessingException {
        var json = """
                {
                    "k": [
                      128,
                      256
                    ]
                }
                """;

        var animated = mapper.readValue(json, Animated.class);

        assertAll(
                () -> assertEquals(2, animated.keyframes().size()),
                () -> assertTrue(animated.keyframes().get(0) instanceof NumberKeyframe),
                () -> assertEquals(128, animated.keyframes().get(0)),
                () -> JSONAssert.assertEquals(json, mapper.writeValueAsString(animated), false)
        );
    }

    @Test
    void testDouble() throws JsonProcessingException {
        var json = """
                {
                    "k": [
                      5.01,
                      6.02
                    ]
                }
                """;

        var animated = mapper.readValue(json, Animated.class);

        assertAll(
                () -> assertEquals(2, animated.keyframes().size()),
                () -> assertTrue(animated.keyframes().get(0) instanceof NumberKeyframe),
                () -> assertEquals(5.01, animated.keyframes().get(0)),
                () -> JSONAssert.assertEquals(json, mapper.writeValueAsString(animated), false)
        );
    }

    @Test
    void testTimed() throws JsonProcessingException {
        var json = """
                {
                    "k": [
                     {
                       "i": {
                         "x": [
                           0.833
                         ],
                         "y": [
                           0.833
                         ]
                       },
                       "o": {
                         "x": [
                           0.167
                         ],
                         "y": [
                           0.167
                         ]
                       },
                       "t": 60,
                       "s": [
                         1.1,
                         2.2,
                         3.3
                       ]
                     },
                     {
                       "t": 60,
                       "s": [
                         360.0
                       ]
                     }
                   ]
                }
                """;

        var animated = mapper.readValue(json, Animated.class);

        assertAll(
                () -> assertEquals(2, animated.keyframes().size()),
                () -> assertTrue(animated.keyframes().get(0) instanceof TimedKeyframe),
                () -> assertEquals(60, ((TimedKeyframe) animated.keyframes().get(0)).time()),
                () -> JSONAssert.assertEquals(json, mapper.writeValueAsString(animated), false)
        );
    }

    @Test
    void testMixed() throws JsonProcessingException {
        var json = """
                {
                    "k": [
                    100,
                    33.44,
                     {
                       "t": 60,
                       "s": [
                         1.1,
                         2.2,
                         3.3
                       ]
                     }
                   ]
                }
                """;

        var keyFrames = mapper.readValue(json, new TypeReference<List<Keyframe>>() {
        });

        assertAll(
                () -> assertEquals(3, keyFrames.size()),
                () -> assertTrue(keyFrames.get(0) instanceof NumberKeyframe),
                () -> assertTrue(keyFrames.get(1) instanceof NumberKeyframe),
                () -> assertTrue(keyFrames.get(2) instanceof TimedKeyframe),
                () -> JSONAssert.assertEquals(json, mapper.writeValueAsString(keyFrames), false)
        );
    }
}

Animated object

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(Include.NON_NULL)
public record Animated(
        @JsonProperty("a") Integer animated,
        @JsonProperty("k") List<Keyframe> keyframes,
        @JsonProperty("ix") Integer ix,
        @JsonProperty("l") Integer l
) {
}

Keyframe objects, using Java Records based on my earlier question here Parse JSON to Java records with fasterxml.jackson

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
        @JsonSubTypes.Type(TimedKeyframe.class),
        @JsonSubTypes.Type(NumberKeyframe.class)
})
public interface Keyframe {

}

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record NumberKeyframe(
        BigDecimal value
) implements Keyframe {
}

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record TimedKeyframe(
        @JsonProperty("t") Integer time, // in frames
        // Use BigDecimal here to be able to handle both Integer and Double
        // https://stackoverflow.com/questions/40885065/jackson-mapper-integer-from-json-parsed-as-double-with-drong-precision
        @JsonProperty("s") List<BigDecimal> values,
        @JsonProperty("i") EasingHandle easingIn,
        @JsonProperty("o") EasingHandle easingOut,
        @JsonProperty("h") Integer holdFrame
) implements Keyframe {
}

This is the failure message for testDouble:

com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Could not resolve subtype of [simple type, class com.lottie4j.core.model.keyframe.Keyframe]: Unexpected input
 at [Source: (String)"{
    "k": [
      5.01,
      6.02
    ]
}
"; line: 3, column: 7] (through reference chain: com.lottie4j.core.model.Animated["k"]->java.util.ArrayList[0])

Solution

  • Looks like jackson has a problem with deserializing numbers into objects. You could solve this using a custom deserializer or by making your NumberKeyFrame extend BigDecimal instead. Here is a working minimal example but I removed a lot of your code. Notice the defaultImpl in the JsonTypeInfo annotaton. This was necessary in order to work though I'm not 100% why :see_no_evil:

    public class MyTest {
    
        @JsonIgnoreProperties(ignoreUnknown = true)
        static class Animated {
    
            public @JsonProperty("k") List<Keyframe> keyframes = new ArrayList<>();
        }
    
        @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, defaultImpl = NumberKeyframe.class)
        @JsonSubTypes({
            @JsonSubTypes.Type(NumberKeyframe.class),
            @JsonSubTypes.Type(TimedKeyframe.class),
        })
        public interface Keyframe {
    
        }
    
        @JsonIgnoreProperties(ignoreUnknown = true)
        static class NumberKeyframe extends BigDecimal implements Keyframe {
    
            public NumberKeyframe(int val) {
                super(val);
            }
        }
    
        @JsonIgnoreProperties(ignoreUnknown = true)
        static class TimedKeyframe implements Keyframe {
    
            @JsonProperty("s")
            List<BigDecimal> values;
        }
    
        private static final ObjectMapper mapper = new ObjectMapper();
    
        @Test
        public void testInteger() throws JsonProcessingException, JSONException {
            var json = "{\"k\": [128,256]}";
    
            var animated = mapper.readValue(json, Animated.class);
            assertEquals(2, animated.keyframes.size());
    
            assertTrue(animated.keyframes.get(0) instanceof NumberKeyframe);
            assertEquals(128, ((NumberKeyframe) animated.keyframes.get(0)).intValue());
        }
    
        @Test
        void testTimed() throws JsonProcessingException {
            var json = "{\"k\": [\n"
                + "  {\"i\": {\"x\": [0.833],\"y\": [0.833]},\"o\": {\"x\": [0.167],\"y\": [0.167]},\"t\": 60,\"s\": [1.1,2.2,3.3]},\n"
                + "  { \"t\": 60, \"s\": [360.0]}\n"
                + "]}";
    
            var animated = mapper.readValue(json, Animated.class);
    
            assertEquals(2, animated.keyframes.size());
            assertTrue(animated.keyframes.get(0) instanceof TimedKeyframe);
            assertEquals(1.1, ((TimedKeyframe) animated.keyframes.get(0)).values.get(0).doubleValue());
            assertEquals(2.2, ((TimedKeyframe) animated.keyframes.get(0)).values.get(1).doubleValue());
            assertEquals(3.3, ((TimedKeyframe) animated.keyframes.get(0)).values.get(2).doubleValue());
        }
    }