From 2daf8aabfb78b6767bf27ac3e473832c872302c7 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Wed, 7 Dec 2016 12:04:30 +0100 Subject: [PATCH] #169 - Added support for creating URI templates by pointing to controller methods. If not all parameters are handed to a fake controller method invocation, the resulting link is now becoming a URI template. That means there are some tiny changes to the general behavior: 1. Missing required parts (like a path segment) are not rejected immediately but only if the resulting link is expanded itself. 2. Missing request parameters now appear in the resulting link, either in mandatory form (e.g. ?foo={foo}) or optionally (e.g. /{?foo}). To resolve these, the Link has to be expanded in turn. --- .../hateoas/core/EncodingUtils.java | 87 ++++++++++++++++ .../hateoas/core/LinkBuilderSupport.java | 19 ++-- .../hateoas/jaxrs/JaxRsLinkBuilder.java | 41 ++++++-- .../AnnotatedParametersParameterAccessor.java | 61 ++++++------ .../hateoas/mvc/ControllerLinkBuilder.java | 79 +++++++++++++-- .../mvc/ControllerLinkBuilderFactory.java | 98 +++++++++++++++++-- .../mvc/ControllerLinkBuilderUnitTest.java | 79 +++++++++++++-- 7 files changed, 394 insertions(+), 70 deletions(-) create mode 100644 src/main/java/org/springframework/hateoas/core/EncodingUtils.java diff --git a/src/main/java/org/springframework/hateoas/core/EncodingUtils.java b/src/main/java/org/springframework/hateoas/core/EncodingUtils.java new file mode 100644 index 00000000..953f584d --- /dev/null +++ b/src/main/java/org/springframework/hateoas/core/EncodingUtils.java @@ -0,0 +1,87 @@ +/* + * Copyright 2016 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 + * + * http://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.core; + +import lombok.experimental.UtilityClass; + +import java.io.UnsupportedEncodingException; + +import org.springframework.util.Assert; +import org.springframework.web.util.UriUtils; + +/** + * Utilities for URI encoding. + * + * @author Oliver Gierke + * @since 0.22 + * @soundtrack Don Philippe - Between Now And Now (Between Now And Now) + */ +@UtilityClass +public class EncodingUtils { + + private static final String ENCODING = "UTF-8"; + + /** + * Encodes the given path value. + * + * @param source must not be {@literal null}. + * @return + */ + public static String encodePath(Object source) { + + Assert.notNull(source, "Path value must not be null!"); + + try { + return UriUtils.encodePath(source.toString(), ENCODING); + } catch (UnsupportedEncodingException o_O) { + throw new IllegalStateException(o_O); + } + } + + /** + * Encodes the given request parameter value. + * + * @param source must not be {@literal null}. + * @return + */ + public static String encodeParameter(Object source) { + + Assert.notNull(source, "Request parameter value must not be null!"); + + try { + return UriUtils.encodeQueryParam(source.toString(), ENCODING); + } catch (UnsupportedEncodingException o_O) { + throw new IllegalStateException(o_O); + } + } + + /** + * Encodes the given fragment value. + * + * @param source must not be {@literal null}. + * @return + */ + public static String encodeFragment(Object source) { + + Assert.notNull(source, "Fragment value must not be null!"); + + try { + return UriUtils.encodeFragment(source.toString(), ENCODING); + } catch (UnsupportedEncodingException o_O) { + throw new IllegalStateException(o_O); + } + } +} diff --git a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java index e79251bc..a2d1ac06 100644 --- a/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java +++ b/src/main/java/org/springframework/hateoas/core/LinkBuilderSupport.java @@ -15,6 +15,7 @@ */ package org.springframework.hateoas.core; +import static org.springframework.hateoas.core.EncodingUtils.*; import static org.springframework.web.util.UriComponentsBuilder.*; import java.net.URI; @@ -85,18 +86,22 @@ public abstract class LinkBuilderSupport implements LinkB return getThis(); } + return slash(UriComponentsBuilder.fromUriString(path).build(), false); + } + + protected T slash(UriComponents components, boolean encoded) { + String uriString = uriComponents.toUriString(); UriComponentsBuilder builder = uriString.isEmpty() ? fromUri(uriComponents.toUri()) : fromUriString(uriString); - UriComponents components = UriComponentsBuilder.fromUriString(path).build(); - for (String pathSegment : components.getPathSegments()) { - builder.pathSegment(pathSegment); + builder.pathSegment(encoded ? pathSegment : encodePath(pathSegment)); } String fragment = components.getFragment(); + if (StringUtils.hasText(fragment)) { - builder.fragment(fragment); + builder.fragment(encoded ? fragment : encodeFragment(fragment)); } return createNewInstance(builder.query(components.getQuery())); @@ -120,7 +125,7 @@ public abstract class LinkBuilderSupport implements LinkB * @see org.springframework.hateoas.LinkBuilder#toUri() */ public URI toUri() { - return uriComponents.encode().toUri(); + return uriComponents.encode().toUri().normalize(); } /* @@ -128,7 +133,7 @@ public abstract class LinkBuilderSupport implements LinkB * @see org.springframework.hateoas.LinkBuilder#withRel(java.lang.String) */ public Link withRel(String rel) { - return new Link(this.toString(), rel); + return new Link(toString(), rel); } /* @@ -145,7 +150,7 @@ public abstract class LinkBuilderSupport implements LinkB */ @Override public String toString() { - return toUri().normalize().toASCIIString(); + return uriComponents.toUriString(); } /** diff --git a/src/main/java/org/springframework/hateoas/jaxrs/JaxRsLinkBuilder.java b/src/main/java/org/springframework/hateoas/jaxrs/JaxRsLinkBuilder.java index ed11de18..5cb94757 100644 --- a/src/main/java/org/springframework/hateoas/jaxrs/JaxRsLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/jaxrs/JaxRsLinkBuilder.java @@ -25,6 +25,7 @@ import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.hateoas.core.MappingDiscoverer; import org.springframework.util.Assert; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.DefaultUriTemplateHandler; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -38,6 +39,7 @@ import org.springframework.web.util.UriComponentsBuilder; public class JaxRsLinkBuilder extends LinkBuilderSupport { private static final MappingDiscoverer DISCOVERER = new AnnotationMappingDiscoverer(Path.class); + private static final CustomUriTemplateHandler HANDLER = new CustomUriTemplateHandler(); /** * Creates a new {@link JaxRsLinkBuilder} from the given {@link UriComponentsBuilder}. @@ -72,10 +74,11 @@ public class JaxRsLinkBuilder extends LinkBuilderSupport { Assert.notNull(resourceType, "Controller type must not be null!"); Assert.notNull(parameters, "Parameters must not be null!"); - UriComponents uriComponents = UriComponentsBuilder.fromUriString(DISCOVERER.getMapping(resourceType)).build(); - UriComponents expandedComponents = uriComponents.expand(parameters); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(DISCOVERER.getMapping(resourceType)); + UriComponents expandedComponents = HANDLER.expandAndEncode(builder, parameters); - return new JaxRsLinkBuilder(ServletUriComponentsBuilder.fromCurrentServletMapping()).slash(expandedComponents); + return new JaxRsLinkBuilder(ServletUriComponentsBuilder.fromCurrentServletMapping())// + .slash(expandedComponents, true); } /** @@ -92,10 +95,11 @@ public class JaxRsLinkBuilder extends LinkBuilderSupport { Assert.notNull(resourceType, "Controller type must not be null!"); Assert.notNull(parameters, "Parameters must not be null!"); - UriComponents uriComponents = UriComponentsBuilder.fromUriString(DISCOVERER.getMapping(resourceType)).build(); - UriComponents expandedComponents = uriComponents.expand(parameters); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(DISCOVERER.getMapping(resourceType)); + UriComponents expandedComponents = HANDLER.expandAndEncode(builder, parameters); - return new JaxRsLinkBuilder(ServletUriComponentsBuilder.fromCurrentServletMapping()).slash(expandedComponents); + return new JaxRsLinkBuilder(ServletUriComponentsBuilder.fromCurrentServletMapping())// + .slash(expandedComponents, true); } /* @@ -115,4 +119,29 @@ public class JaxRsLinkBuilder extends LinkBuilderSupport { protected JaxRsLinkBuilder createNewInstance(UriComponentsBuilder builder) { return new JaxRsLinkBuilder(builder); } + + private static class CustomUriTemplateHandler extends DefaultUriTemplateHandler { + + public CustomUriTemplateHandler() { + setStrictEncoding(true); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.util.DefaultUriTemplateHandler#expandAndEncode(org.springframework.web.util.UriComponentsBuilder, java.util.Map) + */ + @Override + public UriComponents expandAndEncode(UriComponentsBuilder builder, Map uriVariables) { + return super.expandAndEncode(builder, uriVariables); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.util.DefaultUriTemplateHandler#expandAndEncode(org.springframework.web.util.UriComponentsBuilder, java.lang.Object[]) + */ + @Override + public UriComponents expandAndEncode(UriComponentsBuilder builder, Object[] uriVariables) { + return super.expandAndEncode(builder, uriVariables); + } + } } diff --git a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java b/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java index 5284870d..cf4428d9 100644 --- a/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java +++ b/src/main/java/org/springframework/hateoas/mvc/AnnotatedParametersParameterAccessor.java @@ -15,6 +15,9 @@ */ package org.springframework.hateoas.mvc; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.util.ArrayList; @@ -39,24 +42,13 @@ import org.springframework.web.util.UriTemplate; * * @author Oliver Gierke */ +@RequiredArgsConstructor class AnnotatedParametersParameterAccessor { private static final Map METHOD_PARAMETERS_CACHE = new ConcurrentReferenceHashMap( 16, ReferenceType.WEAK); - private final AnnotationAttribute attribute; - - /** - * Creates a new {@link AnnotatedParametersParameterAccessor} using the given {@link AnnotationAttribute}. - * - * @param attribute must not be {@literal null}. - */ - public AnnotatedParametersParameterAccessor(AnnotationAttribute attribute) { - - Assert.notNull(attribute); - - this.attribute = attribute; - } + private final @NonNull AnnotationAttribute attribute; /** * Returns {@link BoundMethodParameter}s contained in the given {@link MethodInvocation}. @@ -78,13 +70,27 @@ class AnnotatedParametersParameterAccessor { Object verifiedValue = verifyParameterValue(parameter, value); if (verifiedValue != null) { - result.add(new BoundMethodParameter(parameter, value, attribute)); + result.add(createParameter(parameter, verifiedValue, attribute)); } } return result; } + /** + * Create the {@link BoundMethodParameter} for the given {@link MethodParameter}, parameter value and + * {@link AnnotationAttribute}. + * + * @param parameter must not be {@literal null}. + * @param value can be {@literal null}. + * @param attribute must not be {@literal null}. + * @return + */ + protected BoundMethodParameter createParameter(MethodParameter parameter, Object value, + AnnotationAttribute attribute) { + return new BoundMethodParameter(parameter, value, attribute); + } + /** * Callback to verifiy the parameter values given for a dummy invocation. Default implementation rejects * {@literal null} values as they indicate an invalid dummy call. @@ -94,17 +100,6 @@ class AnnotatedParametersParameterAccessor { * @return the verified value. */ protected Object verifyParameterValue(MethodParameter parameter, Object value) { - - if (value == null) { - - Object indexOrName = StringUtils.hasText(parameter.getParameterName()) ? parameter.getParameterName() - : parameter.getParameterIndex(); - - throw new IllegalArgumentException( - String.format("Required controller parameter %s of method %s found but null value given!", indexOrName, - parameter.getMethod())); - } - return value; } @@ -174,6 +169,7 @@ class AnnotatedParametersParameterAccessor { Annotation annotation = parameter.getParameterAnnotation(attribute.getAnnotationType()); String annotationAttributeValue = attribute.getValueFrom(annotation); + return StringUtils.hasText(annotationAttributeValue) ? annotationAttributeValue : parameter.getParameterName(); } @@ -192,12 +188,17 @@ class AnnotatedParametersParameterAccessor { * @return */ public String asString() { + return value == null ? null + : (String) CONVERSION_SERVICE.convert(value, parameterTypeDecsriptor, STRING_DESCRIPTOR); + } - if (value == null) { - return null; - } - - return (String) CONVERSION_SERVICE.convert(value, parameterTypeDecsriptor, STRING_DESCRIPTOR); + /** + * Returns whether the given parameter is a required one. Defaults to {@literal true}. + * + * @return + */ + public boolean isRequired() { + return true; } } } diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java index a71d057a..45de6698 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java @@ -17,6 +17,9 @@ package org.springframework.hateoas.mvc; import static org.springframework.util.StringUtils.*; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Delegate; + import java.lang.reflect.Method; import java.net.URI; import java.util.Map; @@ -24,16 +27,19 @@ import java.util.Map; import javax.servlet.http.HttpServletRequest; import org.springframework.hateoas.Link; +import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.core.AnnotationMappingDiscoverer; import org.springframework.hateoas.core.DummyInvocationUtils; import org.springframework.hateoas.core.LinkBuilderSupport; import org.springframework.hateoas.core.MappingDiscoverer; import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.DefaultUriTemplateHandler; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriTemplate; @@ -52,6 +58,9 @@ public class ControllerLinkBuilder extends LinkBuilderSupport uriVariables) { + return super.expandAndEncode(builder, uriVariables); + } + + /* + * (non-Javadoc) + * @see org.springframework.web.util.DefaultUriTemplateHandler#expandAndEncode(org.springframework.web.util.UriComponentsBuilder, java.lang.Object[]) + */ + @Override + public UriComponents expandAndEncode(UriComponentsBuilder builder, Object[] uriVariables) { + return super.expandAndEncode(builder, uriVariables); + } + } } diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java index a2e5ce85..48463498 100644 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java @@ -15,6 +15,11 @@ */ package org.springframework.hateoas.mvc; +import static org.springframework.hateoas.TemplateVariable.VariableType.*; +import static org.springframework.hateoas.TemplateVariables.*; +import static org.springframework.hateoas.core.EncodingUtils.*; +import static org.springframework.web.util.UriComponents.UriTemplateVariables.*; + import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; @@ -28,6 +33,8 @@ import java.util.Map; import org.springframework.core.MethodParameter; import org.springframework.hateoas.Link; import org.springframework.hateoas.MethodLinkBuilderFactory; +import org.springframework.hateoas.TemplateVariable; +import org.springframework.hateoas.TemplateVariables; import org.springframework.hateoas.core.AnnotationAttribute; import org.springframework.hateoas.core.AnnotationMappingDiscoverer; import org.springframework.hateoas.core.DummyInvocationUtils.LastInvocationAware; @@ -133,22 +140,50 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory values = new HashMap(); - Iterator names = template.getVariableNames().iterator(); + while (classMappingParameters.hasNext()) { - values.put(names.next(), classMappingParameters.next()); + values.put(names.next(), encodePath(classMappingParameters.next())); } for (BoundMethodParameter parameter : PATH_VARIABLE_ACCESSOR.getBoundParameters(invocation)) { - values.put(parameter.getVariableName(), parameter.asString()); + values.put(parameter.getVariableName(), encodePath(parameter.asString())); } + List optionalEmptyParameters = new ArrayList(); + for (BoundMethodParameter parameter : REQUEST_PARAM_ACCESSOR.getBoundParameters(invocation)) { + bindRequestParameters(builder, parameter); + + if (SKIP_VALUE.equals(parameter.getValue())) { + + values.put(parameter.getVariableName(), SKIP_VALUE); + + if (!parameter.isRequired()) { + optionalEmptyParameters.add(parameter.getVariableName()); + } + } + } + + for (String variable : template.getVariableNames()) { + if (!values.containsKey(variable)) { + values.put(variable, SKIP_VALUE); + } } UriComponents components = applyUriComponentsContributer(builder, invocation).buildAndExpand(values); - return new ControllerLinkBuilder(components); + TemplateVariables variables = NONE; + + for (String parameter : optionalEmptyParameters) { + + boolean previousRequestParameter = components.getQueryParams().isEmpty() && variables.equals(NONE); + TemplateVariable variable = new TemplateVariable(parameter, + previousRequestParameter ? REQUEST_PARAM : REQUEST_PARAM_CONTINUED); + variables = variables.concat(variable); + } + + return new ControllerLinkBuilder(components, variables); } /* @@ -204,7 +239,7 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory> multiValueEntry : requestParams.entrySet()) { for (String singleEntryValue : multiValueEntry.getValue()) { - builder.queryParam(multiValueEntry.getKey(), singleEntryValue); + builder.queryParam(multiValueEntry.getKey(), encodeParameter(singleEntryValue)); } } @@ -213,17 +248,23 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory requestParams = (Map) value; for (Map.Entry requestParamEntry : requestParams.entrySet()) { - builder.queryParam(requestParamEntry.getKey(), requestParamEntry.getValue()); + builder.queryParam(requestParamEntry.getKey(), encodeParameter(requestParamEntry.getValue())); } } else if (value instanceof Collection) { for (Object element : (Collection) value) { - builder.queryParam(key, element); + builder.queryParam(key, encodeParameter(element)); + } + + } else if (SKIP_VALUE.equals(value)) { + + if (parameter.isRequired()) { + builder.queryParam(key, String.format("{%s}", parameter.getVariableName())); } } else { - builder.queryParam(key, parameter.asString()); + builder.queryParam(key, encodeParameter(parameter.asString())); } } @@ -239,6 +280,35 @@ public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory { @@ -529,7 +588,7 @@ public class ControllerLinkBuilderUnitTest extends TestUtils { } @RequestMapping(value = "/{id}/foo") - HttpEntity methodForNextPage(@PathVariable String id, @RequestParam Integer offset, + HttpEntity methodForNextPage(@PathVariable String id, @RequestParam(required = false) Integer offset, @RequestParam Integer limit) { return null; }