3

I want to handle json to Object conversion differently on different @RequestMapping in my Controller.

I believe if we add Jackson dependency in our spring-boot project it handles json to Object conversion and #spring.jackson.deserialization.fail-on-unknown-properties=true property will make sure that conversion will fail if there is some unknown property present in the json (please correct me if I am wrong).

Can we tell jackson locally when to fail on unknown properties and when to ignore those property.

Following is code snippet to use a flag.

    @GetMapping(value = "sample")
    public @ResponseBody UserDTO test(@RequestParam String str, @RequestParam boolean failFast) {
        ObjectMapper map = new ObjectMapper();
        if( failFast) {
            map.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
        } else {
            map.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        }
        UserDTO userDTO = null;
        try {
            userDTO = map.readValue(str, UserDTO.class);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return userDTO;
    }

I don't need it to be handled at runtime like i am doing using @RequestParam. Is there some property using which i can use to mark mappings where to check for unknown properties and where to ignore them.

Edit: What I am looking for is to change an existing application to handle Unknown property per mapping. For example:

        @PostMapping(value = "fail/fast")
        public @ResponseBody UserDTO test(@FAIL_ON_UNKNOWN @RequestBody UserDTO userDTO, @RequestParam boolean failFast) {
            ..///processing...
            return userDTO;
        }

        @PostMapping(value = "fail/safe")
        public @ResponseBody UserDTO test( @RequestBody UserDTO userDTO, @RequestParam boolean failFast) {
                ..///processing...
                return userDTO;
        }

If some king of validation can be added per mapping then i don't need to change all existing mapping's to customise unknown property and code change will be minimum.

Mohd Waseem
  • 1,244
  • 2
  • 15
  • 36
  • 1
    You can differentiate per DTO by specifying it at the class level what to do with properties. – M. Deinum Oct 08 '19 at 18:51
  • @Deinum but then it will be fixed for this class. Suppose in some cases it is ok to have unknown property in the DTO but for other cases it is not ok. how we will handle these cases if we specify at class level. – Mohd Waseem Oct 09 '19 at 06:27
  • Use different DTOs. So unless you want to manually create all the `ObjectMapper` instances yourself and do the marshaling yourself as well, your option is to use different DTOs. – M. Deinum Oct 09 '19 at 06:31

4 Answers4

2

Jackson's ObjectMapper allows you to create new ObjectReader with custom configuration. You can create one common ObjectMapper instance in your app and for some controllers use it as a base object for creating custom readers. It will allow you to use all common features and registered modules and change few if needed. See below controller:

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.util.Objects;

@RestController
@RequestMapping(value = "/options")
public class JacksonOptionsController {

    private final ObjectMapper objectMapper;

    @Autowired
    public JacksonOptionsController(ObjectMapper objectMapper) {
        this.objectMapper = Objects.requireNonNull(objectMapper);
    }

    @PostMapping(path = "/fail")
    public ResponseEntity<String> readAndFastFail(HttpServletRequest request) throws IOException {
        String json = readAsRawJSON(request);
        Payload payload = createFailFastReader().readValue(json);

        return ResponseEntity.ok("SUCCESS");
    }

    @PostMapping(path = "/success")
    public ResponseEntity<String> readAndIgnore(HttpServletRequest request) throws IOException {
        String json = readAsRawJSON(request);
        Payload payload = createSafeReader().readValue(json);

        return ResponseEntity.ok("SUCCESS");
    }

    private ObjectReader createFailFastReader() {
        return objectMapper
                .readerFor(Payload.class)
                .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
    }

    private ObjectReader createSafeReader() {
        return objectMapper
                .readerFor(Payload.class);
    }

    private String readAsRawJSON(HttpServletRequest request) throws IOException {
        try (InputStreamReader reader = new InputStreamReader(request.getInputStream())) {
            try (StringWriter out = new StringWriter(64)) {
                reader.transferTo(out);
                return out.toString();
            }
        }
    }
}

Payload class has only one property - id. In one controller we use ObjectReader with enabled DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES. In other we use ObjectReader with default configuration with disabled DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES.

For a test request:

curl -i -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"id":"some-value","id1":1}' http://localhost:8080/options/fail

app throws exception and for request:

curl -i -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"id":"some-value"}' http://localhost:8080/options/fail

it returns SUCCESS value. When we send two above payloads on http://localhost:8080/options/success URL, app in both cases returns SUCCESS value.

See also:

