Spring Rest: Dealing with Optional Fields during HTTP PATCH

Spring Rest: Dealing with Optional Fields during HTTP PATCH

·

7 min read

How to do partial updates in Spring Rest while taking into account optional fields? A question I run into some time ago as I wanted to partially update an entity. In this article, I would like to share with you three solutions that I found as I was trying to investigate how to do this.

Project Demo Setup

Suppose we are building a library management system.

To generate the project, you can use spring initializr. Select Spring Web, Validation, and Lombok as dependencies.

Once the project was generated and opened in an IDE, you will also have to add modelmapper as a dependency as we will use it to convert a DTO to and from an entity.

As a first user story, suppose we have to add basic CRUD operations to manage a library member. In this article, we will be focusing exclusively on the PATCH endpoint.

Defining Data and Service Layers

Our Member entity looks like:

package com.example.springrest.partialupdates.entity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;

import java.util.List;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {

    @NonNull
    private Long id;

    @NonNull
    private String username;

    @NonNull
    private String email;

    private String avatarUrl;

    // Will not be used in this article in order to keep the 
    // example simple.
    // private List<Book> reservedBooks;
}

All fields are required, except the avatarUrl, which is optional. Here we use lombok NonNull annotation to enforce the not null rule for the required fields (id, username, email).

In addition to our member entity, let's define our memebr data transfer objects.

First, let's define our input DTO class:

package com.example.springrest.partialupdates.dto.input;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MemberInputDTO 
{

    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    @NotNull(groups = {CreationGroup.class})
    private String username;

    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    @NotNull(groups = {CreationGroup.class})
    private String email;

    @URL
    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    private String avatarUrl;
}

Next, we will define the output DTO class:

package com.example.springrest.partialupdates.dto.output;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MemberDTO
{
    private Long id;

    private String username;

    private String email;

    private String avatarUrl;
}

Now, its time to define the service. (for the sake of simplicity) Our service layer will also act as data access layer, and no data validation1 will be applied.

package com.example.springrest.partialupdates.service;

import com.example.springrest.partialupdates.dto.MemberDTO;
import com.example.springrest.partialupdates.entity.Member;
import com.example.springrest.partialupdates.dto.MemberCreationUpdateDTO;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 *  For sake of simplicity, this service will act as both a service and data access layer.
 *
 *  The data will be stored in a non-persistent storage.
 */
@Service
public class MemberService {

    private static final ModelMapper modelMapper = new ModelMapper();

    private static Map<Long, Member> members;

    private static Long sequence = 1L;

    static {
        members = new HashMap<>();
        members.put(
                sequence, 
                Member.builder().id(sequence).username("testlibrary").email("testlibrary@example.com").build()
        );
    }

    public List<MemberDTO> getMembers() {
        return members.values()
                .stream()
                .map(m -> modelMapper.map(m, MemberDTO.class))
                .collect(Collectors.toList());
    }

   public void partialUpdateMember(Long id, MemberInputDTO member) {
        Member currentMemberInfo = members.get(id);

        if(currentMemberInfo == null) throw new IllegalArgumentException(String.format("No member with id %d was found.", id));


Optional.ofNullable(memberDto.getUsername()).ifPresent(currentMemberInfo::setUsername);
        Optional.ofNullable(memberDto.getEmail()).ifPresent(currentMemberInfo::setEmail);
        Optional.ofNullable(memberDto.getAvatarUrl()).ifPresent(currentMemberInfo::setAvatarUrl);

        members.put(id, currentMemberInfo);
    }

    public void addMember(MemberInputDTO member) {
        Member newMember = modelMapper.map(member, Member.class);

        newMember.setId(++sequence);

        members.put(newMember.getId(), newMember);
 }

We are initializating the members data structure with one member to not have to make a POST request later on to populate our data.

Defining the Rest Controller

We now need to define a rest controller that will handle incoming rest requests made on the members api. The members api will be located at: /api/members

package com.example.springrest.partialupdates.api;

import com.example.springrest.partialupdates.throughlibrary.service.MemberService;
import com.example.springrest.partialupdates.throughlibrary.dto.input.CreationGroup;
import com.example.springrest.partialupdates.throughlibrary.dto.input.MemberInputDTO;
import com.example.springrest.partialupdates.throughlibrary.dto.input.UpdateGroup;
import com.example.springrest.partialupdates.throughlibrary.dto.output.MemberDTO;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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 java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/members")
@AllArgsConstructor
public class MemberController {

    private MemberService memberService;

    @PostMapping
    public ResponseEntity<Void> createMember(
            @RequestBody @Validated(CreationGroup.class) MemberInputDTO member
    ) {
        MemberDTO createdMember = memberService.addMember(member);

        return ResponseEntity.created(URI.create(String.format("/members/%d", createdMember.getId()))).build();
    }

    @PatchMapping("/{id}")
    public void partialUpdateMember(
            @PathVariable Long id,
            @RequestBody @Validated(UpdateGroup.class) MemberInputDTO member
    ) {
        memberService.partialUpdateMember(id, member);
    }

    @GetMapping
    public ResponseEntity<List<MemberDTO>> getMembers() {
        List<MemberDTO> members = memberService.getMembers();

        return ResponseEntity.ok(members);
    }
}

The endpoint that will be our focus in this article is partialUpdateMember.


When doing a partial update using PATCH,

