#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&param=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).
This commit is contained in:
Oliver Drotbohm
2021-07-28 17:28:02 +02:00
parent ce71b144a1
commit d6d816d77b
6 changed files with 136 additions and 18 deletions

View File

@@ -71,8 +71,8 @@ return new ResponseEntity<PersonModel>(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&param=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<PersonModel> showAll(
@NonComposite @RequestParam Collection<String> 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

View File

@@ -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&param=value2} when generating links by pointing to controller methods.
*
* @author Oliver Drotbohm
* @since 1.4
*/
@Retention(RUNTIME)
@Target({ PARAMETER, ANNOTATION_TYPE })
public @interface NonComposite {}

View File

@@ -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<String, ?> 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<Object, Object>) value).entrySet().stream()
return ((Map<Object, Object>) 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);

View File

@@ -438,7 +438,7 @@ public class UriTemplate implements Iterable<TemplateVariable>, Serializable {
public String expand(Map<String, ?> parameters) {
return type.join(variables.stream()
.map(it -> it.prepareValue(parameters))
.map(it -> it.expand(parameters))
.filter(it -> it != null)
.collect(Collectors.toList()));
}

View File

@@ -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) {

View File

@@ -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<Void> methodWithMapRequestParam(@RequestParam Map<String, String> params) {
return null;
}
@RequestMapping("/non-composite")
HttpEntity<Void> nonCompositeRequestParam(@NonComposite @RequestParam("foo") Collection<String> params) {
return null;
}
}
@RequestMapping("/parent")