1

I have a custom bean serializer that I'd like to apply, but when I do, Jackson no longer includes null properties.

The following code reproduces the issue:

import java.io.IOException;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationConfig;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.BeanSerializerModifier;
import lombok.Value;

public class Test {

  @Value
  public static class Contact {
    String first;
    String middle;
    String last;
    String email;
  }
  
  
  public static void main(String[] args) throws Exception {
    Contact contact = new Contact("Bob", null, "Barker", null);
    
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new SimpleModule() {
        @Override public void setupModule(SetupContext context) {
            super.setupModule(context);
            context.addBeanSerializerModifier(new BeanSerializerModifier() {
                @Override public JsonSerializer<?> modifySerializer(SerializationConfig config, BeanDescription desc, JsonSerializer<?> serializer) {
//                  return serializer;
                  return new JsonSerializer<Object>() {
                    @Override public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
                      ((JsonSerializer<Object>) serializer).serialize(value, gen, serializers);
                    }};
                }
            });
        }
    });
    
    
    System.out.println(
        mapper.writerWithDefaultPrettyPrinter().writeValueAsString(contact)
    );
    
  }
}

The above code does nothing other that register a 'custom' serializer (that just delegates back to the original serializer), yet it produces JSON without the null properties:

{ "first" : "Bob", "last" : "Barker" }

