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

306 Views Asked by At

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.

1

There are 1 best solutions below

1
Turing85 On BEST ANSWER

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"}%