diff --git a/docs/src/main/asciidoc/rest-client-reactive.adoc b/docs/src/main/asciidoc/rest-client-reactive.adoc index 79e6b67c3abec..71f32a2c11c50 100644 --- a/docs/src/main/asciidoc/rest-client-reactive.adoc +++ b/docs/src/main/asciidoc/rest-client-reactive.adoc @@ -674,6 +674,10 @@ public class ExtensionsResource { <1> the client will use the registered redirect handler over the redirect handler provided via CDI if any. +== Client Context Providers + + + == Update the test Next, we need to update the functional test to reflect the changes made to the endpoint. @@ -1101,6 +1105,33 @@ 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 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. + == 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. diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java index 68a80e1f0e0a5..29fa5e1297d98 100644 --- a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/main/java/io/quarkus/rest/client/reactive/jackson/deployment/RestClientReactiveJacksonProcessor.java @@ -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; @@ -43,6 +49,12 @@ ReinitializeVertxJsonBuildItem vertxJson() { return new ReinitializeVertxJsonBuildItem(); } + @BuildStep + void additionalProviders(BuildProducer annotation) { + annotation.produce(new AnnotationToRegisterIntoClientContextBuildItem(DotName.createSimple(ClientObjectMapper.class), + ObjectMapper.class)); + } + @BuildStep void additionalProviders( List jacksonProviderDefined, diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/DifferentObjectMapperForClientAndServerTest.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/DifferentObjectMapperForClientAndServerTest.java index 8652317dc3605..dc444fdd5d9ff 100644 --- a/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/DifferentObjectMapperForClientAndServerTest.java +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/deployment/src/test/java/io/quarkus/rest/client/reactive/jackson/test/DifferentObjectMapperForClientAndServerTest.java @@ -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; @@ -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") @@ -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 { @@ -157,19 +167,6 @@ public ObjectMapper getContext(Class type) { } } - public static class ClientObjectMapperNotUnwrappingRootElement implements ContextResolver { - - 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 { diff --git a/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/ClientObjectMapper.java b/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/ClientObjectMapper.java new file mode 100644 index 0000000000000..53d7b2fc170c0 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive-jackson/runtime/src/main/java/io/quarkus/rest/client/reactive/jackson/ClientObjectMapper.java @@ -0,0 +1,31 @@ +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: + *
    + *
  • Is a {@code static} method
  • + *
+ * + * An example method could look like the following: + * + *
+ * {@code
+ * @ClientObjectMapper
+ * static ObjectMapper objectMapper() {
+ *     return new ObjectMapper();
+ * }
+ *
+ * }
+ * 
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface ClientObjectMapper { +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/AnnotationToRegisterIntoClientContextBuildItem.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/AnnotationToRegisterIntoClientContextBuildItem.java new file mode 100644 index 0000000000000..789f057e4d6db --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/AnnotationToRegisterIntoClientContextBuildItem.java @@ -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; + } +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/ClientContextResolverHandler.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/ClientContextResolverHandler.java new file mode 100644 index 0000000000000..3f01be249566a --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/ClientContextResolverHandler.java @@ -0,0 +1,180 @@ +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} + * from an instance of {@link jakarta.ws.rs.ext.Provider} + */ +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: + * + *
+     * {@code
+     *  public class SomeService_map_ContextResolver_a8fb70beeef2a54b80151484d109618eed381626
+     *      implements ResteasyReactiveContextResolver {
+     *
+     *      public T getContext(Class type) {
+     *          // simply call the static method of interface
+     *          return SomeService.map(var1);
+     *      }
+     *
+     * }
+     * 
+ */ + 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 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"); + } +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java index d85ec2d395765..8ac07b73c2712 100644 --- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/RestClientReactiveProcessor.java @@ -269,6 +269,7 @@ public void registerProvidersInstances(CombinedIndexBuildItem indexBuildItem, @BuildStep void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem, List registerProviderAnnotationInstances, + List annotationsToRegisterIntoClientContext, BuildProducer generatedBeans, BuildProducer generatedClasses, BuildProducer unremovableBeans, @@ -333,6 +334,11 @@ void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem, Map generatedProviders = new HashMap<>(); populateClientExceptionMapperFromAnnotations(generatedClasses, reflectiveClasses, index, generatedProviders); populateClientRedirectHandlerFromAnnotations(generatedClasses, reflectiveClasses, index, generatedProviders); + for (AnnotationToRegisterIntoClientContextBuildItem annotation : annotationsToRegisterIntoClientContext) { + populateClientProviderFromAnnotations(annotation, generatedClasses, reflectiveClasses, + index, generatedProviders); + } + addGeneratedProviders(index, constructor, annotationsByClassName, generatedProviders); constructor.returnValue(null); @@ -590,6 +596,29 @@ private void populateClientRedirectHandlerFromAnnotations(BuildProducer generatedClasses, + BuildProducer reflectiveClasses, IndexView index, + Map generatedProviders) { + + ClientContextResolverHandler handler = new ClientContextResolverHandler(annotationBuildItem.getAnnotation(), + annotationBuildItem.getExpectedReturnType(), + new GeneratedClassGizmoAdaptor(generatedClasses, true)); + for (AnnotationInstance instance : index.getAnnotations(annotationBuildItem.getAnnotation())) { + GeneratedClassResult result = handler.generateContextResolver(instance); + if (result == null) { + continue; + } + if (generatedProviders.containsKey(result.interfaceName)) { + throw new IllegalStateException("Only a single instance of '" + annotationBuildItem.getAnnotation() + + "' is allowed per REST Client interface. Offending class is '" + result.interfaceName + "'"); + } + generatedProviders.put(result.interfaceName, result); + reflectiveClasses.produce(ReflectiveClassBuildItem.builder(result.generatedClassName) + .serialization(false).build()); + } + } + private void addGeneratedProviders(IndexView index, MethodCreator constructor, Map> annotationsByClassName, Map generatedProviders) { diff --git a/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ResteasyReactiveContextResolver.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ResteasyReactiveContextResolver.java new file mode 100644 index 0000000000000..bd23010a92eee --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ResteasyReactiveContextResolver.java @@ -0,0 +1,6 @@ +package io.quarkus.rest.client.reactive.runtime; + +import jakarta.ws.rs.ext.ContextResolver; + +public interface ResteasyReactiveContextResolver extends ContextResolver { +}