Search code examples
javajsonjackson

Have Jackson default to string if object not detected


Using this class structure

public class Customer<T>{
    private String name;
    private String store;
    private T details;
}

public class Details{
    private String dob;
    private boolean validated;
}

It works when the JSON is the following

{
  "name": "John Smith",
  "store": "Walmart",
  "details": {
    "dob": "1900/01/01",
    "validated": true
  }

}

But when the given JSON response is like below it fails.

{
  "name": "John Smith",
  "store": "Walmart",
  "details": [
    "Failed customer does not exist in the system", "Please contact administrator"
  ]
}

Is there any way with Jackson mapper to handle these scenarios? If details is provided as pojo then write as such, otherwise write details as a string value. I defined my mapper as such

private static final ObjectMapper Mapper = new ObjectMapper() {{
    setSerializationInclusion(JsonInclude.Include.NON_NULL);
    setDateFormat(new SimpleDateFormat("MM/dd/yyyy HH:mm:ss z", Locale.US));
    configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}}; 

Solution

  • If before the deserialization you knew already what the json contained for the field details, you could simply read a generic Customer with a TypeReference. In this scenario, you would have your error messages as a List<String> instead of a String though, sticking more to the json structure.

    //Reading a generic Customer with a Details pojo
    Customer<Details> c1 = mapper.readValue(jsonPojo, new TypeReference<Customer<Details>>() {});
    
    //Reading a generic Customer with an array of String error messages
    Customer<List<String>> c2 = mapper.readValue(jsonArray, new TypeReference<Customer<List<String>>>() {});
    

    However, this solution requires knowing what the json contains in advance (which cannot be the case), or analyzing it beforehand and then proceeding with the appropriate deserialization (which can be tedious).

    So, in order to offer a more generic solution while maintaining the flexibility of generics, it's necessary to employ a custom deserializer for the field details, as the logic is quite specific and configurations on the ObjectMapper alone won't do it.

    If details is provided as pojo then write as such otherwise write details as a string value.

    However, defining a custom deserializer for a generic type T makes little sense, as after implementing the deserialization logic for the two cases Details and String, we would be forced to cast the result to the generic type T, thwarting the whole point of using generics and losing their main advantage of type safety at compile time.

    What we could do instead is to find a common type denominator among the possible data types for the field details. At the moment, details can be either an instance of Details or a String. The String class implements the interface Serializable, so if the class Details would implement it too, we could use Serializable as an upper bound for the generic type T and define the custom deserializer for Serializable. This approach implies that every type passed in place of T must a sub-type of Serializable. This is not a really strict requirement as most Java classes already implement the interface, while our classes could easily implement it too without having to provide any implementation.

    Pojos

    public class Customer<T extends Serializable> {
    
        private String name;
        private String store;
        @JsonDeserialize(using = MyDeserializer.class)
        private T details;
    
        // ... implementation ...
    }
    
    public class Details implements Serializable {
    
        private String dob;
        private boolean validated;
        
        // ... implementation ...
    }
    

    Custom Deserializer

    public class MyDeserializer extends StdDeserializer<Serializable> {
        public MyDeserializer() {
            this(null);
        }
    
        public MyDeserializer(Class<?> c) {
            super(c);
        }
    
        @Override
        public Serializable deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException {
            JsonNode node = p.getCodec().readTree(p);
            if (node.isArray()) {
                return StreamSupport.<JsonNode>stream(Spliterators.spliteratorUnknownSize(node.iterator(), Spliterator.ORDERED), false)
                        .map(JsonNode::textValue)
                        .collect(Collectors.joining(" - "));
            }
            return ctxt.readTreeAsValue(node, Details.class);
        }
    }
    

    Test Main

    public class Main {
        public static void main(String[] args) throws JsonProcessingException {
            ObjectMapper mapper = new ObjectMapper() {{
                setSerializationInclusion(JsonInclude.Include.NON_NULL);
                setDateFormat(new SimpleDateFormat("MM/dd/yyyy HH:mm:ss z", Locale.US));
                configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
            }};
    
            String jsonPojo = """
                    {
                      "name": "John Smith",
                      "store": "Walmart",
                      "details": {
                        "dob": "1900/01/01",
                        "validated": true
                      }
                    }
                    """;
            Customer<Serializable> c1 = mapper.readValue(jsonPojo, new TypeReference<Customer<Serializable>>() {});
            System.out.println(c1.getDetails().getClass().getSimpleName()); //Prints Details
            System.out.println(c1);
    
            String jsonArray = """
                    {
                      "name": "John Smith",
                      "store": "Walmart",
                      "details": [
                        "Failed customer does not exist in the system", "Please contact administrator"
                      ]
                    }
                    """;
            Customer<Serializable> c2 = mapper.readValue(jsonArray, new TypeReference<Customer<Serializable>>() {});
            System.out.println(c2.getDetails().getClass().getSimpleName()); //Prints String
            System.out.println(c2);
        }
    }
    

    Demo

    Here is also a demo at OneCompiler showcasing the code above.