Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

When using Spring’s default support for JSON deserialization, we’re forced to map the incoming JSON to a single request handler parameter. Sometimes, however, we’d prefer a more fine-grained method signature.

In this tutorial, we will learn how to use a custom HandlerMethodArgumentResolver to deserialize a JSON POST into multiple strongly-typed parameters.

2. The Problem

First, let’s look at the limitations of Spring MVC’s default approach to JSON deserialization.

2.1. The Default @RequestBody Behavior

Let’s start with an example JSON body:

{
   "firstName" : "John",
   "lastName"  :"Smith",
   "age" : 10,
   "address" : {
      "streetName" : "Example Street",
      "streetNumber" : "10A",
      "postalCode" : "1QW34",
      "city" : "Timisoara",
      "country" : "Romania"
   }
}

Next, let’s create DTOs that match the JSON input:

public class UserDto {
    private String firstName;
    private String lastName;
    private String age;
    private AddressDto address;

    // getters and setters
}
public class AddressDto {

    private String streetName;
    private String streetNumber;
    private String postalCode;
    private String city;
    private String country;

    // getters and setters
}

Finally, we’ll use the standard approach for deserializing our JSON request into a UserDto using the @RequestBody annotation:

@Controller
@RequestMapping("/user")
public class UserController {

    @PostMapping("/process")
    public ResponseEntity process(@RequestBody UserDto user) {
        /* business processing */
        return ResponseEntity.ok()
            .body(user.toString());
    }
}

2.2. Limitations

The primary benefit of the standard solution above is that we don’t have to deserialize the JSON POST into a UserDto object manually.

However, the entire JSON POST must be mapped to a single request parameter. This means we have to create a separate POJO for each expected JSON structure, polluting our code base with classes used solely for this purpose.

That consequence is especially evident when we only need a subset of the JSON properties. In our request handler above, we only need the user’s firstName and city properties, but we’re forced to deserialize an entire UserDto.

While Spring allows us to use Map or ObjectNode as a parameter rather than a homegrown DTO, both are single-parameter options. As with a DTO, everything is packaged together. Since the Map and ObjectNode contents are String values, we must marshal them into objects ourselves. These options save us from declaring single-use DTOs but create even more complexity.

3. Custom HandlerMethodArgumentResolver

Let’s look at a solution to the limitations above. We can use Spring MVC’s HandlerMethodArgumentResolver to allow us to declare just the desired JSON attributes as parameters in our request handler.

3.1. Creating the Controller

First, let’s create a custom annotation we can use to map a request handler parameter to a JSON path:

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface JsonArg {
    String value() default "";
}

Next, we’ll create a request handler that uses the annotation to map firstName and city as separate parameters that correlate to properties from our JSON POST body:

@Controller
@RequestMapping("/user")
public class UserController {
    @PostMapping("/process/custom")
    public ResponseEntity process(@JsonArg("firstName") String firstName,
      @JsonArg("address.city") String city) {
        /* business processing */
        return ResponseEntity.ok()
            .body(String.format("{\"firstName\": %s, \"city\" : %s}", firstName, city));
    }
}

3.2. Creating the Custom HandlerMethodArgumentResolver

After Spring MVC has decided which request handler should handle an incoming request, it attempts to resolve the parameters automatically. This includes iterating through all beans in the Spring context that implement the HandlerMethodArgumentResolver interface in case that can resolve any parameters Spring MVC can’t do automatically.

Let’s define an implementation of HandlerMethodArgumentResolver that will process all request handler parameters annotated with @JsonArg:

public class JsonArgumentResolver implements HandlerMethodArgumentResolver {

