Gordon Bean
Gordon Bean

Reputation: 4612

Jackson JSON custom class instantiator for third-party class

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

Upvotes: 4

Views: 1667

Answers (2)

EmeraldD.
EmeraldD.

Reputation: 150

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!

Upvotes: 1

Gordon Bean
Gordon Bean

Reputation: 4612

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

Upvotes: 2

Related Questions