Detecting Absent Properties in Java REST APIs with Jackson, Spring, and Custom Deserializers

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!

Did you find this article valuable?

Support Marcelo Paixão Resende by becoming a sponsor. Any amount is appreciated!