From d6d816d77b52c51abe35ee29a8680adf11dd73cc Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 28 Jul 2021 17:28:02 +0200 Subject: [PATCH] #1575 - Support for non-composite request parameters. This commit introduces @NonComposite, an annotation to be used with collection or array typed @RequestParam handler method parameters. Using the annotation causes the rendering of request parameters to use the non-composite way of rendering URI template values (param=value1,value2) rather than the default, composite flavor of param=value1¶m=value2. A bit of polish in TemplateVariable, which now also exposes a prepareAndEncode(Object) method that renders a given value according to the rules defined in the URI template spec for the particular variable type and state (composite VS. non-composite). --- src/main/asciidoc/server.adoc | 37 ++++++++++++++++++- .../springframework/hateoas/NonComposite.java | 36 ++++++++++++++++++ .../hateoas/TemplateVariable.java | 27 ++++++++------ .../springframework/hateoas/UriTemplate.java | 2 +- .../hateoas/server/core/WebHandler.java | 36 ++++++++++++++++-- .../server/mvc/WebMvcLinkBuilderUnitTest.java | 16 ++++++++ 6 files changed, 136 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/NonComposite.java diff --git a/src/main/asciidoc/server.adoc b/src/main/asciidoc/server.adoc index 2eb577f1..6e3cef1e 100644 --- a/src/main/asciidoc/server.adoc +++ b/src/main/asciidoc/server.adoc @@ -71,8 +71,8 @@ return new ResponseEntity(headers, HttpStatus.CREATED); ---- ==== -[[fundamentals.obtaining-links.builder.methods]] -=== Building links that point to methods +[[server.link-builder.webmvc.methods]] +=== [[fundamentals.obtaining-links.builder.methods]] Building links that point to methods You can even build links that point to methods or create dummy controller method invocations. The first approach is to hand a `Method` instance to the `WebMvcLinkBuilder`. @@ -105,6 +105,39 @@ assertThat(link.getHref()).endsWith("/people/2"); * The return type has to be capable of proxying, as we need to expose the method invocation on it. * The parameters handed into the methods are generally neglected (except the ones referred to through `@PathVariable`, because they make up the URI). +[[server.link-builder.webmvc.methods.request-params]] +==== Controlling the rendering of request parameters + +Collection-valued request parameters can actually be materialized in two different ways. +The URI template specification lists the composite way of rendering them that repeats the parameter name for each value (`param=value1¶m=value2`), and the non-composite one that separates values by a comma (`param=value1,value2`). +Spring MVC properly parses the collection out of both formats. +Rendering the values defaults to the composite style by default. +If you want the values to be rendered in the non-composite style, you can use the `@NonComposite` annotation with the request parameter handler method parameter: + +==== +[source, java] +---- +@Controller +class PersonController { + + @GetMapping("/people") + HttpEntity showAll( + @NonComposite @RequestParam Collection names) { … } <1> +} + +var values = List.of("Matthews", "Beauford"); +var link = linkTo(methodOn(PersonController.class).showAll(values)).withSelfRel(); <2> + +assertThat(link.getHref()).endsWith("/people?names=Matthews,Beauford"); <3> +---- +<1> We use the `@NonComposite` annotation to declare we want values to be rendered comma-separated. +<2> We invoke the method using a list of values. +<3> See how the request parameter is rendered in the expected format. +==== + +NOTE: The reason we're exposing `@NonComposite` is that the composite way of rendering request parameters is baked into the internals of Spring's `UriComponents` builder and we only introduced that non-composite style in Spring HATEOAS 1.4. +If we started from scratch today, we'd probably default to that style and rather let users opt into the composite style explicitly rather than the other way around. + [[server.link-builder.webflux]] == Building links in Spring WebFlux diff --git a/src/main/java/org/springframework/hateoas/NonComposite.java b/src/main/java/org/springframework/hateoas/NonComposite.java new file mode 100644 index 00000000..8d8bd707 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/NonComposite.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021 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 java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.*; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import org.springframework.web.bind.annotation.RequestParam; + +/** + * Annotation to be used in combination with {@link RequestParam} to indicate that collection based values are supposed + * to be rendered as non-composite values, i.e. like {@code param=value1,value2,value3} rather than + * {@code param=value1¶m=value2} when generating links by pointing to controller methods. + * + * @author Oliver Drotbohm + * @since 1.4 + */ +@Retention(RUNTIME) +@Target({ PARAMETER, ANNOTATION_TYPE }) +public @interface NonComposite {} diff --git a/src/main/java/org/springframework/hateoas/TemplateVariable.java b/src/main/java/org/springframework/hateoas/TemplateVariable.java index cdfd3181..c2751df3 100644 --- a/src/main/java/org/springframework/hateoas/TemplateVariable.java +++ b/src/main/java/org/springframework/hateoas/TemplateVariable.java @@ -325,18 +325,13 @@ public final class TemplateVariable implements Serializable, UriTemplate.Expanda return null; } - return prepareValue(value); + return handleComposite(prepareAndEncode(value)); } - @Nullable - String prepareValue(Map parameters) { - return prepareValue(parameters.get(name)); - } - @Nullable @SuppressWarnings("unchecked") - String prepareValue(@Nullable Object value) { + public String prepareAndEncode(@Nullable Object value) { if (value == null) { return null; @@ -352,20 +347,20 @@ public final class TemplateVariable implements Serializable, UriTemplate.Expanda return null; } - return handleComposite(StreamSupport.stream(source.spliterator(), false) + return StreamSupport.stream(source.spliterator(), false) .map(it -> prepareElement(it, false)) - .collect(Collectors.joining(separator))); + .collect(Collectors.joining(separator)); } else if (value instanceof Map) { String keyValueSeparator = isComposite() ? "=" : DEFAULT_SEPARATOR; - return handleComposite(((Map) value).entrySet().stream() + return ((Map) value).entrySet().stream() .map(it -> it.getKey().toString().concat(keyValueSeparator).concat(prepareElement(it.getValue(), true))) - .collect(Collectors.joining(separator))); + .collect(Collectors.joining(separator)); } else { - return handleComposite(prepareElement(value, false)); + return prepareElement(value, false); } } @@ -436,6 +431,10 @@ public final class TemplateVariable implements Serializable, UriTemplate.Expanda } } + /* + * (non-Javadoc) + * @see java.lang.Object#equals(java.lang.Object) + */ @Override public boolean equals(@Nullable Object o) { @@ -454,6 +453,10 @@ public final class TemplateVariable implements Serializable, UriTemplate.Expanda && Objects.equals(this.description, that.description); } + /* + * (non-Javadoc) + * @see java.lang.Object#hashCode() + */ @Override public int hashCode() { return Objects.hash(this.name, this.type, this.description); diff --git a/src/main/java/org/springframework/hateoas/UriTemplate.java b/src/main/java/org/springframework/hateoas/UriTemplate.java index 626a99f1..c9623dfe 100644 --- a/src/main/java/org/springframework/hateoas/UriTemplate.java +++ b/src/main/java/org/springframework/hateoas/UriTemplate.java @@ -438,7 +438,7 @@ public class UriTemplate implements Iterable, Serializable { public String expand(Map parameters) { return type.join(variables.stream() - .map(it -> it.prepareValue(parameters)) + .map(it -> it.expand(parameters)) .filter(it -> it != null) .collect(Collectors.toList())); } diff --git a/src/main/java/org/springframework/hateoas/server/core/WebHandler.java b/src/main/java/org/springframework/hateoas/server/core/WebHandler.java index 6c4b629a..f9b7e5de 100644 --- a/src/main/java/org/springframework/hateoas/server/core/WebHandler.java +++ b/src/main/java/org/springframework/hateoas/server/core/WebHandler.java @@ -41,6 +41,7 @@ import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.NonComposite; import org.springframework.hateoas.TemplateVariable; import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.server.LinkBuilder; @@ -224,9 +225,16 @@ public class WebHandler { if (value instanceof Collection) { - for (Object element : (Collection) value) { - if (key != null) { - builder.queryParam(key, encodeParameter(element)); + if (parameter.isNonComposite()) { + + TemplateVariable variable = TemplateVariable.requestParameter(key); + builder.queryParam(key, variable.prepareAndEncode(value)); + + } else { + for (Object element : (Collection) value) { + if (key != null) { + builder.queryParam(key, encodeParameter(element)); + } } } } else if (SKIP_VALUE.equals(value)) { @@ -310,6 +318,7 @@ public class WebHandler { private final MethodParameter parameter; private final AnnotationAttribute attribute; private final TypeDescriptor typeDescriptor; + private final boolean isNonComposite; private String variableName; @@ -328,6 +337,18 @@ public class WebHandler { int nestingIndex = Optional.class.isAssignableFrom(parameter.getParameterType()) ? 1 : 0; this.typeDescriptor = TypeDescriptor.nested(parameter, nestingIndex); + this.isNonComposite = parameter.hasParameterAnnotation(NonComposite.class); + + if (isNonComposite) { + + Assert.isTrue(parameter.hasParameterAnnotation(RequestParam.class), + "@NonComposite can only be used in combination with @RequestParam!"); + + Class parameterType = parameter.getParameterType(); + + Assert.isTrue(parameterType.isArray() || Collection.class.isAssignableFrom(parameterType), + "@NonComposite can only be used with collections or arrays!"); + } } /** @@ -352,6 +373,15 @@ public class WebHandler { return attribute.getAnnotationType(); } + /** + * Returns whether the + * + * @return + */ + boolean isNonComposite() { + return isNonComposite; + } + public String getVariableName() { if (variableName == null) { diff --git a/src/test/java/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderUnitTest.java index e998883c..831b16ff 100644 --- a/src/test/java/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/server/mvc/WebMvcLinkBuilderUnitTest.java @@ -20,6 +20,7 @@ import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; import java.lang.reflect.Method; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,6 +30,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; +import org.springframework.hateoas.NonComposite; import org.springframework.hateoas.TemplateVariable; import org.springframework.hateoas.TemplateVariable.VariableType; import org.springframework.hateoas.TestUtils; @@ -627,6 +629,15 @@ class WebMvcLinkBuilderUnitTest extends TestUtils { assertThat(linkTo(method, new Object[] { null }).withSelfRel().getHref()).endsWith("?id={id}"); } + @Test // #1575 + void buildsNonCompositeRequestParamUri() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).nonCompositeRequestParam(Arrays.asList("first", "second"))) + .withSelfRel(); + + assertThat(link.getHref()).endsWith("?foo=first,second"); + } + private static UriComponents toComponents(Link link) { return UriComponentsBuilder.fromUriString(link.expand().getHref()).build(); } @@ -717,6 +728,11 @@ class WebMvcLinkBuilderUnitTest extends TestUtils { HttpEntity methodWithMapRequestParam(@RequestParam Map params) { return null; } + + @RequestMapping("/non-composite") + HttpEntity nonCompositeRequestParam(@NonComposite @RequestParam("foo") Collection params) { + return null; + } } @RequestMapping("/parent")