4

I created a new Deserializer to be able to make empty strings be written as null

public class CustomDeserializer extends JsonDeserializer<String> {
    @Override
    public String deserialize(JsonParser jsonParser, DeserializationContext context) throws IOException  {
        JsonNode node = jsonParser.readValueAsTree();
        if (node.asText().isEmpty()) {
            return null;
        }
        return node.toString();
    }
}

Trying to make the single annotation on each User field, the Custom works but by inserting the annotation on the whole class, I can no longer print the Json message

@JsonDeserialize(using = CustomDeserializer.class)
public class User {
    private String firstName;
    private String lastName;
    private String age;
    private String address; }

The CustomExceptionHandler throws me this error :Class MethodArgumentNotValidException This is my Kafka Consumer, the only one where I have entered a validation annotation, but even removing it gives me the same error

public class KafkaConsumer {

    @Autowired
    private UserRepository userRepository;

    @KafkaListener(topics = "${spring.kafka.topic.name}")
    public void listen(@Validated User user) {

        User  user = new User(user);
        UserRepository.save(user.getName(), user);
    }
}

ObjectMapper

public ObjectMapper getObjectMapper() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    mapper.setSerializationInclusion(Include.NON_NULL);
    return mapper;
}

Is it possible to make it work across the whole class?

Jacket
  • 353
  • 6
  • 18
  • Well, of course setting a custom deserializer on a class doesn't apply to the fields only. You set the annotation on the type you want your deserializer to spit out. What you could do: replace the standard String deserializer in the object mapper instance or create a custom deserializer for your class (this could inherit from one of the standard ones) and let that one handle string properties either directly or by delegating to your custom string deserializer. – Thomas Mar 12 '21 at 10:20
  • Hi Thomas, could you give me an example as an answer of this object that I could create? I don't quite understand how to set it up – Jacket Mar 12 '21 at 10:26
  • Well, you've already extended a standatrd deserializer and used it, what else do you need? – Thomas Mar 12 '21 at 10:39
  • I didn't understand how to replace the standard String deserializer in the object mapper instance. I also add the objectMapper in the question – Jacket Mar 12 '21 at 10:52
  • Well, `ObjectMapper` is the central class that Jackson uses. How to alter the object mapper used in your case depends on your framework and I'd need to refer you to the documentation or another search. Replacing the standard deserializer should then be a matter of creating and registering a `SimpleModule` with the object mapper and calling `module.addSerializer(String.class ,new CustomDeserializer());´ or something like that. – Thomas Mar 12 '21 at 11:06

2 Answers2

3

If you want an empty String representing the whole object to be treated as null, you can enable the ACCEPT_EMPTY_STRING_AS_NULL_OBJECT Jackson deserialization feature, disabled by default.

You can include it when configuring your ObjectMapper:

public ObjectMapper getObjectMapper() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.registerModule(new JavaTimeModule());
    mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    mapper.setSerializationInclusion(Include.NON_NULL);
    // Enable ACCEPT_EMPTY_STRING_AS_NULL_OBJECT deserialization feature
    mapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT);
    return mapper;
}

As abovementioned, it is useful when you want to treat an empty String representing the whole object as null; however, it will not work for individual properties of type String: in the later case you can safely use your custom deserializer, so, the solution is in fact a mix of both approaches, use the ACCEPT_EMPTY_STRING_AS_NULL_OBJECT deserialization feature to deal with the whole object, and your custom deserializer for handling individual String properties.

Please, see this and this other related SO questions.

You can improve your custom User deserializer as well. Please, consider for example (I refactored the name to UserDeserializer for clarity):

import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;

public class UserDeserializer extends JsonDeserializer<User> {

  @Override
  public User deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
    JsonNode node = jsonParser.readValueAsTree();
    Iterator<String> fieldNames = node.fieldNames();
    // Process Jackson annotations looking for aliases
    Map<String, String> fieldAliases = this.getAliases();
    User user = new User();
    boolean anyNonNull = false;
    // Iterate over every field. The deserialization process assume simple properties
    while(fieldNames.hasNext()) {
      String fieldName = fieldNames.next();
      JsonNode fieldValue = node.get(fieldName);
      String fieldValueTextRepresentation = fieldValue.asText();
      if (fieldValueTextRepresentation != null && !fieldValueTextRepresentation.trim().isEmpty()) {
        // Check if the field is aliased
        String actualFieldName = fieldAliases.get(fieldName);
        if (actualFieldName == null) {
          actualFieldName = fieldName;
        }

        this.setFieldValue(user, actualFieldName, fieldValueTextRepresentation);
        anyNonNull = true;
      }
    }

