We have a service which currently consumes JSON. We want to slightly restructure this JSON (move one property one level up) but also implement graceful migration so that our service could process old structure as well as new structure. We're using Jackson for JSON deserialization.
How do we restructure JSON prior to deserialization with Jackson?
Here's a MCVE.
Assume our old JSON looks as follows:
{"reference": {"number" : "one", "startDate" : [2016, 11, 16], "serviceId" : "0815"}} We want to move serviceId one level up:
{"reference": {"number" : "one", "startDate" : [2016, 11, 16]}, "serviceId" : "0815"} This are the classes we want to deserialize from both old an new JSONs:
public final static class Container { public final Reference reference; public final String serviceId; @JsonCreator public Container(@JsonProperty("reference") Reference reference, @JsonProperty("serviceId") String serviceId) { this.reference = reference; this.serviceId = serviceId; } } public final static class Reference { public final String number; public final LocalDate startDate; @JsonCreator public Reference(@JsonProperty("number") String number, @JsonProperty("startDate") LocalDate startDate) { this.number = number; this.startDate = startDate; } } We only want serviceId in Container, not in both classes.
What I've got working is the following deserializer:
public static class ServiceIdMigratingContainerDeserializer extends JsonDeserializer<Container> { private final ObjectMapper objectMapper; { objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } @Override public Container deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { ObjectNode node = p.readValueAsTree(); migrate(node); return objectMapper.treeToValue(node, Container.class); } private void migrate(ObjectNode containerNode) { TreeNode referenceNode = containerNode.get("reference"); if (referenceNode != null && referenceNode.isObject()) { TreeNode serviceIdNode = containerNode.get("serviceId"); if (serviceIdNode == null) { TreeNode referenceServiceIdNode = referenceNode.get("serviceId"); if (referenceServiceIdNode != null && referenceServiceIdNode.isValueNode()) { containerNode.set("serviceId", (ValueNode) referenceServiceIdNode); } } } } } This deserializer first retrieves the tree, manipulates it and then deserializers it using an own instance of ObjectMapper. It works but we really dislike the fact that we have another instance of ObjectMapper here. If we don't create it and somehow use the system-wide instance of ObjectMapper we get an infinite cycle because when we try to call objectMapper.treeToValue, our deserializer gets called recursively. So this works (with an own instance of ObjectMapper) but it is not an optimal solution.
Another method I've tried was using a BeanDeserializerModifier and a own JsonDeserializer which "wraps" the default serializer:
public static class ServiceIdMigrationBeanDeserializerModifier extends BeanDeserializerModifier { @Override public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> defaultDeserializer) { if (beanDesc.getBeanClass() == Container.class) { return new ModifiedServiceIdMigratingContainerDeserializer((JsonDeserializer<Container>) defaultDeserializer); } else { return defaultDeserializer; } } } public static class ModifiedServiceIdMigratingContainerDeserializer extends JsonDeserializer<Container> { private final JsonDeserializer<Container> defaultDeserializer; public ModifiedServiceIdMigratingContainerDeserializer(JsonDeserializer<Container> defaultDeserializer) { this.defaultDeserializer = defaultDeserializer; } @Override public Container deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { ObjectNode node = p.readValueAsTree(); migrate(node); return defaultDeserializer.deserialize(new TreeTraversingParser(node, p.getCodec()), ctxt); } private void migrate(ObjectNode containerNode) { TreeNode referenceNode = containerNode.get("reference"); if (referenceNode != null && referenceNode.isObject()) { TreeNode serviceIdNode = containerNode.get("serviceId"); if (serviceIdNode == null) { TreeNode referenceServiceIdNode = referenceNode.get("serviceId"); if (referenceServiceIdNode != null && referenceServiceIdNode.isValueNode()) { containerNode.set("serviceId", (ValueNode) referenceServiceIdNode); } } } } } "Wrapping" a default deserializer seems to be a better approach, but this fails with an NPE:
java.lang.NullPointerException at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeOther(BeanDeserializer.java:157) at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:150) at de.db.vz.rikernpushadapter.migration.ServiceIdMigrationTest$ModifiedServiceIdMigratingContainerDeserializer.deserialize(ServiceIdMigrationTest.java:235) at de.db.vz.rikernpushadapter.migration.ServiceIdMigrationTest$ModifiedServiceIdMigratingContainerDeserializer.deserialize(ServiceIdMigrationTest.java:1) at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:1623) at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1217) at ... The whole MCVE code is in the following PasteBin. It is a single-class all-containing test case which demonstrates both approaches. The migratesViaDeserializerModifierAndUnmarshalsServiceId fails.
So this leaves me with a question:
How do we restructure JSON prior to deserialization with Jackson?