From 16888b01ead7cb80765abcf08e16a72e7368d94b Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 14 Nov 2022 17:32:51 +0200 Subject: [PATCH] Add @ClientQueryParam to Reactive REST Client This is analogous to what MP REST Client provides in @ClientHeaderParam Resolves: #21443 --- .../JaxrsClientReactiveEnricher.java | 14 + .../JaxrsClientReactiveProcessor.java | 12 + .../client/reactive/deployment/DotNames.java | 5 + .../MicroProfileRestClientEnricher.java | 239 +++++++++++++++++- .../RestClientReactiveProcessor.java | 4 + .../ClientQueryParamFromMethodTest.java | 142 +++++++++++ .../ClientQueryParamFromPropertyTest.java | 96 +++++++ .../reactive/queries/ComputedParam.java | 13 + .../client/reactive/ClientQueryParam.java | 89 +++++++ .../client/reactive/ClientQueryParams.java | 25 ++ .../runtime/ClientQueryParamSupport.java | 15 ++ .../reactive/client/impl/WebTargetImpl.java | 11 + 12 files changed, 655 insertions(+), 10 deletions(-) create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromMethodTest.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromPropertyTest.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ComputedParam.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParam.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParams.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ClientQueryParamSupport.java diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveEnricher.java b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveEnricher.java index 516d4bb273526..bdf9bce29894c 100644 --- a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveEnricher.java +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveEnricher.java @@ -31,6 +31,20 @@ public interface JaxrsClientReactiveEnricher { void forClass(MethodCreator ctor, AssignableResultHandle globalTarget, ClassInfo interfaceClass, IndexView index); + /** + * Called when a {@link javax.ws.rs.client.WebTarget} has been populated for a normal Client + */ + void forWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo interfaceClass, MethodInfo method, + AssignableResultHandle webTarget, BuildProducer generatedClasses); + + /** + * Called when a {@link javax.ws.rs.client.WebTarget} has been populated for a sub Client + */ + void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo rootInterfaceClass, + ClassInfo subInterfaceClass, + MethodInfo rootMethod, MethodInfo subMethod, AssignableResultHandle webTarget, + BuildProducer generatedClasses); + /** * Method-level alterations * diff --git a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java index 6889c385dd391..39cfc80d38003 100644 --- a/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java +++ b/extensions/resteasy-reactive/jaxrs-client-reactive/deployment/src/main/java/io/quarkus/jaxrs/client/reactive/deployment/JaxrsClientReactiveProcessor.java @@ -964,6 +964,12 @@ A more full example of generated client (with sub-resource) can is at the bottom } } + for (JaxrsClientReactiveEnricherBuildItem enricher : enrichers) { + enricher.getEnricher() + .forWebTarget(methodCreator, index, interfaceClass, jandexMethod, methodTarget, + generatedClasses); + } + AssignableResultHandle builder = methodCreator.createVariable(Invocation.Builder.class); if (method.getProduces() == null || method.getProduces().length == 0) { // this should never happen! methodCreator.assign(builder, methodCreator.invokeInterfaceMethod( @@ -1457,6 +1463,12 @@ private void handleSubResourceMethod(List // if the response is multipart, let's add it's class to the appropriate collection: addResponseTypeIfMultipart(multipartResponseTypes, jandexSubMethod, index); + for (JaxrsClientReactiveEnricherBuildItem enricher : enrichers) { + enricher.getEnricher() + .forSubResourceWebTarget(subMethodCreator, index, interfaceClass, subInterface, + jandexMethod, jandexSubMethod, methodTarget, generatedClasses); + } + AssignableResultHandle builder = subMethodCreator.createVariable(Invocation.Builder.class); if (method.getProduces() == null || method.getProduces().length == 0) { // this should never happen! subMethodCreator.assign(builder, subMethodCreator.invokeInterfaceMethod( diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java index 587f43c62f9ff..e96c6fe56867f 100644 --- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/DotNames.java @@ -11,6 +11,8 @@ import org.jboss.jandex.DotName; import io.quarkus.rest.client.reactive.ClientExceptionMapper; +import io.quarkus.rest.client.reactive.ClientQueryParam; +import io.quarkus.rest.client.reactive.ClientQueryParams; public class DotNames { @@ -18,6 +20,9 @@ public class DotNames { public static final DotName REGISTER_PROVIDERS = DotName.createSimple(RegisterProviders.class.getName()); public static final DotName CLIENT_HEADER_PARAM = DotName.createSimple(ClientHeaderParam.class.getName()); public static final DotName CLIENT_HEADER_PARAMS = DotName.createSimple(ClientHeaderParams.class.getName()); + + public static final DotName CLIENT_QUERY_PARAM = DotName.createSimple(ClientQueryParam.class.getName()); + public static final DotName CLIENT_QUERY_PARAMS = DotName.createSimple(ClientQueryParams.class.getName()); public static final DotName REGISTER_CLIENT_HEADERS = DotName.createSimple(RegisterClientHeaders.class.getName()); public static final DotName CLIENT_REQUEST_FILTER = DotName.createSimple(ClientRequestFilter.class.getName()); public static final DotName CLIENT_RESPONSE_FILTER = DotName.createSimple(ClientResponseFilter.class.getName()); diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/MicroProfileRestClientEnricher.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/MicroProfileRestClientEnricher.java index 738cc489ee0ea..f9c96a3a1e89b 100644 --- a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/MicroProfileRestClientEnricher.java +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/main/java/io/quarkus/rest/client/reactive/deployment/MicroProfileRestClientEnricher.java @@ -3,6 +3,8 @@ import static io.quarkus.arc.processor.DotNames.STRING; 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_QUERY_PARAM; +import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_QUERY_PARAMS; import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_CLIENT_HEADERS; import static org.jboss.resteasy.reactive.common.processor.HashUtil.sha1; import static org.objectweb.asm.Opcodes.ACC_STATIC; @@ -11,6 +13,7 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -32,6 +35,7 @@ import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.client.impl.WebTargetImpl; import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; @@ -52,6 +56,7 @@ import io.quarkus.gizmo.TryBlock; import io.quarkus.jaxrs.client.reactive.deployment.JaxrsClientReactiveEnricher; import io.quarkus.rest.client.reactive.HeaderFiller; +import io.quarkus.rest.client.reactive.runtime.ClientQueryParamSupport; import io.quarkus.rest.client.reactive.runtime.ConfigUtils; import io.quarkus.rest.client.reactive.runtime.MicroProfileRestClientRequestFilter; import io.quarkus.rest.client.reactive.runtime.NoOpHeaderFiller; @@ -80,6 +85,8 @@ class MicroProfileRestClientEnricher implements JaxrsClientReactiveEnricher { private static final MethodDescriptor MAP_CONTAINS_KEY_METHOD = MethodDescriptor.ofMethod(Map.class, "containsKey", boolean.class, Object.class); public static final String INVOKED_METHOD = "org.eclipse.microprofile.rest.client.invokedMethod"; + private static final MethodDescriptor WEB_TARGET_IMPL_QUERY_PARAMS = MethodDescriptor.ofMethod(WebTargetImpl.class, + "queryParam", WebTargetImpl.class, String.class, Collection.class); private final Map interfaceMocks = new HashMap<>(); @@ -124,6 +131,202 @@ public void forClass(MethodCreator constructor, AssignableResultHandle webTarget webTargetBase, restClientFilter)); } + @Override + public void forWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo interfaceClass, MethodInfo method, + AssignableResultHandle webTarget, BuildProducer generatedClasses) { + Map queryParamsByName = new HashMap<>(); + collectClientQueryParamData(interfaceClass, method, queryParamsByName); + for (var headerEntry : queryParamsByName.entrySet()) { + addQueryParam(method, methodCreator, headerEntry.getValue(), webTarget, generatedClasses, index); + } + } + + @Override + public void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo rootInterfaceClass, + ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, + AssignableResultHandle webTarget, BuildProducer generatedClasses) { + + Map queryParamsByName = new HashMap<>(); + collectClientQueryParamData(rootInterfaceClass, rootMethod, queryParamsByName); + collectClientQueryParamData(subInterfaceClass, subMethod, queryParamsByName); + for (var headerEntry : queryParamsByName.entrySet()) { + addQueryParam(subMethod, methodCreator, headerEntry.getValue(), webTarget, generatedClasses, index); + } + } + + private void collectClientQueryParamData(ClassInfo interfaceClass, MethodInfo method, + Map headerFillersByName) { + AnnotationInstance classLevelHeader = interfaceClass.declaredAnnotation(CLIENT_QUERY_PARAM); + if (classLevelHeader != null) { + headerFillersByName.put(classLevelHeader.value("name").asString(), + new QueryData(classLevelHeader, interfaceClass)); + } + putAllQueryAnnotations(headerFillersByName, + interfaceClass, + extractAnnotations(interfaceClass.declaredAnnotation(CLIENT_QUERY_PARAMS))); + + Map methodLevelHeadersByName = new HashMap<>(); + AnnotationInstance methodLevelHeader = method.annotation(CLIENT_QUERY_PARAM); + if (methodLevelHeader != null) { + methodLevelHeadersByName.put(methodLevelHeader.value("name").asString(), + new QueryData(methodLevelHeader, interfaceClass)); + } + putAllQueryAnnotations(methodLevelHeadersByName, interfaceClass, + extractAnnotations(method.annotation(CLIENT_QUERY_PARAMS))); + + headerFillersByName.putAll(methodLevelHeadersByName); + } + + private void putAllQueryAnnotations(Map headerMap, ClassInfo interfaceClass, + AnnotationInstance[] annotations) { + for (AnnotationInstance annotation : annotations) { + String name = annotation.value("name").asString(); + if (headerMap.put(name, new QueryData(annotation, interfaceClass)) != null) { + throw new RestClientDefinitionException("Duplicate ClientQueryParam annotation for query parameter: " + name + + " on " + annotation.target()); + } + } + } + + private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreator, + QueryData queryData, + AssignableResultHandle webTargetImpl, BuildProducer generatedClasses, + IndexView index) { + + AnnotationInstance annotation = queryData.annotation; + ClassInfo declaringClass = queryData.definingClass; + + String queryName = annotation.value("name").asString(); + ResultHandle queryNameHandle = methodCreator.load(queryName); + + ResultHandle isQueryParamPresent = methodCreator.invokeStaticMethod( + MethodDescriptor.ofMethod(ClientQueryParamSupport.class, "isQueryParamPresent", boolean.class, + WebTargetImpl.class, String.class), + webTargetImpl, queryNameHandle); + BytecodeCreator creator = methodCreator.ifTrue(isQueryParamPresent).falseBranch(); + + String[] values = annotation.value().asStringArray(); + + if (values.length == 0) { + log.warnv("Ignoring ClientQueryParam that specifies an empty array of header values for header {} on {}", + annotation.value("name").asString(), annotation.target()); + return; + } + + if (values.length > 1 || !(values[0].startsWith("{") && values[0].endsWith("}"))) { + boolean required = annotation.valueWithDefault(index, "required").asBoolean(); + ResultHandle valuesList = creator.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); + for (String value : values) { + if (value.startsWith("${") && value.endsWith("}")) { + ResultHandle queryValueFromConfig = creator.invokeStaticMethod( + MethodDescriptor.ofMethod(ConfigUtils.class, "getConfigValue", String.class, String.class, + boolean.class), + creator.load(value), creator.load(required)); + creator.ifNotNull(queryValueFromConfig) + .trueBranch().invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, queryValueFromConfig); + } else { + creator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, creator.load(value)); + } + } + + creator.assign(webTargetImpl, creator.invokeVirtualMethod(WEB_TARGET_IMPL_QUERY_PARAMS, webTargetImpl, + queryNameHandle, valuesList)); + } else { // method call :O {some.package.ClassName.methodName} or {defaultMethodWithinThisInterfaceName} + // if `!required` an exception on header filling does not fail the invocation: + boolean required = annotation.valueWithDefault(index, "required").asBoolean(); + + BytecodeCreator methodCallCreator = creator; + TryBlock tryBlock = null; + + if (!required) { + tryBlock = creator.tryBlock(); + methodCallCreator = tryBlock; + } + String methodName = values[0].substring(1, values[0].length() - 1); // strip curly braces + + MethodInfo queryValueMethod; + ResultHandle queryValue; + if (methodName.contains(".")) { + // calling a static method + int endOfClassName = methodName.lastIndexOf('.'); + String className = methodName.substring(0, endOfClassName); + String staticMethodName = methodName.substring(endOfClassName + 1); + + ClassInfo clazz = index.getClassByName(DotName.createSimple(className)); + if (clazz == null) { + throw new RestClientDefinitionException( + "Class " + className + " used in ClientQueryParam on " + declaringClass + " not found"); + } + queryValueMethod = findMethod(clazz, declaringClass, staticMethodName, CLIENT_QUERY_PARAM.toString()); + + if (queryValueMethod.parametersCount() == 0) { + queryValue = methodCallCreator.invokeStaticMethod(queryValueMethod); + } else if (queryValueMethod.parametersCount() == 1 && isString(queryValueMethod.parameterType(0))) { + queryValue = methodCallCreator.invokeStaticMethod(queryValueMethod, methodCallCreator.load(queryName)); + } else { + throw new RestClientDefinitionException( + "ClientQueryParam method " + declaringClass.toString() + "#" + staticMethodName + + " has too many parameters, at most one parameter, header name, expected"); + } + } else { + // interface method + String mockName = mockInterface(declaringClass, generatedClasses, index); + ResultHandle interfaceMock = methodCallCreator.newInstance(MethodDescriptor.ofConstructor(mockName)); + + queryValueMethod = findMethod(declaringClass, declaringClass, methodName, CLIENT_QUERY_PARAM.toString()); + + if (queryValueMethod == null) { + throw new RestClientDefinitionException( + "ClientQueryParam method " + methodName + " not found on " + declaringClass); + } + + if (queryValueMethod.parametersCount() == 0) { + queryValue = methodCallCreator.invokeInterfaceMethod(queryValueMethod, interfaceMock); + } else if (queryValueMethod.parametersCount() == 1 && isString(queryValueMethod.parameterType(0))) { + queryValue = methodCallCreator.invokeInterfaceMethod(queryValueMethod, interfaceMock, + methodCallCreator.load(queryName)); + } else { + throw new RestClientDefinitionException( + "ClientQueryParam method " + declaringClass + "#" + methodName + + " has too many parameters, at most one parameter, header name, expected"); + } + + } + + Type returnType = queryValueMethod.returnType(); + ResultHandle valuesList; + if (returnType.kind() == Type.Kind.ARRAY && returnType.asArrayType().component().name().equals(STRING)) { + // repack array to list + valuesList = methodCallCreator.invokeStaticMethod( + MethodDescriptor.ofMethod(Arrays.class, "asList", List.class, Object[].class), queryValue); + } else if (returnType.kind() == Type.Kind.CLASS && returnType.name().equals(STRING)) { + valuesList = methodCallCreator.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); + methodCallCreator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, queryValue); + } else { + throw new RestClientDefinitionException("Method " + declaringClass.toString() + "#" + methodName + + " has an unsupported return type for ClientQueryParam. " + + "Only String and String[] return types are supported"); + } + methodCallCreator.assign(webTargetImpl, + methodCallCreator.invokeVirtualMethod(WEB_TARGET_IMPL_QUERY_PARAMS, webTargetImpl, queryNameHandle, + valuesList)); + + if (!required) { + CatchBlockCreator catchBlock = tryBlock.addCatch(Exception.class); + ResultHandle log = catchBlock.invokeStaticMethod( + MethodDescriptor.ofMethod(Logger.class, "getLogger", Logger.class, String.class), + catchBlock.load(declaringClass.name().toString())); + String errorMessage = String.format( + "Invoking query param generation method '%s' for '%s' on method '%s#%s' failed", + methodName, queryName, declaringClass.name(), declaringMethod.name()); + catchBlock.invokeVirtualMethod( + MethodDescriptor.ofMethod(Logger.class, "warn", void.class, Object.class, Throwable.class), + log, + catchBlock.load(errorMessage), catchBlock.getCaughtException()); + } + } + } + @Override public void forSubResourceMethod(ClassCreator subClassCreator, MethodCreator subConstructor, MethodCreator subClinit, MethodCreator subMethodCreator, ClassInfo rootInterfaceClass, @@ -337,7 +540,7 @@ private void addHeaderParam(MethodInfo declaringMethod, MethodCreator fillHeader throw new RestClientDefinitionException( "Class " + className + " used in ClientHeaderParam on " + declaringClass + " not found"); } - headerFillingMethod = findMethod(clazz, declaringClass, staticMethodName); + headerFillingMethod = findMethod(clazz, declaringClass, staticMethodName, CLIENT_HEADER_PARAM.toString()); if (headerFillingMethod.parametersCount() == 0) { headerValue = fillHeader.invokeStaticMethod(headerFillingMethod); @@ -353,7 +556,7 @@ private void addHeaderParam(MethodInfo declaringMethod, MethodCreator fillHeader String mockName = mockInterface(declaringClass, generatedClasses, index); ResultHandle interfaceMock = fillHeader.newInstance(MethodDescriptor.ofConstructor(mockName)); - headerFillingMethod = findMethod(declaringClass, declaringClass, methodName); + headerFillingMethod = findMethod(declaringClass, declaringClass, methodName, CLIENT_HEADER_PARAM.toString()); if (headerFillingMethod == null) { throw new RestClientDefinitionException( @@ -403,20 +606,21 @@ private void addHeaderParam(MethodInfo declaringMethod, MethodCreator fillHeader } } - private MethodInfo findMethod(ClassInfo declaringClass, ClassInfo restInterface, String methodName) { - MethodInfo headerFillingMethod = null; + private MethodInfo findMethod(ClassInfo declaringClass, ClassInfo restInterface, String methodName, + String sourceAnnotationName) { + MethodInfo result = null; for (MethodInfo method : declaringClass.methods()) { if (method.name().equals(methodName)) { - if (headerFillingMethod != null) { - throw new RestClientDefinitionException("Ambiguous ClientHeaderParam definition, " + - "more than one method of name " + methodName + " found on " + declaringClass + - ". Problematic interface: " + restInterface); + if (result != null) { + throw new RestClientDefinitionException(String.format( + "Ambiguous %s definition, more than one method of name %s found on %s. Problematic interface: %s", + sourceAnnotationName, methodName, declaringClass, restInterface)); } else { - headerFillingMethod = method; + result = method; } } } - return headerFillingMethod; + return result; } private static boolean isString(Type type) { @@ -477,4 +681,19 @@ public HeaderData(AnnotationInstance annotation, ClassInfo definingClass) { this.definingClass = definingClass; } } + + /** + * ClientQueryParam annotations can be defined on a JAX-RS interface or a sub-client (sub-resource). + * If we're adding query params for a sub-client, we need to know the defining class of the ClientHeaderParam + * to properly resolve default methods of the "root" client + */ + private static class QueryData { + private final AnnotationInstance annotation; + private final ClassInfo definingClass; + + public QueryData(AnnotationInstance annotation, ClassInfo definingClass) { + this.annotation = annotation; + this.definingClass = definingClass; + } + } } 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 5e8ae1a7a1b6a..9f4a5b239dd4c 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 @@ -4,6 +4,8 @@ import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_EXCEPTION_MAPPER; 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_QUERY_PARAM; +import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_QUERY_PARAMS; import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_CLIENT_HEADERS; import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_PROVIDER; import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_PROVIDERS; @@ -106,6 +108,8 @@ class RestClientReactiveProcessor { REGISTER_PROVIDERS, CLIENT_HEADER_PARAM, CLIENT_HEADER_PARAMS, + CLIENT_QUERY_PARAM, + CLIENT_QUERY_PARAMS, REGISTER_CLIENT_HEADERS); @BuildStep diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromMethodTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromMethodTest.java new file mode 100644 index 0000000000000..46c4ccd7cd6b8 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromMethodTest.java @@ -0,0 +1,142 @@ +package io.quarkus.rest.client.reactive.queries; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.List; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.rest.client.reactive.ClientQueryParam; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; + +public class ClientQueryParamFromMethodTest { + + @TestHTTPResource + URI baseUri; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(Client.class, SubClient.class, Resource.class, ComputedParam.class)); + + @Test + void shouldUseValuesOnlyFromClass() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromClass()).isEqualTo("1/"); + } + + @Test + void shouldUseValuesFromClassAndMethod() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromMethodAndClass()).isEqualTo("1/2"); + } + + @Test + void shouldUseValuesFromMethodWithParam() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromMethodWithParam()).isEqualTo("-11/-2"); + } + + @Test + void shouldUseValuesFromQueryParam() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromQueryParam("111")).isEqualTo("111/2"); + } + + @Test + void shouldUseValuesFromQueryParams() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromQueryParams("111", "222")).isEqualTo("111/222"); + } + + @Test + void shouldUseValuesFromSubclientAnnotations() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.sub().sub("22")).isEqualTo("11/22"); + } + + @Path("/") + @ApplicationScoped + public static class Resource { + @GET + public String returnQueryParamValues(@QueryParam("first") List first, + @QueryParam("second") List second) { + return String.join(",", first) + "/" + String.join(",", second); + } + } + + @ClientQueryParam(name = "first", value = "{first}") + public interface Client { + @GET + String setFromClass(); + + @GET + @ClientQueryParam(name = "second", value = "{second}") + String setFromMethodAndClass(); + + @GET + @ClientQueryParam(name = "second", value = "{second}") + String setFromQueryParam(@QueryParam("first") String first); + + @GET + @ClientQueryParam(name = "second", value = "{second}") + String setFromQueryParams(@QueryParam("first") String first, @QueryParam("second") String second); + + @GET + @ClientQueryParam(name = "first", value = "{io.quarkus.rest.client.reactive.queries.ComputedParam.withParam}") + @ClientQueryParam(name = "second", value = "{withParam}") + String setFromMethodWithParam(); + + @Path("") + SubClient sub(); + + default String first() { + return "1"; + } + + default String second() { + return "2"; + } + + default String withParam(String name) { + if ("first".equals(name)) { + return "-1"; + } else if ("second".equals(name)) { + return "-2"; + } + throw new IllegalArgumentException(); + } + } + + @ClientQueryParam(name = "first", value = "{first}") + public interface SubClient { + + @GET + String sub(@QueryParam("second") String second); + + default String first() { + return "11"; + } + } + +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromPropertyTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromPropertyTest.java new file mode 100644 index 0000000000000..6d89f58bd23ac --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ClientQueryParamFromPropertyTest.java @@ -0,0 +1,96 @@ +package io.quarkus.rest.client.reactive.queries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.rest.client.reactive.ClientQueryParam; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; + +public class ClientQueryParamFromPropertyTest { + private static final String QUERY_VALUE = "foo"; + + @TestHTTPResource + URI baseUri; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar.addClasses(Client.class, Resource.class) + .addAsResource( + new StringAsset("my.property-value=" + QUERY_VALUE), + "application.properties")); + + @Test + void shouldSetFromProperties() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.getWithParam()).isEqualTo(QUERY_VALUE); + } + + @Test + void shouldFailOnMissingRequiredProperty() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThatThrownBy(client::missingRequiredProperty) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shouldSucceedOnMissingNonRequiredProperty() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.missingNonRequiredProperty()).isEqualTo(QUERY_VALUE); + } + + @Test + void shouldSucceedOnMissingNonRequiredPropertyAndUseOverriddenValue() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.missingNonRequiredPropertyAndOverriddenValue()).isEqualTo("other"); + } + + @Path("/") + @ApplicationScoped + public static class Resource { + @GET + public String returnQueryParamValue(@QueryParam("my-param") String param) { + return param; + } + } + + @ClientQueryParam(name = "my-param", value = "${my.property-value}") + public interface Client { + @GET + String getWithParam(); + + @GET + @ClientQueryParam(name = "some-other-param", value = "${non-existent-property}") + String missingRequiredProperty(); + + @GET + @ClientQueryParam(name = "some-other-param", value = "${non-existent-property}", required = false) + String missingNonRequiredProperty(); + + @GET + @ClientQueryParam(name = "some-other-param", value = "${non-existent-property}", required = false) + @ClientQueryParam(name = "my-param", value = "other") + String missingNonRequiredPropertyAndOverriddenValue(); + } + +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ComputedParam.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ComputedParam.java new file mode 100644 index 0000000000000..e841e02776f40 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/queries/ComputedParam.java @@ -0,0 +1,13 @@ +package io.quarkus.rest.client.reactive.queries; + +public class ComputedParam { + + public static String withParam(String name) { + if ("first".equals(name)) { + return "-11"; + } else if ("second".equals(name)) { + return "-22"; + } + throw new IllegalArgumentException(); + } +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParam.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParam.java new file mode 100644 index 0000000000000..cbba6b0596355 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParam.java @@ -0,0 +1,89 @@ +package io.quarkus.rest.client.reactive; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to specify a query that should be sent with the outbound request. + * When this annotation is placed at the interface level of a REST client interface, the specified header will be sent on each + * request for all + * methods in the interface. + * When this annotation is placed on a method, the parameter will be sent only for that method. If the same query parameter is + * specified in an annotation + * for both the type and the method, only the parameter value specified in the annotation on the method will be sent. + *