    private static final String JSON_BODY_ATTRIBUTE = "JSON_REQUEST_BODY";

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(JsonArg.class);
    }

    @Override
    public Object resolveArgument(
      MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
      WebDataBinderFactory binderFactory) 
      throws Exception {
        String body = getRequestBody(webRequest);
        String jsonPath = Objects.requireNonNull(
          Objects.requireNonNull(parameter.getParameterAnnotation(JsonArg.class)).value());
        Class<?> parameterType = parameter.getParameterType();
        return JsonPath.parse(body).read(jsonPath, parameterType);
    }

    private String getRequestBody(NativeWebRequest webRequest) {
        HttpServletRequest servletRequest = Objects.requireNonNull(
          webRequest.getNativeRequest(HttpServletRequest.class));
        String jsonBody = (String) servletRequest.getAttribute(JSON_BODY_ATTRIBUTE);
        if (jsonBody == null) {
            try {
                jsonBody = IOUtils.toString(servletRequest.getInputStream());
                servletRequest.setAttribute(JSON_BODY_ATTRIBUTE, jsonBody);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return jsonBody;
    }
}

Spring uses the supportsParameter() method to check whether this class can resolve a given parameter. Since we want our handler to process any parameter annotated with @JsonArg, we return true if the given parameter has that annotation.

Next, in the resolveArgument() method, we extract the JSON body and then attach it as an attribute to the request so we can access it directly for subsequent calls. We then grab the JSON path from the @JsonArg annotation and use reflection to get the parameter’s type. With the JSON path and the parameter type information, we can deserialize discrete parts of the JSON body into rich objects.

3.3. Registering the Custom HandlerMethodArgumentResolver

For Spring MVC to use our JsonArgumentResolver, we need to register it:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        JsonArgumentResolver jsonArgumentResolver = new JsonArgumentResolver();
        argumentResolvers.add(jsonArgumentResolver);
    }
}

Our JsonArgumentResolver will now process all request handler parameters annotated with @JsonArgs. We’ll need to ensure the @JsonArgs value is a valid JSON path, but that is a lighter process than the @RequestBody approach that requires a separate POJO for every JSON structure.

3.4. Using Parameters With Custom Types

To show that this will work with custom Java classes as well, let’s define a request handler with strongly-typed POJO parameters:

@PostMapping("/process/custompojo")
public ResponseEntity process(
  @JsonArg("firstName") String firstName, @JsonArg("lastName") String lastName,
  @JsonArg("address") AddressDto address) {
    /* business processing */
    return ResponseEntity.ok()
      .body(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
        firstName, lastName, address));
}

We can now map the AddressDto as a separate parameter.

3.5. Testing the Custom JsonArgumentResolver

Let’s write a test case to prove that the JsonArgumentResolver works as expected:

@Test
void whenSendingAPostJSON_thenReturnFirstNameAndCity() throws Exception {

    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"age\":10,\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    
    mockMvc.perform(post("/user/process/custom").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(MockMvcResultMatchers.jsonPath("$.firstName").value("John"))
      .andExpect(MockMvcResultMatchers.jsonPath("$.city").value("Timisoara"));
}

Next, let’s write a test where we call the second endpoint that parses the JSON directly into POJOs:

@Test
void whenSendingAPostJSON_thenReturnUserAndAddress() throws Exception {
    String jsonString = "{\"firstName\":\"John\",\"lastName\":\"Smith\",\"address\":{\"streetName\":\"Example Street\",\"streetNumber\":\"10A\",\"postalCode\":\"1QW34\",\"city\":\"Timisoara\",\"country\":\"Romania\"}}";
    ObjectMapper mapper = new ObjectMapper();
    UserDto user = mapper.readValue(jsonString, UserDto.class);
    AddressDto address = user.getAddress();

    String mvcResult = mockMvc.perform(post("/user/process/custompojo").content(jsonString)
      .contentType(MediaType.APPLICATION_JSON)
      .accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andReturn()
      .getResponse()
      .getContentAsString();

    assertEquals(String.format("{\"firstName\": %s, \"lastName\": %s, \"address\" : %s}",
      user.getFirstName(), user.getLastName(), address), mvcResult);
}

4. Conclusion

In this article, we looked at some limitations in Spring MVC’s default deserialization behavior and then learned how to use a custom HandlerMethodArgumentResolver to overcome them.

As always, the code for these examples is available over on GitHub.

Course – LS (cat=JSON/Jackson)

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
2 Comments
Oldest
Newest
Inline Feedbacks
View all comments
Comments are closed on this article!