Home > database >  Parse JSON to Java records with fasterxml.jackson
Parse JSON to Java records with fasterxml.jackson

Time:12-23

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:

  • No BaseShape
  • The common fields are repeated in all records
@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.

CodePudding user response:

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.

  • Related