Detecting Absent Properties in Java REST APIs with Jackson, Spring, and Custom Deserializers
Ensuring Compatibility and Flexibility in Java REST APIs with Custom Deserializers for Optional Properties
In modern software development, ensuring backward compatibility with older clients while introducing new features can be challenging. One such challenge is detecting whether a specific property was sent through a REST API request. By default, Jackson deserializes absent properties to null
, which complicates the process of identifying whether a property was intentionally set to null
or was absent in the request altogether. This article will walk you through a solution using a custom Jackson deserializer to handle this scenario.
The Problem
Using Java, Spring, and Jackson, I needed to identify whether a specific property was sent through my REST API to maintain compatibility with older clients. If the property was not sent, I must detect that the property was absent in the request and ignore it. However, Jackson's default behavior deserializes absent properties to null
, making it difficult to distinguish between an absent property and one explicitly set to null
.
The Solution: Custom Jackson Deserializer
To solve this problem, we can use a custom Jackson deserializer that can handle three states for a property: present, absent, and explicitly set to null
. This is achieved by creating an OptionalProperty
wrapper and a custom deserializer for this wrapper.
Implementing the OptionalProperty
Wrapper
The OptionalProperty
class represents a property that can be present with a value, absent, or present with a null
value. This class provides methods to check the presence of a value and handle each state appropriately.
import java.io.Serializable;
import java.util.function.Consumer;
/**
* This class represents an optional property.
* <p>
* The difference between this and {@code java.util.Optional} is that this class represents three states:
*
* <ul>
* <li>The value is present.</li>
* <li>The value is absent.</li>
* <li>The value is present but it is null.</li>
* </ul>
* <p>
* It is a container object which may or may not contain a non-null value.
* <p>
* If a value is present, {@code isPresent()} will return {@code true} and {@code get()} will return the value.
* <p>
* Additional methods that depend on the presence or absence of a contained value are provided, such as {@code ifPresent()}.<br>
*
* @param <T> the type of the value
*/
public class OptionalProperty<T extends Serializable> implements Serializable {
private boolean isPresent;
private T value;
private OptionalProperty(boolean isPresent, T value) {
this.isPresent = isPresent;
this.value = value;
}
public static <T extends Serializable> OptionalProperty<T> of(T value) {
return new OptionalProperty<>(true, value);
}
public static <T extends Serializable> OptionalProperty<T> absent() {
return new OptionalProperty<>(false, null);
}
public T get() {
if (!isPresent) {
throw new IllegalStateException("No value present");
}
return value;
}
public boolean isAbsent() {
return !isPresent;
}
public boolean isPresent() {
return isPresent;
}
public OptionalProperty<T> ifPresent(Consumer<? super T> consumer) {
if (isPresent) {
consumer.accept(value);
}
return this;
}
public OptionalProperty<T> ifPresentAndNotNull(Consumer<? super T> consumer) {
if (isPresent && value != null) {
consumer.accept(value);
}
return this;
}
public OptionalProperty<T> ifAbsent(Runnable runnable) {
if (!isPresent) {
runnable.run();
}
return this;
}
public T orElse(T other) {
return isPresent ? value : other;
}
public T orElseNull() {
return orElse(null);
}
}
Creating the Custom Deserializer
Next, we create a custom deserializer for the OptionalProperty
class. This deserializer will handle the three states: present with value, absent, and present with a null
value.
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;
import java.io.IOException;
import java.io.Serializable;
/**
* This class is a custom deserializer for {@link OptionalProperty} objects.
*
* @param <T> the type of the value contained in the {@link OptionalProperty}
*/
public class JsonOptionalPropertyDeserializer<T extends Serializable> extends JsonDeserializer<OptionalProperty<T>> implements ContextualDeserializer {
private Class<T> valueClass;
@Override
public OptionalProperty<T> getNullValue(DeserializationContext ctxt) {
return OptionalProperty.of(null);
}
@Override
public Object getAbsentValue(DeserializationContext ctxt) {
return OptionalProperty.absent();
}
@Override
public OptionalProperty<T> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
T value = jsonParser.readValueAs(valueClass);
return OptionalProperty.of(value);
}
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
valueClass = (Class<T>) property.getType().getBindings().getBoundType(0).getRawClass();
return this;
}
}
Explanation of the Custom Deserializer Code
The custom deserializer JsonOptionalPropertyDeserializer
is designed to handle the deserialization of the OptionalProperty
class, which can represent three states: present with a value, absent, and present with a null
value. Here's a breakdown of how the deserializer works:
Class Declaration and Generics:
public class JsonOptionalPropertyDeserializer<T extends Serializable> extends JsonDeserializer<OptionalProperty<T>> implements ContextualDeserializer {
Handling Null Values:
@Override
public OptionalProperty<T> getNullValue(DeserializationContext ctxt) {
return OptionalProperty.of(null);
}
This method is called when the JSON value is explicitly null
. It returns an OptionalProperty
instance with a null
value.
Handling Absent Values:
@Override
public Object getAbsentValue(DeserializationContext ctxt) {
return OptionalProperty.absent();
}
This method is called when the JSON value is absent. It returns an absent OptionalProperty
instance.
Deserializing JSON Values:
@Override
public OptionalProperty<T> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
T value = jsonParser.readValueAs(valueClass);
return OptionalProperty.of(value);
}
This method is called to deserialize a JSON value into an OptionalProperty
instance. It reads the JSON value as an object of type T
and returns an OptionalProperty
containing the deserialized object.
Creating Contextual Deserializer:
@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
valueClass = (Class<T>) property.getType().getBindings().getBoundType(0).getRawClass();
return this;
}
This method is called to create a contextual deserializer. It retrieves the class of the generic type T
from the property's type and stores it in the valueClass
field for later use in deserialization.
By implementing these methods, the JsonOptionalPropertyDeserializer
ensures that the OptionalProperty
class can correctly handle the three states of a property during deserialization: present with a value, absent, and present with a null
value. This allows for more precise control over the handling of optional properties in your Java REST API.
Using the Custom Deserializer
Finally, we use the custom deserializer in a DTO class to handle the OptionalProperty
.
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
class MyDTO {
@JsonDeserialize(using = JsonOptionalPropertyDeserializer.class)
private OptionalProperty<Long> identifier;
public OptionalProperty<Long> getIdentifier() {
return identifier;
}
public void setIdentifier(OptionalProperty<Long> identifier) {
this.identifier = identifier;
}
}
Complete Controller Class with Business Logic
Here is a comprehensive controller class that includes the business logic for processing MyDTO
using the custom deserializer. This controller will handle incoming POST requests, process the identifier
property, and output the results based on its state.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
@RestController
@RequestMapping("/api")
public class MyController {
@PostMapping("/process")
public void processMyDTO(@RequestBody MyDTO myDTO) {
// Check if identifier is present
if (myDTO.getIdentifier().isPresent()) {
// Check if the identifier is not null
myDTO.getIdentifier().ifPresentAndNotNull(value -> {
// Business logic for when the identifier is present and not null
System.out.println("Identifier is present and not null: " + value);
// Additional processing logic here
});
// If the identifier is present but null
if (myDTO.getIdentifier().orElseNull() == null) {
System.out.println("Identifier is present but null");
// Business logic for when the identifier is explicitly set to null
// Additional processing logic here
}
} else {
// If the identifier is absent
System.out.println("Identifier is absent");
// Business logic for when the identifier is absent
// Additional processing logic here
}
}
}
Conclusion
By using a custom Jackson deserializer, we can effectively distinguish between absent properties, properties explicitly set to null
, and properties with a non-null value in our REST API requests. This approach ensures backward compatibility with older clients while providing the flexibility to handle new requirements. The OptionalProperty
wrapper and custom deserializer provide a robust solution for managing optional properties in Java applications using Spring and Jackson.
Feel free to incorporate this solution into your projects and adapt it to fit your specific needs. Happy coding!