4

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

Gordon Bean
  • 4,272
  • 1
  • 32
  • 47

2 Answers2

2

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

Gordon Bean
  • 4,272
  • 1
  • 32
  • 47
  • can we register this custom instantiator for all the classes on the classpath or can we set it globally through object mapper? @Gordon Bean – Adi Mar 15 '22 at 11:19
  • @Adi - I'm not quite sure I understand your question. Are you wanting to override object instantiation for EVERY object, or just some of them? – Gordon Bean Mar 15 '22 at 20:09
  • yes, every object. – Adi Mar 17 '22 at 02:53
  • I'm not sure how I would do it for every object. That sounds like it might be a good question to post! – Gordon Bean Mar 17 '22 at 20:13
1

There's another bit of seemingly dark magic involved here if you, for whatever reason, can't use the delegates deserialization method to initially create your object. To get back references working, I used a DelegatingDeserializer as described by @GordonBean, but had to add this:

    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {


        final EntityCreateSpec spec = ctxt.readValue(p, EntityCreateSpec.class);
        try {
            final Entity entity = engine.createEntity(
                            (Class<? extends Entity>) ctxt.findClass(spec.getClazz()),
                            spec.getId(),
                            spec.getComponent());

            // The Important Bit
            
            // Bind entities for back references.
            final ObjectIdReader reader = this.getObjectIdReader();
            final ReadableObjectId roid = ctxt.findObjectId(entity.getId(), reader.generator, reader.resolver);
            roid.bindItem(entity);

            // End Important Bit

            return entity;
        } catch (ClassNotFoundException | FactoryException e) {
            throw new RuntimeException("Unable to deserialize " + spec.getClazz(), e);
        }
    }

Where entity here is the thing being deserialized. Note: This uses the delegates ObjectIdReader to get a generator and resolver, uses the contexts findObjectId to create a ReadableObjectId. From there, we bind the newly created object to the ReadableObjectId to make sure it will resolve later!

EmeraldD.
  • 150
  • 6