diff --git a/extensions/resteasy-reactive/rest-servlet/runtime/src/main/java/io/quarkus/resteasy/reactive/server/servlet/runtime/ServletRequestContext.java b/extensions/resteasy-reactive/rest-servlet/runtime/src/main/java/io/quarkus/resteasy/reactive/server/servlet/runtime/ServletRequestContext.java index 369ae603e4610..7d7a0cd97f248 100644 --- a/extensions/resteasy-reactive/rest-servlet/runtime/src/main/java/io/quarkus/resteasy/reactive/server/servlet/runtime/ServletRequestContext.java +++ b/extensions/resteasy-reactive/rest-servlet/runtime/src/main/java/io/quarkus/resteasy/reactive/server/servlet/runtime/ServletRequestContext.java @@ -15,6 +15,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.stream.Collectors; import jakarta.enterprise.event.Event; import jakarta.servlet.AsyncContext; @@ -24,10 +25,14 @@ import jakarta.servlet.WriteListener; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.SecurityContext; import org.jboss.resteasy.reactive.server.core.Deployment; import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; +import org.jboss.resteasy.reactive.server.handlers.ParameterHandler; import org.jboss.resteasy.reactive.server.spi.ServerHttpRequest; import org.jboss.resteasy.reactive.server.spi.ServerHttpResponse; import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; @@ -262,6 +267,40 @@ public List getAllQueryParams(String name) { return context.queryParam(name); } + /** + * Retrieves the parameters from the current HTTP request as a + * {@link Map>}, where the keys are parameter names + * and the values are lists of parameter values. This allows parameters + * to be extracted from the URL without knowing their names in advance. + * + * The method is used by {@link ParameterExtractor}, which works with characteristics + * such as parameter name, single/multiple values, and encoding. Since it's + * not always possible to distinguish between {@link Map} and {@link MultivaluedMap}, + * the method returns a unified {@link Map>} for handling + * both cases downstream by {@link ParameterHandler}. + * + * @return a {@link Map>} containing the parameters and + * their corresponding values. + */ + @Override + public Map> getParametersMap() { + MultiMap entries = context.request().params(); + final MultivaluedHashMap result = new MultivaluedHashMap<>(); + if (!entries.isEmpty()) { + for (Map.Entry entry : entries) { + result.add(entry.getKey(), entry.getValue()); + } + + } + Map> params = result.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue // Values are already a List + )); + + return params; + } + @Override public String query() { return request.getQueryString(); diff --git a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/simple/MapWithParamConverterTest.java b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/simple/MapWithParamConverterTest.java index 419dfdd721533..f48a1db6dc0a3 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/simple/MapWithParamConverterTest.java +++ b/extensions/resteasy-reactive/rest/deployment/src/test/java/io/quarkus/resteasy/reactive/server/test/simple/MapWithParamConverterTest.java @@ -2,6 +2,8 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -22,6 +24,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.type.TypeFactory; @@ -94,10 +97,26 @@ public T fromString(String value) { return (T) value; } try { - return genericType != null ? objectMapper.readValue(value, genericType) - : objectMapper.readValue(value, rawType); + JsonNode jsonNode = objectMapper.readTree(value); + if (jsonNode.isArray()) { + // Process as a list of maps and merge them into a single map + JavaType listType = objectMapper.getTypeFactory() + .constructCollectionType(List.class, rawType); + List> list = objectMapper.readValue(value, listType); + + Map mergedMap = new LinkedHashMap<>(); + for (Map map : list) { + mergedMap.putAll(map); + } + return (T) mergedMap; + } else { + // single object + return genericType != null + ? objectMapper.readValue(value, genericType) + : objectMapper.readValue(value, rawType); + } } catch (JsonProcessingException e) { - throw (new RuntimeException(e)); + throw new RuntimeException(e); } } diff --git a/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/resteasy/reactive/deployment/SpringWebResteasyReactiveProcessor.java b/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/resteasy/reactive/deployment/SpringWebResteasyReactiveProcessor.java index 55adc53e48711..7af75b1c9440e 100644 --- a/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/resteasy/reactive/deployment/SpringWebResteasyReactiveProcessor.java +++ b/extensions/spring-web/resteasy-reactive/deployment/src/main/java/io/quarkus/spring/web/resteasy/reactive/deployment/SpringWebResteasyReactiveProcessor.java @@ -13,6 +13,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import jakarta.ws.rs.Priorities; @@ -25,12 +26,14 @@ import org.jboss.jandex.DotName; import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; import org.jboss.jandex.Type; import org.jboss.logging.Logger; import org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames; import org.jboss.resteasy.reactive.common.processor.scanning.ResteasyReactiveScanner; import org.jboss.resteasy.reactive.common.processor.transformation.AnnotationsTransformer; import org.jboss.resteasy.reactive.common.processor.transformation.Transformation; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; import org.jboss.resteasy.reactive.server.model.FixedHandlerChainCustomizer; import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; import org.jboss.resteasy.reactive.server.processor.scanning.MethodScanner; @@ -47,6 +50,9 @@ import io.quarkus.resteasy.server.common.spi.AdditionalJaxRsResourceMethodParamAnnotations; import io.quarkus.spring.web.resteasy.reactive.runtime.ResponseEntityHandler; import io.quarkus.spring.web.resteasy.reactive.runtime.ResponseStatusHandler; +import io.quarkus.spring.web.resteasy.reactive.runtime.SpringMultiValueListParamExtractor; +import io.quarkus.spring.web.resteasy.reactive.runtime.SpringMultiValueMapParamExtractor; +import io.quarkus.spring.web.resteasy.reactive.runtime.SpringRequestParamHandler; import io.quarkus.spring.web.runtime.common.ResponseStatusExceptionMapper; public class SpringWebResteasyReactiveProcessor { @@ -82,8 +88,10 @@ public class SpringWebResteasyReactiveProcessor { private static final DotName HTTP_ENTITY = DotName.createSimple("org.springframework.http.HttpEntity"); private static final DotName RESPONSE_ENTITY = DotName.createSimple("org.springframework.http.ResponseEntity"); + private static final DotName SPRING_MULTIVALUE_MAP = DotName.createSimple("org.springframework.util.MultiValueMap"); private static final String DEFAULT_NONE = "\n\t\t\n\t\t\n\uE000\uE001\uE002\n\t\t\t\t\n"; // from ValueConstants + public static final String JAVA_UTIL_LIST = "java.util.List"; @BuildStep public AdditionalJaxRsResourceMethodParamAnnotations additionalJaxRsResourceMethodParamAnnotations() { @@ -395,6 +403,72 @@ private String replaceSpringWebWildcards(String methodPath) { })); } + @BuildStep + MethodScannerBuildItem scanner() { + return new MethodScannerBuildItem(new MethodScanner() { + /** + * In Spring, parameters annotated with {@code @RequestParam} are required by default unless explicitly marked as + * optional. + * This method ensures the same behavior in Quarkus by checking if a parameter is required and enforcing its + * presence. + * + * The method scans for parameters annotated with {@code @RequestParam} and verifies: + *
    + *
  • If the parameter is marked as required (default behavior in Spring).
  • + *
  • If it has no default value.
  • + *
  • If it is not of type {@code Optional}.
  • + *
+ * + * If all these conditions are met, it registers a {@link SpringRequestParamHandler} to enforce the required + * constraint. + * + * @param method The method being scanned. + * @param actualEndpointClass The actual class defining the endpoint. + * @param methodContext A map containing metadata about the method. + * @return A singleton list with {@link SpringRequestParamHandler} if the conditions are met, + * otherwise an empty list. + */ + + @Override + public List scan(MethodInfo method, ClassInfo actualEndpointClass, + Map methodContext) { + if (methodContext.containsKey("RequestParam")) { + for (MethodParameterInfo parameterInfo : method.parameters()) { + if (parameterInfo.annotation(REQUEST_PARAM) != null) { + AnnotationInstance annotation = parameterInfo.annotation(REQUEST_PARAM); + boolean required = annotation.value("required") == null || annotation.value("required").asBoolean(); + String defaultValue = annotation.value("defaultValue") != null + ? annotation.value("defaultValue").asString() + : ""; + boolean isOptional = parameterInfo.type().name() + .equals(DotName.createSimple(Optional.class.getName())); + + if (required && defaultValue.isBlank() && !isOptional) { + return Collections.singletonList(new SpringRequestParamHandler()); + } + } + } + } + return Collections.emptyList(); + } + + @Override + public ParameterExtractor handleCustomParameter(Type paramType, Map annotations, + boolean field, Map methodContext) { + if (annotations.containsKey(REQUEST_PARAM)) { + methodContext.put("RequestParam", true); + if (paramType.name().equals(SPRING_MULTIVALUE_MAP)) { + return new SpringMultiValueMapParamExtractor(); + } + if (paramType.name().equals(DotName.createSimple(JAVA_UTIL_LIST))) { + return new SpringMultiValueListParamExtractor(); + } + } + return null; + } + }); + } + @BuildStep public MethodScannerBuildItem responseEntitySupport() { return new MethodScannerBuildItem(new MethodScanner() { diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueListParamExtractor.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueListParamExtractor.java new file mode 100644 index 0000000000000..e35c168dd76d1 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueListParamExtractor.java @@ -0,0 +1,54 @@ +package io.quarkus.spring.web.resteasy.reactive.runtime; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; + +@SuppressWarnings("ForLoopReplaceableByForEach") +public class SpringMultiValueListParamExtractor implements ParameterExtractor { + + /** + * Returns a List containing all query parameters from the request, splitting values by commas if necessary. + * + *

+ * Spring MVC maps comma-delimited request parameters into a {@code List}. + * To maintain compatibility, this method follows the same approach: + * If a query parameter contains multiple values, separated by commas, it adds those multiple values; otherwise, it is added + * directly. + *

+ * + *

+ * Example: + * + *

+     * ?tags=java,quarkus&category=framework
+     * 
+ * + * would produce: + * + *
+     * ["java", "quarkus", "framework"]
+     * 
+ *

+ * + * @param context The ResteasyReactiveRequestContext containing the HTTP request. + * @return An immutable list of all extracted query parameters. + */ + public Object extractParameter(ResteasyReactiveRequestContext context) { + Pattern commaPattern = Pattern.compile(","); + + List allQueryParams = context.serverRequest().queryParamNames().stream() + .map(context.serverRequest()::getAllQueryParams) + .filter(Objects::nonNull) + .flatMap(List::stream) + .flatMap(value -> commaPattern.splitAsStream(value)) + .collect(Collectors.toList()); + + return List.copyOf(allQueryParams); + } + +} diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueMapParamExtractor.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueMapParamExtractor.java new file mode 100644 index 0000000000000..50369a7e624d5 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringMultiValueMapParamExtractor.java @@ -0,0 +1,21 @@ +package io.quarkus.spring.web.resteasy.reactive.runtime; + +import java.util.List; +import java.util.Map; + +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +@SuppressWarnings("ForLoopReplaceableByForEach") +public class SpringMultiValueMapParamExtractor implements ParameterExtractor { + + @Override + public Object extractParameter(ResteasyReactiveRequestContext context) { + Map> parametersMap = context.serverRequest().getParametersMap(); + MultiValueMap springMap = new LinkedMultiValueMap<>(); + parametersMap.forEach(springMap::put); + return springMap; + } +} diff --git a/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringRequestParamHandler.java b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringRequestParamHandler.java new file mode 100644 index 0000000000000..bf758fdec10b2 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/runtime/src/main/java/io/quarkus/spring/web/resteasy/reactive/runtime/SpringRequestParamHandler.java @@ -0,0 +1,38 @@ +package io.quarkus.spring.web.resteasy.reactive.runtime; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.jboss.resteasy.reactive.common.jaxrs.ResponseImpl; +import org.jboss.resteasy.reactive.common.model.ResourceClass; +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; +import org.jboss.resteasy.reactive.server.model.HandlerChainCustomizer; +import org.jboss.resteasy.reactive.server.model.ServerResourceMethod; +import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; + +/** + * In Spring, parameters annotated with {@code @RequestParam} are required by default unless explicitly marked as + * optional. + * This {@link SpringRequestParamHandler} enforces the required constraint responding with a BAD_REQUEST status. + * + */ +public class SpringRequestParamHandler implements HandlerChainCustomizer { + @Override + public List handlers(HandlerChainCustomizer.Phase phase, ResourceClass resourceClass, + ServerResourceMethod resourceMethod) { + if (phase == Phase.AFTER_RESPONSE_CREATED) { + return Collections.singletonList(new ServerRestHandler() { + @Override + public void handle(ResteasyReactiveRequestContext requestContext) throws Exception { + Map> parametersMap = requestContext.serverRequest().getParametersMap(); + if (parametersMap.isEmpty()) { + ResponseImpl response = (ResponseImpl) requestContext.getResponse().get(); + response.setStatus(400); + } + } + }); + } + return Collections.emptyList(); + } +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamController.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamController.java new file mode 100644 index 0000000000000..b5b083ee1c267 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamController.java @@ -0,0 +1,72 @@ +package io.quarkus.spring.web.requestparam; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping +public class RequestParamController { + + @GetMapping("/api/foos") + @ResponseBody + public String getFoos(@RequestParam String id) { + return "ID: " + id; + } + + @PostMapping("/api/foos") + @ResponseBody + public String addFoo(@RequestParam(name = "id") String fooId, @RequestParam String name) { + return "ID: " + fooId + " Name: " + name; + } + + @GetMapping("/api/foos/notParamRequired") + @ResponseBody + public String getFoosNotParamRequired2(@RequestParam(required = false) String id) { + return "ID: " + id; + } + + @GetMapping("/api/foos/optional") + @ResponseBody + public String getFoosOptional(@RequestParam Optional id) { + return "ID: " + id.orElseGet(() -> "not provided"); + } + + @GetMapping("/api/foos/defaultValue") + @ResponseBody + public String getFoosDefaultValue(@RequestParam(defaultValue = "test") String id) { + return "ID: " + id; + } + + @PostMapping("/api/foos/map") + @ResponseBody + public String updateFoos(@RequestParam Map allParams) { + return "Parameters are " + allParams.entrySet(); + } + + @GetMapping("/api/foos/multivalue") + @ResponseBody + public String getFoosMultiValue(@RequestParam List id) { + return "IDs are " + id; + } + + @PostMapping("/api/foos/multiMap") + @ResponseBody + public String updateFoos(@RequestParam MultiValueMap allParams) { + String result = ""; + for (Map.Entry> entry : allParams.entrySet()) { + result = "Parameters are " + entry.getKey() + "=" + entry.getValue().stream().collect(Collectors.joining(", ")); + } + return result; + } + +} diff --git a/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamControllerTest.java b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamControllerTest.java new file mode 100644 index 0000000000000..1612024ec3174 --- /dev/null +++ b/extensions/spring-web/resteasy-reactive/tests/src/test/java/io/quarkus/spring/web/requestparam/RequestParamControllerTest.java @@ -0,0 +1,126 @@ +package io.quarkus.spring.web.requestparam; + +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class RequestParamControllerTest { + + @RegisterExtension + static QuarkusUnitTest runner = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(RequestParamController.class)); + + @Test + public void testSimpleMapping() throws Exception { + when().get("/api/foos?id=abc") + .then() + .statusCode(200) + .body(is("ID: abc")); + + // In Spring, method parameters annotated with @RequestParam are required by default. + when().get("/api/foos") + .then() + .statusCode(400); + } + + @Test + public void testSimpleMappingSpecifyingName() throws Exception { + when().post("/api/foos?id=abc&name=bar") + .then() + .statusCode(200) + .body(is("ID: abc Name: bar")); + + when().post("/api/foos") + .then() + .statusCode(400); + + } + + @Test + public void testNotRequiredParam() throws Exception { + when().get("/api/foos/notParamRequired?id=abc") + .then() + .statusCode(200) + .body(is("ID: abc")); + + when().get("/api/foos/notParamRequired") + .then() + .statusCode(200) + .body(is("ID: null")); + } + + @Test + public void testOptionalParam() throws Exception { + when().get("/api/foos/optional?id=abc") + .then() + .statusCode(200) + .body(is("ID: abc")); + + when().get("/api/foos/optional") + .then() + .statusCode(200) + .body(is("ID: not provided")); + } + + @Test + public void testDefaultValueForParam() throws Exception { + when().get("/api/foos/defaultValue?id=abc") + .then() + .statusCode(200) + .body(is("ID: abc")); + + when().get("/api/foos/defaultValue") + .then() + .statusCode(200) + .body(is("ID: test")); + } + + @Test + public void testMultipleMapping() throws Exception { + when().post("/api/foos/map?id=abc&name=bar") + .then() + .statusCode(200) + .body(containsString("Parameters are [name=[bar], id=[abc]]")); + + when().post("/api/foos/map") + .then() + .statusCode(400); + + } + + @Test + public void testMultivalue() throws Exception { + when().get("/api/foos/multivalue?id=1,2,3") + .then() + .statusCode(200) + .body(containsString("IDs are [1, 2, 3]")); + + when().get("/api/foos/multivalue?id=1,2,3&id=foo") + .then() + .statusCode(200) + .body(containsString("IDs are [1, 2, 3, foo]")); + + when().get("/api/foos/multivalue") + .then() + .statusCode(400); + } + + @Test + public void testMultiMap() throws Exception { + when().post("/api/foos/multiMap?id=abc&id=123") + .then() + .statusCode(200) + .body(containsString("Parameters are id=abc, 123")); + + when().post("/api/foos/multiMap") + .then() + .statusCode(400); + } + +} diff --git a/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java b/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java index 102499b55c060..869c074e2adae 100644 --- a/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java +++ b/independent-projects/resteasy-reactive/common/processor/src/main/java/org/jboss/resteasy/reactive/common/processor/EndpointIndexer.java @@ -31,10 +31,12 @@ import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.LOCAL_DATE_TIME; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.LOCAL_TIME; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.LONG; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MAP; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MATRIX_PARAM; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI_PART_DATA_INPUT; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI_PART_FORM_PARAM; +import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.MULTI_VALUED_MAP; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.NON_BLOCKING; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.OBJECT; import static org.jboss.resteasy.reactive.common.processor.ResteasyReactiveDotNames.OFFSET_DATE_TIME; @@ -1387,6 +1389,14 @@ && isParameterContainerType(paramType.asClassType())) { handleSortedSetParam(existingConverters, errorLocation, hasRuntimeConverters, builder, elementType, currentMethodInfo); } + } else if ((pt.name().equals(MAP) || pt.name().equals(MULTI_VALUED_MAP)) && type != ParameterType.BODY) { + typeHandled = true; + builder.setSingle(false); + elementType = toClassName(pt.arguments().get(0), currentClassInfo, actualEndpointInfo, index); + if (convertible) { + handleMapParam(existingConverters, errorLocation, hasRuntimeConverters, builder, elementType, + currentMethodInfo); + } } else if (pt.name().equals(OPTIONAL)) { typeHandled = true; elementType = toClassName(pt.arguments().get(0), currentClassInfo, actualEndpointInfo, index); @@ -1547,6 +1557,10 @@ protected void handleSetParam(Map existingConverters, String err PARAM builder, String elementType, MethodInfo currentMethodInfo) { } + protected void handleMapParam(Map existingConverters, String errorLocation, boolean hasRuntimeConverters, + PARAM builder, String elementType, MethodInfo currentMethodInfo) { + } + protected void handleListParam(Map existingConverters, String errorLocation, boolean hasRuntimeConverters, PARAM builder, String elementType, MethodInfo currentMethodInfo) { } diff --git a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java index 35566962ad19b..01eff1d76d166 100644 --- a/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java +++ b/independent-projects/resteasy-reactive/server/processor/src/main/java/org/jboss/resteasy/reactive/server/processor/ServerEndpointIndexer.java @@ -75,6 +75,7 @@ import org.jboss.resteasy.reactive.server.core.parameters.converters.LocalDateParamConverter; import org.jboss.resteasy.reactive.server.core.parameters.converters.LocalDateTimeParamConverter; import org.jboss.resteasy.reactive.server.core.parameters.converters.LocalTimeParamConverter; +import org.jboss.resteasy.reactive.server.core.parameters.converters.MapConverter; import org.jboss.resteasy.reactive.server.core.parameters.converters.NoopParameterConverter; import org.jboss.resteasy.reactive.server.core.parameters.converters.OffsetDateTimeParamConverter; import org.jboss.resteasy.reactive.server.core.parameters.converters.OffsetTimeParamConverter; @@ -534,6 +535,14 @@ protected void handleSetParam(Map existingConverters, String err builder.setConverter(new SetConverter.SetSupplier(converter)); } + @Override + protected void handleMapParam(Map existingConverters, String errorLocation, boolean hasRuntimeConverters, + ServerIndexedParameter builder, String elementType, MethodInfo currentMethodInfo) { + ParameterConverterSupplier converter = extractConverter(elementType, index, + existingConverters, errorLocation, hasRuntimeConverters, builder.getAnns(), currentMethodInfo); + builder.setConverter(new MapConverter.MapSupplier(converter)); + } + @Override protected void handleListParam(Map existingConverters, String errorLocation, boolean hasRuntimeConverters, ServerIndexedParameter builder, String elementType, MethodInfo currentMethodInfo) { diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java index e72ad3b6913df..cc5acc27fc4bf 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/ResteasyReactiveRequestContext.java @@ -14,6 +14,7 @@ import java.util.Deque; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.concurrent.Executor; import java.util.regex.Matcher; @@ -870,29 +871,36 @@ public Object getQueryParameter(String name, boolean single, boolean encoded, St } return val; } - - // empty collections must not be turned to null - List strings = serverRequest().getAllQueryParams(name).stream() - .filter(p -> !p.isEmpty()) - .toList(); - if (encoded) { - List newStrings = new ArrayList<>(); - for (String i : strings) { - newStrings.add(Encode.encodeQueryParam(i)); + List allQueryParams = serverRequest().getAllQueryParams(name); + if (allQueryParams != null && !allQueryParams.isEmpty()) { + // empty collections must not be turned to null + List strings = allQueryParams.stream() + .filter(p -> !p.isEmpty()) + .toList(); + if (encoded) { + List newStrings = new ArrayList<>(); + for (String i : strings) { + newStrings.add(Encode.encodeQueryParam(i)); + } + strings = newStrings; } - strings = newStrings; - } - if (separator != null) { - List result = new ArrayList<>(strings.size()); - for (int i = 0; i < strings.size(); i++) { - String[] parts = strings.get(i).split(separator); - result.addAll(Arrays.asList(parts)); + if (separator != null) { + List result = new ArrayList<>(strings.size()); + for (int i = 0; i < strings.size(); i++) { + String[] parts = strings.get(i).split(separator); + result.addAll(Arrays.asList(parts)); + } + return result; + } else { + return strings; } - return result; - } else { - return strings; } + Map> queryParams = serverRequest().getParametersMap(); + if (queryParams != null && !queryParams.isEmpty()) { + return queryParams; + } + return Collections.EMPTY_LIST; } @Override diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/parameters/converters/MapConverter.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/parameters/converters/MapConverter.java new file mode 100644 index 0000000000000..1b04c88c75f40 --- /dev/null +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/core/parameters/converters/MapConverter.java @@ -0,0 +1,100 @@ +package org.jboss.resteasy.reactive.server.core.parameters.converters; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; + +import org.jboss.resteasy.reactive.server.model.ParamConverterProviders; + +public class MapConverter implements ParameterConverter { + + private final ParameterConverter delegate; + + public MapConverter(ParameterConverter delegate) { + this.delegate = delegate; + } + + @Override + public Object convert(Object parameter) { + if (parameter instanceof Map) { + Map ret = new HashMap<>(); + Map map = (Map) parameter; + for (Map.Entry entry : map.entrySet()) { + if (delegate == null) { + ret.put(entry.getKey(), entry.getValue()); + } else { + ret.put(entry.getKey(), delegate.convert(entry.getValue())); + } + } + return ret; + } else if (parameter instanceof MultivaluedMap) { + MultivaluedMap ret = new MultivaluedHashMap<>(); + MultivaluedMap multivaluedMap = (MultivaluedMap) parameter; + for (Map.Entry> entry : multivaluedMap.entrySet()) { + List retValues = new ArrayList<>(); + for (Object value : entry.getValue()) { + if (delegate == null) { + retValues.add(value); + } else { + retValues.add(delegate.convert(value)); + } + } + ret.put(entry.getKey(), retValues); + } + return ret; + } + if (parameter == null) { + return Collections.emptyMap(); + } else { + return Collections.emptyMap(); + } + } + + @Override + public void init(ParamConverterProviders deployment, Class rawType, Type genericType, Annotation[] annotations) { + delegate.init(deployment, rawType, genericType, annotations); + } + + @Override + public boolean isForSingleObjectContainer() { + return true; + } + + public static class MapSupplier implements DelegatingParameterConverterSupplier { + private ParameterConverterSupplier delegate; + + public MapSupplier() { + } + + // invoked by reflection for BeanParam in ClassInjectorTransformer + public MapSupplier(ParameterConverterSupplier delegate) { + this.delegate = delegate; + } + + @Override + public String getClassName() { + return MapConverter.class.getName(); + } + + @Override + public ParameterConverter get() { + return delegate == null ? new MapConverter(null) : new MapConverter(delegate.get()); + } + + public ParameterConverterSupplier getDelegate() { + return delegate; + } + + public MapSupplier setDelegate(ParameterConverterSupplier delegate) { + this.delegate = delegate; + return this; + } + } +} diff --git a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ServerHttpRequest.java b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ServerHttpRequest.java index 53c2d6ea0c05a..c338a7217214e 100644 --- a/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ServerHttpRequest.java +++ b/independent-projects/resteasy-reactive/server/runtime/src/main/java/org/jboss/resteasy/reactive/server/spi/ServerHttpRequest.java @@ -34,6 +34,8 @@ public interface ServerHttpRequest { String getQueryParam(String name); + Map> getParametersMap(); + List getAllQueryParams(String name); String query(); diff --git a/independent-projects/resteasy-reactive/server/vertx/src/main/java/org/jboss/resteasy/reactive/server/vertx/VertxResteasyReactiveRequestContext.java b/independent-projects/resteasy-reactive/server/vertx/src/main/java/org/jboss/resteasy/reactive/server/vertx/VertxResteasyReactiveRequestContext.java index 51e1c8d9605f8..feb93188402ed 100644 --- a/independent-projects/resteasy-reactive/server/vertx/src/main/java/org/jboss/resteasy/reactive/server/vertx/VertxResteasyReactiveRequestContext.java +++ b/independent-projects/resteasy-reactive/server/vertx/src/main/java/org/jboss/resteasy/reactive/server/vertx/VertxResteasyReactiveRequestContext.java @@ -14,8 +14,10 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.stream.Collectors; import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import org.jboss.resteasy.reactive.common.ResteasyReactiveConfig; @@ -24,6 +26,8 @@ import org.jboss.resteasy.reactive.server.core.LazyResponse; import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; import org.jboss.resteasy.reactive.server.core.multipart.FormData; +import org.jboss.resteasy.reactive.server.core.parameters.ParameterExtractor; +import org.jboss.resteasy.reactive.server.handlers.ParameterHandler; import org.jboss.resteasy.reactive.server.spi.ServerHttpRequest; import org.jboss.resteasy.reactive.server.spi.ServerHttpResponse; import org.jboss.resteasy.reactive.server.spi.ServerRestHandler; @@ -215,6 +219,40 @@ public List getAllQueryParams(String name) { return context.queryParam(name); } + /** + * Retrieves the parameters from the current HTTP request as a + * {@link Map>}, where the keys are parameter names + * and the values are lists of parameter values. This allows parameters + * to be extracted from the URL without knowing their names in advance. + * + * The method is used by {@link ParameterExtractor}, which works with characteristics + * such as parameter name, single/multiple values, and encoding. Since it's + * not always possible to distinguish between {@link Map} and {@link Multimap}, + * the method returns a unified {@link Map>} for handling + * both cases downstream by {@link ParameterHandler}. + * + * @return a {@link Map>} containing the parameters and + * their corresponding values. + */ + @Override + public Map> getParametersMap() { + MultiMap entries = context.request().params(); + final MultivaluedHashMap result = new MultivaluedHashMap<>(); + if (!entries.isEmpty()) { + for (Map.Entry entry : entries) { + result.add(entry.getKey(), entry.getValue()); + } + + } + Map> params = result.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue // Values are already a List + )); + + return params; + } + @Override public String query() { return request.query();