Michael C
Michael C

Reputation: 113

Applying custom runtime logic to JSON serialization in SpringBoot @RestController response

We are building an app that may produce hundreds of unique JSON payload structures from our streamlined object model and we want to avoid adding hundreds of POJOs to our Java codebase (1 per unique payload structure).

We built a way to build, parse and overlay a simple string spec of the payload structure to a POJO and to walk its tree to match any @JsonProperty fields. Here's an example spec:

String spec = """
  firstName
  lastName
  addresses.line1
  addresses.city
  children.firstName
  children.schedule.bedtime
"""

This would be overlaid on a Person POJO at runtime and it would traverse and get the fields and arrays specified. Though the Person, Child and Address POJOs have plenty more fields in them, this specific message request should only populate the ones we specified. The JSON output of this should be:

  {
    "firstName": "Mickey",
    "lastName": "Mouse",
    "addresses": [
      {
        "line1": "123 Disneyland Way",
        "city": "Anaheim"
      },
      {
        "line2": "345 Disneyworld Drive",
        "city": "Orlando"
      }
    ],
    "children": [
      "firstName": "Junior",
      "schedule": [
        "bedtime": "20:00"
      ]
    ]
  }

bear with me here...seems like only 1 Disney character has any children (Ariel)

Currently, we have only tested our Specs and the traversal of POJOs to find where the Spec applies. But we cannot figure out how to wire this into the JSON serialization process.

Everything I've read about Jackson/JSON/Spring serializers and deserializers seem to only take 1 input - the POJO. Is there a way to use custom serializers that use 2 inputs (POJO + Spec), where Spec is identified at runtime (thus eliminating the need to create a "wrapper" POJO per and/or serializer per Spec)?

To make things even more challenging, we want to simplify our @RestController methods to simply include our new annotation, @APIMessageSpec(<details go here>) alongside the @ResponseBody annotation and then have Spring invoke our custom serialization process, passing in the POJO and details of our @APIMessageSpec (or possibly for us to subclass @ResponseBody to parameterize the Spec info in its arguments, so that we don't need 2 annotations)?.

Thanks in advance! Michael

Upvotes: 2

Views: 988

Answers (1)

Michael C
Michael C

Reputation: 113

I figured out a solution not long after posting this!

All of our data/object model POJOs share a superclass in their ancestry, BaseModelEntity (where we put some common fields like UUID, last update user/timestamp, etc.)

We created ModelJsonSerializer extends JsonSerializer<BaseModelEntity> for our custom serialization rules.

We picked a bean with @Autowired ObjectMapper objectMapper to add the following method:

    @PostConstruct
    public void init() {
        SimpleModule simpleModule = new SimpleModule("OurCustomModelJsonModule", new Version(1, 0, 0, null, null, null));
        simpleModule.addSerializer(BaseModelEntity.class, new ModelJsonSerializer());
        objectMapper.registerModule(simpleModule);
    }

We have a static APISpecRegistry class where we load all of our specs [which all subclass our custom APISpec superclass] in a HashMap.

On our @RestController's methods, or anywhere in our application for that matter, we annotate the return type with @JsonView(<our_Spec_class>)

When Jackson identifies and starts to process any of our data model objects it successfully finds ModelJsonSerializer as the correct one for our data type BaseModelEntity.

In ModelJsonSerializer, we have the following method:

@Override
    public void serialize(BaseModelEntity value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        Class c = serializers.getActiveView();
        if(c != null && APISpec.class.isAssignableFrom(c)) {
            List<Field> fields = ((APISpec)APISpecRegistry.getSpec(c).get()).getFields();
            serialize(value, gen, serializers, fields);
        } else {
            // default to out-of-the-box JSON serializers
        }
    }

NOTE: APISpec.getFields() is where we parsed our spec from string format to a hierarchy of POJO fields to traverse.

We get the current @JsonView via serializers.getActiveView(), get our APISpec class from that view and use that as the key to lookup the APISpec object from our APISpecRegistry

Then, in our custom 4-argument serialize method, we cross-reference our API rules for each field with the corresponding field values from our BaseModelEntity instance and ignore any BaseModelEntity field that's not in our Spec.

TIP: Underneath the covers, we use Java reflection to find java.lang.reflect.Fields with a @JsonProperty annotation that matches up to the field in our APISpec. So, you can image, we have the following fields in our Person object:

@JsonProperty("firstName")
private String fName;

@JsonProperty("lastName")
private String lName;

@JsonProperty("addresses")
List<Address> addresses;

@JsonProperty("children")
List<Child> kiddos;

It's MAGIC!

Upvotes: 1

Related Questions