Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 128 additions & 9 deletions src/main/java/org/springframework/hateoas/QueryParameter.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ModelAttribute;

/**
* Representation of a web request's query parameter (https://example.com?name=foo) => {"name", "foo", true}.
Expand All @@ -37,26 +38,58 @@ public final class QueryParameter {
private final String name;
private final @Nullable String value;
private final boolean required;
private final boolean exploded; // RFC6570 explode modifier support

private QueryParameter(String name, @Nullable String value, boolean required) {
this(name, value, required, false);
}

private QueryParameter(String name, @Nullable String value, boolean required, boolean exploded) {

this.name = name;
this.value = value;
this.required = required;
this.exploded = exploded;
}

/**
* Creates a new {@link QueryParameter} from the given {@link MethodParameter}.
* Supports both {@link RequestParam} and {@link ModelAttribute} annotations.
*
* @param parameter must not be {@literal null}.
* @return will never be {@literal null}.
*/
public static QueryParameter of(MethodParameter parameter) {

MergedAnnotation<RequestParam> annotation = MergedAnnotations //
// Check for @RequestParam first (existing behavior)
MergedAnnotation<RequestParam> requestParamAnnotation = MergedAnnotations //
.from(parameter.getParameter()) //
.get(RequestParam.class);

if (requestParamAnnotation.isPresent()) {
return createFromRequestParam(parameter, requestParamAnnotation);
}

// Check for @ModelAttribute
MergedAnnotation<ModelAttribute> modelAttributeAnnotation = MergedAnnotations //
.from(parameter.getParameter()) //
.get(ModelAttribute.class);

if (modelAttributeAnnotation.isPresent()) {
return createFromModelAttribute(parameter, modelAttributeAnnotation);
}

// Check for implicit @ModelAttribute (when parameter is a complex object and no other annotations)
if (isImplicitModelAttribute(parameter)) {
return createFromImplicitModelAttribute(parameter);
}

// Fallback to original logic for backward compatibility
return createFromRequestParam(parameter, requestParamAnnotation);
}