Michał Ziober
  • 37,175
  • 18
  • 99
  • 146
  • thanks for the answer. I want to do something similar but for my existing application. Here we are working on HttpRequest itself and converting on our own. But all my controllers mapping are receiving DTO after converted from json by jackson. I need to hint jackson before converting json to DTO , when to check for unKnown property. Is it possible at all to configure unknown property failure per mapping at the time of json to DTO conversion. – Mohd Waseem Oct 09 '19 at 06:14
  • 1
    @MohdWaseem, this new requirement changes perspective. You need to take a look at [MappingJackson2HttpMessageConverter](https://www.baeldung.com/spring-httpmessageconverter-rest) class. Maybe you could extend it and configure `ObjectReader` there. – Michał Ziober Oct 09 '19 at 07:47
2

I was able to achieve the desired result by implementing my own HttpMessageConverter. Thanks to @MichalZiober for suggesting it.

I created a Custom HttpMessageConvertor and registered it with my custom MediaType:{"application", "json-failFast"}.

How this works is whenever Header: Content-Type:application/json-failFast is present then unknown properties in @RequestBody/@ResponseBody will not be accepted while converting from json to Object and UnrecognizedPropertyException will be thrown.

And whenever Header: Content-Type:application/json is present then unrecognised properties in @RequestBody/ResponseBody will be ignored.

Here is my custom HttpMessageConverter:

@Component
public class CustomJsonMessageConverter extends AbstractJackson2HttpMessageConverter {

    @Nullable
    private String jsonPrefix;

    public CustomJsonMessageConverter() {
        this(Jackson2ObjectMapperBuilder.json().build().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,true));
    }
    public CustomJsonMessageConverter(ObjectMapper objectMapper) {
        super(objectMapper, new MediaType[]{ new MediaType("application", "json-failFast")});
    }

    public void setJsonPrefix(String jsonPrefix) {
        this.jsonPrefix = jsonPrefix;
    }

    public void setPrefixJson(boolean prefixJson) {
        this.jsonPrefix = prefixJson ? ")]}', " : null;
    }

    protected void writePrefix(JsonGenerator generator, Object object) throws IOException {
            if (this.jsonPrefix != null) {
            generator.writeRaw(this.jsonPrefix);
        }
    }
}
Mohd Waseem
  • 1,244
  • 2
  • 15
  • 36
0
@Autowired
private RequestMappingHandlerAdapter converter;

@Override
public void afterPropertiesSet() throws Exception {
    configureJacksonToFailOnUnknownProperties();
}

private void configureJacksonToFailOnUnknownProperties() {
    MappingJackson2HttpMessageConverter httpMessageConverter = converter.getMessageConverters().stream()
            .filter(mc -> mc.getClass().equals(MappingJackson2HttpMessageConverter.class))
            .map(mc -> (MappingJackson2HttpMessageConverter)mc)
            .findFirst()
            .get();

    httpMessageConverter.getObjectMapper().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
dhruv_0707
  • 41
  • 9
0

We implemented this based on this other answer to the question based on AbstractJackson2HttpMessageConverter. While it does work, it turned out to be missing a few important things that impacted us.

Here's what I ended up with:

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public Jackson2ObjectMapperBuilder jacksonBuilder() {
    return new Jackson2ObjectMapperBuilder()
        .serializerByType(LocalDateTime.class, new LocalDateTimeJsonSerializer())
        .deserializerByType(LocalDateTime.class, new LocalDateTimeJsonDeserializer()
        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).timeZone(TimeZone.getDefault());
}

// Allows the frontend to set the media type to application/json-fail-on-unknown-properties in order to have the server reject incorrect property names
@Component
public static class CustomJsonMessageConverter extends AbstractJackson2HttpMessageConverter {
    public CustomJsonMessageConverter(@Autowired Jackson2ObjectMapperBuilder jacksonBuilder) {
        super(jacksonBuilder.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(),
              new MediaType("application", "json-fail-on-unknown-properties") );
    }

    // Only allow de-serialisation, not serialisation
    @Override
    protected boolean canWrite(@Nullable MediaType mediaType) {
        return false;
    }
}

Importantly, canWrite is set to return false. That means this this converter can only be used to de-serialise incoming messages, not serialise outgoing messages. Without this, if the client doesn't set the Accepts header or sets it to */* then you can end up returning a media type of application/json-fail-on-unknown-properties to the client. Additionally, not all serialisation features are necessarily set the same so the result may also sometimes be different. In our case if a controller returned a plain String rather than an object then it was changing the result. Setting canWrite to false avoids all this as there's no reason you should be using this one for serialisation.

The JsonPrefix-related function have also been removed from the other example as they are not needed for de-serialisation. They don't do any harm, but they are simply not needed.

In our case we've got some custom Jackson configuration in a Jackson2ObjectMapperBuilder builder bean, so we re-use that in CustomJsonMessageConverter to try to match the normal spring behaviour as closely as possible. I think I'm still missing some config that spring sets itself, but it is close enough for us now - your config may be different or you may use a different mechanism to configure Jackson in spring in which case this might not be appropriate for you, but if it is then it is important to mark that bean as @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE), otherwise the call to .featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) changes the original singleton builder and affects what Spring uses for normal Json de-serialisation as well. Note that this is still the case even if you call the function marked with @Bean directly as spring intercepts that call and makes sure you still get the singleton.

And finally, the behaviour of Spring with this config when we send a content-type of application/json-fail-on-unknown-properties still doesn't match the behaviour with application/json in all cases. We had some endpoints that take in @RequestBody String xxxx and read the body as a raw string that worked with the spring config, but not with this CustomJsonMessageConverter. For these we had to make sure we continued to send application/json, which is fine as these ones don't need the extra de-serialisation check.

We got to the final desired result of being able to set the content type to application/json-fail-on-unknown-properties and get an error on unknown properties, but there were a few extra things to sort out along the way.