Search code examples
javaexceptionerror-handlingquarkusquarkus-reactive

How to properly handle json to class-instance conversion errors in Quarkus


I am using Quarkus with Jakarta, resteasy-reactive-jackson and Hibernate Orm.. but afiak that doesnt make a difference.

When you have any quarkus endpoint that takes json data in the post body and you then tell quarkus to map that json data to a class-instance by providing a type other then JsonObject in the function, quarkus automatically handles the conversion and any occurring errors.

@POST
@Path("/create")
@Transactional
@Consumes(MediaType.APPLICATION_JSON)
public Response create(UserDTO data){
    return Response.ok().build();
}

So if in the example above the JsonData from the postbody will be mapped to a UserDTO. Now if the UserDTO requires the property isMinor to be a boolean but a String is provided: quarkus will handle that error and show something like this:

{
  "objectName": "Class",
  "attributeName": "isMinor",
  "line": 2,
  "column": 16,
  "value": "string"
}

how can I transform this into a usable error?

The obvious approach would be a ExceptionMapper but even with a global implementation like this. Quarkus/hibernate seemingly handles the Json conversion before everything else. Even 404s get caught by the mapper but the json conversion error isnt.

Here i am trying to use my own Response system but that doesn't really matter.

@Provider
public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
    @Override
    public Response toResponse(Throwable throwable) {
        return CCResponse.error(new CCStatus(1100, "Something bad happend :c"));
    }
}

As a workaround I started using JsonObjects and mapping them to the desired type on my own

ClassName instance = null;
String dataString = String.valueOf(data);
ObjectMapper objectMapper = new ObjectMapper();

try {
    instance = objectMapper.readValue(dataString, ClassName.class);
} catch (JsonProcessingException e) {
    throw new CCException(1106, e.getMessage());
}

This does work but is more code and more ugly.. there has to be a better way to to this that I am not seeing.

Thanks in advance for any help.


Solution

  • Analysis

    I wrote a little reproducer to force this behaviour, in essence with

    RequestDto.java:

    import lombok.AccessLevel;
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.Value;
    import lombok.extern.jackson.Jacksonized;
    
    @Value
    @Builder
    @Jacksonized
    @AllArgsConstructor(access = AccessLevel.PACKAGE)
    public class RequestDto {
      boolean foo;
    }
    

    Resource.java:

    import jakarta.ws.rs.Consumes;
    import jakarta.ws.rs.POST;
    import jakarta.ws.rs.Path;
    import jakarta.ws.rs.Produces;
    import jakarta.ws.rs.core.MediaType;
    import jakarta.ws.rs.core.Response;
    
    import io.smallrye.mutiny.Uni;
    import lombok.Getter;
    
    @Path(Resource.HELLO_PATH)
    @Getter
    public class Resource {
      public static final String HELLO_PATH = "hello";
      @POST
      @Consumes(MediaType.APPLICATION_JSON)
      @Produces(MediaType.APPLICATION_JSON)
      public Uni<Response> getHelloJson(RequestDto request) {
        return Uni.createFrom().item(Response.ok())
            .map(responseBuilder -> responseBuilder.entity(request))
            .map(Response.ResponseBuilder::build);
      }
    }
    

    If we now POST to this endpoint with a non-boolean value for "foo":

    $ curl \
      --verbose \
      --location 'http://localhost:8080/hello' \
      --header 'Content-Type: application/json' \
      --data '{"foo": "bar"}'
    

    We get a response similar to this:

    *   Trying 127.0.0.1:8080...
    * Connected to localhost (127.0.0.1) port 8080 (#0)
    > POST /hello HTTP/1.1
    > Host: localhost:8080
    > User-Agent: curl/7.81.0
    > Accept: */*
    > Content-Type: application/json
    > Content-Length: 14
    > 
    * Mark bundle as not supporting multiuse
    < HTTP/1.1 400 Bad Request
    < Content-Type: application/json;charset=UTF-8
    < content-length: 90
    < 
    * Connection #0 to host localhost left intact
    {"objectName":"RequestDtoBuilder","attributeName":"foo","line":1,"column":9,"value":"bar"}%
    

    It is worth mentioning that the return code is a 400 BAD REQUEST, not a 500 INTERNAL SERVER ERROR. This indicates that most probably a (default) mapper for this exception-type (we will see which one in the next section) already exists. If this is true, this is the reason why the catch-all mapper is not triggered.

    Enabling debug logging by setting quarkus.log.level=DEBUG in application.properties and re-running the curl-command from above shows the following logs

    ...
    2023-12-24 15:14:04,193 DEBUG [org.jbo.res.rea.ser.han.RequestDeserializeHandler] (vert.x-eventloop-thread-1) Error occurred during deserialization of input: com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `boolean` from String "bar": only "true"/"True"/"TRUE" or "false"/"False"/"FALSE" recognized
    ...
    

    So we see that an InvalidFormatException has been thrown.

    Handling this exception

    To handle this specific exception, we can write a custom mapper for the InvalidFormatException. I opted to use method getOriginalMessage() since it seem to provide a human-readable error message. I also opted to create a dedicated ErrorResponse-class:

    ErrorResponse.java:

    import io.quarkus.runtime.annotations.RegisterForReflection;
    import lombok.Value;
    
    @RegisterForReflection
    @Value
    public class ErrorResponse {
      String message;
    }
    

    InvalidFormatExceptionMapper.java:

    import jakarta.ws.rs.core.Response;
    import jakarta.ws.rs.ext.ExceptionMapper;
    import jakarta.ws.rs.ext.Provider;
    
    import com.fasterxml.jackson.databind.exc.InvalidFormatException;
    
    @Provider
    @SuppressWarnings("unused")
    public class InvalidFormatExceptionMapper implements ExceptionMapper<InvalidFormatException> {
    
      public static final String BODY_FORMAT = "Parameter \"%s\": %s%n";
      public static final String UNNAMED_PROPERTY = "(unnamed)";
    
      @Override
      public Response toResponse(InvalidFormatException exception) {
        StringBuilder message = new StringBuilder();
        return Response.status(Response.Status.BAD_REQUEST.getStatusCode())
            .entity(new ErrorResponse(exception.getOriginalMessage())).build();
      }
    }
    

    Testing

    With these changes in place, we can re-run the curl-command:

    $ curl \
      --verbose \
      --location 'http://localhost:8080/hello' \
      --header 'Content-Type: application/json' \
      --data '{"foo": "bar"}'
    

    And get a human-readable error:

    *   Trying 127.0.0.1:8080...
    * Connected to localhost (127.0.0.1) port 8080 (#0)
    > POST /hello HTTP/1.1
    > Host: localhost:8080
    > User-Agent: curl/7.81.0
    > Accept: */*
    > Content-Type: application/json
    > Content-Length: 14
    > 
    * Mark bundle as not supporting multiuse
    < HTTP/1.1 400 Bad Request
    < Content-Type: application/json;charset=UTF-8
    < content-length: 153
    < 
    * Connection #0 to host localhost left intact
    {"message":"Cannot deserialize value of type `boolean` from String \"bar\": only \"true\"/\"True\"/\"TRUE\" or \"false\"/\"False\"/\"FALSE\" recognized"}%