Search code examples
javajsonspringspring-bootconverters

Spring Boot Converter Doesn't Work in REST Calls


I'm trying to use Converter to convert String input coming from POST request to CountryDTO object. I have created CountryConverter class and implemented the Converter interface coming from Spring, I also added my converter to FormatterRegistry in my WebConfig class.

It works with normal @Controller class methods, if I use some HTML templates and forms, however, it doesn't work before any @RestController class method gets executed.

This is the message I get in the console, and I understand the reason for this message. Since my converter doesn't work, the application tries to deserialize String to a CountryDTO object, and it fails because it just cannot take String "1" for example, and deserialize it to a whole CountryDTO object.

Console Message:

Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of com.cydeo.dto.CountryDTO (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('1'); nested exception is com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of com.cydeo.dto.CountryDTO (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('1') at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 5, column: 16] (through reference chain: com.cydeo.dto.UserDTO["country"])]

I'm using Spring Boot version 2.7.7

Here is my JSON Request Body:

{
    "username": "MikeS",
    "password": "Abc1",
    "country": "1"
}

Here is my code:

My CountryConverter class:

import com.cydeo.dto.CountryDTO;
import com.cydeo.service.CountryService;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
public class CountryConverter implements Converter<String, CountryDTO> {

    private final CountryService countryService;

    public CountryConverter(CountryService countryService) {
        this.countryService = countryService;
    }

    @Override
    public CountryDTO convert(String source) {

        if (source.isEmpty()) {
            return null;
        }

        try {
            return countryService.findById(Long.parseLong(source));
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

}

My WebConfig class:

import com.cydeo.converter.CountryConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    private final CountryConverter countryConverter;

    public WebConfig(CountryConverter countryConverter) {
        this.countryConverter = countryConverter;
    }

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(countryConverter);
    }

}

My UserDTO class:

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.validation.constraints.*;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {

    @JsonIgnore
    private Long id;

    @NotBlank(message = "Username is required")
    @Size(min = 3, max = 16, message = "Username length should be min 2, max 16")
    private String username;

    @NotBlank(message = "Password is required")
    @Pattern(regexp = "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{4,}", message = "The password should be at least 4 characters long and include at least 1 capital letter, 1 small letter and 1 digit")
    private String password;

    @NotNull(message = "Country is required")
    private CountryDTO country;

}

My CountryDTO class:

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CountryDTO {

    @JsonIgnore
    private Long id;

    private String countryName;

}

My UserController class:

import com.cydeo.dto.ResponseWrapper;
import com.cydeo.dto.UserDTO;
import com.cydeo.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.*;

@RestController
@RequestMapping("/api/user")
public class UserRestController {

    private final UserService userService;

    public UserRestController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/create")
    public ResponseEntity<ResponseWrapper> createUser(@Valid @RequestBody UserDTO userDTO, BindingResult bindingResult) throws Exception {

        if (bindingResult.hasErrors()) {

            List<FieldError> fieldErrors = bindingResult.getFieldErrors();
            Map<String, Map<String, String>> response = new HashMap<>();

            for (FieldError fieldError : fieldErrors) {

                if (response.containsKey(fieldError.getField())) {
                    response.get(fieldError.getField()).put(fieldError.getCode(), fieldError.getDefaultMessage());
                } else {
                    response.put(fieldError.getField(), new HashMap<>());
                    response.get(fieldError.getField()).put(fieldError.getCode(), fieldError.getDefaultMessage());
                }

            }

            return ResponseEntity.badRequest().body(new ResponseWrapper(false, "Please check the information.", HttpStatus.BAD_REQUEST, response));

        }

        return ResponseEntity.status(HttpStatus.CREATED).body(new ResponseWrapper("User is created.", userService.create(userDTO), HttpStatus.CREATED));
    }

}

Solution

  • Converter interface you implemented from

    org.springframework.core.convert.converter.Converter

    is primarily used for type conversion in the application layer, like converting between different object types within the service or controller layers. However, it is not directly involved in HTTP message conversion.

    When processing JSON data, Spring Boot uses Jackson by default to serialize and deserialize the request and response bodies. Jackson, in this case, does not automatically utilize the Spring Converter interface for JSON deserialization.

    You have a couple of options:

    1. Implement a custom deserializer for Jackson
    2. Modify the UserDTO class

    When it comes to JSON data handling in @RestController, integration with Jackson is necessary for it to be applicable in JSON deserialization.

    Example:

       public class CountryDTODeserializer extends JsonDeserializer<CountryDTO> {
    
        private final CountryService countryService;
    
        public CountryDTODeserializer(CountryService countryService) {
            this.countryService = countryService;
        }
    
        @Override
        public CountryDTO deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
            JsonNode node = jsonParser.getCodec().readTree(jsonParser);
            Long id = node.asLong(); // Something like this
    
            try {
                return countryService.findById(id);
            } catch (Exception e) {
                throw new RuntimeException("Error during deserialization", e);
            }
        }
    }
    

    Also don't forget to put the following annotation:

     @JsonDeserialize(using = CountryDTODeserializer.class)
     @NotNull(message = "Country is required")
     private CountryDTO country;