Search code examples
javaspring-bootjacksonspring-data

How to deserialize json and distinguish `null` from the absence of a value


I have REST api, and when client call POST request with body, backend after deserialize should distinguish null from the absence of a value.

Because if value in JSON is null, then value in DB should become null.

If value in JSON absence, then value in DB should remain unchanged.

JSON:

{
  "id" : 1,
  "name" : "sample name",
  "value" : null
}

OR

{
  "id" : 1,
  "name" : "sample name"
}

For Java after deserialization it is look like : value = null;

Java:

@Entity
@Table("sample")
public class Sample {
    @Id
    @Column
    private Long id;
    @Column
    private String name;
    @Column
    private Integer value;

    // getters / setters
}

Sample REST request:

@PutMapping
public ResponseEntity<SampleDto> updateSample(@RequestBody SampleDto dto) {
    return ResponseEntity.ok(service.updateSample(dto));
}

Sample service impl:

public SampleDto updateSample(SampleDto dto) {
  Sample sample = sampleRepository.findById(dto.getId);
  sample.setName(dto.getName());
  sample.setValue(dto.getValue());

//In this operation back need understand: value is null or absence
//Because if value in JSON is null, then value in DB should become null
//If value in JSON absence, then value in DB should remain unchanged

  Sample newSample = sampleRepository.save(sample);
  return modelMapper.map(newSample, SampleDto.class);
}

Project use Spring Data.

Maybe I should use @JsonDeserialize annotation or other Hibernate annotation

I tried use @JsonDeserialize, but it is not solution.