private static QueryParameter createFromRequestParam(MethodParameter parameter, MergedAnnotation<RequestParam> annotation) {

String name = annotation.isPresent() && annotation.hasNonDefaultValue("name") //
? annotation.getString("name") //
: parameter.getParameterName();
Expand All @@ -72,11 +105,70 @@ public static QueryParameter of(MethodParameter parameter) {
return required ? required(name) : optional(name);
}

private static QueryParameter createFromModelAttribute(MethodParameter parameter, MergedAnnotation<ModelAttribute> annotation) {

String name = annotation.hasNonDefaultValue("name") //
? annotation.getString("name") //
: parameter.getParameterName();

if (name == null || !StringUtils.hasText(name)) {
throw new IllegalStateException(String.format("Couldn't determine parameter name for %s!", parameter));
}

// @ModelAttribute parameters are typically required unless they're Optional
boolean required = !Optional.class.equals(parameter.getParameterType());

// ModelAttribute represents composite values, so mark as exploded for RFC6570
return required ? requiredExploded(name) : optionalExploded(name);
}

private static QueryParameter createFromImplicitModelAttribute(MethodParameter parameter) {

String name = parameter.getParameterName();

if (name == null || !StringUtils.hasText(name)) {
throw new IllegalStateException(String.format("Couldn't determine parameter name for %s!", parameter));
}

boolean required = !Optional.class.equals(parameter.getParameterType());

// Implicit ModelAttribute also represents composite values
return required ? requiredExploded(name) : optionalExploded(name);
}

private static boolean isImplicitModelAttribute(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();

// Simple types are not implicit @ModelAttribute
if (isSimpleValueType(parameterType)) {
return false;
}

// Check if it's annotated with other Spring MVC annotations
MergedAnnotations annotations = MergedAnnotations.from(parameter.getParameter());

return !annotations.isPresent(RequestParam.class) &&
!annotations.isPresent(org.springframework.web.bind.annotation.RequestBody.class) &&
!annotations.isPresent(org.springframework.web.bind.annotation.PathVariable.class) &&
!annotations.isPresent(org.springframework.web.bind.annotation.RequestHeader.class) &&
!annotations.isPresent(org.springframework.web.bind.annotation.CookieValue.class);
}

private static boolean isSimpleValueType(Class<?> type) {
return type.isPrimitive() ||
type == String.class ||
Number.class.isAssignableFrom(type) ||
type == Boolean.class ||
type.isEnum() ||
java.util.Date.class.isAssignableFrom(type) ||
java.time.temporal.Temporal.class.isAssignableFrom(type);
}

/**
* Creates a new required {@link QueryParameter} with the given name;
*
* @param name must not be {@literal null} or empty.
* @return
* @return a new required QueryParameter instance
*/
public static QueryParameter required(String name) {

Expand All @@ -89,20 +181,43 @@ public static QueryParameter required(String name) {
* Creates a new optional {@link QueryParameter} with the given name;
*
* @param name must not be {@literal null} or empty.
* @return
* @return a new optional QueryParameter instance
*/
public static QueryParameter optional(String name) {
return new QueryParameter(name, null, false);
}

/**
* Creates a new required {@link QueryParameter} with explode modifier for composite values.
*
* @param name must not be {@literal null} or empty.
* @return a new required QueryParameter instance with explode modifier
*/
public static QueryParameter requiredExploded(String name) {

Assert.hasText(name, "Name must not be null or empty!");

return new QueryParameter(name, null, true, true);
}

/**
* Creates a new optional {@link QueryParameter} with explode modifier for composite values.
*
* @param name must not be {@literal null} or empty.
* @return a new optional QueryParameter instance with explode modifier
*/
public static QueryParameter optionalExploded(String name) {
return new QueryParameter(name, null, false, true);
}

/**
* Create a new {@link QueryParameter} by copying all attributes and applying the new {@literal value}.
*
* @param value
* @return
* @param value the new value to apply
* @return a new QueryParameter instance with the updated value
*/
public QueryParameter withValue(@Nullable String value) {
return this.value == value ? this : new QueryParameter(this.name, value, this.required);
return this.value == value ? this : new QueryParameter(this.name, value, this.required, this.exploded);
}

public String getName() {
Expand All @@ -118,6 +233,10 @@ public boolean isRequired() {
return this.required;
}

public boolean isExploded() {
return this.exploded;
}

@Override
public boolean equals(@Nullable Object o) {

Expand All @@ -128,17 +247,17 @@ public boolean equals(@Nullable Object o) {
return false;
}
QueryParameter that = (QueryParameter) o;
return this.required == that.required && Objects.equals(this.name, that.name)
return this.required == that.required && this.exploded == that.exploded && Objects.equals(this.name, that.name)
&& Objects.equals(this.value, that.value);
}

@Override
public int hashCode() {
return Objects.hash(this.name, this.value, this.required);
return Objects.hash(this.name, this.value, this.required, this.exploded);
}

@Override
public String toString() {
return "QueryParameter(name=" + this.name + ", value=" + this.value + ", required=" + this.required + ")";
return "QueryParameter(name=" + this.name + ", value=" + this.value + ", required=" + this.required + ", exploded=" + this.exploded + ")";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.ConcurrentLruCache;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
Expand All @@ -58,7 +59,7 @@ public class SpringAffordanceBuilder {
* @param type must not be {@literal null}.
* @param method must not be {@literal null}.
* @param href must not be {@literal null} or empty.
* @return
* @return list of affordances for the method
*/
public static List<Affordance> getAffordances(Class<?> type, Method method, String href) {

Expand All @@ -75,7 +76,7 @@ public static List<Affordance> getAffordances(Class<?> type, Method method, Stri
*
* @param type must not be {@literal null}.
* @param method must not be {@literal null}.
* @return
* @return the URI mapping for the method
* @since 2.0
*/
public static UriMapping getUriMapping(Class<?> type, Method method) {
Expand Down Expand Up @@ -106,9 +107,10 @@ private static Function<Affordances, List<Affordance>> create(Class<?> type, Met
.map(ResolvableType::forMethodParameter) //
.orElse(ResolvableType.NONE);

List<QueryParameter> queryMethodParameters = parameters.getParametersWith(RequestParam.class).stream() //
.filter(it -> !Map.class.isAssignableFrom(it.getParameterType()))
.map(QueryParameter::of) //
// Include both @RequestParam and @ModelAttribute parameters
List<QueryParameter> queryMethodParameters = parameters.getParameters().stream()
.filter(it -> shouldIncludeAsQueryParameter(it))
.map(QueryParameter::of)
.collect(Collectors.toList());

return affordances -> requestMethods.stream() //
Expand All @@ -123,6 +125,78 @@ private static Function<Affordances, List<Affordance>> create(Class<?> type, Met
.collect(Collectors.toList());
}

/**
* Determines if a method parameter should be included as a query parameter.
* Includes @RequestParam, @ModelAttribute (explicit and implicit), but excludes
* Map parameters and @RequestBody parameters.
*/
private static boolean shouldIncludeAsQueryParameter(org.springframework.core.MethodParameter parameter) {
// Exclude Map parameters (existing logic)
if (Map.class.isAssignableFrom(parameter.getParameterType())) {
return false;
}

// Exclude @RequestBody parameters
if (parameter.hasParameterAnnotation(RequestBody.class)) {
return false;
}

// Include @RequestParam parameters
if (parameter.hasParameterAnnotation(RequestParam.class)) {
return true;
}

// Include @ModelAttribute parameters
if (parameter.hasParameterAnnotation(ModelAttribute.class)) {
return true;
}

// Include implicit @ModelAttribute (complex objects without other annotations)
return isImplicitModelAttribute(parameter);
}

/**
* Checks if a parameter is an implicit @ModelAttribute according to Spring MVC rules.
*/
private static boolean isImplicitModelAttribute(org.springframework.core.MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();

// Simple types are not implicit @ModelAttribute
if (isSimpleValueType(parameterType)) {
return false;
}

// Check if it's annotated with other Spring MVC annotations
return !parameter.hasParameterAnnotation(RequestParam.class) &&
!parameter.hasParameterAnnotation(RequestBody.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.PathVariable.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.RequestHeader.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.CookieValue.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.RequestPart.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.SessionAttribute.class) &&
!parameter.hasParameterAnnotation(org.springframework.web.bind.annotation.RequestAttribute.class);
}

/**
* Determines if a type is a simple value type that should not be treated as @ModelAttribute.
*/
private static boolean isSimpleValueType(Class<?> type) {
return type.isPrimitive() ||
type == String.class ||
Number.class.isAssignableFrom(type) ||
type == Boolean.class ||
type.isEnum() ||
java.util.Date.class.isAssignableFrom(type) ||
java.time.temporal.Temporal.class.isAssignableFrom(type) ||
type == java.net.URI.class ||
type == java.net.URL.class ||
type == java.util.Locale.class ||
type == java.util.TimeZone.class ||
type == java.io.InputStream.class ||
type == java.io.Reader.class ||
type == org.springframework.web.multipart.MultipartFile.class;
}

private static final class AffordanceKey {

private final Class<?> type;
Expand Down
Loading