Skip to content

Commit

Permalink
Enhance Quarkus support for Spring @RequestParam annotated parameters.
Browse files Browse the repository at this point in the history
  • Loading branch information
aureamunoz committed Feb 5, 2025
1 parent c52d210 commit d09ef82
Show file tree
Hide file tree
Showing 14 changed files with 636 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -262,6 +267,40 @@ public List<String> getAllQueryParams(String name) {
return context.queryParam(name);
}

/**
* Retrieves the parameters from the current HTTP request as a
* {@link Map<String, List<String>>}, 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<String, List<String>>} for handling
* both cases downstream by {@link ParameterHandler}.
*
* @return a {@link Map<String, List<String>>} containing the parameters and
* their corresponding values.
*/
@Override
public Map<String, List<String>> getParametersMap() {
MultiMap entries = context.request().params();
final MultivaluedHashMap<String, String> result = new MultivaluedHashMap<>();
if (!entries.isEmpty()) {
for (Map.Entry<String, String> entry : entries) {
result.add(entry.getKey(), entry.getValue());
}

}
Map<String, List<String>> params = result.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue // Values are already a List<String>
));

return params;
}

@Override
public String query() {
return request.getQueryString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<Map<String, Object>> list = objectMapper.readValue(value, listType);

Map<String, Object> mergedMap = new LinkedHashMap<>();
for (Map<String, Object> 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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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:
* <ul>
* <li>If the parameter is marked as required (default behavior in Spring).</li>
* <li>If it has no default value.</li>
* <li>If it is not of type {@code Optional<T>}.</li>
* </ul>
*
* 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<HandlerChainCustomizer> scan(MethodInfo method, ClassInfo actualEndpointClass,
Map<String, Object> 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<DotName, AnnotationInstance> annotations,
boolean field, Map<String, Object> 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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* Spring MVC maps comma-delimited request parameters into a {@code List<String>}.
* 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.
* </p>
*
* <p>
* Example:
*
* <pre>
* ?tags=java,quarkus&amp;category=framework
* </pre>
*
* would produce:
*
* <pre>
* ["java", "quarkus", "framework"]
* </pre>
* </p>
*
* @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<String> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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<String, List<String>> parametersMap = context.serverRequest().getParametersMap();
MultiValueMap<String, String> springMap = new LinkedMultiValueMap<>();
parametersMap.forEach(springMap::put);
return springMap;
}
}
Original file line number Diff line number Diff line change
@@ -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<ServerRestHandler> 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<String, List<String>> parametersMap = requestContext.serverRequest().getParametersMap();
if (parametersMap.isEmpty()) {
ResponseImpl response = (ResponseImpl) requestContext.getResponse().get();
response.setStatus(400);
}
}
});
}
return Collections.emptyList();
}
}
Loading

0 comments on commit d09ef82

Please sign in to comment.