From 41e4de9e3e005b35cf496221717070e8a4b45665 Mon Sep 17 00:00:00 2001 From: Fouad Almalki Date: Fri, 7 Jul 2023 02:36:36 +0300 Subject: [PATCH] Add @ClientFormParam to Reactive REST Client --- .../main/asciidoc/rest-client-reactive.adoc | 89 ++++- .../JaxrsClientReactiveEnricher.java | 6 +- .../JaxrsClientReactiveProcessor.java | 9 +- .../client/reactive/deployment/DotNames.java | 4 + .../MicroProfileRestClientEnricher.java | 310 ++++++++++++++---- .../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, 752 insertions(+), 77 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 c13a2c8a97fb0c..fdd6308f92dadc 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 599bc2760762d2..abc6dfb29fe00a 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 @@ -35,7 +35,8 @@ void forClass(MethodCreator ctor, AssignableResultHandle globalTarget, * Called when a {@link jakarta.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); + AssignableResultHandle webTarget, BuildProducer generatedClasses, + AssignableResultHandle[] formParamsRef, boolean multipart); /** * Called when a {@link jakarta.ws.rs.client.WebTarget} has been populated for a sub Client @@ -43,7 +44,8 @@ void forWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo interf void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo rootInterfaceClass, ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, AssignableResultHandle webTarget, - BuildProducer generatedClasses); + BuildProducer generatedClasses, AssignableResultHandle[] formParamsRef, + 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 951e5103a2c899..03642353298492 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 @@ -1034,9 +1034,11 @@ A more full example of generated client (with sub-resource) can is at the bottom } for (JaxrsClientReactiveEnricherBuildItem enricher : enrichers) { + AssignableResultHandle[] formParamsRef = { formParams }; enricher.getEnricher() .forWebTarget(methodCreator, index, interfaceClass, jandexMethod, methodTarget, - generatedClasses); + generatedClasses, formParamsRef, multipart); + formParams = formParamsRef[0]; } AssignableResultHandle builder = methodCreator.createVariable(Invocation.Builder.class); @@ -1657,9 +1659,12 @@ private void handleSubResourceMethod(List addResponseTypeIfMultipart(multipartResponseTypes, jandexSubMethod, index); for (JaxrsClientReactiveEnricherBuildItem enricher : enrichers) { + AssignableResultHandle[] formParamsRef = { formParams }; enricher.getEnricher() .forSubResourceWebTarget(subMethodCreator, index, interfaceClass, subInterface, - jandexMethod, jandexSubMethod, methodTarget, generatedClasses); + jandexMethod, jandexSubMethod, methodTarget, generatedClasses, formParamsRef, + multipart); + formParams = formParamsRef[0]; } 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 df1a36f223decc..add3e44795d658 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 5f22e57c186b33..f0802066e9e121 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; @@ -32,6 +34,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; @@ -124,6 +127,11 @@ 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 Type STRING_TYPE = Type.create(DotName.STRING_NAME, Type.Kind.CLASS); private final Map interfaceMocks = new HashMap<>(); @@ -171,68 +179,136 @@ 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<>(); + AssignableResultHandle webTarget, BuildProducer generatedClasses, + AssignableResultHandle[] formParamsRef, boolean multipart) { + + Map queryParamsByName = new HashMap<>(); collectClientQueryParamData(interfaceClass, method, queryParamsByName); for (var headerEntry : queryParamsByName.entrySet()) { addQueryParam(method, methodCreator, headerEntry.getValue(), webTarget, generatedClasses, index); } + + if (!multipart) { + Map formParamsByName = new HashMap<>(); + collectClientFormParamData(interfaceClass, method, formParamsByName); + + if (!formParamsByName.isEmpty() && formParamsRef[0] == null) { + formParamsRef[0] = methodCreator.createVariable(MultivaluedMap.class); + methodCreator.assign(formParamsRef[0], + methodCreator.newInstance(MethodDescriptor.ofConstructor(MultivaluedHashMap.class))); + } + + for (var formEntry : formParamsByName.entrySet()) { + addFormParam(method, methodCreator, formEntry.getValue(), generatedClasses, index, formParamsRef[0]); + } + } } @Override public void forSubResourceWebTarget(MethodCreator methodCreator, IndexView index, ClassInfo rootInterfaceClass, ClassInfo subInterfaceClass, MethodInfo rootMethod, MethodInfo subMethod, - AssignableResultHandle webTarget, BuildProducer generatedClasses) { + AssignableResultHandle webTarget, BuildProducer generatedClasses, + AssignableResultHandle[] formParamsRef, boolean multipart) { - Map queryParamsByName = new HashMap<>(); + 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); } + + if (!multipart) { + Map formParamsByName = new HashMap<>(); + collectClientFormParamData(rootInterfaceClass, rootMethod, formParamsByName); + collectClientFormParamData(subInterfaceClass, subMethod, formParamsByName); + + if (!formParamsByName.isEmpty() && formParamsRef[0] == null) { + formParamsRef[0] = methodCreator.createVariable(MultivaluedMap.class); + methodCreator.assign(formParamsRef[0], + methodCreator.newInstance(MethodDescriptor.ofConstructor(MultivaluedHashMap.class))); + } + + for (var formEntry : formParamsByName.entrySet()) { + addFormParam(subMethod, methodCreator, formEntry.getValue(), generatedClasses, index, formParamsRef[0]); + } + } } 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)); + Map queryFillersByName) { + AnnotationInstance classLevelQuery = interfaceClass.declaredAnnotation(CLIENT_QUERY_PARAM); + if (classLevelQuery != null) { + queryFillersByName.put(classLevelQuery.value("name").asString(), + new ParamData(classLevelQuery, interfaceClass)); } - putAllQueryAnnotations(headerFillersByName, + putAllQueryAnnotations(queryFillersByName, 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)); + Map methodLevelQueriesByName = new HashMap<>(); + AnnotationInstance methodLevelQuery = method.annotation(CLIENT_QUERY_PARAM); + if (methodLevelQuery != null) { + methodLevelQueriesByName.put(methodLevelQuery.value("name").asString(), + new ParamData(methodLevelQuery, interfaceClass)); } - putAllQueryAnnotations(methodLevelHeadersByName, interfaceClass, + putAllQueryAnnotations(methodLevelQueriesByName, interfaceClass, extractAnnotations(method.annotation(CLIENT_QUERY_PARAMS))); - headerFillersByName.putAll(methodLevelHeadersByName); + queryFillersByName.putAll(methodLevelQueriesByName); } - private void putAllQueryAnnotations(Map headerMap, ClassInfo interfaceClass, + 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) { + if (headerMap.put(name, new ParamData(annotation, interfaceClass)) != null) { throw new RestClientDefinitionException("Duplicate ClientQueryParam annotation for query parameter: " + name + " on " + annotation.target()); } } } + private void collectClientFormParamData(ClassInfo interfaceClass, MethodInfo method, + Map formFillersByName) { + AnnotationInstance classLevelForm = interfaceClass.declaredAnnotation(CLIENT_FORM_PARAM); + if (classLevelForm != null) { + formFillersByName.put(classLevelForm.value("name").asString(), + new ParamData(classLevelForm, interfaceClass)); + } + putAllFormAnnotations(formFillersByName, + interfaceClass, + extractAnnotations(interfaceClass.declaredAnnotation(CLIENT_FORM_PARAMS))); + + Map methodLevelFormsByName = new HashMap<>(); + AnnotationInstance methodLevelForm = method.annotation(CLIENT_FORM_PARAM); + if (methodLevelForm != null) { + methodLevelFormsByName.put(methodLevelForm.value("name").asString(), + new ParamData(methodLevelForm, interfaceClass)); + } + putAllFormAnnotations(methodLevelFormsByName, interfaceClass, + extractAnnotations(method.annotation(CLIENT_FORM_PARAMS))); + + formFillersByName.putAll(methodLevelFormsByName); + } + + private void putAllFormAnnotations(Map formMap, ClassInfo interfaceClass, + AnnotationInstance[] annotations) { + for (AnnotationInstance annotation : annotations) { + String name = annotation.value("name").asString(); + if (formMap.put(name, new ParamData(annotation, interfaceClass)) != null) { + throw new RestClientDefinitionException("Duplicate ClientFormParam annotation for form parameter: " + name + + " on " + annotation.target()); + } + } + } + private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreator, - QueryData queryData, + ParamData paramData, AssignableResultHandle webTargetImpl, BuildProducer generatedClasses, IndexView index) { - 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); @@ -246,7 +322,7 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat String[] values = annotation.value().asStringArray(); if (values.length == 0) { - log.warnv("Ignoring ClientQueryParam that specifies an empty array of header values for header {} on {}", + log.warnv("Ignoring ClientQueryParam that specifies an empty array of query values for query {} on {}", annotation.value("name").asString(), annotation.target()); return; } @@ -270,7 +346,7 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat 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: + // if `!required` an exception on query filling does not fail the invocation: boolean required = annotation.valueWithDefault(index, "required").asBoolean(); BytecodeCreator methodCallCreator = creator; @@ -304,7 +380,7 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat } else { throw new RestClientDefinitionException( "ClientQueryParam method " + declaringClass.toString() + "#" + staticMethodName - + " has too many parameters, at most one parameter, header name, expected"); + + " has too many parameters, at most one parameter, query name, expected"); } } else { // interface method @@ -326,7 +402,7 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat } else { throw new RestClientDefinitionException( "ClientQueryParam method " + declaringClass + "#" + methodName - + " has too many parameters, at most one parameter, header name, expected"); + + " has too many parameters, at most one parameter, query name, expected"); } } @@ -365,6 +441,139 @@ private void addQueryParam(MethodInfo declaringMethod, MethodCreator methodCreat } } + private void addFormParam(MethodInfo declaringMethod, MethodCreator methodCreator, + ParamData paramData, BuildProducer generatedClasses, + IndexView index, AssignableResultHandle formParams) { + + AnnotationInstance annotation = paramData.annotation; + ClassInfo declaringClass = paramData.definingClass; + + String formName = annotation.value("name").asString(); + ResultHandle formNameHandle = methodCreator.load(formName); + + ResultHandle isFormParamPresent = methodCreator.invokeInterfaceMethod(MAP_CONTAINS_KEY_METHOD, formParams, + formNameHandle); + BytecodeCreator creator = methodCreator.ifTrue(isFormParamPresent).falseBranch(); + + String[] values = annotation.value().asStringArray(); + + if (values.length == 0) { + log.warnv("Ignoring ClientFormParam that specifies an empty array of form values for form parameter {} 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.contains("${")) { + ResultHandle formValueFromConfig = creator.invokeStaticMethod( + MethodDescriptor.ofMethod(ConfigUtils.class, "interpolate", String.class, String.class, + boolean.class), + creator.load(value), creator.load(required)); + creator.ifNotNull(formValueFromConfig) + .trueBranch().invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, formValueFromConfig); + } else { + creator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, creator.load(value)); + } + } + + creator.invokeInterfaceMethod(MULTIVALUED_MAP_ADD_ALL_METHOD, formParams, formNameHandle, valuesList); + } else { // method call :O {some.package.ClassName.methodName} or {defaultMethodWithinThisInterfaceName} + // if `!required` an exception on form 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 formValueMethod; + ResultHandle formValue; + 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 ClientFormParam on " + declaringClass + " not found"); + } + formValueMethod = findMethod(clazz, declaringClass, staticMethodName, CLIENT_FORM_PARAM.toString()); + + if (formValueMethod.parametersCount() == 0) { + formValue = methodCallCreator.invokeStaticMethod(formValueMethod); + } else if (formValueMethod.parametersCount() == 1 && isString(formValueMethod.parameterType(0))) { + formValue = methodCallCreator.invokeStaticMethod(formValueMethod, methodCallCreator.load(formName)); + } else { + throw new RestClientDefinitionException( + "ClientFormParam method " + declaringClass.toString() + "#" + staticMethodName + + " has too many parameters, at most one parameter, form parameter name, expected"); + } + } else { + // interface method + String mockName = mockInterface(declaringClass, generatedClasses, index); + ResultHandle interfaceMock = methodCallCreator.newInstance(MethodDescriptor.ofConstructor(mockName)); + + formValueMethod = findMethod(declaringClass, declaringClass, methodName, CLIENT_FORM_PARAM.toString()); + + if (formValueMethod == null) { + throw new RestClientDefinitionException( + "ClientFormParam method " + methodName + " not found on " + declaringClass); + } + + if (formValueMethod.parametersCount() == 0) { + formValue = methodCallCreator.invokeInterfaceMethod(formValueMethod, interfaceMock); + } else if (formValueMethod.parametersCount() == 1 && isString(formValueMethod.parameterType(0))) { + formValue = methodCallCreator.invokeInterfaceMethod(formValueMethod, interfaceMock, + methodCallCreator.load(formName)); + } else { + throw new RestClientDefinitionException( + "ClientFormParam method " + declaringClass + "#" + methodName + + " has too many parameters, at most one parameter, form parameter name, expected"); + } + + } + + Type returnType = formValueMethod.returnType(); + ResultHandle valuesList; + if (isStringArray(returnType)) { + // repack array to list + valuesList = methodCallCreator.invokeStaticMethod(ARRAYS_AS_LIST, formValue); + } else if (isString(returnType)) { + valuesList = methodCallCreator.newInstance(MethodDescriptor.ofConstructor(ArrayList.class)); + methodCallCreator.invokeInterfaceMethod(LIST_ADD_METHOD, valuesList, formValue); + } else { + throw new RestClientDefinitionException("Method " + declaringClass.toString() + "#" + methodName + + " has an unsupported return type for ClientFormParam. " + + "Only String and String[] return types are supported"); + } + + methodCallCreator.invokeInterfaceMethod(MULTIVALUED_MAP_ADD_ALL_METHOD, formParams, formNameHandle, 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 form param generation method '%s' for '%s' on method '%s#%s' failed", + methodName, formName, 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, @@ -375,7 +584,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 +603,7 @@ public void forMethod(ClassCreator classCreator, MethodCreator constructor, // header filler - Map headerFillersByName = new HashMap<>(); + Map headerFillersByName = new HashMap<>(); collectHeaderFillers(interfaceClass, method, headerFillersByName); @@ -407,7 +616,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 +644,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 +683,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 +723,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 +736,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 +1126,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 b4fba326dc0fc2..d85ec2d3957651 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 00000000000000..e31fede8036c5f --- /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 00000000000000..a761de25fbdd6d --- /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 00000000000000..58d114eda75083 --- /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 00000000000000..75cf59a900f34d --- /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 00000000000000..754f8d9dd978dc --- /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 cbba6b0596355b..f188e098aa5224 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;