+ * The value of the parameter to send can be specified explicitly by using the value attribute. + * The value can also be computed via a default method on the client interface or a public static method on a different class. + * The compute method + * must return a String or String[] (indicating a multivalued header) value. This method must be specified in the + * value attribute but + * wrapped in curly-braces. The compute method's signature must either contain no arguments or a single String + * argument. The String argument is the name of the header. + *

+ * Here is an example that explicitly defines a header value and computes a value: + * + *

+ * public interface MyClient {
+ *
+ *    static AtomicInteger counter = new AtomicInteger(1);
+ *
+ *    default String determineQueryValue(String name) {
+ *        if ("SomeHeader".equals(name)) {
+ *            return "InvokedCount " + counter.getAndIncrement();
+ *        }
+ *        throw new UnsupportedOperationException("unknown name");
+ *    }
+ *
+ *    {@literal @}ClientQueryParam(name="SomeName", value="ExplicitlyDefinedValue")
+ *    {@literal @}GET
+ *    Response useExplicitQueryValue();
+ *
+ *    {@literal @}ClientQueryParam(name="SomeName", value="{determineQueryValue}")
+ *    {@literal @}DELETE
+ *    Response useComputedQueryValue();
+ * }
+ * 
+ * + * The implementation should fail to deploy a client interface if the annotation contains a @ClientQueryParam + * annotation with a + * value attribute that references a method that does not exist, or contains an invalid signature. + *

