I am using a third-party POJO class RetryOptions
that can only be created using a builder. The builder can only be instantiated using a static method RetryOptions.newBuilder()
, or by calling options.toBuilder()
on an existing instance.
I would like to create custom de/serializers for the third-party POJO (RetryOptions
). My first approach was to write the object as a builder, then read the object as a builder and return the built result:
class RetryOptionsSerializer extends StdSerializer<RetryOptions> { @Override public void serialize(RetryOptions value, JsonGenerator gen, SerializerProvider provider) throws IOException { // Save as a builder gen.writeObject(value.toBuilder()); } } class RetryOptionsDeserializer extends StdDeserializer<RetryOptions> { @Override public RetryOptions deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { // Read as builder, then build return p.readValueAs(RetryOptions.Builder.class).build(); } }
But the problem is that Jackson doesn’t know how to create an instance of RetryOptions.Builder
in order to populate it’s fields.
Is there a way I can instruct Jackson in how to create the builder instance, but let Jackson handle the parsing, reflection, and assignment of the fields?
Perhaps something like:
class RetryOptionsDeserializer extends StdDeserializer<RetryOptions> { @Override public RetryOptions deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { // Read as builder, then build var builder = RetryOptions.newBuilder(); return p.readValueInto(builder).build(); } }
Or perhaps there is a way to tell the object mapper how to create an instance of RetryOptions.Builder
:
var mapper = new ObjectMapper(); mapper.registerValueInstantiator(RetryOptions.Builder, () -> RetryOptions.newBuilder());
Or is there another way to slice this problem without resorting to my own reflection logic or a brute-force duplication of the third-party class?
Note: my solution must use the Jackson JSON library (no Guava, etc.)
Note: there are several classes in this third party library that run into this same issue, so a generic solution is helpful
Advertisement
Answer
Update
Jackson can deserialize private fields as long as they have a getter (see https://www.baeldung.com/jackson-field-serializable-deserializable-or-not).
So, it turns out, in my scenario, that I don’t need to deserialize RetryOptions
through the builder, I just need to be able to construct an instance of RetryOptions
that Jackson can use to populate the fields.
As I had multiple classes with this same constraint (no public constructors on a third-party class), I wrote the following method to generate ValueInstantiators
from a Supplier
lambda:
static ValueInstantiator createDefaultValueInstantiator(DeserializationConfig config, JavaType valueType, Supplier<?> creator) { class Instantiator extends StdValueInstantiator { public Instantiator(DeserializationConfig config, JavaType valueType) { super(config, valueType); } @Override public boolean canCreateUsingDefault() { return true; } @Override public Object createUsingDefault(DeserializationContext ctxt) { return creator.get(); } } return new Instantiator(config, valueType); }
Then I registered ValueInstantiators
for each of my classes, e.g:
var mapper = new ObjectMapper(); var module = new SimpleModule() .addValueInstantiator( RetryOptions.class, createDefaultValueInstantiator( mapper.getDeserializationConfig(), mapper.getTypeFactory().constructType(RetryOptions.class), () -> RetryOptions.newBuilder().validateBuildWithDefaults() ) ) .addValueInstantiator( ActivityOptions.class, createDefaultValueInstantiator( mapper.getDeserializationConfig(), mapper.getTypeFactory().constructType(ActivityOptions.class), () -> ActivityOptions.newBuilder().validateAndBuildWithDefaults() ) ); mapper.registerModule(module);
No custom de/serializers are needed.
Original response
I found a way.
First, define a ValueInstantiator
for the class. The Jackson documentation strongly encourages you to extend StdValueInstantiator
.
In my scenario, I only needed the “default” (parameter-less) instantiator, so I overrode the canCreateUsingDefault
and createUsingDefault
methods.
There are other methods for creating from arguments if needed.
class RetryOptionsBuilderValueInstantiator extends StdValueInstantiator { public RetryOptionsBuilderValueInstantiator(DeserializationConfig config, JavaType valueType) { super(config, valueType); } @Override public boolean canCreateUsingDefault() { return true; } @Override public Object createUsingDefault(DeserializationContext ctxt) throws IOException { return RetryOptions.newBuilder(); } }
Then I register my ValueInstantiator
with the ObjectMapper
:
var mapper = new ObjectMapper(); var module = new SimpleModule(); module.addDeserializer(RetryOptions.class, new RetryOptionsDeserializer()); module.addSerializer(RetryOptions.class, new RetryOptionsSerializer()); module.addValueInstantiator( RetryOptions.Builder.class, new RetryOptionsBuilderValueInstantiator( mapper.getDeserializationConfig(), mapper.getTypeFactory().constructType(RetryOptions.Builder.class)) ); mapper.registerModule(module);
Now I can deserialize an instance of RetryOptions
like so:
var options = RetryOptions.newBuilder() .setInitialInterval(Duration.ofMinutes(1)) .setMaximumAttempts(7) .setBackoffCoefficient(1.0) .build(); var json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(options); var moreOptions = mapper.readValue(json, RetryOptions.class);
Note: my solution makes use of the de/serializers defined in the question – i.e. that first convert the RetryOptions
instance to a builder before serializing, then deserializing back to a builder and building to restore the instance.
End of original response