Skip to content

Commit

Permalink
Allows providing contextual objects per client in REST Client Reactive
Browse files Browse the repository at this point in the history
The REST Client Reactive supports adding contextual beans to the Client using the annotation `@ClientProvider` that will be available as part of the `jakarta.ws.rs.ext.Providers` interface in filters/handlers/message readers and writers. 

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);

    @ClientProvider <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 `@ClientProvider`.
<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.

Now, we can resolve the object we provided using the `jakarta.ws.rs.ext.Providers` interface, for example:

```java
@Provider
public class TestClientRequestFilter implements ResteasyReactiveClientRequestFilter {

    @OverRide
    public void filter(ResteasyReactiveClientRequestFilter requestContext) {
        Providers providers = requestContext.getProviders();
        ObjectMapper objectMapper = providers.getContextResolver(ObjectMapper.class, MediaType.APPLICATION_JSON).getContext(null);
        // ...
    }
}
```

NOTE: When providing a custom ObjectMapper, the REST Client Reactive Jackson extension will automatically check the client context to use it. 

Fix #23979
  • Loading branch information
Sgitario committed Jul 26, 2023
1 parent e017719 commit 0045063
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 17 deletions.
49 changes: 49 additions & 0 deletions docs/src/main/asciidoc/rest-client-reactive.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -1101,6 +1105,51 @@ public class TestClientRequestFilter implements ResteasyReactiveClientRequestFil
}
----

== Adding contextual objects to the Client context

The REST Client Reactive supports adding contextual beans to the Client using the annotation `@ClientProvider` that will be available as part of the `jakarta.ws.rs.ext.Providers` interface in filters/handlers/message readers and writers.

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);
@ClientProvider <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 `@ClientProvider`.
<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.

Now, we can resolve the object we provided using the `jakarta.ws.rs.ext.Providers` interface, for example:

[source, java]
----
@Provider
public class TestClientRequestFilter implements ResteasyReactiveClientRequestFilter {
@Override
public void filter(ResteasyReactiveClientRequestFilter requestContext) {
Providers providers = requestContext.getProviders();
ObjectMapper objectMapper = providers.getContextResolver(ObjectMapper.class, MediaType.APPLICATION_JSON).getContext(null);
// ...
}
}
----

NOTE: When providing a custom ObjectMapper, the REST Client Reactive Jackson extension will automatically check the client context to use it.

== 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 @@ -29,6 +29,7 @@
import com.fasterxml.jackson.databind.SerializationFeature;

import io.quarkus.jackson.ObjectMapperCustomizer;
import io.quarkus.rest.client.reactive.ClientProvider;
import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder;
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 `@ClientProvider`
* 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();

@ClientProvider
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,167 @@
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.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 ClassOutput classOutput;

ClientContextResolverHandler(ClassOutput classOutput) {
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 (!DotNames.CLIENT_PROVIDER.equals(instance.name())) {
throw new IllegalArgumentException(
"'clientExceptionMapperInstance' must be an instance of " + DotNames.CLIENT_PROVIDER);
}
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);
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 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 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(DotNames.CLIENT_PROVIDER
+ " 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 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import io.quarkus.rest.client.reactive.ClientExceptionMapper;
import io.quarkus.rest.client.reactive.ClientFormParam;
import io.quarkus.rest.client.reactive.ClientFormParams;
import io.quarkus.rest.client.reactive.ClientProvider;
import io.quarkus.rest.client.reactive.ClientQueryParam;
import io.quarkus.rest.client.reactive.ClientQueryParams;
import io.quarkus.rest.client.reactive.ClientRedirectHandler;
Expand All @@ -36,6 +37,7 @@ public class DotNames {
public static final DotName CLIENT_RESPONSE_FILTER = DotName.createSimple(ClientResponseFilter.class.getName());
public static final DotName CLIENT_EXCEPTION_MAPPER = DotName.createSimple(ClientExceptionMapper.class.getName());
public static final DotName CLIENT_REDIRECT_HANDLER = DotName.createSimple(ClientRedirectHandler.class.getName());
public static final DotName CLIENT_PROVIDER = DotName.createSimple(ClientProvider.class.getName());

public static final DotName RESPONSE_EXCEPTION_MAPPER = DotName.createSimple(ResponseExceptionMapper.class.getName());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_FORM_PARAMS;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_HEADER_PARAM;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_HEADER_PARAMS;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_PROVIDER;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_QUERY_PARAM;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_QUERY_PARAMS;
import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_REDIRECT_HANDLER;
Expand Down Expand Up @@ -333,6 +334,7 @@ void registerProvidersFromAnnotations(CombinedIndexBuildItem indexBuildItem,
Map<String, GeneratedClassResult> generatedProviders = new HashMap<>();
populateClientExceptionMapperFromAnnotations(generatedClasses, reflectiveClasses, index, generatedProviders);
populateClientRedirectHandlerFromAnnotations(generatedClasses, reflectiveClasses, index, generatedProviders);
populateClientProviderFromAnnotations(generatedClasses, reflectiveClasses, index, generatedProviders);
addGeneratedProviders(index, constructor, annotationsByClassName, generatedProviders);

constructor.returnValue(null);
Expand Down Expand Up @@ -590,6 +592,27 @@ private void populateClientRedirectHandlerFromAnnotations(BuildProducer<Generate
}
}

private void populateClientProviderFromAnnotations(BuildProducer<GeneratedClassBuildItem> generatedClasses,
BuildProducer<ReflectiveClassBuildItem> reflectiveClasses, IndexView index,
Map<String, GeneratedClassResult> generatedProviders) {

ClientContextResolverHandler handler = new ClientContextResolverHandler(
new GeneratedClassGizmoAdaptor(generatedClasses, true));
for (AnnotationInstance instance : index.getAnnotations(CLIENT_PROVIDER)) {
GeneratedClassResult result = handler.generateContextResolver(instance);
if (result == null) {
continue;
}
if (generatedProviders.containsKey(result.interfaceName)) {
throw new IllegalStateException("Only a single instance of '" + CLIENT_PROVIDER
+ "' 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<String, List<AnnotationInstance>> annotationsByClassName,
Map<String, GeneratedClassResult> generatedProviders) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.quarkus.rest.client.reactive;

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

import jakarta.ws.rs.Priorities;

/**
* Used to easily define a custom provider 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;ClientProvider
* static Bean context() {
* return ...
* }
*
* }
* </pre>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ClientProvider {

/**
* The priority with which the exception mapper will be executed
*/
int priority() default Priorities.USER;
}
Loading

0 comments on commit 0045063

Please sign in to comment.