Right Way to Write JSON Deserializer in Spring or Extend It

Right way to write JSON deserializer in Spring or extend it

I've searched a lot and the best way I've found so far is on this article:

Class to serialize

package net.sghill.example;

import net.sghill.example.UserDeserializer
import net.sghill.example.UserSerializer
import org.codehaus.jackson.map.annotate.JsonDeserialize;
import org.codehaus.jackson.map.annotate.JsonSerialize;

@JsonDeserialize(using = UserDeserializer.class)
public class User {
private ObjectId id;
private String username;
private String password;

public User(ObjectId id, String username, String password) {
this.id = id;
this.username = username;
this.password = password;
}

public ObjectId getId() { return id; }
public String getUsername() { return username; }
public String getPassword() { return password; }
}

Deserializer class

package net.sghill.example;

import net.sghill.example.User;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonParser;
import org.codehaus.jackson.ObjectCodec;
import org.codehaus.jackson.map.DeserializationContext;
import org.codehaus.jackson.map.JsonDeserializer;

import java.io.IOException;

public class UserDeserializer extends JsonDeserializer<User> {

@Override
public User deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
ObjectCodec oc = jsonParser.getCodec();
JsonNode node = oc.readTree(jsonParser);
return new User(null, node.get("username").getTextValue(), node.get("password").getTextValue());
}
}

Edit:
Alternatively you can look at this article which uses new versions of com.fasterxml.jackson.databind.JsonDeserializer.

JsonDeserializer does not work on the Class but only on the single element of the class

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.

Spring @RestController custom JSON deserializer

First of all you don't need to override Jackson2ObjectMapperBuilder to add custom deserializer. This approach should be used when you can't add @JsonDeserialize annotation. You should use @JsonDeserialize or override Jackson2ObjectMapperBuilder.

What is missed is the @RequestBody annotation:

@RestController
public class JacksonCustomDesRestEndpoint {

@RequestMapping(value = "/role", method = RequestMethod.POST, consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseBody
public Object createRole(@RequestBody Role role) {
return role;
}
}

@JsonDeserialize(using = RoleDeserializer.class)
public class Role {
// ......
}

public class RoleDeserializer extends JsonDeserializer<Role> {
@Override
public Role deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
// .................
return something;
}
}

Make Jackson use a custom deserializer everywhere (for a type which isn't mine)

LongJsonDeserializer deserializer = new LongJsonDeserializer();

SimpleModule module =
new SimpleModule("LongDeserializerModule",
new Version(1, 0, 0, null, null, null));
module.addDeserializer(Long.class, deserializer);

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(module);

Here's a full demo application. This works with the latest release of Jackson, and probably also with Jackson versions going back to 1.7.

import java.io.IOException;

import org.codehaus.jackson.JsonParser;
import org.codehaus.jackson.JsonProcessingException;
import org.codehaus.jackson.Version;
import org.codehaus.jackson.map.DeserializationContext;
import org.codehaus.jackson.map.JsonDeserializer;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.map.module.SimpleModule;

public class Foo
{
public static void main(String[] args) throws Exception
{
TestBean bean = new TestBean();
bean.value = 42L;

ObjectMapper mapper = new ObjectMapper();

String beanJson = mapper.writeValueAsString(bean);
System.out.println(beanJson);
// output: {"value":42}

TestBean beanCopy1 = mapper.readValue(beanJson, TestBean.class);
System.out.println(beanCopy1.value);
// output: 42

SimpleModule module =
new SimpleModule("LongDeserializerModule",
new Version(1, 0, 0, null));
module.addDeserializer(Long.class, new LongJsonDeserializer());

mapper = new ObjectMapper();
mapper.registerModule(module);

TestBean beanCopy2 = mapper.readValue(beanJson, TestBean.class);
System.out.println(beanCopy2.value);
// output: 126
}
}

class TestBean
{
Long value;
public Long getValue() {return value;}
public void setValue(Long value) {this.value = value;}
}

class LongJsonDeserializer extends JsonDeserializer<Long>
{
@Override
public Long deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException
{
Long value = jp.getLongValue();
return value * 3;
}
}

Is it possible to use a custom serializer/deserializer for a type by default in spring?

You need to create new com.fasterxml.jackson.databind.module.SimpleModule instance and register all custom serialisers and deserialisers. Next, you need to check out how to register new custom module in your version of Spring Boot.

@Bean
public SimpleModule jooqModule() {
SimpleModule jooqModule = new SimpleModule();
jooqModule.addSerializer(JSONB.class, new JSONBSerializer());
jooqModule.addDeserializer(JSONB.class, new JSONBDeserializer());
}

Take a look at:

  • How can I register and use the jackson AfterburnerModule in Spring Boot?
  • Jackson global settings to deserialise array to custom list implementation
  • Jackson custom serialization and deserialization
  • In Spring Boot, adding a custom converter by extending MappingJackson2HttpMessageConverter seems to overwrite the existing converter
  • Customizing HttpMessageConverters with Spring Boot and Spring MVC


Related Topics



Leave a reply



Submit