From 3457b3520ccdf9690c909bc7ed67d6639e74dc8b Mon Sep 17 00:00:00 2001 From: Jose Date: Wed, 14 Jun 2023 09:41:56 +0200 Subject: [PATCH] Fix resolving custom ObjectMapper at deserialization in Resteasy Reactive Fix https://github.com/quarkusio/quarkus/issues/34008 --- .../test/CustomObjectMapperTest.java | 99 +++++++++++++++++++ .../test/MessageBodyReaderTests.java | 37 ++++++- .../ServerJacksonMessageBodyReader.java | 50 +++++++++- .../ClientJacksonMessageBodyReader.java | 54 +++++++--- 4 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomObjectMapperTest.java diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomObjectMapperTest.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomObjectMapperTest.java new file mode 100644 index 00000000000000..99e66a254a9fa3 --- /dev/null +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/CustomObjectMapperTest.java @@ -0,0 +1,99 @@ +package io.quarkus.resteasy.reactive.jackson.deployment.test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.equalTo; + +import java.util.Objects; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Provider; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.arc.Unremovable; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.http.ContentType; + +public class CustomObjectMapperTest { + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest().withEmptyApplication(); + + /** + * Because we have configured the server Object Mapper instance with: + * `objectMapper.enable(SerializationFeature.WRAP_ROOT_VALUE);` + */ + @Test + void serverShouldUnwrapRootElement() { + given().body("{\"Request\":{\"value\":\"good\"}}") + .contentType(ContentType.JSON) + .post("/server") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo("good")); + } + + @Path("/server") + public static class MyResource { + @POST + @Consumes(MediaType.APPLICATION_JSON) + public String post(Request request) { + return request.value; + } + } + + public static class Request { + private String value; + + public Request() { + + } + + public Request(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Request request = (Request) o; + return Objects.equals(value, request.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } + + @Provider + @Unremovable + public static class CustomObjectMapperContextResolver implements ContextResolver { + + @Override + public ObjectMapper getContext(final Class type) { + final ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.enable(DeserializationFeature.UNWRAP_ROOT_VALUE); + return objectMapper; + } + } +} diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/MessageBodyReaderTests.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/MessageBodyReaderTests.java index d4e1238aadc601..d75a3a9faea022 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/MessageBodyReaderTests.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/MessageBodyReaderTests.java @@ -7,6 +7,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -17,6 +19,11 @@ import jakarta.ws.rs.container.ConnectionCallback; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.MessageBodyReader; +import jakarta.ws.rs.ext.MessageBodyWriter; +import jakarta.ws.rs.ext.Providers; import org.jboss.resteasy.reactive.common.providers.serialisers.AbstractJsonMessageBodyReader; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; @@ -116,7 +123,8 @@ void shouldThrowInvalidDefinitionException() { @Nested @DisplayName("ServerJacksonMessageBodyReader") class ServerJacksonMessageBodyReaderTests { - private final CommonReaderTests tests = new CommonReaderTests(new ServerJacksonMessageBodyReader(new ObjectMapper())); + private final CommonReaderTests tests = new CommonReaderTests( + new ServerJacksonMessageBodyReader(new ObjectMapper(), new MockProviders())); @Test void shouldThrowWebExceptionWithStreamReadExceptionCause() { @@ -143,7 +151,7 @@ void shouldThrowInvalidDefinitionException() { @Test void shouldThrowWebExceptionWithValueInstantiationExceptionCauseUsingServerRequestContext() throws IOException { - var reader = new ServerJacksonMessageBodyReader(new ObjectMapper()); + var reader = new ServerJacksonMessageBodyReader(new ObjectMapper(), new MockProviders()); // missing non-nullable property var stream = new ByteArrayInputStream("{\"cost\": 2}".getBytes(StandardCharsets.UTF_8)); var context = new MockServerRequestContext(stream); @@ -271,4 +279,29 @@ public void abortWith(Response response) { } } + + private static class MockProviders implements Providers { + + @Override + public MessageBodyReader getMessageBodyReader(Class aClass, Type type, Annotation[] annotations, + MediaType mediaType) { + return null; + } + + @Override + public MessageBodyWriter getMessageBodyWriter(Class aClass, Type type, Annotation[] annotations, + MediaType mediaType) { + return null; + } + + @Override + public ExceptionMapper getExceptionMapper(Class aClass) { + return null; + } + + @Override + public ContextResolver getContextResolver(Class aClass, MediaType mediaType) { + return null; + } + } } diff --git a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/ServerJacksonMessageBodyReader.java b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/ServerJacksonMessageBodyReader.java index 1064ff509e28eb..a2b0f2b93381c5 100644 --- a/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/ServerJacksonMessageBodyReader.java +++ b/extensions/resteasy-reactive/quarkus-resteasy-reactive-jackson/runtime/src/main/java/io/quarkus/resteasy/reactive/jackson/runtime/serialisers/ServerJacksonMessageBodyReader.java @@ -4,12 +4,17 @@ import java.io.InputStream; import java.lang.annotation.Annotation; import java.lang.reflect.Type; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Function; import jakarta.inject.Inject; import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Providers; import org.jboss.resteasy.reactive.common.util.StreamUtil; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; @@ -26,16 +31,20 @@ public class ServerJacksonMessageBodyReader extends JacksonBasicMessageBodyReader implements ServerMessageBodyReader { + private final Providers providers; + private final ConcurrentMap contextResolverMap = new ConcurrentHashMap<>(); + @Inject - public ServerJacksonMessageBodyReader(ObjectMapper mapper) { + public ServerJacksonMessageBodyReader(ObjectMapper mapper, Providers providers) { super(mapper); + this.providers = providers; } @Override public Object readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, InputStream entityStream) throws IOException, WebApplicationException { try { - return doReadFrom(type, genericType, entityStream); + return doReadFrom(type, genericType, mediaType, entityStream); } catch (MismatchedInputException | InvalidDefinitionException e) { /* * To extract additional details when running in dev mode or test mode, Quarkus previously offered the @@ -77,12 +86,13 @@ public Object readFrom(Class type, Type genericType, MediaType mediaType return readFrom(type, genericType, null, mediaType, null, context.getInputStream()); } - private Object doReadFrom(Class type, Type genericType, InputStream entityStream) throws IOException { + private Object doReadFrom(Class type, Type genericType, MediaType responseMediaType, InputStream entityStream) + throws IOException { if (StreamUtil.isEmpty(entityStream)) { return null; } try { - ObjectReader reader = getEffectiveReader(); + ObjectReader reader = getEffectiveReader(type, responseMediaType); return reader.forType(reader.getTypeFactory().constructType(genericType != null ? genericType : type)) .readValue(entityStream); } catch (MismatchedInputException e) { @@ -97,4 +107,36 @@ private boolean isEmptyInputException(MismatchedInputException e) { // this isn't great, but Jackson doesn't have a specific exception for empty input... return e.getMessage().startsWith("No content"); } + + private ObjectReader getEffectiveReader(Class type, MediaType responseMediaType) { + ObjectMapper effectiveMapper = getObjectMapperFromContext(type, responseMediaType); + if (effectiveMapper == null) { + return getEffectiveReader(); + } + + return contextResolverMap.computeIfAbsent(effectiveMapper, new Function<>() { + @Override + public ObjectReader apply(ObjectMapper objectMapper) { + return objectMapper.reader(); + } + }); + } + + private ObjectMapper getObjectMapperFromContext(Class type, MediaType responseMediaType) { + if (providers == null) { + return null; + } + + ContextResolver contextResolver = providers.getContextResolver(ObjectMapper.class, + responseMediaType); + if (contextResolver == null) { + // TODO: not sure if this is correct, but Jackson does this as well... + contextResolver = providers.getContextResolver(ObjectMapper.class, null); + } + if (contextResolver != null) { + return contextResolver.getContext(type); + } + + return null; + } } diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/runtime/serialisers/ClientJacksonMessageBodyReader.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/runtime/serialisers/ClientJacksonMessageBodyReader.java index 5939422b47b3ac..ad7e481b8405bc 100644 --- a/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/runtime/serialisers/ClientJacksonMessageBodyReader.java +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/runtime/serialisers/ClientJacksonMessageBodyReader.java @@ -13,11 +13,14 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.ws.rs.ext.Providers; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.ClientWebApplicationException; import org.jboss.resteasy.reactive.client.impl.RestClientRequestContext; import org.jboss.resteasy.reactive.client.spi.ClientRestHandler; +import org.jboss.resteasy.reactive.common.util.EmptyInputStream; import org.jboss.resteasy.reactive.server.jackson.JacksonBasicMessageBodyReader; import com.fasterxml.jackson.core.JsonParseException; @@ -42,7 +45,13 @@ public ClientJacksonMessageBodyReader(ObjectMapper mapper) { public Object readFrom(Class type, Type genericType, Annotation[] annotations, MediaType mediaType, MultivaluedMap httpHeaders, InputStream entityStream) throws IOException, WebApplicationException { try { - return super.readFrom(type, genericType, annotations, mediaType, httpHeaders, entityStream); + if (entityStream instanceof EmptyInputStream) { + return null; + } + ObjectReader reader = getEffectiveReader(type, mediaType); + return reader.forType(reader.getTypeFactory().constructType(genericType != null ? genericType : type)) + .readValue(entityStream); + } catch (JsonParseException e) { log.debug("Server returned invalid json data", e); throw new ClientWebApplicationException(e, Response.Status.OK); @@ -56,23 +65,44 @@ public void handle(RestClientRequestContext requestContext) { this.context = requestContext; } - @Override - protected ObjectReader getEffectiveReader() { - if (context == null) { - // no context injected when reader is not running within a rest client context - return super.getEffectiveReader(); - } - - ObjectMapper objectMapper = context.getConfiguration().getFromContext(ObjectMapper.class); - if (objectMapper == null) { - return super.getEffectiveReader(); + private ObjectReader getEffectiveReader(Class type, MediaType responseMediaType) { + ObjectMapper effectiveMapper = getObjectMapperFromContext(type, responseMediaType); + if (effectiveMapper == null) { + return getEffectiveReader(); } - return contextResolverMap.computeIfAbsent(objectMapper, new Function<>() { + return contextResolverMap.computeIfAbsent(effectiveMapper, new Function<>() { @Override public ObjectReader apply(ObjectMapper objectMapper) { return objectMapper.reader(); } }); } + + private ObjectMapper getObjectMapperFromContext(Class type, MediaType responseMediaType) { + Providers providers = getProviders(); + if (providers == null) { + return null; + } + + ContextResolver contextResolver = providers.getContextResolver(ObjectMapper.class, + responseMediaType); + if (contextResolver == null) { + // TODO: not sure if this is correct, but Jackson does this as well... + contextResolver = providers.getContextResolver(ObjectMapper.class, null); + } + if (contextResolver != null) { + return contextResolver.getContext(type); + } + + return null; + } + + private Providers getProviders() { + if (context != null && context.getClientRequestContext() != null) { + return context.getClientRequestContext().getProviders(); + } + + return null; + } }