javajsonjacksonrecord

Parse JSON to Java records with fasterxml.jackson


Java records can not - by design - inherit from another object (see Why Java records do not support inheritance?). So I wonder what would be the best way to achieve the following.

Given my JSON data contains objects that have some common data + unique data. For example, type, width and height are in all shapes, but depending on the type, they can have additional fields:

{
  "name": "testDrawing",
  "shapes": [
    {
      "type": "shapeA",
      "width": 100,
      "height": 200,
      "label": "test"
    },
    {
      "type": "shapeB",
      "width": 100,
      "height": 200,
      "length": 300
    },
    {
      "type": "shapeC",
      "width": 100,
      "height": 200,
      "url": "www.test.be",
      "color": "#FF2233"
    }
  ]
}

In "traditional" Java you would do this with

BaseShape with width and height
ShapeA extends BaseShape with label
ShapeB extends BaseShape with length
ShapeC extends BaseShape with URL and color

But I'm a bit stubborn and really would like to use records.

My solution now looks like this:

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Drawing(
        @JsonProperty("name")
        String name,

        @JsonProperty("shapes")
        @JsonDeserialize(using = TestDeserializer.class)
        List<Object> shapes // I don't like the Objects here... 
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ShapeA (
        @JsonProperty("type") String type,
        @JsonProperty("width") Integer width,
        @JsonProperty("height") Integer height,
        @JsonProperty("label") String label
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ShapeB(
        @JsonProperty("type") String type,
        @JsonProperty("width") Integer width,
        @JsonProperty("height") Integer height,
        @JsonProperty("length") Integer length
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ShapeC(
        @JsonProperty("type") String type,
        @JsonProperty("width") Integer width,
        @JsonProperty("height") Integer height,
        @JsonProperty("url") String url,
        @JsonProperty("color") String color
) {
}

I don't like repeated code and it's a bad practice... But in the end I can get this loaded with this helper class:

public class TestDeserializer extends JsonDeserializer {

    ObjectMapper mapper = new ObjectMapper();

    @Override
    public List<Object> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
        List<Object> rt = new ArrayList<>();

        JsonNode node = jsonParser.getCodec().readTree(jsonParser);

        if (node instanceof ArrayNode array) {
            for (Iterator<JsonNode> it = array.elements(); it.hasNext(); ) {
                JsonNode childNode = it.next();
                rt.add(getShape(childNode));
            }
        } else {
            rt.add(getShape(node));
        }

        return rt;
    }

    private Object getShape(JsonNode node) {
        var type = node.get("type").asText();
        switch (type) {
            case "shapeA":
                return mapper.convertValue(node, ShapeA.class);
            case "shapeB":
                return mapper.convertValue(node, ShapeB.class);
            case "shapeC":
                return mapper.convertValue(node, ShapeC.class);
            default:
                throw new IllegalArgumentException("Shape could not be parsed");
        }
    }
}

And this test proves to be working OK:

@Test
    void fromJsonToJson() throws IOException, JSONException {
        File f = new File(this.getClass().getResource("/test.json").getFile());
        String jsonFromFile = Files.readString(f.toPath());

        ObjectMapper mapper = new ObjectMapper();
        Drawing drawing = mapper.readValue(jsonFromFile, Drawing.class);
        String jsonFromObject = mapper.writeValueAsString(drawing);

        System.out.println("Original:\n" + jsonFromFile.replace("\n", "").replace(" ", ""));
        System.out.println("Generated:\n" + jsonFromObject);

        assertAll(
                //() -> assertEquals(jsonFromFile, jsonFromObject),
                () -> assertEquals("testDrawing", drawing.name()),
                () -> assertTrue(drawing.shapes().get(0) instanceof ShapeA),
                () -> assertTrue(drawing.shapes().get(1) instanceof ShapeB),
                () -> assertTrue(drawing.shapes().get(2) instanceof ShapeC)
        );
    }

What would be the best way to achieve this with the Jackson library and Java Records?

Extra sidenote: I will also need to be able to write back to JSON in the same format as the original.


Solution

  • Records cannot inherit because they are intended to be a solid contract, but they can implement an interface. So you can do something like this with JasonSubTypes with Jackson 2.12 or above:

    Models

    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record Drawing(
            String name,
            List<BaseShape> shapes
    ) { }
    
    // added benefit of interface here is it reminds you to have the default fields
    @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
    @JsonSubTypes({
            @JsonSubTypes.Type(ShapeA.class),
            @JsonSubTypes.Type(ShapeB.class),
            @JsonSubTypes.Type(ShapeC.class)
    })
    public interface BaseShape {
        Integer width();
        Integer height();
    }
    
    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record ShapeA (
            Integer width,
            Integer height,
            String label
    ) implements BaseShape { }
    
    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record ShapeB(
            Integer width,
            Integer height,
            Integer length
    ) implements BaseShape { }
    
    @JsonIgnoreProperties(ignoreUnknown = true)
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public record ShapeC(
            Integer width,
            Integer height,
            String url,
            String color
    ) implements BaseShape { }
    

    Test Class

    @Slf4j
    class DemoTest {
    
        private ObjectMapper objectMapper = ObjectMapperBuilder.getObjectMapper();
    
        @Test
        void test() throws JsonProcessingException {
            final String testString = objectMapper
                    .writerWithDefaultPrettyPrinter()
                    .writeValueAsString(
                            new Drawing(
                                    "happy",
                                    List.of(
                                            new ShapeA(1, 1, "happyShape"),
                                            new ShapeB(2, 2, 3),
                                            new ShapeC(2, 2, "www.shape.com/shape", "blue"
                                            )
                                    )
                            )
                    );
    
            log.info("From model to string {}", testString);
    
            Drawing drawing = objectMapper.readValue(testString, Drawing.class);
    
            log.info(
                    "Captured types {}",
                    drawing
                        .shapes()
                        .stream()
                        .map(s -> s.getClass().getName())
                        .collect(Collectors.toSet())
            );
    
            log.info(
                    "From string back to model then again to string {}",
                    objectMapper
                        .writerWithDefaultPrettyPrinter()
                        .writeValueAsString(drawing)
            );
        }
    
    }
    
    

    Here's the test log output:

    17:06:41.293 [Test worker] INFO com.demo.DemoTest - From model to string {
      "name" : "happy",
      "shapes" : [ {
        "width" : 1,
        "height" : 1,
        "label" : "happyShape"
      }, {
        "width" : 2,
        "height" : 2,
        "length" : 3
      }, {
        "width" : 2,
        "height" : 2,
        "url" : "www.shape.com/shape",
        "color" : "blue"
      } ]
    }
    17:06:41.353 [Test worker] INFO com.demo.DemoTest - Captured types [com.demo.DemoTest$ShapeB, com.demo.DemoTest$ShapeA, com.demo.DemoTest$ShapeC]
    17:06:41.354 [Test worker] INFO com.demo.DemoTest - From string back to model then again to string {
      "name" : "happy",
      "shapes" : [ {
        "width" : 1,
        "height" : 1,
        "label" : "happyShape"
      }, {
        "width" : 2,
        "height" : 2,
        "length" : 3
      }, {
        "width" : 2,
        "height" : 2,
        "url" : "www.shape.com/shape",
        "color" : "blue"
      } ]
    }
    
    

    Note that you can add the type field as a name property of the @JsonSubTypes.Type annotation, but this works with or without discriminator as long as the fields in your records are never exactly the same.

    You can read more about JsonSubtypes here.