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; }