If you comment out the return new JsonSerializer<Object>() {... and return the passed in serializer as is return serializer;, then Jackson serializes the null properties:

{ "first" : "Bob", "middle" : null, "last" : "Barker", "email" : null }


I have read over many seemingly related SO articles, but none have led me to a solution yet. I've tried explicitly setting the mapper to Include.ALWAYS on serialization, with no luck.

My only lead is a comment in the JavaDoc for JsonSerializer:

NOTE: various serialize methods are never (to be) called with null values -- caller must handle null values, usually by calling {@link SerializerProvider#findNullValueSerializer} to obtain serializer to use.
This also means that custom serializers cannot be directly used to change
the output to produce when serializing null values.

I am using Jackson version 2.11.2.


My question is: How can I write a custom serializer and have Jackson respect its usual Include directives with regard to null property serialization?

Context Info: My actual custom serializer's job is to conditionally hide properties from serialization. I have a custom annotation, @JsonAuth that is meta-annotated with @JacksonAnnotationsInside @JsonInclude(Include.NON_EMPTY) which my custom serializer (a ContextualSerializer) looks for in an overriden isEmpty method and returns true (treat as empty) if authorization is lacking. The end result is that I have an annotation that can be applied to properties which will hide the property from serialization if the client is not authorized. Except ... usage of the custom serializer has the unintended side effect of dropping all null properties.


Update: Jackson's BeanPropertyWriter.serializeAsField(...) method will completely ignore any custom serializer assigned to the property if the value is null.

I was able to override this behavior by writing a small extension to the class, which allowed my "isAuthorized" logic to preempt the null check:

  public class JsonAuthPropertyWriter extends BeanPropertyWriter {
   
    private final Predicate<Object> authFilter; 
    
    private JsonAuthPropertyWriter(BeanPropertyWriter delegate, Predicate<Object> authFilter) {
      super(delegate);
      this.authFilter = authFilter;
      // set null serializer or authorized null values disappear
      super.assignNullSerializer(NullSerializer.instance);
    }
    
    @Override
    public void serializeAsField(
        Object bean,
        JsonGenerator gen,
        SerializerProvider prov) throws Exception {
      boolean authorized = authFilter.test(bean);
      if (!authorized) return;
      super.serializeAsField(bean, gen, prov);
    }
  }

And I injected these custom BeanPropertyWriters using a BeanSerializerModifier:

  private static class JsonAuthBeanSerializerModifier extends BeanSerializerModifier {
    
    @Override
    public List<BeanPropertyWriter> changeProperties(
        SerializationConfig config,
        BeanDescription beanDesc, 
        List<BeanPropertyWriter> beanProperties
        ) {
      
      for (int i = 0; i < beanProperties.size(); i++) {
        BeanPropertyWriter beanPropertyWriter = beanProperties.get(i);
        JsonAuth jsonAuth = beanPropertyWriter.findAnnotation(JsonAuth.class);
        if (jsonAuth != null) {
          Predicate<Object> authPredicate = ...
          beanProperties.set(i, new JsonAuthPropertyWriter(beanPropertyWriter, authPredicate));
        }
      }
      return beanProperties;
    }
    
  }
allenru
  • 697
  • 6
  • 22
  • I don't quite follow where your 'overridden isEmpty' method is, but why not use a `SimpleBeanPropertyFilter` as you don't need to change serialisation, just filter properties, as far as I can tell. Perhaps including your actual code would help. – tgdavies Nov 21 '20 at 02:53
  • `SimpleBeanPropertyFilter` does not support conditional filtering at serialization time. For example, it does not support including the property on every other serialization attempt. The `isEmpty` method I mention is at the end of my larger, more complex custom serializer implementation, which I did not include because I was able to demonstrate via the code above that as soon as a custom serializer is introduced, Jackson's null value handling changes. behavior – allenru Nov 22 '20 at 00:20
  • I was wrong! `SimpleFilterProvider` DOES support conditional filter at serialization time. (I would not be surprised if this changed. Jackson typically tries to cache 'decisions' that don't change.) @tgdavies was gracious enough to provide proof, and kudos to him. I actually prefer the use of Jackson's filter semantics better than a custom serializer for my use case, as I am trying to filter properties on a resource being returned to a rest client based on that client's authorization set. – allenru Nov 22 '20 at 02:22

1 Answers1

0

I may be misunderstanding what you want, but this approach seems useful:

import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.ser.BeanPropertyWriter;
import com.fasterxml.jackson.databind.ser.FilterProvider;
import com.fasterxml.jackson.databind.ser.PropertyWriter;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.HashMap;
import java.util.Map;

public class Test2 {

    @Target(ElementType.FIELD)
    @Retention(RetentionPolicy.RUNTIME)
    @interface JsonAuth {

    }

    @JsonFilter("myFilter")
    public static class Contact {

        @JsonAuth
        String first;
        @JsonAuth
        String middle;
        @JsonAuth
        String last;
        String email;

        public Contact(String first, String middle, String last, String email) {
            this.first = first;
            this.middle = middle;
            this.last = last;
            this.email = email;
        }
        public String getFirst() {
            return first;
        }
        public void setFirst(String first) {
            this.first = first;
        }
        public String getMiddle() {
            return middle;
        }
        public void setMiddle(String middle) {
            this.middle = middle;
        }
        public String getLast() {
            return last;
        }
        public void setLast(String last) {
            this.last = last;
        }
        public String getEmail() {
            return email;
        }
        public void setEmail(String email) {
            this.email = email;
        }
    }
    public static Map<String,Boolean> fieldSerialisationCount = new HashMap<>();

    public static void main(String[] args) throws Exception {
        Contact contact = new Contact("Bob", null, "Barker", null);

        ObjectMapper mapper = new ObjectMapper();
        FilterProvider filters = new SimpleFilterProvider().addFilter("myFilter", new SimpleBeanPropertyFilter() {
            @Override
            protected boolean include(BeanPropertyWriter writer) {
                return super.include(writer) && isAuthed(writer);
            }
            @Override
            protected boolean include(PropertyWriter writer) {
                return super.include(writer) && isAuthed(writer);
            }

            private boolean isAuthed(PropertyWriter writer) {
                if (!writer.getMember().hasAnnotation(JsonAuth.class)) {
                    return true;
                } else {

                    return fieldSerialisationCount.compute(writer.getName(), (n, b) -> b == null ? true : !b); // check auth here
                }
            }
        });
        mapper.setFilterProvider(filters);
        ObjectWriter writer = mapper.writer(filters).withDefaultPrettyPrinter();

        System.out.println(
                writer.writeValueAsString(contact)
        );
        System.out.println(
                writer.writeValueAsString(contact)
        );
        System.out.println(
                writer.writeValueAsString(contact)
        );
    }
}

It serialises annotated fields every other time, just as an example of a filter using persistent state.

Please let me know whether this works for you.

By the way, I agree that Jackson has the problem you describe, and I don't know how to solve it, so this is a work-around rather than an answer to your original question.

tgdavies
  • 10,307
  • 4
  • 35
  • 40
  • You're right, the include methods are getting called every time an object is serialized. One down side to the filter approach though is that the include method call does not have access to the property value. That's ok in my case since the authorization decision is based on static access (thread local) to the principal. However, if my requirements changed to narrow the auth decision based on the property value as well ... I guess I'd have to look at the custom serializer again? – allenru Nov 22 '20 at 02:28
  • For others (and my future reference), I discovered that it is possible to configure a custom serializer to return null values. See [this SO question](https://stackoverflow.com/questions/50746384/jackson-custom-annotation-for-custom-null-value-serialization) for the process. – allenru Nov 22 '20 at 02:32
  • I also ended up using a mixin [as described here](https://stackoverflow.com/questions/44671154/jackson-filtering-out-fields-without-annotations) to eliminate the need for annotating the filter on all resource classes. – allenru Nov 22 '20 at 04:20