Skip to content

Commit

Permalink
Merge pull request #35025 from Sgitario/23979
Browse files Browse the repository at this point in the history
Allows customizing the ObjectMapper in REST Client Reactive Jackson
  • Loading branch information
geoand committed Jul 26, 2023
2 parents 8f0e94e + 799e878 commit 02d2e43
Show file tree
Hide file tree
Showing 8 changed files with 350 additions and 17 deletions.
28 changes: 28 additions & 0 deletions docs/src/main/asciidoc/rest-client-reactive.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,34 @@ public class TestClientRequestFilter implements ResteasyReactiveClientRequestFil
}
----

== Customizing the ObjectMapper in REST Client Reactive Jackson

The REST Client Reactive supports adding a custom ObjectMapper to be used only the Client using the annotation `@ClientObjectMapper`.

A simple example is to provide a custom ObjectMapper to the REST Client Reactive Jackson extension by doing:

[source, java]
----
@Path("/extensions")
@RegisterRestClient
public interface ExtensionsService {
@GET
Set<Extension> getById(@QueryParam("id") String id);
@ClientObjectMapper <1>
static ObjectMapper objectMapper(ObjectMapper defaultObjectMapper) { <2>
return defaultObjectMapper.copy() <3>
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(DeserializationFeature.UNWRAP_ROOT_VALUE);
}
}
----

<1> The method must be annotated with `@ClientObjectMapper`.
<2> It's must be a static method. Also, the parameter `defaultObjectMapper` will be resolved via CDI. If not found, it will throw an exception at runtime.
<3> In this example, we're creating a copy of the default object mapper. You should *NEVER* modify the default object mapper, but create a copy instead.

== Exception handling

The MicroProfile REST Client specification introduces the `org.eclipse.microprofile.rest.client.ext.ResponseExceptionMapper` whose purpose is to convert an HTTP response to an exception.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
import jakarta.ws.rs.RuntimeType;
import jakarta.ws.rs.core.MediaType;

import org.jboss.jandex.DotName;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.rest.client.reactive.deployment.AnnotationToRegisterIntoClientContextBuildItem;
import io.quarkus.rest.client.reactive.jackson.ClientObjectMapper;
import io.quarkus.rest.client.reactive.jackson.runtime.serialisers.ClientJacksonMessageBodyReader;
import io.quarkus.rest.client.reactive.jackson.runtime.serialisers.ClientJacksonMessageBodyWriter;
import io.quarkus.resteasy.reactive.jackson.deployment.processor.ResteasyReactiveJacksonProviderDefinedBuildItem;
Expand Down Expand Up @@ -43,6 +49,12 @@ ReinitializeVertxJsonBuildItem vertxJson() {
return new ReinitializeVertxJsonBuildItem();
}

@BuildStep
void additionalProviders(BuildProducer<AnnotationToRegisterIntoClientContextBuildItem> annotation) {
annotation.produce(new AnnotationToRegisterIntoClientContextBuildItem(DotName.createSimple(ClientObjectMapper.class),
ObjectMapper.class));
}

