Skip to content

Commit

Permalink
Allows customizing the ObjectMapper in REST Client Reactive Jackson
Browse files Browse the repository at this point in the history
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:

```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()
                .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.

Fix #23979
  • Loading branch information
Sgitario committed Jul 26, 2023
1 parent e017719 commit 799e878
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
}

Check warning on line 1101 in docs/src/main/asciidoc/rest-client-reactive.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in 'Customizing the ObjectMapper in REST Client Reactive Jackson'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in 'Customizing the ObjectMapper in REST Client Reactive Jackson'.", "location": {"path": "docs/src/main/asciidoc/rest-client-reactive.adoc", "range": {"start": {"line": 1101, "column": 1}}}, "severity": "INFO"}
----

== 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.

Check warning on line 1129 in docs/src/main/asciidoc/rest-client-reactive.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsWarnings] Consider using 'through' rather than 'via' unless updating existing content that uses it. Raw Output: {"message": "[Quarkus.TermsWarnings] Consider using 'through' rather than 'via' unless updating existing content that uses it.", "location": {"path": "docs/src/main/asciidoc/rest-client-reactive.adoc", "range": {"start": {"line": 1129, "column": 83}}}, "severity": "WARNING"}
<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");
}
}
Loading

0 comments on commit 799e878

Please sign in to comment.