+ * The required attribute will determine what action the implementation should take if the method specified in the + * value + * attribute throws an exception. If the attribute is true (default), then the implementation will abort the request and will + * throw the exception + * back to the caller. If the required attribute is set to false, then the implementation will not send this header + * if the method throws + * an exception. + *

+ * Note that if an interface method contains an argument annotated with @QueryParam, that argument will take + * priority over anything + * specified in a @ClientQueryParam annotation. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Repeatable(ClientQueryParams.class) +public @interface ClientQueryParam { + + /** + * @return the name of the query param. + */ + String name(); + + /** + * @return the value(s) of the param - or the method to invoke to get the value (surrounded by curly braces). + */ + String[] value(); + + /** + * @return whether to abort the request if the method to compute the query value throws an exception (true; default) or just + * skip this header + * (false) + */ + boolean required() default true; +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParams.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParams.java new file mode 100644 index 0000000000000..021903fdd9723 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientQueryParams.java @@ -0,0 +1,25 @@ +package io.quarkus.rest.client.reactive; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Used to specify query parameters that should be sent with the outbound request. + * When this annotation is placed at the interface level of a REST client interface, the specified query parameters will be sent + * on each request for all + * methods in the interface. + * When this annotation is placed on a method, the parameters will be sent only for that method. If the same query parameter is + * specified in an annotation + * for both the type and the method, only the header value specified in the annotation on the method will be sent. + *

+ * This class serves to act as the {@link java.lang.annotation.Repeatable} implementation for {@link ClientQueryParam}. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ClientQueryParams { + ClientQueryParam[] value(); +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ClientQueryParamSupport.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ClientQueryParamSupport.java new file mode 100644 index 0000000000000..7a6ff1091665d --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/runtime/ClientQueryParamSupport.java @@ -0,0 +1,15 @@ +package io.quarkus.rest.client.reactive.runtime; + +import org.jboss.resteasy.reactive.client.impl.WebTargetImpl; + +@SuppressWarnings("unused") +public final class ClientQueryParamSupport { + + private ClientQueryParamSupport() { + } + + public static boolean isQueryParamPresent(WebTargetImpl webTarget, String name) { + String query = webTarget.getUriBuilderUnsafe().getQuery(); + return query != null && query.contains(name + "="); + } +} diff --git a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/WebTargetImpl.java b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/WebTargetImpl.java index 1b4b0355ae6e1..2a4989df8ea5e 100644 --- a/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/WebTargetImpl.java +++ b/independent-projects/resteasy-reactive/client/runtime/src/main/java/org/jboss/resteasy/reactive/client/impl/WebTargetImpl.java @@ -1,6 +1,7 @@ package org.jboss.resteasy.reactive.client.impl; import java.net.URI; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -83,6 +84,10 @@ public UriBuilder getUriBuilder() { return uriBuilder.clone(); } + public UriBuilderImpl getUriBuilderUnsafe() { + return (UriBuilderImpl) uriBuilder; + } + @Override public ConfigurationImpl getConfiguration() { abortIfClosed(); @@ -219,6 +224,11 @@ private String[] toStringValues(Object[] values) { return stringValues; } + @SuppressWarnings("unused") + public WebTargetImpl queryParam(String name, Collection values) throws NullPointerException { + return queryParam(name, values.toArray(new Object[0])); + } + @Override public WebTargetImpl queryParam(String name, Object... values) throws NullPointerException { abortIfClosed(); @@ -244,6 +254,7 @@ public WebTargetImpl queryParam(String name, Object... values) throws NullPointe return newInstance(client, copy, configuration); } + @SuppressWarnings("unused") public WebTargetImpl queryParams(MultivaluedMap parameters) throws IllegalArgumentException, NullPointerException { abortIfClosed();