2

Is this possible? I want to deserialise a JSON into a POJO structure but in addition to this, save a copy of the raw json in the POJO (or sub POJO). For example, lets say I have this structure:

{
  "test": 123,
  "testStr": "foo",
  "testSubModel": {
    "testStr2": "foobar",
    "testFloat2": 1.2
  }
}

now I have a simple set of two POJOs:

package test.model;

public class TestModel {

    private int test;
    private String testStr;
    private TestSubModel testSubModel;

    public int getTest() {
        return test;
    }

    public String getTestStr() {
        return testStr;
    }

    public TestSubModel getTestSubModel() {
        return testSubModel;
    }
}

package test.model;

public class TestSubModel {

    private String testStr2;
    private float testFloat2;
    private String rawJson; // i want this to contain something like { "testStr2": "foobar",  "testFloat2": 1.2 }
    
    public String getTestStr2() {
        return testStr2;
    }

    public float getTestFloat2() {
        return testFloat2;
    }
}

is it possible to get the rawJson in TestSubModel set with the full JSON of the class in addition to getting the pojo fields set correctly?

whilst I can re-invent this with a custom method, any extra JSON fields that I didn't map, gets lost which I want to keep for exception logging purposes (i.e. I need the raw JSON sent by the upstream system and not a res-constructed one that may miss fields that i don't normally store in the POJO).

I was hoping there was a way of doing this with an annotation (but don't think its there) or a custom post de-serializer hook (so that Jackson does its usual stuff to map the object without me having to write this code all myself for each applicable class). I tried something with a DelegatingDeserializer but the JsonParser isn't repeatable, as in when I read it once it wasn't reusable to call Object deserializedObject = super.deserialize(p, ctxt); in addition to getting the tree out and converting to a string.

DazzaL
  • 21,638
  • 3
  • 49
  • 57

1 Answers1

0

I decided to take a crack at this, though it turned out to be more complicated than I would expect. With this solution, simply register the rawJsonModule with your ObjectMapper and then apply @RawJson to the target field.

The code can definitely be optimized a bit too (the reflection in the deserializer is definitely suboptimal). Let me know if you have any questions.

Output: TestModel{test=123, testStr='foo', testSubModel=TestSubModel{testStr2='foobar', testFloat2=1.2, rawJson='{"testStr2":"foobar","testFloat2":1.2}'}}

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.ResolvableDeserializer;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.module.SimpleModule;

import java.io.IOException;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.reflect.Field;

public class SO67140419 {

    public static void main(String[] args) throws JsonProcessingException {
        String json = """
        {
          "test": 123,
          "testStr": "foo",
          "testSubModel": {
            "testStr2": "foobar",
            "testFloat2": 1.2
          }
        }""";

        var rawJsonModule = new SimpleModule();
        // Credits to schummar for this technique; take the default deserializer and 
        // pass it to RawJsonDeserializer, which intercepts all deserialization
        // https://stackoverflow.com/a/18405958/5378187
        rawJsonModule.setDeserializerModifier(new BeanDeserializerModifier() {
            @Override
            public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config,
                BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
                return new RawJsonDeserializer(beanDesc, deserializer);
            }
        });

        var mapper = new ObjectMapper();
        mapper.registerModule(rawJsonModule);

        var model = mapper.readValue(json, TestModel.class);
        System.out.println(model);
    }

}

@JsonIgnore
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface RawJson {}

class RawJsonDeserializer extends StdDeserializer<Object> implements ResolvableDeserializer {

    /**
     * The default deserializer
     */
    private final JsonDeserializer<?> deser;
    private final BeanDescription desc;

    public RawJsonDeserializer(BeanDescription desc, JsonDeserializer<?> deser) {
        super((JavaType) null);
        this.deser = deser;
        this.desc = desc;
    }

    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        // Read p into a json node in case we need it later for an @RawJson field
        JsonNode node = p.getCodec().readTree(p); 
        p = p.getCodec().treeAsTokens(node); // Refresh p

        if (p.getCurrentToken() == null) {
            p.nextToken();
        }

        // Deserialize object using the default deserialization
        Object obj = deser.deserialize(p, ctxt);

        // Check for RawJson annotated fields
        for (Field f : desc.getBeanClass().getDeclaredFields()) {

            if (f.getDeclaredAnnotation(RawJson.class) == null) {
                continue;
            } else if (f.getType() != String.class) {
                throw new IllegalStateException("@RawJson annotation applied to non-string field: " + f);
            }

            // Set the field to the json we stored earlier
            try {
                f.setAccessible(true);
                f.set(obj, node.toString());
            } catch (IllegalAccessException e) {
                throw new IOException(e);
            }
        }

        return obj;
    }

    @Override
    public void resolve(DeserializationContext ctxt) throws JsonMappingException {
        // Not sure why we need this but ok
        if (deser instanceof ResolvableDeserializer rd) {
            rd.resolve(ctxt);
        }
    }
}


class TestModel {

    private int test;
    private String testStr;
    private TestSubModel testSubModel;

    public int getTest() {
        return test;
    }

    public String getTestStr() {
        return testStr;
    }

    public TestSubModel getTestSubModel() {
        return testSubModel;
    }

    @Override
    public String toString() {
        return "TestModel{" +
            "test=" + test +
            ", testStr='" + testStr + '\'' +
            ", testSubModel=" + testSubModel +
            '}';
    }
}

class TestSubModel {

    private String testStr2;
    private float testFloat2;
    @RawJson
    private String rawJson; // i want this to contain something like { "testStr2": "foobar",  "testFloat2": 1.2 }

    public String getTestStr2() {
        return testStr2;
    }

    public float getTestFloat2() {
        return testFloat2;
    }

    @Override
    public String toString() {
        return "TestSubModel{" +
            "testStr2='" + testStr2 + '\'' +
            ", testFloat2=" + testFloat2 +
            ", rawJson='" + rawJson + '\'' +
            '}';
    }
}
Rubydesic
  • 3,386
  • 12
  • 27