At SportsLabs we regularly encounter proprietary, non-standard APIs and formats. Our job is to integrate with these APIs, normalize them, and distribute the data in web- and mobile-friendly web services.
One of the scenarios we often encounter is a provider supplying multiple resource JSON-based APIs that share a lot of the same data in their responses, but without any particular field dedicated to identying the type of the resource e.g.
{
...
"common": "a common field within multiple resource responses",
"one": "one is a field only within this response type"
...
}
and
{
...
"common": "a common field within multiple resource responses",
"two": "two is a field only within this response type"
...
}
Instead of mapping 1-to-1 with these APIs, we often try to follow DRY principles and model them as implementations of a common polymorphic abstraction.
When using Jackson for polmorphic deserialization and not being in control of the API response data, the lack of any kind of type
identifier requires a different solution.
One of the ways we’ve addressed this problem is to identify fields and properties that are unique to a particular resource API’s response.
We then add this field to a registry of known unique-property-to-type mappings and then, during deserialization, lookup the response’s field names to see if any of them are stored within the registry.
Planning out the solution, there were a couple of things I wanted to incorporate:
- the deserializer should be initialized with the abstract class representing the shared response data
- a
register(String uniqueProperty, Class<? extends T> clazz)
method will add the field-name-to-concrete-class mapping to the registry - the custom deserializer would be added to a Jackson
SimpleModule
which in turn would be registered with theObjectMapper
.
package org.springframework.social.dto.ser; | |
import java.io.IOException; | |
import java.util.HashMap; | |
import java.util.Iterator; | |
import java.util.Map; | |
import java.util.Map.Entry; | |
import com.fasterxml.jackson.core.JsonParser; | |
import com.fasterxml.jackson.core.JsonProcessingException; | |
import com.fasterxml.jackson.databind.DeserializationContext; | |
import com.fasterxml.jackson.databind.JsonNode; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import com.fasterxml.jackson.databind.deser.std.StdDeserializer; | |
import com.fasterxml.jackson.databind.node.ObjectNode; | |
/** | |
* Deserializes documents without a specific field designated for Polymorphic Type | |
* identification, when the document contains a field registered to be unique to that type | |
* | |
* @author robin | |
*/ | |
public class UniquePropertyPolymorphicDeserializer<T> extends StdDeserializer<T> { | |
private static final long serialVersionUID = 1L; | |
// the registry of unique field names to Class types | |
private Map<String, Class<? extends T>> registry; | |
public UniquePropertyPolymorphicDeserializer(Class<T> clazz) { | |
super(clazz); | |
registry = new HashMap<String, Class<? extends T>>(); | |
} | |
public void register(String uniqueProperty, Class<? extends T> clazz) { | |
registry.put(uniqueProperty, clazz); | |
} | |
/* (non-Javadoc) | |
* @see com.fasterxml.jackson.databind.JsonDeserializer#deserialize(com.fasterxml.jackson.core.JsonParser, com.fasterxml.jackson.databind.DeserializationContext) | |
*/ | |
@Override | |
public T deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException { | |
Class<? extends T> clazz = null; | |
ObjectMapper mapper = (ObjectMapper) jp.getCodec(); | |
ObjectNode obj = (ObjectNode) mapper.readTree(jp); | |
Iterator<Entry<String, JsonNode>> elementsIterator = obj.fields(); | |
while (elementsIterator.hasNext()) { | |
Entry<String, JsonNode> element = elementsIterator.next(); | |
String name = element.getKey(); | |
if (registry.containsKey(name)) { | |
clazz = registry.get(name); | |
break; | |
} | |
} | |
if (clazz == null) { | |
throw ctxt.mappingException("No registered unique properties found for polymorphic deserialization"); | |
} | |
return mapper.treeToValue(obj, clazz); | |
} | |
} |
The deserialize
method reads each of the fields in the response and looks up the registry to see if it is present. If it finds a match, the mapper.treeToValue
method is invoked with the response object and the mapped class returned by the registry. If no match is found an exception is thrown.
For the unit test, I created an inner static abstract class AbstractTestObject
(containing shared data) with two concrete implementations (TestObjectOne
and TestObjectTwo
) that each contain a property unique to that type.
The test also contains a inner class TestObjectDeserializer
that extends UniquePropertyPolymorphicDeserializer
. The test’s setUp
method:
- initializes the custom deserializer,
- registers the unique-field-name-to-type mappings, and
- adds the custom deserializer to a new
SimpleModule
which is registered with theObjectMapper
in turn.
package org.springframework.social.dto.ser; | |
import java.io.IOException; | |
import org.junit.After; | |
import org.junit.Assert; | |
import org.junit.Before; | |
import org.junit.Test; | |
import com.fasterxml.jackson.core.JsonParseException; | |
import com.fasterxml.jackson.core.JsonProcessingException; | |
import com.fasterxml.jackson.core.Version; | |
import com.fasterxml.jackson.databind.JsonMappingException; | |
import com.fasterxml.jackson.databind.ObjectMapper; | |
import com.fasterxml.jackson.databind.module.SimpleModule; | |
/** | |
* Test that documents without a specific field designated for Polymorphic Type | |
* identification can be deserialized with {@link UniquePropertyPolymorphicDeserializer}, | |
* when the document contains a field registered to be unique to that type | |
* | |
* @author robin | |
*/ | |
@SuppressWarnings("unused") | |
public class UniquePropertyPolymorphicDeserializerTest { | |
private static final String ONE = "one"; | |
private static final String TWO = "two"; | |
private static final String THREE = "three"; | |
private ObjectMapper mapper; | |
/* | |
* Example extension of UniquePropertyPolymorphicDeserializer | |
*/ | |
private class TestObjectDeserializer extends UniquePropertyPolymorphicDeserializer<AbstractTestObject> { | |
private static final long serialVersionUID = 1L; | |
// Register the abstract class that doesn't provide type information for child implementations | |
public TestObjectDeserializer() { | |
super(AbstractTestObject.class); | |
} | |
} | |
/** | |
* @throws java.lang.Exception | |
*/ | |
@Before | |
public void setUp() throws Exception { | |
mapper = new ObjectMapper(); | |
/* | |
* Register unique field names to TestObject types | |
*/ | |
TestObjectDeserializer deserializer = new TestObjectDeserializer(); | |
deserializer.register(ONE, TestObjectOne.class); // if "one" field is present, then it's a TestObjectOne | |
deserializer.register(TWO, TestObjectTwo.class); // if "two" field is present, then it's a TestObjectTwo | |
// Add and register the UniquePropertyPolymorphicDeserializer to the Jackson module | |
SimpleModule module = new SimpleModule("PolymorphicTestObjectDeserializer", | |
new Version(1, 0, 0, null, "com.sportslabs.amp", "spring-social-bootstrap")); | |
module.addDeserializer(AbstractTestObject.class, deserializer); | |
mapper.registerModule(module); | |
} | |
/** | |
* @throws java.lang.Exception | |
*/ | |
@After | |
public void tearDown() throws Exception { | |
mapper = null; | |
} | |
@Test | |
public void deserialize_WithRegisteredFieldNameOne_CreatesATestObjectOne() throws JsonParseException, IOException { | |
AbstractTestObject testObject = deserializeJsonToTestObjectWithProvidedFieldValueName(ONE); | |
Assert.assertTrue(testObject.getClass().isAssignableFrom(TestObjectOne.class)); | |
} | |
@Test | |
public void deserialize_WithRegisteredFieldNameTwo_CreatesATestObjectTwo() throws JsonParseException, IOException { | |
AbstractTestObject testObject = deserializeJsonToTestObjectWithProvidedFieldValueName(TWO); | |
Assert.assertTrue(testObject.getClass().isAssignableFrom(TestObjectTwo.class)); | |
} | |
@Test(expected=JsonMappingException.class) | |
public void deserialize_WithUnregisteredFieldName_ThrowsException() throws JsonParseException, IOException { | |
deserializeJsonToTestObjectWithProvidedFieldValueName(THREE); | |
} | |
private AbstractTestObject deserializeJsonToTestObjectWithProvidedFieldValueName(String field) throws IOException, JsonParseException, JsonProcessingException { | |
return mapper.readValue("{\"common\": \"value\", \"" + field + "\": \"value\"}", AbstractTestObject.class); | |
} | |
/* | |
* The abstract class that doesn't provide type information for its child implementations | |
*/ | |
private static abstract class AbstractTestObject { | |
private final String common = "value"; | |
public String getCommon() { | |
return common; | |
} | |
} | |
private static class TestObjectOne extends AbstractTestObject { | |
private final String one = "value"; | |
public String getOne() { | |
return one; | |
} | |
} | |
private static class TestObjectTwo extends AbstractTestObject { | |
private final String two = "value"; | |
public String getTwo() { | |
return two; | |
} | |
} | |
} |
If others have solved this problem differently, I’d love to hear about it!
Comments