Development
10 min readAny Spring application developer who wants to support polymorphic deserialization without hard-coding @JsonTypeInfo
and @JsonSubTypes
annotations on their classes.
Broadleaf previously announced that we had joined TMForum as a member, and recently, we were awarded OpenAPI certifications for implementing various TMForum APIs (more on that here).
Schemas in the TMForum APIs have a @type
field, where an API caller can declare a well-known 'type' to which their request payload structure conforms. The backend is expected to use this type for polymorphic deserialization.
Typically, when using the popular Jackson library, polymorphic deserialization is implemented through the @JsonTypeInfo
annotation. Baeldung has an excellent article that provides an overview of this approach if you are unfamiliar with it.
Convenient as it is, a significant drawback of this approach is that it requires hard-coding the type and subtype information at the class level. The Broadleaf framework is designed with extensibility and customization as a top priority, so during our TMForum implementation efforts, we developed a much more flexible pattern for this problem, which we'll share with you below.
Our solution leverages the power of Spring beans to eliminate hard-coded configuration in favor of dynamic components that are much more maintainable and customizable.
This tutorial will use the CharacteristicValueSpecification
from TMForum's TMF 620 Product Catalog Management API as an example. In TMF, this class contains many subtypes, but for our example, we will consider BooleanCharacteristicValueSpecification
, NumberCharacteristicValueSpecification
, and StringCharacteristicValueSpecification
. We expect that when we get a @type
value that matches one of those names, we will deserialize to the corresponding type.
If you want to jump past the setup and skip to the solution, click here.
Below, we can see the payload structure (note that we use Lombok annotations for readability):
/** * A lightweight copy of TMF's CharacteristicValueSpecification class. */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class CharacteristicValueSpecification { /** * When sub-classing, this defines the sub-class extensible name */ @JsonProperty("@type") private String _atType = null; /** * If true, the Boolean indicates if the value is the default value for a characteristic */ @JsonProperty("isDefault") private Boolean isDefault = null; }
@Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) public class BooleanCharacteristicValueSpecification extends CharacteristicValueSpecification { /** * Value of the characteristic */ @JsonProperty("value") private Boolean value = null; }
@Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) public class NumberCharacteristicValueSpecification extends CharacteristicValueSpecification { /** * Value of the characteristic */ @JsonProperty("value") private BigDecimal value = null; }
@Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) public class StringCharacteristicValueSpecification extends CharacteristicValueSpecification { /** * Value of the characteristic */ @JsonProperty("value") private String value = null; }
In TMF, CharacteristicValueSpecification
instances are held inside a CharacteristicSpecification
:
/** * A lightweight copy of TMF's CharacteristicSpecification */ @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class CharacteristicSpecification { @JsonProperty("id") private String id = null; @JsonProperty("name") private String name = null; @JsonProperty("characteristicValueSpecification") private List<CharacteristicValueSpecification> characteristicValueSpecification = null; }
Ultimately, we expect that when a CharacteristicSpecification
payload comes in, we perform polymorphic deserialization on CharacteristicSpecification.characteristicValueSpecification
such that each element is instantiated to the appropriate subtype of CharacteristicValueSpecification
.
For our example, let's expose an API to create a CharacteristicSpecification
. To achieve this, we'll introduce a controller and a service. The service is just a stub; we will rely on mocks for testing.
(Remember to register these as Spring beans!)
@RestController public class CharacteristicSpecificationEndpoint { @Autowired private CharacteristicSpecificationService characteristicSpecificationService; @PostMapping(path = "/characteristic-specifications", consumes = MediaType.APPLICATION_JSON_VALUE) public void postCharacteristicSpecification(@RequestBody CharacteristicSpecification characteristicSpecification) { characteristicSpecificationService.createCharacteristicSpecification(characteristicSpecification); } }
public class CharacteristicSpecificationService { public void createCharacteristicSpecification(CharacteristicSpecification characteristicSpecification) { // do nothing by default } }
As attached below, we will set up an integration test to verify the behavior is implemented correctly.
The idea is to submit a JSON payload to the endpoint via MockMvc. The controller will deserialize the request body and pass the result to our service component. We use 'MockBean' to mock out the service component so that we can capture the deserialized argument and verify its state.
Since we have yet to add the actual polymorphic support, the tests will fail for now.
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @AutoConfigureMockMvc class PolymorphicDeserializationIT { @MockBean private CharacteristicSpecificationService charSpecServiceMock; @Autowired private MockMvc mockMvc; @Test void deserializesToString() throws Exception { String inputJson = """ { "id": "charSpecIdVal", "name": "charSpecNameVal", "characteristicValueSpecification": [ { "@type": "StringCharacteristicValueSpecification", "value": "stringVal1" }, { "@type": "StringCharacteristicValueSpecification", "value": "stringVal2", "isDefault": true } ] } """; performPostCharSpecRequest(inputJson).andExpect(status().isOk()); CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload(); assertThat(capturedPayload) .extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName) .contains("charSpecIdVal", "charSpecNameVal"); assertThat(capturedPayload.getCharacteristicValueSpecification()) .allMatch(valueSpec -> valueSpec instanceof StringCharacteristicValueSpecification) .allMatch(valueSpec -> "StringCharacteristicValueSpecification".equals(valueSpec.get_atType())) .extracting(CharacteristicValueSpecification::getIsDefault, valueSpec -> ((StringCharacteristicValueSpecification) valueSpec).getValue()) .containsExactlyInAnyOrder( tuple(null, "stringVal1"), tuple(true, "stringVal2")); } @Test void deserializesToNumber() throws Exception { String inputJson = """ { "id": "charSpecIdVal", "name": "charSpecNameVal", "characteristicValueSpecification": [ { "@type": "NumberCharacteristicValueSpecification", "value": 123 }, { "@type": "NumberCharacteristicValueSpecification", "value": 456, "isDefault": true }, { "@type": "NumberCharacteristicValueSpecification", "value": 789.123 } ] } """; performPostCharSpecRequest(inputJson).andExpect(status().isOk()); CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload(); assertThat(capturedPayload) .extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName) .contains("charSpecIdVal", "charSpecNameVal"); assertThat(capturedPayload.getCharacteristicValueSpecification()) .allMatch(valueSpec -> valueSpec instanceof NumberCharacteristicValueSpecification) .allMatch(valueSpec -> "NumberCharacteristicValueSpecification".equals(valueSpec.get_atType())) .extracting(CharacteristicValueSpecification::getIsDefault, valueSpec -> ((NumberCharacteristicValueSpecification) valueSpec).getValue()) .containsExactlyInAnyOrder( tuple(null, new BigDecimal("123")), tuple(true, new BigDecimal("456")), tuple(null, new BigDecimal("789.123"))); } @Test void deserializesToBoolean() throws Exception { String inputJson = """ { "id": "charSpecIdVal", "name": "charSpecNameVal", "characteristicValueSpecification": [ { "@type": "BooleanCharacteristicValueSpecification", "value": true }, { "@type": "BooleanCharacteristicValueSpecification", "value": false, "isDefault": true } ] } """; performPostCharSpecRequest(inputJson).andExpect(status().isOk()); CharacteristicSpecification capturedPayload = getCapturedDeserializedPayload(); assertThat(capturedPayload) .extracting(CharacteristicSpecification::getId, CharacteristicSpecification::getName) .contains("charSpecIdVal", "charSpecNameVal"); assertThat(capturedPayload.getCharacteristicValueSpecification()) .allMatch(valueSpec -> valueSpec instanceof BooleanCharacteristicValueSpecification) .allMatch(valueSpec -> "BooleanCharacteristicValueSpecification".equals(valueSpec.get_atType())) .extracting(CharacteristicValueSpecification::getIsDefault, valueSpec -> ((BooleanCharacteristicValueSpecification) valueSpec).getValue()) .containsExactlyInAnyOrder( tuple(null, true), tuple(true, false)); } private ResultActions performPostCharSpecRequest(String json) throws Exception { return mockMvc.perform(post("/characteristic-specifications") .content(json) .contentType(MediaType.APPLICATION_JSON)); } private CharacteristicSpecification getCapturedDeserializedPayload() { ArgumentCaptor<CharacteristicSpecification> charSpecArgCaptor = ArgumentCaptor.forClass(CharacteristicSpecification.class); verify(charSpecServiceMock).createCharacteristicSpecification(charSpecArgCaptor.capture()); return charSpecArgCaptor.getValue(); } }
At this point, we've got our classes defined, our endpoint defined, and integration tests set up to match our expectations of behavior. Now, let's get to the reason you clicked on this blog!
At a high level, this is the pattern we will follow:
PolymorphicCharacteristicValueSpecificationDeserializer
component, a simple extension of Jackson's JsonDeserializer.
SpringHandlerInstantiator
, we can autowire beans into this deserializer!CharacteristicValueSpecificationDeserializationHandler
.
CharacteristicValueSpecification
sub-type we want to deserialize, we will implement this interface and register it as a bean.@type
value from the payload and (if it recognizes the @type
) return the Java type to deserialize the payload to.PolymorphicCharacteristicValueSpecificationDeserializer
will inject all of the available CharacteristicValueSpecificationDeserializationHandler
beans.
ObjectMapper
to perform the actual deserialization.Below, you will find the full implementations of each component.
public class PolymorphicCharacteristicValueSpecificationDeserializer extends JsonDeserializer<CharacteristicValueSpecification> { @Getter(AccessLevel.PROTECTED) private List<CharacteristicValueSpecificationDeserializationHandler> charValueSpecDeserializationHandlers = Collections.emptyList(); /** * @param charValueSpecDeserializationHandlers the value specification * deserialization handler beans from the Spring context * @see SpringHandlerInstantiator */ @Autowired(required = false) public void setCharValueSpecDeserializationHandlers( @Nullable List<CharacteristicValueSpecificationDeserializationHandler> charValueSpecDeserializationHandlers) { this.charValueSpecDeserializationHandlers = ListUtils.emptyIfNull(charValueSpecDeserializationHandlers); } @Override public CharacteristicValueSpecification deserialize(JsonParser parser, DeserializationContext ctxt) throws IOException { ObjectMapper mapper = (ObjectMapper) parser.getCodec(); // We have to read to advance the parser JsonNode node = mapper.readTree(parser); Class<? extends CharacteristicValueSpecification> deserializationType = charValueSpecDeserializationHandlers.stream() .map(handler -> handler .determineDeserializedCharacteristicValueSpecificationType(node)) .filter(Objects::nonNull) .findFirst() .orElse(null); if (deserializationType == null) { return handleNoEligibleCharacteristicValueSpecDeserializationHandler(node, parser, ctxt); } else { return mapper.readValue( // Restore it to a JSON string for a more 'pure' deserialization node.toString(), deserializationType); } } protected CharacteristicValueSpecification handleNoEligibleCharacteristicValueSpecDeserializationHandler( JsonNode source, JsonParser parser, DeserializationContext ctxt) throws JacksonException { throw new JsonMappingException(parser, """ Could not find a CharacteristicValueSpecificationDeserializationHandler capable of \ deserializing CharacteristicValueSpecification from:\s""" + source.toString()); } }
(Don't forget to register these as Spring beans!)
public interface CharacteristicValueSpecificationDeserializationHandler extends Ordered { /** * @param jsonRepresentation the JSON object representation of a * {@link CharacteristicValueSpecification} payload (or a subtype of it) * @return the specific subtype of {@link CharacteristicValueSpecification} that the JSON can be * deserialized to, if this handler knows how to deal with it. If this handler does not * know how to deal with it, return {@code null} */ @Nullable Class<? extends CharacteristicValueSpecification> determineDeserializedCharacteristicValueSpecificationType( JsonNode jsonRepresentation); @Override default int getOrder() { return 0; } }
To follow 'DRY' principles, we will use an abstract class for our implementations:
public abstract class AbstractCharacteristicValueSpecificationDeserializationHandler implements CharacteristicValueSpecificationDeserializationHandler { @Override public Class<? extends CharacteristicValueSpecification> determineDeserializedCharacteristicValueSpecificationType( JsonNode jsonRepresentation) { String type = safeGetTypeForCharacteristicValueSpecificationJson(jsonRepresentation); if (canDeserializeCharacteristicValueSpecBasedOnType(type)) { return getDefaultDeserializedCharacteristicValueSpecificationType(); } // We don't support this return null; } @Nullable protected String safeGetTypeForCharacteristicValueSpecificationJson( JsonNode characteristicValueSpecification) { return characteristicValueSpecification.path("@type").asText(null); } /** * @param typeValue the {@link CharacteristicValueSpecification#get_atType()} found in a * candidate {@link JsonNode} to deserialize * @return whether (just based on this type value) this handler knows how to deserialize the * JSON */ protected abstract boolean canDeserializeCharacteristicValueSpecBasedOnType( @Nullable String typeValue); /** * @return the {@link CharacteristicValueSpecification} type/subtype that this handler supports * deserialization to */ protected abstract Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType(); }
public class BooleanCharacteristicValueSpecificationDeserializationHandler extends AbstractCharacteristicValueSpecificationDeserializationHandler { @Override protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) { return "BooleanCharacteristicValueSpecification".equals(typeValue); } @Override protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() { return BooleanCharacteristicValueSpecification.class; } }
public class NumberCharacteristicValueSpecificationDeserializationHandler extends AbstractCharacteristicValueSpecificationDeserializationHandler { @Override protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) { return "NumberCharacteristicValueSpecification".equals(typeValue); } @Override protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() { return NumberCharacteristicValueSpecification.class; } }
public class StringCharacteristicValueSpecificationDeserializationHandler extends AbstractCharacteristicValueSpecificationDeserializationHandler { @Override protected boolean canDeserializeCharacteristicValueSpecBasedOnType(String typeValue) { return "StringCharacteristicValueSpecification".equals(typeValue); } @Override protected Class<? extends CharacteristicValueSpecification> getDefaultDeserializedCharacteristicValueSpecificationType() { return StringCharacteristicValueSpecification.class; } }
To engage our new deserializer, we can simply annotate our CharacteristicSpecification.characteristicValueSpecification
field like so:
@JsonProperty("characteristicValueSpecification") @JsonDeserialize(contentUsing = PolymorphicCharacteristicValueSpecificationDeserializer.class) private List<CharacteristicValueSpecification> characteristicValueSpecification = null;
(Note that since we want the deserializer to operate on each element of the list rather than the list itself, we use contentUsing
.)
And that's it! If you run the integration tests now, you will see that they pass, and each element will have been deserialized to the appropriate sub-type.
As you can see, this approach is highly flexible:
@JsonTypeInfo
approach requires that every time you introduce a new subtype, you have to explicitly update the Jackson annotations to include it. This approach removes that requirement.@ConditionalOnProperty
. Lots of possibilities!I hope you found this helpful! Happy deserializing!