Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make Jackson throw exception as is when deserialization mapping fail

Jackson has a weird behavior in handling Exceptions that occur during deserialization mapping: it throws a JsonMappingException whose .getCause() returns the innermost of the exception chain.

//in main
ObjectMapper jsonMapper = new ObjectMapper();
String json = "{\"id\": 1}";
try {
    Q q = jsonMapper.readValue(json, Q.class);
} catch (JsonMappingException e) {
    System.out.println(e.getCause()); //java.lang.RuntimeException: ex 2
}

//class Q
public class Q {
    @JsonCreator
    public Q(@JsonProperty("id") int id) {
        throw new RuntimeException("ex 0", 
            new RuntimeException("ex 1", 
                new RuntimeException("ex 2")));
    }
}

In the code above, I use jsonMapper.readValue(..) to map the String json to an instance of class Q whose the constructor, marked @JsonCreator, throws a chain of RuntimeException: "ex 0", "ex 1", "ex 2". When the mapping fail, I expected the line System.out.println(e.getCause()); to print out ex 0, but it prints ex 2.

Why Jackson decides to do this and is there a way to configure it so that it doesn't discard my ex 0? I have tried

jsonMapper.configure(DeserializationFeature.WRAP_EXCEPTIONS, false);

but it doesn't do anything.

like image 649
asinkxcoswt Avatar asked Jun 04 '14 14:06

asinkxcoswt


People also ask

What is JsonMappingException?

public class JsonMappingException extends JsonProcessingException. Checked exception used to signal fatal problems with mapping of content, distinct from low-level I/O problems (signaled using simple IOException s) or data encoding/decoding problems (signaled with JsonParseException , JsonGenerationException ).

Does Jackson require default constructor?

Need of Default ConstructorBy default, Java provides a default constructor(if there's no parameterized constructor) which is used by Jackson to parse the response into POJO or bean classes.

What is JSON processing exception?

Class JsonProcessingExceptionIntermediate base class for all problems encountered when processing (parsing, generating) JSON content that are not pure I/O problems. Regular IOException s will be passed through as is. Sub-class of IOException for convenience.

What is Jackson deserialization?

Jackson is a powerful and efficient Java library that handles the serialization and deserialization of Java objects and their JSON representations. It's one of the most widely used libraries for this task, and runs under the hood of many other frameworks.


2 Answers

Inside of Jackson's StdValueInstantiator this method gets hit when an exception is thrown during deserialization:

protected JsonMappingException wrapException(Throwable t)
{
    while (t.getCause() != null) {
        t = t.getCause();
    }
    if (t instanceof JsonMappingException) {
        return (JsonMappingException) t;
    }
    return new JsonMappingException("Instantiation of "+getValueTypeDesc()+" value failed: "+t.getMessage(), t);
}

As you can see, this will iterate through each "level" of your nested runtime exceptions and set the last one it hits as the cause for the JsonMappingException it returns.

Here is the code I needed to get this working:

  1. Register a new module to the ObjectMapper.

    @Test
    public void testJackson() {
        ObjectMapper jsonMapper = new ObjectMapper();
        jsonMapper.registerModule(new MyModule(jsonMapper.getDeserializationConfig()));
        String json = "{\"id\": \"1\"}";
        try {
            Q q = jsonMapper.readValue(json, Q.class);
            System.out.println(q.getId());
        } catch (JsonMappingException e) {
            System.out.println(e.getCause()); //java.lang.RuntimeException: ex 2
        } catch (JsonParseException e) {
        } catch (IOException e) {
        }
    }
    
  2. Create a custom module class.

    public class MyModule extends SimpleModule {
        public MyModule(DeserializationConfig deserializationConfig) {
            super("MyModule", ModuleVersion.instance.version());
            addValueInstantiator(Q.class, new MyValueInstantiator(deserializationConfig, Q.class));
            addDeserializer(Q.class, new CustomDeserializer());
        }
    }
    
  3. Create a custom ValueInstantiator class to override wrapException(...). Add the instantiator to the module.

    public class MyValueInstantiator extends StdValueInstantiator {
        public MyValueInstantiator(DeserializationConfig config, Class<?> valueType) {
            super(config, valueType);
        }
    
        @Override
        protected JsonMappingException wrapException(Throwable t) {
            if (t instanceof JsonMappingException) {
                return (JsonMappingException) t;
            }
            return new JsonMappingException("Instantiation of "+getValueTypeDesc()+" value failed: "+t.getMessage(), t);
        }
    }
    
  4. Create a custom deserializer to get the module to work properly. Add this class to the module initialization as well.

    public class CustomDeserializer extends StdScalarDeserializer<Q> {
        public CustomDeserializer() {
            super(Q.class);
        }
    
        @Override
        public Q deserialize(JsonParser jp, DeserializationContext context) throws IOException {
            JsonNode node = jp.getCodec().readTree(jp);
            return new Q(node.get("id").asText());
        }
    
        @Override
        public Object deserializeWithType(JsonParser jp, DeserializationContext ctxt, TypeDeserializer typeDeserializer) throws IOException {
            return deserialize(jp, ctxt);
        }
    }
    
like image 128
Sam Berry Avatar answered Oct 11 '22 18:10

Sam Berry


For anyone looking for a different solution, this worked for me on Spring Boot 2.2.8.RELEASE. NB: This is example is when you have a rest controller with request body that is has an enum and clients could send a wrong field string gender and you want to provide proper error message:

@PostMapping
public ResponseEntity<ProfileResponse> updateProfile(@RequestBody @Valid ProfileRequest profileRequest) {
    
    ProfileResponse profile = //do something important here that returns profile object response
    return ResponseEntity
            .status(HttpStatus.OK)
            .body(profile);
}

ProfileRequest looks like

@Data //Lombok getters and setters
public class ProfileRequest{
    private GenderEnum gender;
    //some more attributes
}

Add this property to the aplication.properties file to make sure that our custom exception GlobalRuntimeException (see later for definition) is not wrapped in JsonMappingException exception.

spring.jackson.deserialization.WRAP_EXCEPTIONS=false

Then create a class which spring boot will auto create a bean for (This will be used for Deserializing the field gender of type enum). If we don't find an the enum, then we know to throw an error.

@JsonComponent
public class GenderEnumDeserializer extends JsonDeserializer<GenderEnum> {

    @Override
    public GenderEnum deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        String val = p.getValueAsString();
        GenderEnum genderEnum = GenderEnum.fromName(val);
        if(genderEnum == null){
            throw new GlobalRuntimeException("Invalid gender provided. Valid values are MALE | FEMALE | OTHER");
        }
        return genderEnum;
    }

}

The "forName" method in GenderEnum looks like below.

public static GenderEnum fromName(String name) {
    GenderEnum foundGenderEnum = null;
    for (GenderEnum genderEnum : values()) {
        if (genderEnum.name().equalsIgnoreCase(name)) {
            foundGenderEnum = genderEnum;
        }
    }
    return foundGenderEnum;
}

We would then setup catching the GlobalRuntimeException in our ControllerAdvice:

@ResponseBody
@ExceptionHandler(GlobalRuntimeException.class)
ResponseEntity<?> handleInvalidGlobalRuntimeException(HttpServletRequest request, GlobalRuntimeException ex) {
    LOGGER.error("Error " + ex);
    return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(new ErrorMessage(ex.getCustomMessage));
}

That's it.

like image 34
Given Avatar answered Oct 11 '22 19:10

Given