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.
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.
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();
}
}
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"}%