I've got an interface + 2 concrete subclasses, all annotated for Jackson. The two subclassses work fine independently. The two subclasses are distinguished by the name of a field (K/V pair), not the value of some K/V pair.
I want them serialized w/o any wrapper or injected fields.
The example I'm going to use (full MCVE gist is here) is a BookId
interface where the concrete implementors are ISBN
and ASIN
, each with one field. An example array of BookId
s would look like this:
{ "ids": [
{ "isbn" : "978-0-596-52306-0" },
{ "asin" : "B07QKFQ7QJ" }
]}
So ISBN
and ASIN
are pretty simple, here's ISBN
(and ASIN
is similar with a different validator method):
public static class ISBN implements BookId {
final String isbn;
@JsonCreator
public ISBN(@JsonProperty("isbn") String isbn) {
if (!valid(isbn)) throw new IllegalArgumentException("bad isbn syntax");
this.isbn = isbn;
}
boolean valid(String isbn) { return isbn != null && !isbn.isBlank() /* && checksum ok ... */; }
}
So trying what I thought was a proper approach - simply distinguish the two cases and then call the object mapper appropriately - looks like this:
@JsonDeserialize(using = BookId.DeserializerDirectViaJackson.class)
public interface BookId {
class DeserializerDirectViaJackson extends StdDeserializer<BookId> {
public DeserializerDirectViaJackson() { this(null); }
public DeserializerDirectViaJackson(final Class<?> vc) { super(vc); }
@Override
public BookId deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
final var om = (ObjectMapper) jsonParser.getCodec();
final var node = (JsonNode) om.readTree(jsonParser);
if (!node.isObject()) throw new IllegalStateException("expected JSON object");
if (node.has("isbn")) return om.treeToValue(node, ISBN.class);
if (node.has("asin")) return om.treeToValue(node, ASIN.class);
throw new IllegalStateException("expected 'isbn' or 'asin' field");
}
}
...
where the key lines are:
if (node.has("isbn")) return om.treeToValue(node, ISBN.class);
if (node.has("asin")) return om.treeToValue(node, ASIN.class);
where I look in the TreeValue
I sucked in for the distinguishing key and then call ObjectMapper.treeToValue(node, <concreteclass>.class)
to get it parsed properly.
But that leads to infinite recursion, as seen here (top of the call stack):
> Task :cli:JacksonInterfaceCustomDeserializerMCVE.main() FAILED
Exception in thread "main" java.lang.StackOverflowError
at java.base/java.util.LinkedHashMap$LinkedEntryIterator.next(LinkedHashMap.java:788)
at java.base/java.util.LinkedHashMap$LinkedEntryIterator.next(LinkedHashMap.java:786)
at com.fasterxml.jackson.databind.node.NodeCursor$ObjectCursor.nextToken(NodeCursor.java:214)
at com.fasterxml.jackson.databind.node.TreeTraversingParser.nextToken(TreeTraversingParser.java:108)
at com.fasterxml.jackson.core.JsonParser.nextFieldName(JsonParser.java:1091)
at com.fasterxml.jackson.databind.deser.std.BaseNodeDeserializer._deserializeContainerNoRecursion(JsonNodeDeserializer.java:536)
at com.fasterxml.jackson.databind.deser.std.JsonNodeDeserializer.deserialize(JsonNodeDeserializer.java:100)
at com.fasterxml.jackson.databind.deser.std.JsonNodeDeserializer.deserialize(JsonNodeDeserializer.java:25)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4801)
at com.fasterxml.jackson.databind.ObjectMapper.readTree(ObjectMapper.java:3084)
at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:58)
at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:51)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4801)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2974)
at com.fasterxml.jackson.databind.ObjectMapper.treeToValue(ObjectMapper.java:3438)
at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:60)
at com.bakinsbits.bookcatalog.JacksonInterfaceCustomDeserializerMCVE$BookId$DeserializerDirectViaJackson.deserialize(JacksonInterfaceCustomDeserializerMCVE.java:51)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323)
at com.fasterxml.jackson.databind.ObjectMapper._readValue(ObjectMapper.java:4801)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2974)
and so on and so forth.
So, question: Why does it do that and - more important - how do I make it work?
For comparision (and to show I did the work) the following deserializer does work but has the (major) disadvantage that I basically have to parse & validate the concrete subclasses myself. Doable for this toy example, but not so much for a real use case.
class DeserializerHomegrown extends StdDeserializer<BookId> {
public DeserializerHomegrown() { this(null); }
public DeserializerHomegrown(final Class<?> vc) { super(vc); }
@Override
public BookId deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
final var om = (ObjectMapper) jsonParser.getCodec();
final var node = (JsonNode) om.readTree(jsonParser);
if (!node.isObject()) throw new IllegalStateException("expected JSON object");
if (node.has("isbn")) return new ISBN(getValueNode(node, "isbn"));
if (node.has("asin")) return new ASIN(getValueNode(node, "asin"));
throw new IllegalStateException("expected 'isbn' or 'asin' field");
}
String getValueNode(JsonNode node, String fieldName) {
final var field = node.get(fieldName);
if (field == null) return null;
if (!field.isValueNode()) throw new IllegalStateException("%s field is not JSON value".formatted(fieldName));
return field.asText();
}
}
You can deduce specific subtypes of one type related with the presence of specific properties with the use of JsonTypeInfo
annotation and its deduction system:
@JsonTypeInfo(use = Id.DEDUCTION)
@JsonSubTypes({ @Type(ASIN.class), @Type(ISBN.class)})
public interface BookId {}
@Data
public class ISBN implements BookId {
private String isbn;
}
@Data
public class ASIN implements BookId {
private String asin;
}
In this case the classes implementing the BookId
interface can be identified by the presence or not of their own asin and isbn properties, so you can deserialize an array of BookId
like below:
Input:
{ "ids": [
{ "isbn" : "978-0-596-52306-0" },
{ "asin" : "B07QKFQ7QJ" }
]}
An example based on the json input provided:
JsonNode ids = mapper.readTree(json).at("/ids");
BookId[] bookIds = mapper.treeToValue(ids, BookId[].class);
//ok, it prints [ISBN(isbn=978-0-596-52306-0), ASIN(asin=B07QKFQ7QJ)]
System.out.println(Arrays.toString(bookIds));