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.
- Because of that I can't use any of the normal Jackson PTH features. Or can I?? Please let me know.
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();
}
}