1

I want to write a custom serializer that, when it encounters a null value for a set, serializes it as an empty set. I want to pair that with a deserializer which deserializes an empty set back to null. If the collection has elements, it can be serialized/deserialized as normal.

I've written a couple of deserializers and they work well but the methods I used there don't seem applicable to collections. For example, I wrote this to turn empty strings into nulls:

JsonNode node = p.readValueAsTree();        
        String text = (Objects.isNull(node) ? null : node.asText());
        if (StringUtils.isEmpty(text)) {
            return null;
        }

I don't think this will work because JsonNode doesn't have an asSet() method.

I've found examples online that look promising but it seems like all the examples of working with collections involve working with the elements inside the collection, not the collection itself.

So far, I've been hand-coding this process but I'm sure there's a better way to deal with it.

I'm at the point of figuring it out by trial and error so any examples, ideas, or advice would be appreciated.

Here's what I'm thinking it should look like:

@JsonComponent
public class SetDeserializer extends Std???Deserializer<Set<?>> {
    
    public SetDeserializer() {
        super(Set.class);
    }

    @Override
    public Set<?> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        JsonNode node = p.readValueAsTree();        
        Set<?> mySet = (Objects.isNull(node) ? null : node.asSet());
        if (CollectionUtils.isEmpty(mySet)) {
            return null;
        }
        return super().deserialize(p, ctxt);
    }

}
  • How hard is your requirement to serialize to and consume from empty lists? Would simply leave out empty and null lists in the JSON be an option? – cyberbrain Jan 17 '23 at 16:46
  • Pretty hard. The part of the system that receives the JSON goes looking for specific attributes and chokes if it does not find them. In the case of collections, it then chokes if they're null rather than empty. I'm storing data in Hibernate with Envers and using modified flags and an empty set and a null set are two different things as far as it is concerned. This confusion shows up on reports and I have to answer the same question over and over. – Occams Stubble Jan 17 '23 at 17:10

2 Answers2

1

To make it work as it is required:

  • Serialise null Set as an empty JSON Array []
  • Deserialise an empty JSON Array [] as null
  • Configure it global

We need to use at the same time:

  • com.fasterxml.jackson.databind.JsonSerializer to generate an empty JSON Array []
  • com.fasterxml.jackson.databind.util.StdConverter to convert an empty Set or List to null
  • com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector to register serialiser and converters for all properties.

Below example shows all above components and how to use them:

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.util.StdConverter;
import lombok.Data;
import org.springframework.util.CollectionUtils;

import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Set;

public class SetApp {
    public static void main(String[] args) throws JsonProcessingException {
        var mapper = JsonMapper.builder()
                .enable(SerializationFeature.INDENT_OUTPUT)
                .annotationIntrospector(new EmptyAsNullCollectionJacksonAnnotationIntrospector())
                .build();

        var json = mapper.writeValueAsString(new CollectionsPojo());
        System.out.println(json);
        var collectionsPojo = mapper.readValue(json, CollectionsPojo.class);
        System.out.println(collectionsPojo);
    }
}

class EmptyAsNullCollectionJacksonAnnotationIntrospector extends JacksonAnnotationIntrospector {

    @Override
    public Object findNullSerializer(Annotated a) {
        if (Collection.class.isAssignableFrom(a.getRawType())) {
            return NullAsEmptyCollectionJsonSerializer.INSTANCE;
        }
        return super.findNullSerializer(a);
    }

    @Override
    public Object findDeserializationConverter(Annotated a) {
        if (List.class.isAssignableFrom(a.getRawType())) {
            return EmptyListAsNullConverter.INSTANCE;
        }
        if (Set.class.isAssignableFrom(a.getRawType())) {
            return EmptySetAsNullConverter.INSTANCE;
        }
        return super.findDeserializationConverter(a);
    }
}

class NullAsEmptyCollectionJsonSerializer extends JsonSerializer<Object> {

    public static final NullAsEmptyCollectionJsonSerializer INSTANCE = new NullAsEmptyCollectionJsonSerializer();

    @Override
    public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        gen.writeStartArray();
        gen.writeEndArray();
    }
}

class EmptySetAsNullConverter extends StdConverter<Set<?>, Set<?>> {