    return anyNonNull ? user : null;
  }

  // Set field value via Reflection
  private void setFieldValue(User user, String fieldName, String fieldValueTextRepresentation) {
    try {
      Field field = User.class.getDeclaredField(fieldName);
      Object fieldValue = null;
      Class clazz = field.getType();
      // Handle each class type: probably this code can be improved, but it is extensible and adaptable,
      // you can include as many cases as you need.
      if (clazz.isAssignableFrom(String.class)) {
        fieldValue = fieldValueTextRepresentation;
      } else if (clazz.isAssignableFrom(LocalDate.class)) {
        // Adjust the date pattern as required
        // For example, if you are receiving the information
        // like this: year-month-day, as in the provided example,
        // you can use the following pattern
        fieldValue = LocalDate.parse(fieldValueTextRepresentation, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
      } else if (clazz.isAssignableFrom(Integer.class)) {
        fieldValue = Integer.parseInt(fieldValueTextRepresentation);
      }
      field.setAccessible(true);
      field.set(user, fieldValue);
    } catch (Exception e) {
      // Handle the problem as appropriate
      e.printStackTrace();
    }
  }
  
  /* Look for Jackson aliases */
  private Map<String, String> getAliases() {
    Map<String, String> fieldAliases = new HashMap<>();

    Field[] fields = User.class.getDeclaredFields();
    for (Field field: fields) {
      Annotation annotation = field.getAnnotation(JsonAlias.class);
      if (annotation != null) {
        String fieldName = field.getName();
        JsonAlias jsonAliasAnnotation = (JsonAlias) annotation;
        String[] aliases = jsonAliasAnnotation.value();
        for (String alias: aliases) {
          fieldAliases.put(alias, fieldName);
        }
      }
    }

    return fieldAliases;
  }
}

With this serializer in place, given a User class similar to:

import java.time.LocalDate;

import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

@JsonDeserialize(using = UserDeserializer.class)
public class User {
  private String firstName;
  private String lastName;
  private Integer age;
  private String address;
  @JsonAlias("dateofbirth")
  private LocalDate dateOfBirth;

  // Setters and getters omitted for brevity

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    User user = (User) o;

    if (firstName != null ? !firstName.equals(user.firstName) : user.firstName != null) return false;
    if (lastName != null ? !lastName.equals(user.lastName) : user.lastName != null) return false;
    if (age != null ? !age.equals(user.age) : user.age != null) return false;
    if (address != null ? !address.equals(user.address) : user.address != null) return false;
    return dateOfBirth != null ? dateOfBirth.equals(user.dateOfBirth) : user.dateOfBirth == null;
  }

  @Override
  public int hashCode() {
    int result = firstName != null ? firstName.hashCode() : 0;
    result = 31 * result + (lastName != null ? lastName.hashCode() : 0);
    result = 31 * result + (age != null ? age.hashCode() : 0);
    result = 31 * result + (address != null ? address.hashCode() : 0);
    result = 31 * result + (dateOfBirth != null ? dateOfBirth.hashCode() : 0);
    return result;
  }

And the following JSON (I changed to name of the dateofbirth field just for testing aliases):

{"firstName":"John","age":40,"dateofbirth":"1978-03-16"}

You should obtain the appropriate results, consider the following test:

  public static void main(String... args) throws JsonProcessingException {
    User user = new User();
    user.setFirstName("John");
    user.setAge(40);
    user.setDateOfBirth(LocalDate.of(1978, Month.MARCH, 16));

    ObjectMapper mapper = new ObjectMapper();
    mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

    String json = "{\"firstName\":\"John\",\"age\":40,\"dateofbirth\":\"1978-03-16\"}";

    User reconstructed = mapper.readValue(json, User.class);

    System.out.println(user.equals(reconstructed));
  }

Finally, please, be aware that in order to allow your @KafkaListener to handle null values, you must use the @Payload annotation with required = false, something like:

public class KafkaConsumer {

    @Autowired
    private UserRepository userRepository;

    @KafkaListener(topics = "${spring.kafka.topic.name}")
    public void listen(@Payload(required = false) User user) {
        // Handle null value
        if (user == null) {
          // Consider logging the event
          // logger.debug("Null message received");
          System.out.println("Null message received");
          return;
        }

        // Continue as usual
        User  user = new User(user);
        UserRepository.save(user.getName(), user);
    }
}

See the relevant Spring Kafka documentation and this Github issue and the related commit. This SO question could be relevant as well.

