From f588ccabf8a42597a8d3f89322fc0be2bd4eed26 Mon Sep 17 00:00:00 2001 From: Kim Tae Eun Date: Tue, 9 Sep 2025 00:03:40 +0900 Subject: [PATCH] #1240 - Add @ModelAttribute support to QueryParameter - Extended QueryParameter.of() to handle @ModelAttribute annotations - Added support for explicit and implicit @ModelAttribute parameters - Implemented RFC6570 explode modifier for composite values - Updated SpringAffordanceBuilder to include @ModelAttribute parameters - Added comprehensive tests for new functionality Supports both simple form style and composite value representations: - Simple: {?field1,field2,field3} - Composite with explode: {?myClass*} Signed-off-by: Kim Tae Eun --- .../hateoas/QueryParameter.java | 137 ++++++++++++- .../server/core/SpringAffordanceBuilder.java | 84 +++++++- .../QueryParameterModelAttributeTest.java | 184 +++++++++++++++++ ...ngAffordanceBuilderModelAttributeTest.java | 185 ++++++++++++++++++ 4 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 src/test/java/org/springframework/hateoas/QueryParameterModelAttributeTest.java create mode 100644 src/test/java/org/springframework/hateoas/server/core/SpringAffordanceBuilderModelAttributeTest.java diff --git a/src/main/java/org/springframework/hateoas/QueryParameter.java b/src/main/java/org/springframework/hateoas/QueryParameter.java index bf8f07e72..4de051064 100644 --- a/src/main/java/org/springframework/hateoas/QueryParameter.java +++ b/src/main/java/org/springframework/hateoas/QueryParameter.java @@ -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}. @@ -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 annotation = MergedAnnotations // + // Check for @RequestParam first (existing behavior) + MergedAnnotation requestParamAnnotation = MergedAnnotations // .from(parameter.getParameter()) // .get(RequestParam.class); + if (requestParamAnnotation.isPresent()) { + return createFromRequestParam(parameter, requestParamAnnotation); + } + + // Check for @ModelAttribute + MergedAnnotation 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 annotation) { + String name = annotation.isPresent() && annotation.hasNonDefaultValue("name") // ? annotation.getString("name") // : parameter.getParameterName(); @@ -72,11 +105,70 @@ public static QueryParameter of(MethodParameter parameter) { return required ? required(name) : optional(name); } + private static QueryParameter createFromModelAttribute(MethodParameter parameter, MergedAnnotation 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) { @@ -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() { @@ -118,6 +233,10 @@ public boolean isRequired() { return this.required; } + public boolean isExploded() { + return this.exploded; + } + @Override public boolean equals(@Nullable Object o) { @@ -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 + ")"; } } diff --git a/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java b/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java index 2e37d4727..ae59b7474 100644 --- a/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java +++ b/src/main/java/org/springframework/hateoas/server/core/SpringAffordanceBuilder.java @@ -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; @@ -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 getAffordances(Class type, Method method, String href) { @@ -75,7 +76,7 @@ public static List 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) { @@ -106,9 +107,10 @@ private static Function> create(Class type, Met .map(ResolvableType::forMethodParameter) // .orElse(ResolvableType.NONE); - List queryMethodParameters = parameters.getParametersWith(RequestParam.class).stream() // - .filter(it -> !Map.class.isAssignableFrom(it.getParameterType())) - .map(QueryParameter::of) // + // Include both @RequestParam and @ModelAttribute parameters + List queryMethodParameters = parameters.getParameters().stream() + .filter(it -> shouldIncludeAsQueryParameter(it)) + .map(QueryParameter::of) .collect(Collectors.toList()); return affordances -> requestMethods.stream() // @@ -123,6 +125,78 @@ private static Function> 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; diff --git a/src/test/java/org/springframework/hateoas/QueryParameterModelAttributeTest.java b/src/test/java/org/springframework/hateoas/QueryParameterModelAttributeTest.java new file mode 100644 index 000000000..2d8e2e9c2 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/QueryParameterModelAttributeTest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2018-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.SoftAssertions.*; + +import java.lang.reflect.Method; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Unit tests for {@link QueryParameter} with {@link ModelAttribute} support. + * + * @author Oliver Drotbohm + */ +class QueryParameterModelAttributeTest { + + @Test + void createsQueryParameterFromExplicitModelAttribute() throws Exception { + + Method method = SampleController.class.getMethod("methodWithExplicitModelAttribute", SearchCriteria.class); + MethodParameter parameter = new MethodParameter(method, 0); + + QueryParameter queryParameter = QueryParameter.of(parameter); + + assertSoftly(softly -> { + softly.assertThat(queryParameter.getName()).isEqualTo("criteria"); + softly.assertThat(queryParameter.isRequired()).isTrue(); + softly.assertThat(queryParameter.isExploded()).isTrue(); + }); + } + + @Test + void createsQueryParameterFromImplicitModelAttribute() throws Exception { + + Method method = SampleController.class.getMethod("methodWithImplicitModelAttribute", SearchCriteria.class); + MethodParameter parameter = new MethodParameter(method, 0); + + QueryParameter queryParameter = QueryParameter.of(parameter); + + assertSoftly(softly -> { + softly.assertThat(queryParameter.getName()).isEqualTo("criteria"); + softly.assertThat(queryParameter.isRequired()).isTrue(); + softly.assertThat(queryParameter.isExploded()).isTrue(); + }); + } + + @Test + void createsOptionalQueryParameterFromOptionalModelAttribute() throws Exception { + + Method method = SampleController.class.getMethod("methodWithOptionalModelAttribute", Optional.class); + MethodParameter parameter = new MethodParameter(method, 0); + + QueryParameter queryParameter = QueryParameter.of(parameter); + + assertSoftly(softly -> { + softly.assertThat(queryParameter.getName()).isEqualTo("criteria"); + softly.assertThat(queryParameter.isRequired()).isFalse(); + softly.assertThat(queryParameter.isExploded()).isTrue(); + }); + } + + @Test + void doesNotCreateQueryParameterFromSimpleType() throws Exception { + + Method method = SampleController.class.getMethod("methodWithSimpleType", String.class); + MethodParameter parameter = new MethodParameter(method, 0); + + QueryParameter queryParameter = QueryParameter.of(parameter); + + assertSoftly(softly -> { + softly.assertThat(queryParameter.getName()).isEqualTo("name"); + softly.assertThat(queryParameter.isRequired()).isTrue(); + softly.assertThat(queryParameter.isExploded()).isFalse(); // Simple types should not be exploded + }); + } + + @Test + void respectsModelAttributeNameOverride() throws Exception { + + Method method = SampleController.class.getMethod("methodWithNamedModelAttribute", SearchCriteria.class); + MethodParameter parameter = new MethodParameter(method, 0); + + QueryParameter queryParameter = QueryParameter.of(parameter); + + assertSoftly(softly -> { + softly.assertThat(queryParameter.getName()).isEqualTo("search"); + softly.assertThat(queryParameter.isRequired()).isTrue(); + softly.assertThat(queryParameter.isExploded()).isTrue(); + }); + } + + @Test + void createsExplodedQueryParameter() { + + QueryParameter exploded = QueryParameter.requiredExploded("test"); + + assertSoftly(softly -> { + softly.assertThat(exploded.getName()).isEqualTo("test"); + softly.assertThat(exploded.isRequired()).isTrue(); + softly.assertThat(exploded.isExploded()).isTrue(); + }); + } + + @Test + void createsOptionalExplodedQueryParameter() { + + QueryParameter exploded = QueryParameter.optionalExploded("test"); + + assertSoftly(softly -> { + softly.assertThat(exploded.getName()).isEqualTo("test"); + softly.assertThat(exploded.isRequired()).isFalse(); + softly.assertThat(exploded.isExploded()).isTrue(); + }); + } + + @Test + void preservesExplodedStateInWithValue() { + + QueryParameter original = QueryParameter.requiredExploded("test"); + QueryParameter withValue = original.withValue("value"); + + assertSoftly(softly -> { + softly.assertThat(withValue.getName()).isEqualTo("test"); + softly.assertThat(withValue.getValue()).isEqualTo("value"); + softly.assertThat(withValue.isRequired()).isTrue(); + softly.assertThat(withValue.isExploded()).isTrue(); + }); + } + + // Test controller for method parameter examples + static class SampleController { + + public void methodWithExplicitModelAttribute(@ModelAttribute("criteria") SearchCriteria criteria) {} + + public void methodWithImplicitModelAttribute(@ModelAttribute("criteria") SearchCriteria criteria) {} + + public void methodWithOptionalModelAttribute(@ModelAttribute("criteria") Optional criteria) {} + + public void methodWithSimpleType(@RequestParam("name") String name) {} + + public void methodWithNamedModelAttribute(@ModelAttribute("search") SearchCriteria criteria) {} + + public void methodWithRequestParam(@RequestParam("param") String param) {} + } + + // Sample model class for testing + static class SearchCriteria { + private String query; + private String category; + private Integer page; + private Integer size; + + public String getQuery() { return query; } + public void setQuery(String query) { this.query = query; } + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public Integer getPage() { return page; } + public void setPage(Integer page) { this.page = page; } + + public Integer getSize() { return size; } + public void setSize(Integer size) { this.size = size; } + } +} diff --git a/src/test/java/org/springframework/hateoas/server/core/SpringAffordanceBuilderModelAttributeTest.java b/src/test/java/org/springframework/hateoas/server/core/SpringAffordanceBuilderModelAttributeTest.java new file mode 100644 index 000000000..445e0d483 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/server/core/SpringAffordanceBuilderModelAttributeTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2018-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.hateoas.server.core; + +import static org.assertj.core.api.Assertions.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.AffordanceModel; +import org.springframework.hateoas.QueryParameter; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Integration tests for {@link SpringAffordanceBuilder} with {@link ModelAttribute} support. + * + * @author Oliver Drotbohm + */ +class SpringAffordanceBuilderModelAttributeTest { + + @Test + void includesExplicitModelAttributeParametersInAffordances() throws Exception { + + Method method = TestController.class.getMethod("searchWithExplicitModelAttribute", SearchForm.class); + List affordances = SpringAffordanceBuilder.getAffordances(TestController.class, method, "/search"); + + assertThat(affordances).hasSize(1); + + AffordanceModel model = affordances.get(0).getAffordanceModel(MediaType.APPLICATION_JSON); + List queryParameters = model.getQueryMethodParameters(); + + assertThat(queryParameters).hasSize(1); + QueryParameter parameter = queryParameters.get(0); + + assertThat(parameter.getName()).isEqualTo("searchForm"); + assertThat(parameter.isRequired()).isTrue(); + assertThat(parameter.isExploded()).isTrue(); + } + + @Test + void includesImplicitModelAttributeParametersInAffordances() throws Exception { + + Method method = TestController.class.getMethod("searchWithImplicitModelAttribute", SearchForm.class); + List affordances = SpringAffordanceBuilder.getAffordances(TestController.class, method, "/search"); + + assertThat(affordances).hasSize(1); + + AffordanceModel model = affordances.get(0).getAffordanceModel(MediaType.APPLICATION_JSON); + List queryParameters = model.getQueryMethodParameters(); + + assertThat(queryParameters).hasSize(1); + QueryParameter parameter = queryParameters.get(0); + + assertThat(parameter.getName()).isEqualTo("searchForm"); + assertThat(parameter.isRequired()).isTrue(); + assertThat(parameter.isExploded()).isTrue(); + } + + @Test + void includesBothRequestParamAndModelAttributeParameters() throws Exception { + + Method method = TestController.class.getMethod("searchWithMixedParameters", String.class, SearchForm.class, Integer.class); + List affordances = SpringAffordanceBuilder.getAffordances(TestController.class, method, "/search"); + + assertThat(affordances).hasSize(1); + + AffordanceModel model = affordances.get(0).getAffordanceModel(MediaType.APPLICATION_JSON); + List queryParameters = model.getQueryMethodParameters(); + + assertThat(queryParameters).hasSize(3); + + // Find parameters by name + QueryParameter queryParam = queryParameters.stream() + .filter(p -> "q".equals(p.getName())) + .findFirst() + .orElseThrow(); + + QueryParameter modelAttrParam = queryParameters.stream() + .filter(p -> "filters".equals(p.getName())) + .findFirst() + .orElseThrow(); + + QueryParameter implicitParam = queryParameters.stream() + .filter(p -> "page".equals(p.getName())) + .findFirst() + .orElseThrow(); + + // Verify @RequestParam behavior + assertThat(queryParam.getName()).isEqualTo("q"); + assertThat(queryParam.isRequired()).isTrue(); + assertThat(queryParam.isExploded()).isFalse(); + + // Verify @ModelAttribute behavior + assertThat(modelAttrParam.getName()).isEqualTo("filters"); + assertThat(modelAttrParam.isRequired()).isTrue(); + assertThat(modelAttrParam.isExploded()).isTrue(); + + // Verify simple type (implicit @RequestParam, not @ModelAttribute) + assertThat(implicitParam.getName()).isEqualTo("page"); + assertThat(implicitParam.isRequired()).isTrue(); + assertThat(implicitParam.isExploded()).isFalse(); + } + + @Test + void handlesOptionalModelAttributeParameters() throws Exception { + + Method method = TestController.class.getMethod("searchWithOptionalModelAttribute", Optional.class); + List affordances = SpringAffordanceBuilder.getAffordances(TestController.class, method, "/search"); + + assertThat(affordances).hasSize(1); + + AffordanceModel model = affordances.get(0).getAffordanceModel(MediaType.APPLICATION_JSON); + List queryParameters = model.getQueryMethodParameters(); + + assertThat(queryParameters).hasSize(1); + QueryParameter parameter = queryParameters.get(0); + + assertThat(parameter.getName()).isEqualTo("searchForm"); + assertThat(parameter.isRequired()).isFalse(); + assertThat(parameter.isExploded()).isTrue(); + } + + // Test controller for integration testing + @RestController + static class TestController { + + @GetMapping("/search") + public String searchWithExplicitModelAttribute(@ModelAttribute("searchForm") SearchForm searchForm) { + return "result"; + } + + @GetMapping("/search") + public String searchWithImplicitModelAttribute(SearchForm searchForm) { + return "result"; + } + + @GetMapping("/search") + public String searchWithMixedParameters(@RequestParam("q") String query, + @ModelAttribute("filters") SearchForm filters, + Integer page) { + return "result"; + } + + @GetMapping("/search") + public String searchWithOptionalModelAttribute(@ModelAttribute("searchForm") Optional searchForm) { + return "result"; + } + } + + // Sample form class for testing + static class SearchForm { + private String category; + private String sortBy; + private Boolean includeArchived; + + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + + public String getSortBy() { return sortBy; } + public void setSortBy(String sortBy) { this.sortBy = sortBy; } + + public Boolean getIncludeArchived() { return includeArchived; } + public void setIncludeArchived(Boolean includeArchived) { this.includeArchived = includeArchived; } + } +}