How do I use my Jackson KeyDeserializer in nested maps?

Viewed 685

Problem

In an existing application, I have to deserialize possibly deeply nested maps, and apply a custom Jackson de-serializer on all keys at all levels.

Since the application handles all kinds of datamodels dynamically, I cannot use an explicit datamodel with clearly typed maps... Instead, I use a Map<String, Object> and checks whether the deserialized Object value itself is a Map.

This causes the custom de-serializer to only apply to the top-level map.

Update: It's important to note that I cannot bind a deserializer to all maps because my datamodel often have more specific maps as well. I typically have fields that are a generic Map<String, Object> for open-ended JSON settings; living together with e.g. Map<EnumType, Double> for more explicit bits of configuration.


Example

To illustrate, I have come up with toy-example below:

ObjectMapper objectMapper = new ObjectMapper().registerModule(new SimpleModule()
    .addKeyDeserializer(String.class, new KeyDeserializer() {
        @Override
        public Object deserializeKey(String s, DeserializationContext deserializationContext) {
            return s.toUpperCase();
        }
    }));
Map<String, Object> value = objectMapper.readValue(
    "{\"zero\": 1, \"two\": { \"three\": 4 }}",
    new TypeReference<Map<String, Object>>() {}
);
assertThat(value).containsKeys("ZERO", "TWO"); // pass
assertThat((Map) value.get("TWO")).containsKey("THREE"); // fail

The last line of code fails:

java.lang.AssertionError: Expecting: <{"three"=4}> to contain key: <"THREE">

I cannot declare the type as Map<String, Map<String, Object>> because of the int value that is at the same level. If I try, I get:

Cannot deserialize instance of java.util.LinkedHashMap out of VALUE_NUMBER_INT token


Questions

In this example, what is the best way for me to get upper-cased keys in all nested Maps, even though I need to declare them as Object?

Is there an alternative way to approach this?

1 Answers

Below is a working solution. Though it does not use the KeyDeserializer, as you found out, the KeyDeserializer won't go deep into the JSON to uppercase all the inner JSON field names.

The solution below uses a JsonDeserializer instead of the KeyDeserializer. I took your example and passed a more complicated JSON to test it. It traverses through all the objects in the JSON and uppercases all the field names it comes across.

public static void main(String[] args) throws JsonProcessingException {

    ObjectMapper objectMapper = new ObjectMapper().registerModule(
            new SimpleModule()
                    .addDeserializer(Map.class, new JsonDeserializer<>() {
                        @Override
                        public Map<String, Object> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
                            var map = new HashMap<String, Object>();
                            while(!p.isClosed()){
                                JsonToken jsonToken = p.nextToken();
                                if (JsonToken.FIELD_NAME.equals(jsonToken)) {
                                    String fieldName = p.getCurrentName().toUpperCase();
                                    jsonToken = p.nextToken();
                                    if (JsonToken.START_OBJECT.equals(jsonToken)) {
                                        map.put(fieldName, getObject(p));
                                    } else if (JsonToken.START_ARRAY.equals(jsonToken)) {
                                        map.put(fieldName, getArray(p));
                                    } else {
                                        map.put(fieldName, getScalar(jsonToken, p));
                                    }
                                }

                            }
                            return map;
                        }
                    })
    );
    Map<String, Object> value = objectMapper.readValue(
            "{\"zero\": 1, \"two\": { \"three\": 4 }, \"four\": [\"item\", 5], \"five\": \"string\", \"six\": true, \"seven\": 1.2}",
            new TypeReference<Map<String, Object>>() {}
    );
    assertThat(value).containsKeys("ZERO", "TWO"); // pass
    assertThat((Map) value.get("TWO")).containsKey("THREE"); // pass

    System.out.println("JSON = " + new GsonBuilder().setPrettyPrinting().create().toJson(value));
}

static Map<String, Object> getObject(JsonParser p) throws IOException {
    var map = new HashMap<String, Object>();
    JsonToken jt = p.nextToken();
    while (!JsonToken.END_OBJECT.equals(jt)) {
        if (JsonToken.FIELD_NAME.equals(jt)) {
            String fieldName = p.getCurrentName().toUpperCase();
            jt = p.nextToken();
            if (JsonToken.START_OBJECT.equals(jt)) {
                map.put(fieldName, getObject(p));
            } else if (JsonToken.START_ARRAY.equals(jt)) {
                map.put(fieldName, getArray(p));
            } else {
                map.put(fieldName, getScalar(jt, p));
            }
        }
        jt = p.nextToken();
    }
    return map;
}

static List<Object> getArray(JsonParser p) throws IOException {
    var list = new ArrayList<>();
    JsonToken jt = p.nextToken();
    while (!JsonToken.END_ARRAY.equals(jt)) {
        if (JsonToken.START_OBJECT.equals(jt)) {
            list.add(getObject(p));
        } else if (JsonToken.START_ARRAY.equals(jt)) {
            list.add(getArray(p));
        } else {
            list.add(getScalar(jt, p));
        }
        jt = p.nextToken();
    }
    return list;
}

static Object getScalar(JsonToken jsonToken, JsonParser p) throws IOException {
    if (JsonToken.VALUE_NUMBER_INT.equals(jsonToken) || JsonToken.VALUE_NUMBER_FLOAT.equals(jsonToken)) {
        return p.getNumberValue();
    } else if (JsonToken.VALUE_FALSE.equals(jsonToken)) {
        return false;
    } else if (JsonToken.VALUE_TRUE.equals(jsonToken)) {
        return true;
    } else if (JsonToken.VALUE_STRING.equals(jsonToken)) {
        return p.getValueAsString();
    } else if (JsonToken.VALUE_NULL.equals(jsonToken)) {
        return null;
    }
    throw new RuntimeException("did not find a scalar for JsonToken = " + jsonToken);
}

Right below is the output it generates.

JSON = {
  "ZERO": 1,
  "FIVE": "string",
  "SIX": true,
  "FOUR": [
    "item",
    5
  ],
  "TWO": {
    "THREE": 4
  },
  "SEVEN": 1.2
}

The code assumes the outermost JSON object is a map. So it would need a minor update in the JsonDeserializer to handle an array or scalar as the outer outermost JSON.

Related