Solution

  • Partial update is different from full-resource update and we should implement it in a different way. Let's create two request POJO classes. One class will be used to create and update resources, second will be used to partially update given resource. To emphasise it we will use different HTTP methods. To distinguish null from absence we can use java.util.Optional class.

    • SampleCompleteRequest class we use together with POST (create) and PUT (update) methods.
    • SamplePartialRequest class we use together with PATCH (partially update) method.

    To avoid boilerplate code in this example I'm using Lombok and MapStruct but it is not required.


    Model

    import jakarta.validation.constraints.NotBlank;
    import lombok.Data;
    
    @Data
    public class SampleCompleteRequest {
    
        @NotBlank
        private String name;
        private String value;
    }
    
    import jakarta.validation.constraints.NotBlank;
    import lombok.Data;
    
    import java.util.Optional;
    
    @Data
    public class SamplePartialRequest {
        private Optional<@NotBlank String> name;
        private Optional<String> value;
    }
    
    import lombok.Data;
    
    @Data
    public class SampleResponse {
        private Long id;
        private String name;
        private String value;
    }
    
    import lombok.Data;
    
    @Data
    public class Sample {
        //@Id - Hibernate annotations are removed
        private Long id;
        private String name;
        private String value;
    }
    

    MapStruct

    In MapStruct we need to define an interface with all methods we need.

    import com.example.demo.model.SampleCompleteRequest;
    import com.example.demo.model.SamplePartialRequest;
    import com.example.demo.model.SampleResponse;
    import jakarta.annotation.Nullable;
    import org.mapstruct.BeanMapping;
    import org.mapstruct.Mapper;
    import org.mapstruct.MappingTarget;
    import org.mapstruct.ReportingPolicy;
    
    import java.util.Optional;
    
    import static org.mapstruct.MappingConstants.ComponentModel.SPRING;
    import static org.mapstruct.NullValueCheckStrategy.ALWAYS;
    import static org.mapstruct.NullValuePropertyMappingStrategy.IGNORE;
    
    @Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE, componentModel = SPRING)
    public interface SamplesMapper {
        @BeanMapping(nullValueCheckStrategy = ALWAYS, nullValuePropertyMappingStrategy = IGNORE)
        Sample patch(SamplePartialRequest input, @MappingTarget Sample target);
    
        Sample update(SampleCompleteRequest input, @MappingTarget Sample target);
    
        SampleResponse mapToResponse(Sample input);
    
        default String optionalToString(@Nullable Optional<String> nullable) {
            return nullable == null ? null : nullable.orElse(null);
        }
    }
    

    Plugin will generate boilerplate code for us. Below class is autogenerated and we do not need to implement it manually.

    @Component
    public class SamplesMapperImpl implements SamplesMapper {
    
        @Override
        public Sample patch(SamplePartialRequest input, Sample target) {
            if ( input == null ) {
                return target;
            }
    
            if ( input.getName() != null ) {
                target.setName( optionalToString( input.getName() ) );
            }
            if ( input.getValue() != null ) {
                target.setValue( optionalToString( input.getValue() ) );
            }
    
            return target;
        }
    
        @Override
        public Sample update(SampleCompleteRequest input, Sample target) {
            if ( input == null ) {
                return target;
            }
    
            target.setName( input.getName() );
            target.setValue( input.getValue() );
    
            return target;
        }
    
        @Override
        public SampleResponse mapToResponse(Sample input) {
            if ( input == null ) {
                return null;
            }
    
            SampleResponse sampleResponse = new SampleResponse();
    
            sampleResponse.setId( input.getId() );
            sampleResponse.setName( input.getName() );
            sampleResponse.setValue( input.getValue() );
    
            return sampleResponse;
        }
    }
    

    Resource

    A controller class is easy to implement:

    import com.example.demo.model.SampleCompleteRequest;
    import com.example.demo.model.SamplePartialRequest;
    import com.example.demo.model.SampleResponse;
    import com.example.service.SamplesMapper;
    import com.example.service.SamplesService;
    import jakarta.validation.Valid;
    import lombok.AllArgsConstructor;
    import org.springframework.hateoas.CollectionModel;
    import org.springframework.hateoas.EntityModel;
    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.PutMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.List;
    
    @AllArgsConstructor
    @RestController
    @RequestMapping(value = "/api/v1/samples")
    public class SamplesResource {
    
        private final SamplesMapper mapper;
        private final SamplesService samplesService;
    
        @GetMapping
        public CollectionModel<SampleResponse> listAll() {
            List<SampleResponse> entities = samplesService.list().stream().map(mapper::mapToResponse).toList();
    
            return CollectionModel.of(entities);
        }
    
        @PostMapping
        public EntityModel<SampleResponse> addSample(@Valid @RequestBody SampleCompleteRequest request) {
            var entity = samplesService.create(request);
            var response = mapper.mapToResponse(entity);
    
            return EntityModel.of(response);
        }
    
        @PutMapping(path = "{id}")
        public EntityModel<SampleResponse> updateSample(@PathVariable Long id, @Valid @RequestBody SampleCompleteRequest request) {
            var entity = samplesService.update(id, request);
            var response = mapper.mapToResponse(entity);
    
            return EntityModel.of(response);
        }
    
        @PatchMapping(path = "{id}")
        public EntityModel<SampleResponse> partiallyUpdateSample(@PathVariable Long id, @Valid @RequestBody SamplePartialRequest request) {
            var entity = samplesService.patch(id, request);
            var response = mapper.mapToResponse(entity);
    
            return EntityModel.of(response);
        }
    }
    

    A service class is also straightforward:

    import com.example.demo.model.SampleCompleteRequest;
    import com.example.demo.model.SamplePartialRequest;
    import lombok.AllArgsConstructor;
    import org.springframework.stereotype.Service;
    
    import java.util.List;
    
    @Service
    @AllArgsConstructor
    public class SamplesService {
    
        private final SamplesMapper mapper;
        private final SamplesRepository repository;
    
        public List<Sample> list() {
            return repository.listAll();
        }
    
        public Sample create(SampleCompleteRequest request) {
            var sample = mapper.update(request, new Sample());
            return repository.save(sample);
        }
    
        public Sample update(Long id, SampleCompleteRequest request) {
            var sample = repository.find(id).orElseThrow();
            mapper.update(request, sample);
    
            return repository.save(sample);
        }
    
        public Sample patch(Long id, SamplePartialRequest request) {
            var sample = repository.find(id).orElseThrow();
            mapper.patch(request, sample);
    
            return repository.save(sample);
        }
    }
    

    See also:

    1. HTTP PUT vs HTTP PATCH in a REST API
    2. Difference between Jackson objectMapper to others
    3. Spring MVC PATCH method: partial updates