Deserialize JSON into record with generic type

76 views Asked by At

I have a service returning JSON with a "stable" response, but containing "projection" field which can vary. Example:

{
  "name": "test",
  "projection": {
    "id": 1,
    "description": "Hey there",
    "blurb": "This is a test"
  }
}

I want to deserialize into records and provide a mechanism where one can define their own record for the projection that has been returned. This is what I came up with, a Response record with a generic member that implements a Projection interface.

package com.example.json

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import java.io.IOException;

public class Example {
  private static final ObjectMapper MAPPER =
      new ObjectMapper()
          .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
          .configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, true)
          .registerModule(new Jdk8Module());

  private static final String SAMPLE_JSON =
      "{\"name\":\"test\", \"projection\": {\"id\": 1, \"description\": \"Hey there\", \"blurb\": \"This is a test\"}}";

  record Response<T extends Projection>(String name, T projection) {}

  interface Projection {
    int id();

    static <T extends Projection> Response<T> parse(byte[] bytes) {
      try {
        TypeReference<Response<T>> typeReference = new TypeReference<>() {};
        return MAPPER.readValue(bytes, typeReference);  // Explodes here
      } catch (IOException e) {
        throw new RuntimeException(e);
      }
    }
  }

  record Projection1(int id, String description, String blurb) implements Projection {}

  public static void main(String[] args) {
    Response<Projection1> response = Projection.<Projection1>parse(SAMPLE_JSON.getBytes());

    System.out.println(response);
  }
}

This fails with an error:

Exception in thread "main" java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `com.example.json.Example$Projection` (no Creators, like default constructor, exist): abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 31] (through reference chain: com.example.json.Example$Response["projection"])
    at com.example.json.Example$Projection.parse(Example.java:29)
    at com.example.json.Example.main(Example.java:37)

I'm foreseeing quite a few variations of the projections. Is there a way for me to keep the parsing logic in the interface / one place, and avoid having to e.g. implement the parse method on each concrete Projection implementation, which would have access to the concrete types?

1

There are 1 answers

1
haeger On BEST ANSWER

Got this to work by adding the TypeReference to the Projection::parse signature.

  interface Projection {
    int id();

    static <T extends Projection> Response<T> parse(byte[] bytes, TypeReference<Response<T>> typeReference) {
      try {
        return MAPPER.readValue(bytes, typeReference);
      } catch (IOException e) {
        throw new RuntimeException(e);
      }
    }

And then pass it when calling:

  public static void main(String[] args) {
    Response<Projection1> response = Projection.parse(SAMPLE_JSON.getBytes(), new TypeReference<>() {});
    System.out.println(response);
  }

Ideally, I wouldn't want to pass the TypeReference, but this works.