diff --git a/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java b/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java new file mode 100644 index 0000000000000..2eba8ce96542c --- /dev/null +++ b/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusProxyInvocationHandler.java @@ -0,0 +1,296 @@ +package io.quarkus.restclient.runtime; + +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.spi.CreationalContext; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.CDI; +import jakarta.enterprise.inject.spi.InterceptionType; +import jakarta.enterprise.inject.spi.Interceptor; +import jakarta.ws.rs.ProcessingException; +import jakarta.ws.rs.client.ResponseProcessingException; +import jakarta.ws.rs.ext.ParamConverter; +import jakarta.ws.rs.ext.ParamConverterProvider; + +import org.jboss.logging.Logger; +import org.jboss.resteasy.client.jaxrs.ResteasyClient; +import org.jboss.resteasy.microprofile.client.ExceptionMapping; +import org.jboss.resteasy.microprofile.client.InvocationContextImpl; +import org.jboss.resteasy.microprofile.client.RestClientProxy; +import org.jboss.resteasy.microprofile.client.header.ClientHeaderFillingException; + +/** + * Quarkus version of {@link org.jboss.resteasy.microprofile.client.ProxyInvocationHandler} retaining the ability to + * create a custom interceptor chain and invoke it manually. + *

+ * This is needed due to changes in https://github.com/resteasy/resteasy-microprofile/pull/182 + *

+ * In theory, it could be improved by pre-generating proxies for {@code @RegisterRestClient} interfaces and registering + * them as standard beans with all their interceptor bindings. + */ +public class QuarkusProxyInvocationHandler implements InvocationHandler { + + private static final Logger LOGGER = Logger.getLogger(QuarkusProxyInvocationHandler.class); + public static final Type[] NO_TYPES = {}; + + private final Object target; + + private final Set providerInstances; + + private final Map> interceptorChains; + + private final ResteasyClient client; + + private final CreationalContext creationalContext; + + private final AtomicBoolean closed; + + public QuarkusProxyInvocationHandler(final Class restClientInterface, + final Object target, + final Set providerInstances, + final ResteasyClient client, final BeanManager beanManager) { + this.target = target; + this.providerInstances = providerInstances; + this.client = client; + this.closed = new AtomicBoolean(); + if (beanManager != null) { + this.creationalContext = beanManager.createCreationalContext(null); + this.interceptorChains = initInterceptorChains(beanManager, creationalContext, restClientInterface); + } else { + this.creationalContext = null; + this.interceptorChains = Collections.emptyMap(); + } + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (RestClientProxy.class.equals(method.getDeclaringClass())) { + return invokeRestClientProxyMethod(proxy, method, args); + } + // Autocloseable/Closeable + if (method.getName().equals("close") && (args == null || args.length == 0)) { + close(); + return null; + } + if (closed.get()) { + throw new IllegalStateException("RestClientProxy is closed"); + } + + boolean replacementNeeded = false; + Object[] argsReplacement = args != null ? new Object[args.length] : null; + Annotation[][] parameterAnnotations = method.getParameterAnnotations(); + + if (args != null) { + for (Object p : providerInstances) { + if (p instanceof ParamConverterProvider) { + + int index = 0; + for (Object arg : args) { + // ParamConverter's are not allowed to be passed null values. If we have a null value do not process + // it through the provider. + if (arg == null) { + continue; + } + + if (parameterAnnotations[index].length > 0) { // does a parameter converter apply? + ParamConverter converter = ((ParamConverterProvider) p).getConverter(arg.getClass(), null, + parameterAnnotations[index]); + if (converter != null) { + Type[] genericTypes = getGenericTypes(converter.getClass()); + if (genericTypes.length == 1) { + + // minimum supported types + switch (genericTypes[0].getTypeName()) { + case "java.lang.String": + @SuppressWarnings("unchecked") + ParamConverter stringConverter = (ParamConverter) converter; + argsReplacement[index] = stringConverter.toString((String) arg); + replacementNeeded = true; + break; + case "java.lang.Integer": + @SuppressWarnings("unchecked") + ParamConverter intConverter = (ParamConverter) converter; + argsReplacement[index] = intConverter.toString((Integer) arg); + replacementNeeded = true; + break; + case "java.lang.Boolean": + @SuppressWarnings("unchecked") + ParamConverter boolConverter = (ParamConverter) converter; + argsReplacement[index] = boolConverter.toString((Boolean) arg); + replacementNeeded = true; + break; + default: + continue; + } + } + } + } else { + argsReplacement[index] = arg; + } + index++; + } + } + } + } + + if (replacementNeeded) { + args = argsReplacement; + } + + List chain = interceptorChains.get(method); + if (chain != null) { + // Invoke business method interceptors + return new InvocationContextImpl(target, method, args, chain).proceed(); + } else { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof CompletionException) { + cause = cause.getCause(); + } + if (cause instanceof ExceptionMapping.HandlerException) { + ((ExceptionMapping.HandlerException) cause).mapException(method); + // no applicable exception mapper found or applicable mapper returned null + return null; + } + if (cause instanceof ResponseProcessingException) { + ResponseProcessingException rpe = (ResponseProcessingException) cause; + cause = rpe.getCause(); + if (cause instanceof RuntimeException) { + throw cause; + } + } else { + if (cause instanceof ProcessingException && + cause.getCause() instanceof ClientHeaderFillingException) { + throw cause.getCause().getCause(); + } + if (cause instanceof RuntimeException) { + throw cause; + } + } + throw e; + } + } + } + + private Object invokeRestClientProxyMethod(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "getClient": + return client; + case "close": + close(); + return null; + default: + throw new IllegalStateException("Unsupported RestClientProxy method: " + method); + } + } + + private void close() { + if (closed.compareAndSet(false, true)) { + if (creationalContext != null) { + creationalContext.release(); + } + client.close(); + } + } + + private Type[] getGenericTypes(Class aClass) { + Type[] genericInterfaces = aClass.getGenericInterfaces(); + Type[] genericTypes = NO_TYPES; + for (Type genericInterface : genericInterfaces) { + if (genericInterface instanceof ParameterizedType) { + genericTypes = ((ParameterizedType) genericInterface).getActualTypeArguments(); + } + } + return genericTypes; + } + + private static List getBindings(Annotation[] annotations, BeanManager beanManager) { + if (annotations.length == 0) { + return Collections.emptyList(); + } + List bindings = new ArrayList<>(); + for (Annotation annotation : annotations) { + if (beanManager.isInterceptorBinding(annotation.annotationType())) { + bindings.add(annotation); + } + } + return bindings; + } + + private static BeanManager getBeanManager(Class restClientInterface) { + try { + CDI current = CDI.current(); + return current != null ? current.getBeanManager() : null; + } catch (IllegalStateException e) { + LOGGER.warnf("CDI container is not available - interceptor bindings declared on %s will be ignored", + restClientInterface.getSimpleName()); + return null; + } + } + + private static Map> initInterceptorChains( + BeanManager beanManager, CreationalContext creationalContext, Class restClientInterface) { + + Map> chains = new HashMap<>(); + // Interceptor as a key in a map is not entirely correct (custom interceptors) but should work in most cases + Map, Object> interceptorInstances = new HashMap<>(); + + List classLevelBindings = getBindings(restClientInterface.getAnnotations(), beanManager); + + for (Method method : restClientInterface.getMethods()) { + if (method.isDefault() || Modifier.isStatic(method.getModifiers())) { + continue; + } + List methodLevelBindings = getBindings(method.getAnnotations(), beanManager); + + if (!classLevelBindings.isEmpty() || !methodLevelBindings.isEmpty()) { + + Annotation[] interceptorBindings = merge(methodLevelBindings, classLevelBindings); + + List> interceptors = beanManager.resolveInterceptors(InterceptionType.AROUND_INVOKE, + interceptorBindings); + if (!interceptors.isEmpty()) { + List chain = new ArrayList<>(); + for (Interceptor interceptor : interceptors) { + chain.add(new InvocationContextImpl.InterceptorInvocation(interceptor, + interceptorInstances.computeIfAbsent(interceptor, + i -> beanManager.getReference(i, i.getBeanClass(), creationalContext)))); + } + chains.put(method, chain); + } + } + } + return chains.isEmpty() ? Collections.emptyMap() : chains; + } + + private static Annotation[] merge(List methodLevelBindings, List classLevelBindings) { + Set> types = methodLevelBindings.stream() + .map(a -> a.annotationType()) + .collect(Collectors.toSet()); + List merged = new ArrayList<>(methodLevelBindings); + for (Annotation annotation : classLevelBindings) { + if (!types.contains(annotation.annotationType())) { + merged.add(annotation); + } + } + return merged.toArray(new Annotation[] {}); + } + +} diff --git a/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java b/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java index 3c26f09ba35e9..ead9b44944922 100644 --- a/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java +++ b/extensions/resteasy-classic/rest-client/runtime/src/main/java/io/quarkus/restclient/runtime/QuarkusRestClientBuilder.java @@ -73,7 +73,6 @@ import org.jboss.resteasy.microprofile.client.DefaultResponseExceptionMapper; import org.jboss.resteasy.microprofile.client.ExceptionMapping; import org.jboss.resteasy.microprofile.client.MethodInjectionFilter; -import org.jboss.resteasy.microprofile.client.ProxyInvocationHandler; import org.jboss.resteasy.microprofile.client.RestClientListeners; import org.jboss.resteasy.microprofile.client.RestClientProxy; import org.jboss.resteasy.microprofile.client.async.AsyncInterceptorRxInvokerProvider; @@ -363,7 +362,7 @@ public T build(Class aClass, ClientHttpEngine httpEngine) final BeanManager beanManager = getBeanManager(); T proxy = (T) Proxy.newProxyInstance(classLoader, interfaces, - new ProxyInvocationHandler(aClass, actualClient, getLocalProviderInstances(), client)); + new QuarkusProxyInvocationHandler(aClass, actualClient, getLocalProviderInstances(), client, beanManager)); ClientHeaderProviders.registerForClass(aClass, proxy, beanManager); return proxy; }