Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allows customizing the ObjectMapper in REST Client Reactive Jackson #35025

Merged
merged 1 commit into from
Jul 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
geoand marked this conversation as resolved.
Show resolved Hide resolved

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");
}
}
Loading