  • Not providing a field should keep the property unchanged in the database.

  • Providing explicitely a null field (for example "avatarUrl": null), should update the field to null in the database.

  • Providing explicitely a not null field (for example "avatarUrl": "ourdomain.com/user1.jpeg"), should update the field to null in the database.

Let's dive into the different ways we can implement this.

Using Java Optional

This way consists of wrapping each property with an Optional. Then, using the Optional api to check if the property was explicitly provided or not. This goes against the Optional intented usage (Optional is intened to be used as return type and not as a type of an instance variable). For this reason, I do not suggest using this approach. Let`s see though how one can achieve this.

We update our MemberInputDTOby wrapping the optional members with an Optional:

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberInputDTO
{
    @JsonProperty("username")
    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    @NotNull(groups = {CreationGroup.class})
    private String username;

    @JsonProperty("email")
    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    @NotNull(groups = {CreationGroup.class})
    private String email;

    @JsonProperty("avatarUrl")
    @URL
    private Optional<@Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1) String> avatarUrl;
}

Then in the service layer, we update the partialUpdateMember method to check if the Optional contains a value or is empty.

@Service
public class MemberService {

    // other methods go here
    public void partialUpdateMember(Long id, MemberInputDTO memberDto)
    {
        Member currentMemberInfo = members.get(id);

        if(currentMemberInfo == null) throw new IllegalArgumentException(String.format("No member with id %d was found.", id));
        if (memberDto.getUsername() != null) throw new IllegalArgumentException("Username cannot be updated.");

        mapDTOToEntity(memberDto, currentMemberInfo);

        members.put(id, currentMemberInfo);
    }

    private void mapDTOToEntity(MemberInputDTO memberDto, Member member)
    {
        Optional.ofNullable(memberDto.getUsername())
                .ifPresent(member::setUsername);
        Optional.ofNullable(memberDto.getEmail())
                .ifPresent(member::setEmail);

        Optional.ofNullable( memberDto.getAvatarUrl())
                .ifPresent(res -> member.setAvatarUrl(res.orElse(null)));
    }

}

Let's test this out: Making a PATCH request to update our member by providing a not null avatarUrl will update the avatar url:

curl --location --request PATCH 'http://localhost:8080/members/1' \
--header 'Content-Type: application/json' \
--data-raw '{
     "avatarUrl": "https://ourdomain.com/user1.jpeg"
}'

Making a PATCH request with an empty body for example will not change the value of the avatarUrl:

curl --location --request PATCH 'http://localhost:8080/members/1' \
--header 'Content-Type: application/json' \
--data-raw '{}'

Finally, making a PATCH request to update the avatarUrl to null will set the avatar url to null:

curl --location --request PATCH 'http://localhost:8080/members/1' \
--header 'Content-Type: application/json' \
--data-raw '{
     "avatarUrl": null
}'

Using Setter

Another option is to use setters. For the optional properties that can be nullable, we can declare a boolean flag to check if this property was explicitly provided in the request. In the property's setter, we will set this flag to true.

To accomplish this, we will update our MemberInputDTO to:

@Data
@NoArgsConstructor
public class MemberInputDTO
{
    // username and email code goes here (stays the same as our previous example)


    @JsonProperty("avatarUrl")
    @URL
    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    private String avatarUrl;
    @Setter(AccessLevel.NONE)
    private boolean avatarUrlProvided;

    public void setAvatarUrl(@URL @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1) String avatarUrl) {
        this.avatarUrl = avatarUrl;
        this.avatarUrlProvided = true;
    }
}

Also, we will update the mapDTOToEntity helper method that is in our service:

 private void mapDTOToEntity(MemberInputDTO memberDto, Member member)
    {
         // username and email code goes here (stays the same as our previous example)

        if (memberDto.isAvatarUrlProvided())
        {
            member.setAvatarUrl(memberDto.getAvatarUrl());
        }
    }

We provided a new private property avatarUrlProvided that is set to true in the setAvatarUrl. This way, when Jackson deserialzies the payload, it will set this flag to true when the avatarUrl was explictly provided.

Using jackson-nullbable-databaind Library

We can use a helper library to deal with optional fields. The library is jackson-nullbable-databaind

To integrate it, simply add its dependency into the project:

<dependency>
    <groupId>org.openapitools</groupId>
    <artifactId>jackson-databind-nullable</artifactId>
    <version>0.2.1</version>
</dependency>

We can make use of the JsonNullable data type to wrap our optional field:

@Data
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MemberInputDTO
{
    // username and email code goes here (stays the same as our example above)

    @JsonProperty("avatarUrl")
    @URL
    @Size(groups = {CreationGroup.class, UpdateGroup.class}, min = 1)
    private JsonNullable<String> avatarUrl;
}

Finally, we just have to update the helper method in the service layer:

 private void mapDTOToEntity(MemberInputDTO memberDto, Member member)
    {
         // username and email code goes here (stays the same as our example above)

        Optional.ofNullable( memberDto.getAvatarUrl())
                .ifPresent(res -> member.setAvatarUrl(res.orElse(null)));
    }

In this article we went over three ways to deal with optional fields when implementing a PATCH request. By far, the third solution is my favorite.

You can find the source code in my git repo: partialupdates repo

[1] for example: checking that the username is unique