    public static final EmptySetAsNullConverter INSTANCE = new EmptySetAsNullConverter();

    @Override
    public Set<?> convert(Set<?> value) {
        if (CollectionUtils.isEmpty(value)) {
            return null;
        }
        return value;
    }
}

class EmptyListAsNullConverter extends StdConverter<List<?>, List<?>> {

    public static final EmptyListAsNullConverter INSTANCE = new EmptyListAsNullConverter();

    @Override
    public List<?> convert(List<?> value) {
        if (CollectionUtils.isEmpty(value)) {
            return null;
        }
        return value;
    }
}

@Data
class CollectionsPojo {
    private List<Integer> nullList;
    private List<Integer> emptyList = List.of();
    private List<Integer> listOfOne = List.of(1);
    private Set<String> nullSet;
    private Set<String> emptySet = Set.of();
    private Set<String> setOfOne = Set.of("One");
}

Above code prints:

{
  "nullList" : [ ],
  "emptyList" : [ ],
  "listOfOne" : [ 1 ],
  "nullSet" : [ ],
  "emptySet" : [ ],
  "setOfOne" : [ "One" ]
}
CollectionsPojo(nullList=null, emptyList=null, listOfOne=[1], nullSet=null, emptySet=null, setOfOne=[One])

You can also register converters and null serialiser using annotations directly on the field you want:

@JsonSerialize(nullsUsing = NullAsEmptyCollectionJsonSerializer.class)
@JsonDeserialize(converter = EmptyListAsNullConverter.class)
private List<Integer> nullList;

@JsonSerialize(nullsUsing = NullAsEmptyCollectionJsonSerializer.class)
@JsonDeserialize(converter = EmptySetAsNullConverter.class)
private Set<String> nullSet;

In this case you do not need to register EmptyAsNullCollectionJacksonAnnotationIntrospector.

See also:

Michał Ziober
  • 37,175
  • 18
  • 99
  • 146
  • 1
    This looks to be what I'm looking for. I'm using Spring Boot. Is there anything special I need to do to implement this in that environment or can I just annotate the overrides with JsonComponent and be done with it? Thanks! – Occams Stubble Jan 18 '23 at 19:59
  • You need to configure your `ObjectMapper` instance. How to do that properly depends from the Spring Boot version, app settings and how generally app is configured. Try with [Jackson2ObjectMapperBuilderCustomizer](https://www.baeldung.com/spring-boot-customize-jackson-objectmapper#2-jackson2objectmapperbuildercustomizer), [How to customise the Jackson JSON mapper implicitly used by Spring Boot?](https://stackoverflow.com/a/28324976/51591), You can also skip `EmptyAsNullCollectionJacksonAnnotationIntrospector ` and use `@JsonSerialize` but it will require you to mark every set field one-by-one. – Michał Ziober Jan 18 '23 at 22:30
  • @OccamsStubble, tak a look on my updated answer. – Michał Ziober Jan 18 '23 at 22:37
  • It's Spring Boot 2.7. My other deserializers are simply classes like this: ` (at sign)JsonComponent public class StringDeserializer extends StdDeserializer ` Then I override the deserialize method and it all works. I'd rather not have to mark every Set in the system. I'm a "set it and forget it" type of programmer. – Occams Stubble Jan 19 '23 at 14:09
  • @OccamsStubble, have you taken a look on articles I mentioned in previous message? It should help. In case no, please, create a new question. – Michał Ziober Jan 19 '23 at 15:59
  • I'm finally getting around to implementing your suggestion. I chose the first of your examples on integrating it with Spring Boot. Now, when I deserialize Object A, it produces an instance of Java object A. However, if it contains a collection of Object B, those do not get deserialized into Java object B, they get turned into a collection of LinkedHashMap which have all the properties of object B in name/value pars producing the Map. It's as if it can't deserialize collections properly. I'll keep looking at this but hopefully you see this soon. Thanks! – Occams Stubble Mar 06 '23 at 20:50
  • @OccamsStubble, I propose to create a new question with an example how to reproduce this issue. – Michał Ziober Mar 06 '23 at 22:13
  • Here is the new question: https://stackoverflow.com/questions/75664797/hibernate-modified-flags-and-serializing-collections – Occams Stubble Mar 07 '23 at 16:41
0

This is quite simple using ObjectMapper.

public static List<String> deserializeStringArray(JsonNode node) throws IOException
{
  ObjectMapper mapper = new ObjectMapper();
  boolean isArrayNode = Objects.nonNull(node) && node.isArray();

  if (isArrayNode && !node.isEmpty()) {
    ObjectReader reader = mapper.readerFor(mapper.getTypeFactory().constructCollectionType(List.class, String.class));
    return reader.readValue(node);
  }
  else if (isArrayNode && node.isEmpty()) {
    return Collections.emptyList();
  }
  else {
    return null;
  }
}

This returns a List of the Nodes elements by first verifying that the node is an array and is not empty. But, if the list is an empty array node, we return an empty list, and if it isn't an arrayNode, then we return null.

Based on your requirements, I wasn't sure if the contents of your array list were empty (ie null) or the json node itself is expected to be null. If the JsonNode itself is expected to be null, then you can easily modify this to return an empty list when it is null:

public static List<String> deserializeStringArray(JsonNode node) throws IOException
{
  ObjectMapper mapper = new ObjectMapper();
  if (Objects.nonNull(node) && node.isArray()) {
    ObjectReader reader = mapper.readerFor(mapper.getTypeFactory().constructCollectionType(List.class, String.class));
    return reader.readValue(node);
  }
  else {
    return Collections.emptyList();
  }
}

You can test this via the following

JsonNode arrayNode = mapper.createArrayNode().add("Bob").add("Sam");
System.out.println(deserializeStringArray(arrayNode));

JsonNode emptyArrayNode = mapper.createArrayNode();
System.out.println(deserializeStringArray(emptyArrayNode));

Here's how to use the above code, to deserialize an object into an array of animals

@Component
public class AnimalDeserializer extends JsonDeserializer<Animal>
{
  ObjectMapper mapper;
  @Override
  public Animal deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException
  {
    mapper = (ObjectMapper) p.getCodec();
    JsonNode node = p.getCodec().readTree(p);

    JsonNode arrayNode = node.get("myArrayField");

    List<Animal> animals = deserializeAnimalArray(arrayNode);

    return animals;
  }

  public List<Animal> deserializeAnimalArray(JsonNode node) throws IOException
  {
    boolean isArrayNode = Objects.nonNull(node) && node.isArray();

    if (isArrayNode && !node.isEmpty()) {
      ObjectReader reader = mapper.readerFor(mapper.getTypeFactory().constructCollectionType(List.class, String.class));
      return reader.readValue(node);
    }
    else if (isArrayNode && node.isEmpty()) {
      return Collections.emptyList();
    }
    else {
      return null;
    }
  }
}

You can reverse this to get your JsonNode.

Edit: Added a working deserializer example

tbatch
  • 1,398
  • 10
  • 21
  • Thanks. I'm using Spring Boot and was hoping it would be simpler like the ones I had written before: Just subclass a standard serializer and override its serialize() method. – Occams Stubble Jan 17 '23 at 20:32
  • Yes, that is exactly what this code is designed to do. This is a working example of how to convert a `JsonNode` into an empty `ArrayList`. You can place this in your deserializer and call it when you encounter the node you want to check. Likewise, you can do a similar thing for a serializer by using objectmapper's `createArrayNode`, which is in the example. – tbatch Jan 17 '23 at 20:38
  • I've gone ahead an updated my answer with a working deserializer that is compatible with Spring – tbatch Jan 17 '23 at 21:12
  • Maybe I'm not explaining this correctly. I don't want a collection processor for every entity in my system. I want a central collection processor. I'm thinking something like what I added above. Thanks. – Occams Stubble Jan 17 '23 at 21:46
  • Nothing about the example I've provided is a global serializer. You assign the serializer to the object you want serialized via the annotations. You can annotate the object or field within the object with `@JsonDeserializer` and/or `@JsonSerializer` to tell jackson to use only that serializer with that object. If you want to deserialize every collection the same way, then you can register the serializer with jackson via the `MappingJackson2HttpMessageConverter` in your springs configuration file. If you only want to serialize/deserialize some collections, then annotate them. – tbatch Jan 17 '23 at 22:49