From 3bea048828aa22398d301ebf067ce9c26cd9e37d Mon Sep 17 00:00:00 2001 From: Fouad Almalki Date: Mon, 24 Jul 2023 23:46:57 +0300 Subject: [PATCH] Add @ClientFormParam to Reactive REST Client --- .../main/asciidoc/rest-client-reactive.adoc | 89 ++++- .../JaxrsClientReactiveEnricher.java | 9 + .../JaxrsClientReactiveProcessor.java | 5 + .../client/reactive/deployment/DotNames.java | 4 + .../MicroProfileRestClientEnricher.java | 331 ++++++++++++------ .../RestClientReactiveProcessor.java | 6 +- .../form/ClientFormParamFromMethodTest.java | 159 +++++++++ .../form/ClientFormParamFromPropertyTest.java | 109 ++++++ .../client/reactive/form/ComputedParam.java | 13 + .../rest/client/reactive/ClientFormParam.java | 86 +++++ .../client/reactive/ClientFormParams.java | 24 ++ .../client/reactive/ClientQueryParam.java | 14 +- 12 files changed, 723 insertions(+), 126 deletions(-) create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromMethodTest.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromPropertyTest.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ComputedParam.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParam.java create mode 100644 extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParams.java diff --git a/docs/src/main/asciidoc/rest-client-reactive.adoc b/docs/src/main/asciidoc/rest-client-reactive.adoc index 965c679e375b5..79e6b67c3abec 100644 --- a/docs/src/main/asciidoc/rest-client-reactive.adoc +++ b/docs/src/main/asciidoc/rest-client-reactive.adoc @@ -219,7 +219,82 @@ Note that if an interface method contains an argument annotated with `@QueryPara priority over anything specified in any `@ClientQueryParam` annotation. ==== -More information about this annotation can be found on the javadoc of +More information about this annotation can be found on the javadoc of link:https://javadoc.io/doc/io.quarkus/quarkus-rest-client-reactive/latest/io/quarkus/rest/client/reactive/ClientQueryParam.html[`@ClientQueryParam`]. + +=== Form Parameters + +Form parameters can be specified using `@RestForm` (or `@FormParam`) annotations: + +[source, java] +---- +package org.acme.rest.client; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.jboss.resteasy.reactive.RestForm; + +import jakarta.ws.rs.PORT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.core.MultivaluedMap; +import java.util.Map; +import java.util.Set; + +@Path("/extensions") +@RegisterRestClient(configKey = "extensions-api") +public interface ExtensionsService { + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + Set postId(@FormParam("id") Integer id); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + Set postName(@RestForm String name); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + Set postFilter(@RestForm Map filter); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + Set postFilters(@RestForm MultivaluedMap filters); + +} +---- + +==== Using @ClientFormParam + +Form parameters can also be specified using `@ClientFormParam`, similar to `@ClientQueryParam`: + +[source, java] +---- +@ClientFormParam(name = "my-param", value = "${my.property-value}") +public interface Client { + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + String postWithParam(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @ClientFormParam(name = "some-other-param", value = "other") + String postWithOtherParam(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @ClientFormParam(name = "param-from-method", value = "{with-param}") + String postFromMethod(); + + default String withParam(String name) { + if ("param-from-method".equals(name)) { + return "test"; + } + throw new IllegalArgumentException(); + } +} +---- + +More information about this annotation can be found on the javadoc of link:https://javadoc.io/doc/io.quarkus/quarkus-rest-client-reactive/latest/io/quarkus/rest/client/reactive/ClientFormParam.html[`@ClientFormParam`]. === Path Parameters @@ -520,7 +595,7 @@ A HTTP server can redirect a response to another location by sending a response - `quarkus.rest-client.follow-redirects` to enable redirection for all REST clients. - `quarkus.rest-client..follow-redirects` to enable redirection for a specific REST client. -If this property is true, then REST Client will perform a new request that it receives a redirection response from the HTTP server. +If this property is true, then REST Client will perform a new request that it receives a redirection response from the HTTP server. Additionally, we can limit the number of redirections using the property "max-redirects". @@ -994,7 +1069,7 @@ QuarkusRestClientBuilder.newBuilder() === Injecting the `jakarta.ws.rs.ext.Providers` instance in filters -The `jakarta.ws.rs.ext.Providers` is useful when we need to lookup the provider instances of the current client. +The `jakarta.ws.rs.ext.Providers` is useful when we need to lookup the provider instances of the current client. We can get the `Providers` instance in our filters from the request context as follows: @@ -1086,7 +1161,7 @@ NOTE: Methods annotated with `@ClientExceptionMapper` can also take a `java.lang === Using @Blocking annotation in exception mappers -In cases that warrant using `InputStream` as the return type of REST Client method (such as when large amounts of data need to be read): +In cases that warrant using `InputStream` as the return type of REST Client method (such as when large amounts of data need to be read): [source, java] ---- @@ -1120,8 +1195,8 @@ public class MyResponseExceptionMapper implements ResponseExceptionMapper With the `@Blocking` annotation, the MyResponseExceptionMapper exception mapper will be executed in the worker thread pool. -<2> Reading the entity is now allowed because we're executing the mapper on the worker thread pool. +<1> With the `@Blocking` annotation, the MyResponseExceptionMapper exception mapper will be executed in the worker thread pool. +<2> Reading the entity is now allowed because we're executing the mapper on the worker thread pool. Note that you can also use the `@Blocking` annotation when using @ClientExceptionMapper: @@ -1288,7 +1363,7 @@ As previously mentioned, the body parameter needs to be properly crafted by the === Receiving compressed messages REST Client Reactive also supports receiving compressed messages using GZIP. You can enable the HTTP compression support by adding the property `quarkus.http.enable-compression=true`. -When this feature is enabled and a server returns a response that includes the header `Content-Encoding: gzip`, REST Client Reactive will automatically decode the content and proceed with the message handling. +When this feature is enabled and a server returns a response that includes the header `Content-Encoding: gzip`, REST Client Reactive will automatically decode the content and proceed with the message handling. == Proxy support REST Client Reactive supports sending requests through a proxy. 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 599bc2760762d..c5f716aac2bf4 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 @@ -45,6 +45,15 @@ void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index, Class MethodInfo rootMethod, MethodInfo subMethod, AssignableResultHandle webTarget, BuildProducer generatedClasses); + AssignableResultHandle handleFormParams(MethodCreator methodCreator, IndexView index, ClassInfo interfaceClass, + MethodInfo method, BuildProducer generatedClasses, + AssignableResultHandle formParams, boolean multipart); + + AssignableResultHandle handleFormParamsForSubResource(MethodCreator methodCreator, IndexView index, + ClassInfo rootInterfaceClass, ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, + AssignableResultHandle webTarget, BuildProducer generatedClasses, + AssignableResultHandle formParams, boolean multipart); + /** * 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 951e5103a2c89..7f8fcbc4577b7 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 @@ -1037,6 +1037,8 @@ A more full example of generated client (with sub-resource) can is at the bottom enricher.getEnricher() .forWebTarget(methodCreator, index, interfaceClass, jandexMethod, methodTarget, generatedClasses); + formParams = enricher.getEnricher().handleFormParams(methodCreator, index, interfaceClass, jandexMethod, + generatedClasses, formParams, multipart); } AssignableResultHandle builder = methodCreator.createVariable(Invocation.Builder.class); @@ -1660,6 +1662,9 @@ private void handleSubResourceMethod(List enricher.getEnricher() .forSubResourceWebTarget(subMethodCreator, index, interfaceClass, subInterface, jandexMethod, jandexSubMethod, methodTarget, generatedClasses); + formParams = enricher.getEnricher().handleFormParamsForSubResource(subMethodCreator, index, + interfaceClass, subInterface, jandexMethod, jandexSubMethod, methodTarget, generatedClasses, + formParams, multipart); } AssignableResultHandle builder = subMethodCreator.createVariable(Invocation.Builder.class); 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 df1a36f223dec..add3e44795d65 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 @@ -14,6 +14,8 @@ import org.jboss.jandex.DotName; 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.ClientQueryParam; import io.quarkus.rest.client.reactive.ClientQueryParams; import io.quarkus.rest.client.reactive.ClientRedirectHandler; @@ -27,6 +29,8 @@ public class DotNames { 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 CLIENT_FORM_PARAM = DotName.createSimple(ClientFormParam.class.getName()); + public static final DotName CLIENT_FORM_PARAMS = DotName.createSimple(ClientFormParams.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 5f22e57c186b3..2efc1aaaa84a2 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 @@ -2,6 +2,8 @@ import static io.quarkus.arc.processor.DotNames.STRING; import static io.quarkus.gizmo.MethodDescriptor.ofMethod; +import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_FORM_PARAM; +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_QUERY_PARAM; @@ -25,6 +27,7 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -32,6 +35,7 @@ import jakarta.ws.rs.client.ClientRequestContext; import jakarta.ws.rs.client.Invocation; import jakarta.ws.rs.core.Configurable; +import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import org.eclipse.microprofile.rest.client.RestClientDefinitionException; @@ -47,7 +51,9 @@ import org.jboss.jandex.Type; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.client.impl.WebTargetImpl; +import org.jboss.resteasy.reactive.client.impl.multipart.QuarkusMultipartForm; import org.jboss.resteasy.reactive.client.spi.ResteasyReactiveClientRequestContext; +import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; @@ -69,6 +75,8 @@ import io.quarkus.gizmo.ResultHandle; import io.quarkus.gizmo.TryBlock; import io.quarkus.jaxrs.client.reactive.deployment.JaxrsClientReactiveEnricher; +import io.quarkus.rest.client.reactive.ClientFormParam; +import io.quarkus.rest.client.reactive.ClientQueryParam; import io.quarkus.rest.client.reactive.ComputedParamContext; import io.quarkus.rest.client.reactive.HeaderFiller; import io.quarkus.rest.client.reactive.deployment.MicroProfileRestClientEnricher.RestClientAnnotationExpressionParser.Node; @@ -124,6 +132,14 @@ class MicroProfileRestClientEnricher implements JaxrsClientReactiveEnricher { ComputedParamContextImpl.class, "getMethodParameterFromContext", Object.class, ClientRequestContext.class, int.class); + private static final MethodDescriptor MAP_CONTAINS_KEY_METHOD = MethodDescriptor.ofMethod(Map.class, + "containsKey", boolean.class, Object.class); + private static final MethodDescriptor MULTIVALUED_MAP_ADD_ALL_METHOD = MethodDescriptor.ofMethod(MultivaluedMap.class, + "addAll", void.class, Object.class, List.class); + private static final MethodDescriptor QUARKUS_MULTIPART_FORM_ATTRIBUTE_METHOD = MethodDescriptor.ofMethod( + QuarkusMultipartForm.class, + "attribute", QuarkusMultipartForm.class, String.class, String.class, String.class); + private static final Type STRING_TYPE = Type.create(DotName.STRING_NAME, Type.Kind.CLASS); private final Map interfaceMocks = new HashMap<>(); @@ -172,10 +188,13 @@ public void forClass(MethodCreator constructor, AssignableResultHandle webTarget @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); + + Map queryParamsByName = new HashMap<>(); + collectClientParamData(interfaceClass, method, queryParamsByName, + CLIENT_QUERY_PARAM, CLIENT_QUERY_PARAMS, ClientQueryParam.class.getSimpleName()); + + for (var queryEntry : queryParamsByName.entrySet()) { + addQueryParam(method, methodCreator, queryEntry.getValue(), webTarget, generatedClasses, index); } } @@ -184,70 +203,178 @@ public void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, AssignableResultHandle webTarget, BuildProducer generatedClasses) { - Map queryParamsByName = new HashMap<>(); - collectClientQueryParamData(rootInterfaceClass, rootMethod, queryParamsByName); - collectClientQueryParamData(subInterfaceClass, subMethod, queryParamsByName); + Map queryParamsByName = new HashMap<>(); + collectClientParamData(rootInterfaceClass, rootMethod, queryParamsByName, + CLIENT_QUERY_PARAM, CLIENT_QUERY_PARAMS, ClientQueryParam.class.getSimpleName()); + collectClientParamData(subInterfaceClass, subMethod, queryParamsByName, + CLIENT_QUERY_PARAM, CLIENT_QUERY_PARAMS, ClientQueryParam.class.getSimpleName()); + 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)); + @Override + public AssignableResultHandle handleFormParams(MethodCreator methodCreator, IndexView index, ClassInfo interfaceClass, + MethodInfo method, BuildProducer generatedClasses, AssignableResultHandle formParams, + boolean multipart) { + + Map formParamsByName = new HashMap<>(); + collectClientParamData(interfaceClass, method, formParamsByName, + CLIENT_FORM_PARAM, CLIENT_FORM_PARAMS, ClientFormParam.class.getSimpleName()); + + if (!formParamsByName.isEmpty() && formParams == null) { + formParams = createFormData(methodCreator, multipart); + } + + for (var formEntry : formParamsByName.entrySet()) { + addFormParam(method, methodCreator, formEntry.getValue(), generatedClasses, index, formParams, multipart); + } + + return formParams; + } + + @Override + public AssignableResultHandle handleFormParamsForSubResource(MethodCreator methodCreator, IndexView index, + ClassInfo rootInterfaceClass, ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, + AssignableResultHandle webTarget, BuildProducer generatedClasses, + AssignableResultHandle formParams, boolean multipart) { + + Map formParamsByName = new HashMap<>(); + collectClientParamData(rootInterfaceClass, rootMethod, formParamsByName, + CLIENT_FORM_PARAM, CLIENT_FORM_PARAMS, ClientFormParam.class.getSimpleName()); + collectClientParamData(subInterfaceClass, subMethod, formParamsByName, + CLIENT_FORM_PARAM, CLIENT_FORM_PARAMS, ClientFormParam.class.getSimpleName()); + + if (!formParamsByName.isEmpty() && formParams == null) { + formParams = createFormData(methodCreator, multipart); + } + + for (var formEntry : formParamsByName.entrySet()) { + addFormParam(subMethod, methodCreator, formEntry.getValue(), generatedClasses, index, formParams, multipart); } - putAllQueryAnnotations(headerFillersByName, + + return formParams; + } + + private AssignableResultHandle createFormData(BytecodeCreator methodCreator, boolean multipart) { + AssignableResultHandle formParams; + if (multipart) { + formParams = methodCreator.createVariable(QuarkusMultipartForm.class); + methodCreator.assign(formParams, + methodCreator.newInstance(MethodDescriptor.ofConstructor(QuarkusMultipartForm.class))); + } else { + formParams = methodCreator.createVariable(MultivaluedMap.class); + methodCreator.assign(formParams, + methodCreator.newInstance(MethodDescriptor.ofConstructor(MultivaluedHashMap.class))); + } + return formParams; + } + + private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreator, + ParamData paramData, AssignableResultHandle webTargetImpl, + BuildProducer generatedClasses, IndexView index) { + + String paramName = paramData.annotation.value("name").asString(); + + Supplier existenceChecker = () -> methodCreator.invokeStaticMethod( + MethodDescriptor.ofMethod(ClientQueryParamSupport.class, "isQueryParamPresent", boolean.class, + WebTargetImpl.class, String.class), + webTargetImpl, methodCreator.load(paramName)); + + BiConsumer paramAdder = (creator, valuesList) -> creator.assign(webTargetImpl, + creator.invokeVirtualMethod(WEB_TARGET_IMPL_QUERY_PARAMS, webTargetImpl, methodCreator.load(paramName), + valuesList)); + + addParam(declaringMethod, methodCreator, paramData, generatedClasses, index, CLIENT_QUERY_PARAM, + ClientQueryParam.class.getSimpleName(), paramName, existenceChecker, paramAdder); + } + + private void addFormParam(MethodInfo declaringMethod, MethodCreator methodCreator, + ParamData paramData, BuildProducer generatedClasses, + IndexView index, AssignableResultHandle formParams, boolean multipart) { + + String paramName = paramData.annotation.value("name").asString(); + + Supplier existenceChecker = () -> methodCreator.invokeInterfaceMethod(MAP_CONTAINS_KEY_METHOD, + formParams, methodCreator.load(paramName)); + + BiConsumer paramAdder = (creator, valuesList) -> { + if (multipart) { + String filename = null; + AnnotationInstance partFileName = declaringMethod.annotation(ResteasyReactiveDotNames.PART_FILE_NAME); + if (partFileName != null && partFileName.value() != null) { + filename = partFileName.value().asString(); + } + + ForEachLoop loop = creator.forEach(valuesList); + BytecodeCreator block = loop.block(); + + block.invokeVirtualMethod(QUARKUS_MULTIPART_FORM_ATTRIBUTE_METHOD, formParams, block.load(paramName), + loop.element(), block.load(filename)); + } else { + creator.invokeInterfaceMethod( + MULTIVALUED_MAP_ADD_ALL_METHOD, formParams, creator.load(paramName), valuesList); + } + }; + + addParam(declaringMethod, methodCreator, paramData, generatedClasses, index, CLIENT_FORM_PARAM, + ClientFormParam.class.getSimpleName(), paramName, existenceChecker, paramAdder); + } + + private void collectClientParamData(ClassInfo interfaceClass, MethodInfo method, + Map paramFillersByName, + DotName clientParamAnnotation, DotName clientParamsAnnotation, + String annotationName) { + AnnotationInstance classLevelParam = interfaceClass.declaredAnnotation(clientParamAnnotation); + if (classLevelParam != null) { + paramFillersByName.put(classLevelParam.value("name").asString(), + new ParamData(classLevelParam, interfaceClass)); + } + putAllParamAnnotations(paramFillersByName, interfaceClass, - extractAnnotations(interfaceClass.declaredAnnotation(CLIENT_QUERY_PARAMS))); + extractAnnotations(interfaceClass.declaredAnnotation(clientParamsAnnotation)), annotationName); - Map methodLevelHeadersByName = new HashMap<>(); - AnnotationInstance methodLevelHeader = method.annotation(CLIENT_QUERY_PARAM); - if (methodLevelHeader != null) { - methodLevelHeadersByName.put(methodLevelHeader.value("name").asString(), - new QueryData(methodLevelHeader, interfaceClass)); + Map methodLevelParamsByName = new HashMap<>(); + AnnotationInstance methodLevelParam = method.annotation(clientParamAnnotation); + if (methodLevelParam != null) { + methodLevelParamsByName.put(methodLevelParam.value("name").asString(), + new ParamData(methodLevelParam, interfaceClass)); } - putAllQueryAnnotations(methodLevelHeadersByName, interfaceClass, - extractAnnotations(method.annotation(CLIENT_QUERY_PARAMS))); + putAllParamAnnotations(methodLevelParamsByName, interfaceClass, + extractAnnotations(method.annotation(clientParamsAnnotation)), annotationName); - headerFillersByName.putAll(methodLevelHeadersByName); + paramFillersByName.putAll(methodLevelParamsByName); } - private void putAllQueryAnnotations(Map headerMap, ClassInfo interfaceClass, - AnnotationInstance[] annotations) { + private void putAllParamAnnotations(Map paramMap, ClassInfo interfaceClass, + AnnotationInstance[] annotations, String annotationName) { 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 + + if (paramMap.put(name, new ParamData(annotation, interfaceClass)) != null) { + throw new RestClientDefinitionException("Duplicate " + annotationName + " annotation for parameter: " + name + " on " + annotation.target()); } } } - private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreator, - QueryData queryData, - AssignableResultHandle webTargetImpl, BuildProducer generatedClasses, - IndexView index) { + private void addParam(MethodInfo declaringMethod, MethodCreator methodCreator, + ParamData paramData, BuildProducer generatedClasses, + IndexView index, DotName clientParamAnnotation, String annotationName, String paramName, + Supplier existenceChecker, + BiConsumer paramAdder) { - AnnotationInstance annotation = queryData.annotation; - ClassInfo declaringClass = queryData.definingClass; + AnnotationInstance annotation = paramData.annotation; + ClassInfo declaringClass = paramData.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(); + ResultHandle isParamPresent = existenceChecker.get(); + BytecodeCreator creator = methodCreator.ifTrue(isParamPresent).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()); + log.warnv("Ignoring {} that specifies an empty array of values for parameter {} on {}", + annotationName, annotation.value("name").asString(), annotation.target()); return; } @@ -256,21 +383,20 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat ResultHandle valuesList = creator.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); for (String value : values) { if (value.contains("${")) { - ResultHandle queryValueFromConfig = creator.invokeStaticMethod( + ResultHandle paramValueFromConfig = creator.invokeStaticMethod( MethodDescriptor.ofMethod(ConfigUtils.class, "interpolate", String.class, String.class, boolean.class), creator.load(value), creator.load(required)); - creator.ifNotNull(queryValueFromConfig) - .trueBranch().invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, queryValueFromConfig); + creator.ifNotNull(paramValueFromConfig) + .trueBranch().invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, paramValueFromConfig); } else { creator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, creator.load(value)); } } - creator.assign(webTargetImpl, creator.invokeVirtualMethod(WEB_TARGET_IMPL_QUERY_PARAMS, webTargetImpl, - queryNameHandle, valuesList)); + paramAdder.accept(creator, valuesList); } else { // method call :O {some.package.ClassName.methodName} or {defaultMethodWithinThisInterfaceName} - // if `!required` an exception on header filling does not fail the invocation: + // if `!required` an exception on param filling does not fail the invocation: boolean required = annotation.valueWithDefault(index, "required").asBoolean(); BytecodeCreator methodCallCreator = creator; @@ -282,8 +408,8 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat } String methodName = values[0].substring(1, values[0].length() - 1); // strip curly braces - MethodInfo queryValueMethod; - ResultHandle queryValue; + MethodInfo paramValueMethod; + ResultHandle paramValue; if (methodName.contains(".")) { // calling a static method int endOfClassName = methodName.lastIndexOf('.'); @@ -293,61 +419,59 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat ClassInfo clazz = index.getClassByName(DotName.createSimple(className)); if (clazz == null) { throw new RestClientDefinitionException( - "Class " + className + " used in ClientQueryParam on " + declaringClass + " not found"); + "Class " + className + " used in " + annotationName + " on " + declaringClass + " not found"); } - queryValueMethod = findMethod(clazz, declaringClass, staticMethodName, CLIENT_QUERY_PARAM.toString()); + paramValueMethod = findMethod(clazz, declaringClass, staticMethodName, clientParamAnnotation.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)); + if (paramValueMethod.parametersCount() == 0) { + paramValue = methodCallCreator.invokeStaticMethod(paramValueMethod); + } else if (paramValueMethod.parametersCount() == 1 && isString(paramValueMethod.parameterType(0))) { + paramValue = methodCallCreator.invokeStaticMethod(paramValueMethod, methodCallCreator.load(paramName)); } else { throw new RestClientDefinitionException( - "ClientQueryParam method " + declaringClass.toString() + "#" + staticMethodName - + " has too many parameters, at most one parameter, header name, expected"); + annotationName + " method " + declaringClass.toString() + "#" + staticMethodName + + " has too many parameters, at most one parameter, param 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()); + paramValueMethod = findMethod(declaringClass, declaringClass, methodName, clientParamAnnotation.toString()); - if (queryValueMethod == null) { + if (paramValueMethod == null) { throw new RestClientDefinitionException( - "ClientQueryParam method " + methodName + " not found on " + declaringClass); + annotationName + " 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)); + if (paramValueMethod.parametersCount() == 0) { + paramValue = methodCallCreator.invokeInterfaceMethod(paramValueMethod, interfaceMock); + } else if (paramValueMethod.parametersCount() == 1 && isString(paramValueMethod.parameterType(0))) { + paramValue = methodCallCreator.invokeInterfaceMethod(paramValueMethod, interfaceMock, + methodCallCreator.load(paramName)); } else { throw new RestClientDefinitionException( - "ClientQueryParam method " + declaringClass + "#" + methodName - + " has too many parameters, at most one parameter, header name, expected"); + annotationName + " method " + declaringClass + "#" + methodName + + " has too many parameters, at most one parameter, param name, expected"); } } - Type returnType = queryValueMethod.returnType(); + Type returnType = paramValueMethod.returnType(); ResultHandle valuesList; if (isStringArray(returnType)) { // repack array to list - valuesList = methodCallCreator.invokeStaticMethod( - ARRAYS_AS_LIST, queryValue); + valuesList = methodCallCreator.invokeStaticMethod(ARRAYS_AS_LIST, paramValue); } else if (isString(returnType)) { valuesList = methodCallCreator.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); - methodCallCreator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, queryValue); + methodCallCreator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, paramValue); } else { throw new RestClientDefinitionException("Method " + declaringClass.toString() + "#" + methodName - + " has an unsupported return type for ClientQueryParam. " + + + " has an unsupported return type for " + annotationName + ". " + "Only String and String[] return types are supported"); } - methodCallCreator.assign(webTargetImpl, - methodCallCreator.invokeVirtualMethod(WEB_TARGET_IMPL_QUERY_PARAMS, webTargetImpl, queryNameHandle, - valuesList)); + + paramAdder.accept(methodCallCreator, valuesList); if (!required) { CatchBlockCreator catchBlock = tryBlock.addCatch(Exception.class); @@ -355,8 +479,8 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat 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()); + "Invoking param generation method '%s' for '%s' on method '%s#%s' failed", + methodName, paramName, declaringClass.name(), declaringMethod.name()); catchBlock.invokeVirtualMethod( MethodDescriptor.ofMethod(Logger.class, "warn", void.class, Object.class, Throwable.class), log, @@ -375,7 +499,7 @@ public void forSubResourceMethod(ClassCreator subClassCreator, MethodCreator sub addJavaMethodToContext(javaMethodField, subMethodCreator, invocationBuilder); - Map headerFillersByName = new HashMap<>(); + Map headerFillersByName = new HashMap<>(); collectHeaderFillers(rootInterfaceClass, rootMethod, headerFillersByName); collectHeaderFillers(subInterfaceClass, subMethod, headerFillersByName); String subHeaderFillerName = subInterfaceClass.name().toString() + sha1(rootInterfaceClass.name().toString()) + @@ -394,7 +518,7 @@ public void forMethod(ClassCreator classCreator, MethodCreator constructor, // header filler - Map headerFillersByName = new HashMap<>(); + Map headerFillersByName = new HashMap<>(); collectHeaderFillers(interfaceClass, method, headerFillersByName); @@ -407,7 +531,7 @@ private void createAndReturnHeaderFiller(ClassCreator classCreator, MethodCreato MethodCreator methodCreator, MethodInfo method, AssignableResultHandle invocationBuilder, IndexView index, BuildProducer generatedClasses, int methodIndex, String fillerClassName, - Map headerFillersByName) { + Map headerFillersByName) { FieldDescriptor headerFillerField = FieldDescriptor.of(classCreator.getClassName(), "headerFiller" + methodIndex, HeaderFiller.class); classCreator.getFieldCreator(headerFillerField).setModifiers(Modifier.PRIVATE | Modifier.FINAL); @@ -435,7 +559,7 @@ private void createAndReturnHeaderFiller(ClassCreator classCreator, MethodCreato MethodDescriptor.ofMethod(HeaderFiller.class, "addHeaders", void.class, MultivaluedMap.class, ResteasyReactiveClientRequestContext.class)); - for (Map.Entry headerEntry : headerFillersByName.entrySet()) { + for (Map.Entry headerEntry : headerFillersByName.entrySet()) { addHeaderParam(method, fillHeaders, headerEntry.getValue(), generatedClasses, fillerClassName, index); } @@ -474,21 +598,21 @@ private void createAndReturnHeaderFiller(ClassCreator classCreator, MethodCreato } private void collectHeaderFillers(ClassInfo interfaceClass, MethodInfo method, - Map headerFillersByName) { + Map headerFillersByName) { AnnotationInstance classLevelHeader = interfaceClass.declaredAnnotation(CLIENT_HEADER_PARAM); if (classLevelHeader != null) { headerFillersByName.put(classLevelHeader.value("name").asString(), - new HeaderData(classLevelHeader, interfaceClass)); + new ParamData(classLevelHeader, interfaceClass)); } putAllHeaderAnnotations(headerFillersByName, interfaceClass, extractAnnotations(interfaceClass.declaredAnnotation(CLIENT_HEADER_PARAMS))); - Map methodLevelHeadersByName = new HashMap<>(); + Map methodLevelHeadersByName = new HashMap<>(); AnnotationInstance methodLevelHeader = method.annotation(CLIENT_HEADER_PARAM); if (methodLevelHeader != null) { methodLevelHeadersByName.put(methodLevelHeader.value("name").asString(), - new HeaderData(methodLevelHeader, interfaceClass)); + new ParamData(methodLevelHeader, interfaceClass)); } putAllHeaderAnnotations(methodLevelHeadersByName, interfaceClass, extractAnnotations(method.annotation(CLIENT_HEADER_PARAMS))); @@ -514,11 +638,11 @@ private void addJavaMethodToContext(FieldDescriptor javaMethodField, MethodCreat methodCreator.load(INVOKED_METHOD_PROP), javaMethodAsObject)); } - private void putAllHeaderAnnotations(Map headerMap, ClassInfo interfaceClass, + private void putAllHeaderAnnotations(Map headerMap, ClassInfo interfaceClass, AnnotationInstance[] annotations) { for (AnnotationInstance annotation : annotations) { String headerName = annotation.value("name").asString(); - if (headerMap.put(headerName, new HeaderData(annotation, interfaceClass)) != null) { + if (headerMap.put(headerName, new ParamData(annotation, interfaceClass)) != null) { throw new RestClientDefinitionException("Duplicate ClientHeaderParam annotation for header: " + headerName + " on " + annotation.target()); } @@ -527,13 +651,13 @@ private void putAllHeaderAnnotations(Map headerMap, ClassInf // fillHeaders takes `MultivaluedMap` as param and modifies it private void addHeaderParam(MethodInfo declaringMethod, MethodCreator fillHeadersCreator, - HeaderData headerData, + ParamData paramData, BuildProducer generatedClasses, String fillerClassName, IndexView index) { - AnnotationInstance annotation = headerData.annotation; - ClassInfo declaringClass = headerData.definingClass; + AnnotationInstance annotation = paramData.annotation; + ClassInfo declaringClass = paramData.definingClass; String headerName = annotation.value("name").asString(); @@ -917,30 +1041,15 @@ private AnnotationInstance[] extractAnnotations(AnnotationInstance groupAnnotati } /** - * ClientHeaderParam annotations can be defined on a JAX-RS interface or a sub-client (sub-resource). - * If we're filling headers 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 HeaderData { - private final AnnotationInstance annotation; - private final ClassInfo definingClass; - - public HeaderData(AnnotationInstance annotation, ClassInfo definingClass) { - this.annotation = annotation; - 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 + * ClientxxxParam annotations can be defined on a JAX-RS interface or a sub-client (sub-resource). + * If we're filling parameters for a sub-client, we need to know the defining class of the ClientxxxParam * to properly resolve default methods of the "root" client */ - private static class QueryData { + private static class ParamData { private final AnnotationInstance annotation; private final ClassInfo definingClass; - public QueryData(AnnotationInstance annotation, ClassInfo definingClass) { + public ParamData(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 b4fba326dc0fc..d85ec2d395765 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 @@ -2,6 +2,8 @@ import static io.quarkus.arc.processor.MethodDescriptors.MAP_PUT; import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_EXCEPTION_MAPPER; +import static io.quarkus.rest.client.reactive.deployment.DotNames.CLIENT_FORM_PARAM; +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_QUERY_PARAM; @@ -12,7 +14,7 @@ import static io.quarkus.rest.client.reactive.deployment.DotNames.REGISTER_PROVIDERS; import static io.quarkus.rest.client.reactive.deployment.DotNames.RESPONSE_EXCEPTION_MAPPER; import static java.util.Arrays.asList; -import static java.util.stream.Collectors.*; +import static java.util.stream.Collectors.toList; import static org.jboss.resteasy.reactive.common.processor.EndpointIndexer.CDI_WRAPPER_SUFFIX; import static org.jboss.resteasy.reactive.common.processor.JandexUtil.isImplementorOf; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.APPLICATION; @@ -122,6 +124,8 @@ class RestClientReactiveProcessor { CLIENT_HEADER_PARAMS, CLIENT_QUERY_PARAM, CLIENT_QUERY_PARAMS, + CLIENT_FORM_PARAM, + CLIENT_FORM_PARAMS, REGISTER_CLIENT_HEADERS); @BuildStep diff --git a/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromMethodTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromMethodTest.java new file mode 100644 index 0000000000000..e31fede8036c5 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromMethodTest.java @@ -0,0 +1,159 @@ +package io.quarkus.rest.client.reactive.form; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +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.ClientFormParam; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; + +public class ClientFormParamFromMethodTest { + + @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 shouldUseValuesFromFormParam() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromFormParam("111")).isEqualTo("111/2"); + } + + @Test + void shouldUseValuesFromFormParams() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.setFromFormParams("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 { + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + public String returnFormParamValues(@FormParam("first") List first, + @FormParam("second") List second) { + return String.join(",", first) + "/" + String.join(",", second); + } + } + + @ClientFormParam(name = "first", value = "{first}") + public interface Client { + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + String setFromClass(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "second", value = "{second}") + String setFromMethodAndClass(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "second", value = "{second}") + String setFromFormParam(@FormParam("first") String first); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "second", value = "{second}") + String setFromFormParams(@FormParam("first") String first, @FormParam("second") String second); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "first", value = "{io.quarkus.rest.client.reactive.form.ComputedParam.withParam}") + @ClientFormParam(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(); + } + } + + @ClientFormParam(name = "first", value = "{first}") + public interface SubClient { + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + String sub(@FormParam("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/form/ClientFormParamFromPropertyTest.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromPropertyTest.java new file mode 100644 index 0000000000000..a761de25fbdd6 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ClientFormParamFromPropertyTest.java @@ -0,0 +1,109 @@ +package io.quarkus.rest.client.reactive.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.FormParam; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +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.ClientFormParam; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; + +public class ClientFormParamFromPropertyTest { + private static final String FORM_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=" + FORM_VALUE), + "application.properties")); + + @Test + void shouldSetFromProperties() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.getWithParam()).isEqualTo(FORM_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(FORM_VALUE); + } + + @Test + void shouldSucceedOnMissingNonRequiredPropertyAndUseOverriddenValue() { + Client client = RestClientBuilder.newBuilder().baseUri(baseUri) + .build(Client.class); + + assertThat(client.missingNonRequiredPropertyAndOverriddenValue()).isEqualTo("other"); + } + + @Path("/") + @ApplicationScoped + public static class Resource { + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + public String returnFormParamValue(@FormParam("my-param") String param) { + return param; + } + } + + @ClientFormParam(name = "my-param", value = "${my.property-value}") + public interface Client { + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + String getWithParam(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "some-other-param", value = "${non-existent-property}") + String missingRequiredProperty(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "some-other-param", value = "${non-existent-property}", required = false) + String missingNonRequiredProperty(); + + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.TEXT_PLAIN) + @ClientFormParam(name = "some-other-param", value = "${non-existent-property}", required = false) + @ClientFormParam(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/form/ComputedParam.java b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ComputedParam.java new file mode 100644 index 0000000000000..58d114eda7508 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/deployment/src/test/java/io/quarkus/rest/client/reactive/form/ComputedParam.java @@ -0,0 +1,13 @@ +package io.quarkus.rest.client.reactive.form; + +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/ClientFormParam.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParam.java new file mode 100644 index 0000000000000..75cf59a900f34 --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParam.java @@ -0,0 +1,86 @@ +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 form parameter that should be sent with the outbound request. + * When this annotation is placed at the interface level of a REST client interface, the specified form parameter 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 form 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 form parameter. + *

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

+ * public interface MyClient {
+ *
+ *    static AtomicInteger counter = new AtomicInteger(1);
+ *
+ *    default String determineFormParamValue(String name) {
+ *        if ("SomeParam".equals(name)) {
+ *            return "InvokedCount " + counter.getAndIncrement();
+ *        }
+ *        throw new UnsupportedOperationException("unknown name");
+ *    }
+ *
+ *    {@literal @}ClientFormParam(name="SomeName", value="ExplicitlyDefinedValue")
+ *    {@literal @}GET
+ *    Response useExplicitFormParamValue();
+ *
+ *    {@literal @}ClientFormParam(name="SomeName", value="{determineFormParamValue}")
+ *    {@literal @}DELETE
+ *    Response useComputedFormParamValue();
+ * }
+ * 
+ * + * The implementation should fail to deploy a client interface if the annotation contains a @ClientFormParam + * 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 + * form parameter if the method throws an exception. + *

+ * Note that if an interface method contains an argument annotated with @FormParam, that argument will take + * priority over anything specified in a @ClientFormParam annotation. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Repeatable(ClientFormParams.class) +public @interface ClientFormParam { + + /** + * @return the name of the form 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 form parameter value throws an exception (true; + * default) or just + * skip this form parameter (false) + */ + boolean required() default true; +} diff --git a/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParams.java b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParams.java new file mode 100644 index 0000000000000..754f8d9dd978d --- /dev/null +++ b/extensions/resteasy-reactive/rest-client-reactive/runtime/src/main/java/io/quarkus/rest/client/reactive/ClientFormParams.java @@ -0,0 +1,24 @@ +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 form 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 form 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 form parameter is + * specified in an annotation for both the type and the method, only the form 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 ClientFormParam}. + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ClientFormParams { + ClientFormParam[] value(); +} 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 index cbba6b0596355..f188e098aa522 100644 --- 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 @@ -9,7 +9,7 @@ /** * 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 + * When this annotation is placed at the interface level of a REST client interface, the specified query 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 @@ -19,12 +19,12 @@ * 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 + * must return a String or String[] (indicating a multivalued query) 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. + * argument. The String argument is the name of the query. *

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

  * public interface MyClient {
@@ -32,7 +32,7 @@
  *    static AtomicInteger counter = new AtomicInteger(1);
  *
  *    default String determineQueryValue(String name) {
- *        if ("SomeHeader".equals(name)) {
+ *        if ("SomeQuery".equals(name)) {
  *            return "InvokedCount " + counter.getAndIncrement();
  *        }
  *        throw new UnsupportedOperationException("unknown name");
@@ -56,7 +56,7 @@
  * 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
+ * back to the caller. If the required attribute is set to false, then the implementation will not send this query
  * if the method throws
  * an exception.
  * 

@@ -82,7 +82,7 @@ /** * @return whether to abort the request if the method to compute the query value throws an exception (true; default) or just - * skip this header + * skip this query * (false) */ boolean required() default true;