jccampanero
  • 50,989
  • 3
  • 20
  • 49
  • Hi, I have tried your answer but it keeps giving me an error like this `MethodArgumentNotValidException` .I have seen over and over again the questions you have listed for me but I have not found a solution to this error it generates me – Jacket Mar 15 '21 at 00:01
  • 2
    @Jacket I updated the answer with further information. Basically, please, can you annotate your method as `@Payload(required = false) User user` and see if it works? – jccampanero Mar 15 '21 at 23:24
  • Hi, I tried inserting the @Payload annotation and indeed there is a change, the error is now a NullPointException – Jacket Mar 16 '21 at 00:39
  • I am happy to hear that @Jacket. Sorry, of course, now it is necessary to handle the null value. Please, see the updated answer. I hope it helps. – jccampanero Mar 16 '21 at 07:54
  • @Jacket Did it work? Were you able to test the proposed solution? – jccampanero Mar 16 '21 at 16:47
  • @jccampero I have tested your solution, but the problem is not solved. However, I found in your solution an excellent starting point to find a solution. – Jacket Mar 16 '21 at 16:53
  • That is great @Jacket, I am happy to hear that the provided solution was helpful at least. Please, do not hesitate to comment here and contact me if you think I can be of any help in solving the rest of the problem. – jccampanero Mar 16 '21 at 17:00
  • @Jacket Although I think that with Jackson properties customization you can obtain your desired results, I updated the answer to provide a generalization of your custom deserializer, maybe it can be of help. – jccampanero Mar 16 '21 at 19:22
  • Thanks you are helping me a lot! as soon as I can try this new solution and let you know, you are great! – Jacket Mar 16 '21 at 20:17
  • You are welcome @Jacket. I hope the new information is helpful. – jccampanero Mar 16 '21 at 22:07
  • I tested the solution, it fails to change the string to null and it gives me problems with the variables in the "dateBirth" format, not being able to read them. – Jacket Mar 17 '21 at 10:41
  • You are right, sorry, fieldValue.isNull() will not work for your use case: please @jacket, can you try the modified version of the deserializer? I am quite sure that it will work. Jacket, in order to adapt the answer, in which format are you receiving your date? – jccampanero Mar 17 '21 at 11:09
  • For convenience, let's move to chat – Jacket Mar 17 '21 at 11:38
  • Yes, of course, can you give me ten minutes? – jccampanero Mar 17 '21 at 11:45
  • Yep, take your time – Jacket Mar 17 '21 at 11:58
  • Thank you @Jacket. I started the chat. – jccampanero Mar 17 '21 at 12:01
  • Hi @Jacket. How is it going? I am very sorry for the late reply. Did you make any further progress on your current issue? – jccampanero Apr 13 '21 at 21:40
3

The CustomDeserializer is defined for the type String and it is being used to deserialize a User object. That is the reason why the deserializer is working on individual User fields when applied, but not on the entire User object. In order to apply a deserilizer on the entire User object, the CustomDeserializer should be of type User. Something like this:

public class CustomDeserializer extends JsonDeserializer<User> {
@Override
public User deserialize(JsonParser jsonParser, DeserializationContext context) throws
    IOException {
    JsonNode node = jsonParser.readValueAsTree();
    String firstName = null;
    String lastName = null;
    String age = null;
    String address = null;
    if(node.has("firstName") && !node.get("firstName").asText().isEmpty()) {
        firstName = node.get("firstName").asText();
    }
    if(node.has("lastName") && !node.get("lastName").asText().isEmpty()) {
        lastName = node.get("lastName").asText();
    }
    if(node.has("age") && !node.get("age").asText().isEmpty()) {
        age = node.get("age").asText();
    }
    if(node.has("address") && !node.get("address").asText().isEmpty()) {
        address = node.get("address").asText();
    }
    if(firstName == null && lastName == null && age == null && address == null) {
        return null;
    }
    return new User(firstName, lastName, age, address);
}

}

Now, this can be used to deserialize entire User object:

Sample Input:

{
    "firstName" : "",
    "lastName" : "Paul",
    "age" : "31"
}

Will be deserialized into:

User{firstName='null', lastName='Paul', age='31', address='null'}
Shyam Baitmangalkar
  • 1,075
  • 12
  • 18
  • I'm testing your solution, but I have a case with a LocalDate and not a string, how can I change the if to make it work with that type too? – Jacket Mar 15 '21 at 10:04
  • You need to read `LocalDate` field as text and then convert it to `LocalDate`. Let's say the field is `LocalDate dateOfBirth`. Then the if condition for that in `CustomDeserializer` will be `if(node.has("dateOfBirth") && !node.get("dateOfBirth").asText().isEmpty()) { dateOfBirth = LocalDate.parse(node.get("dateOfBirth").asText()); }` – Shyam Baitmangalkar Mar 15 '21 at 17:40
  • I tried to use your solution but it keeps giving me the same problem. If I insert the annotation on the class it throws me this error "MethodArgumentNotValidException" while on the single fields it overwrites each value with null. Is there maybe a trick to do in the solution? – Jacket Mar 15 '21 at 20:07
  • Can you share the stacktrace so that it gives more details on where the error has occurred? – Shyam Baitmangalkar Mar 16 '21 at 07:39
  • I checked and noticed an error on my part, your code is right. Just wanted to ask, if it's a problem for you I create another question, if you could optimize this answer, i.e. is it possible not to instantiate null for each variable? so I avoid that every time there is a change I have to change a Deserializer too – Jacket Mar 16 '21 at 16:19
  • @Jacket I don't have any problem if you open up a different question related to performance improvements and I'll be happy to address that. But before doing that, don't you think this question needs to be concluded? If you feel the solution is right for the question which is asked, can you accept it? – Shyam Baitmangalkar Mar 17 '21 at 05:20
  • Inglese Hi, having opened a bounty I have to wait for the end time to be able to give anyone the opportunity to write a possible solution. Clearly for the moment your solution is the best and I will not hesitate to assign you the points. I put the performance question at here " https://stackoverflow.com/questions/66671639/is-it-possible-to-optimize-my-custom-deserializer " I hope in your answer . – Jacket Mar 17 '21 at 10:58
  • Sure @Jacket I'll be happy to post a solution for your other question – Shyam Baitmangalkar Mar 21 '21 at 12:06