@BuildStep
void additionalProviders(
List<ResteasyReactiveJacksonProviderDefinedBuildItem> jacksonProviderDefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import io.quarkus.jackson.ObjectMapperCustomizer;
import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder;
import io.quarkus.rest.client.reactive.jackson.ClientObjectMapper;
import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.http.TestHTTPResource;

Expand Down Expand Up @@ -76,15 +77,15 @@ void shouldClientUseCustomObjectMapperUnwrappingRootElement() {
}

/**
* Because MyClientNotUnwrappingRootElement is using `@RegisterProvider(ClientObjectMapperNotUnwrappingRootElement.class)`
* Because MyClientNotUnwrappingRootElement uses `@ClientObjectMapper`
* which is configured with: `.disable(DeserializationFeature.UNWRAP_ROOT_VALUE)`.
*/
@Test
void shouldClientUseCustomObjectMapperNotUnwrappingRootElement() {
assertFalse(ClientObjectMapperNotUnwrappingRootElement.USED.get());
assertFalse(MyClientNotUnwrappingRootElement.CUSTOM_OBJECT_MAPPER_USED.get());
Request request = clientNotUnwrappingRootElement.get();
assertNull(request.value);
assertTrue(ClientObjectMapperNotUnwrappingRootElement.USED.get());
assertTrue(MyClientNotUnwrappingRootElement.CUSTOM_OBJECT_MAPPER_USED.get());
}

@Path("/server")
Expand All @@ -106,10 +107,19 @@ public interface MyClientUnwrappingRootElement {

@Path("/server")
@Produces(MediaType.APPLICATION_JSON)
@RegisterProvider(ClientObjectMapperNotUnwrappingRootElement.class)
public interface MyClientNotUnwrappingRootElement {
AtomicBoolean CUSTOM_OBJECT_MAPPER_USED = new AtomicBoolean(false);

@GET
Request get();

@ClientObjectMapper
static ObjectMapper objectMapper(ObjectMapper defaultObjectMapper) {
CUSTOM_OBJECT_MAPPER_USED.set(true);
return defaultObjectMapper.copy()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(DeserializationFeature.UNWRAP_ROOT_VALUE);
}
}

public static class Request {
Expand Down Expand Up @@ -157,19 +167,6 @@ public ObjectMapper getContext(Class<?> type) {
}
}

public static class ClientObjectMapperNotUnwrappingRootElement implements ContextResolver<ObjectMapper> {

static final AtomicBoolean USED = new AtomicBoolean(false);

@Override
public ObjectMapper getContext(Class<?> type) {
USED.set(true);
return new ObjectMapper()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(DeserializationFeature.UNWRAP_ROOT_VALUE);
}
}

@Singleton
public static class ServerCustomObjectMapperDisallowUnknownProperties implements ObjectMapperCustomizer {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.quarkus.rest.client.reactive.jackson;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Used to easily define a custom object mapper for the specific REST Client on which it's used.
*
* The annotation MUST be placed on a method of the REST Client interface that meets the following criteria:
* <ul>
* <li>Is a {@code static} method</li>
* </ul>
*
* An example method could look like the following:
*
* <pre>
* {@code
* &#64;ClientObjectMapper
* static ObjectMapper objectMapper() {
* return new ObjectMapper();
* }
*
* }
* </pre>
*
* Moreover, we can inject the default ObjectMapper instance to create a copy of it by doing:
*
* <pre>
* {@code
* &#64;ClientObjectMapper
* static ObjectMapper objectMapper(ObjectMapper defaultObjectMapper) {
* return defaultObjectMapper.copy() <3>
* .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
* .disable(DeserializationFeature.UNWRAP_ROOT_VALUE);
* }
*
* }
* </pre>
*
* Remember that the default object mapper instance should NEVER be modified, but instead always use copy if they pan to
* inherit the default settings.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ClientObjectMapper {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.quarkus.rest.client.reactive.deployment;

import org.jboss.jandex.DotName;

import io.quarkus.builder.item.MultiBuildItem;

/**
* A Build Item that is used to register annotations that are used by the client to register services into the client context.
*/
public final class AnnotationToRegisterIntoClientContextBuildItem extends MultiBuildItem {

private final DotName annotation;
private final Class<?> expectedReturnType;

public AnnotationToRegisterIntoClientContextBuildItem(DotName annotation, Class<?> expectedReturnType) {
this.annotation = annotation;
this.expectedReturnType = expectedReturnType;
}

public DotName getAnnotation() {
return annotation;
}

public Class<?> getExpectedReturnType() {
return expectedReturnType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package io.quarkus.rest.client.reactive.deployment;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.LinkedHashMap;

import jakarta.ws.rs.Priorities;

import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationTarget;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.MethodInfo;
import org.jboss.jandex.Type;
import org.jboss.resteasy.reactive.client.impl.RestClientRequestContext;

import io.quarkus.arc.Arc;
import io.quarkus.arc.ArcContainer;
import io.quarkus.arc.InstanceHandle;
import io.quarkus.gizmo.ClassCreator;
import io.quarkus.gizmo.ClassOutput;
import io.quarkus.gizmo.MethodCreator;
import io.quarkus.gizmo.MethodDescriptor;
import io.quarkus.gizmo.ResultHandle;
import io.quarkus.gizmo.SignatureBuilder;
import io.quarkus.rest.client.reactive.runtime.ResteasyReactiveContextResolver;
import io.quarkus.runtime.util.HashUtil;

/**
* Generates an implementation of {@link ResteasyReactiveContextResolver}
*
* The extension will search for methods annotated with a special annotation like `@ClientObjectMapper` (if the REST Client
* Jackson extension is present) and create the context resolver to register a custom object into the client context like the
* ObjectMapper instance.
*/
class ClientContextResolverHandler {

private static final String[] EMPTY_STRING_ARRAY = new String[0];
private static final ResultHandle[] EMPTY_RESULT_HANDLES_ARRAY = new ResultHandle[0];
private static final MethodDescriptor GET_INVOKED_METHOD =
MethodDescriptor.ofMethod(RestClientRequestContext.class, "getInvokedMethod", Method.class);

private final DotName annotation;
private final Class<?> expectedReturnType;
private final ClassOutput classOutput;

ClientContextResolverHandler(DotName annotation, Class<?> expectedReturnType, ClassOutput classOutput) {
this.annotation = annotation;
this.expectedReturnType = expectedReturnType;
this.classOutput = classOutput;
}

/**
* Generates an implementation of {@link ResteasyReactiveContextResolver} that looks something like:
*
* <pre>
* {@code
* public class SomeService_map_ContextResolver_a8fb70beeef2a54b80151484d109618eed381626
* implements ResteasyReactiveContextResolver<T> {
*
* public T getContext(Class<?> type) {
* // simply call the static method of interface
* return SomeService.map(var1);
* }
*
* }
* </pre>
*/
GeneratedClassResult generateContextResolver(AnnotationInstance instance) {
if (!annotation.equals(instance.name())) {
throw new IllegalArgumentException(
"'clientContextResolverInstance' must be an instance of " + annotation);
}
MethodInfo targetMethod = findTargetMethod(instance);
if (targetMethod == null) {
return null;
}

int priority = Priorities.USER;
AnnotationValue priorityAnnotationValue = instance.value("priority");
if (priorityAnnotationValue != null) {
priority = priorityAnnotationValue.asInt();
}

Class<?> returnTypeClassName = lookupReturnClass(targetMethod);
if (!expectedReturnType.isAssignableFrom(returnTypeClassName)) {
throw new IllegalStateException(annotation
+ " is only supported on static methods of REST Client interfaces that return '" + expectedReturnType + "'."
+ " Offending instance is '" + targetMethod.declaringClass().name().toString() + "#"
+ targetMethod.name() + "'");
}

ClassInfo restClientInterfaceClassInfo = targetMethod.declaringClass();
String generatedClassName = getGeneratedClassName(targetMethod);
try (ClassCreator cc = ClassCreator.builder().classOutput(classOutput).className(generatedClassName)
.signature(SignatureBuilder.forClass().addInterface(io.quarkus.gizmo.Type.parameterizedType(io.quarkus.gizmo.Type.classType(ResteasyReactiveContextResolver.class), io.quarkus.gizmo.Type.classType(returnTypeClassName))))
.build()) {
MethodCreator getContext = cc.getMethodCreator("getContext", Object.class, Class.class);
LinkedHashMap<String, ResultHandle> targetMethodParams = new LinkedHashMap<>();
for (Type paramType : targetMethod.parameterTypes()) {
ResultHandle targetMethodParamHandle;
if (paramType.name().equals(DotNames.METHOD)) {
targetMethodParamHandle = getContext.invokeVirtualMethod(GET_INVOKED_METHOD, getContext.getMethodParam(1));
} else {
targetMethodParamHandle = getFromCDI(getContext, targetMethod.returnType().name().toString());
}

targetMethodParams.put(paramType.name().toString(), targetMethodParamHandle);
}

ResultHandle resultHandle = getContext.invokeStaticInterfaceMethod(
MethodDescriptor.ofMethod(
restClientInterfaceClassInfo.name().toString(),
targetMethod.name(),
targetMethod.returnType().name().toString(),
targetMethodParams.keySet().toArray(EMPTY_STRING_ARRAY)),
targetMethodParams.values().toArray(EMPTY_RESULT_HANDLES_ARRAY));
getContext.returnValue(resultHandle);
}

return new GeneratedClassResult(restClientInterfaceClassInfo.name().toString(), generatedClassName, priority);
}

private MethodInfo findTargetMethod(AnnotationInstance instance) {
MethodInfo targetMethod = null;
if (instance.target().kind() == AnnotationTarget.Kind.METHOD) {
targetMethod = instance.target().asMethod();
if (ignoreAnnotation(targetMethod)) {
return null;
}
if ((targetMethod.flags() & Modifier.STATIC) != 0) {
if (targetMethod.returnType().kind() == Type.Kind.VOID) {
throw new IllegalStateException(annotation
+ " is only supported on static methods of REST Client interfaces that return an object."
+ " Offending instance is '" + targetMethod.declaringClass().name().toString() + "#"
+ targetMethod.name() + "'");
}


}
}

return targetMethod;
}

private static Class<?> lookupReturnClass(MethodInfo targetMethod) {
Class<?> returnTypeClassName = null;
try {
returnTypeClassName = Class.forName(targetMethod.returnType().name().toString(), false, Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException ignored) {

}
return returnTypeClassName;
}

private static ResultHandle getFromCDI(MethodCreator getContext, String className) {
ResultHandle containerHandle = getContext
.invokeStaticMethod(MethodDescriptor.ofMethod(Arc.class, "container", ArcContainer.class));
ResultHandle instanceHandle = getContext.invokeInterfaceMethod(MethodDescriptor.ofMethod(ArcContainer.class, "instance", InstanceHandle.class, Class.class, Annotation[].class),
containerHandle, getContext.loadClassFromTCCL(className),
getContext.newArray(Annotation.class, 0));
return getContext.invokeInterfaceMethod(MethodDescriptor.ofMethod(InstanceHandle.class, "get", Object.class), instanceHandle);
}

public static String getGeneratedClassName(MethodInfo methodInfo) {
StringBuilder sigBuilder = new StringBuilder();
sigBuilder.append(methodInfo.name()).append("_").append(methodInfo.returnType().name().toString());
for (Type i : methodInfo.parameterTypes()) {
sigBuilder.append(i.name().toString());
}

return methodInfo.declaringClass().name().toString() + "_" + methodInfo.name() + "_"
+ "ContextResolver" + "_" + HashUtil.sha1(sigBuilder.toString());
}

private static boolean ignoreAnnotation(MethodInfo methodInfo) {
// ignore the annotation if it's placed on a Kotlin companion class
// this is not a problem since the Kotlin compiler will also place the annotation the static method interface method
return methodInfo.declaringClass().name().toString().contains("$Companion");
}
}

0 comments on commit 02d2e43

Please sign in to comment.