From 1d9703abbed867b783efbff954343bd53bac3f76 Mon Sep 17 00:00:00 2001 From: Oliver Drotbohm Date: Wed, 20 Feb 2019 16:45:41 +0100 Subject: [PATCH] #728 - API polishing. Renamed LinkBuilder implementations for the different web stacks Web(MVC|Flux)LinkBuilder for symmetry. Introduced dedicated builder types to be used from WebFluxLinkBuilder to more conveniently integrate with reactive flows (see the changes in WebFluxEmployeeController). Deprecated ControllerLinkBuilder(Factory) in favor of WebMvcLinkBuilder(Factory). Generally untangled the use of WebMvcLinkBuilder from the reactive examples. --- .../hateoas/MethodLinkBuilderFactory.java | 8 +- .../config/EntityLinksConfiguration.java | 8 +- .../{mvc => core}/UriTemplateFactory.java | 4 +- .../hateoas/core/WebHandler.java | 8 +- .../hateoas/mvc/ControllerLinkBuilder.java | 3 + .../mvc/ControllerLinkBuilderFactory.java | 2 + .../IdentifiableResourceAssemblerSupport.java | 2 +- .../hateoas/mvc/ResourceAssemblerSupport.java | 2 +- .../hateoas/mvc/WebMvcLinkBuilder.java | 265 +++++++ .../hateoas/mvc/WebMvcLinkBuilderFactory.java | 135 ++++ .../hateoas/reactive/ReactiveLinkBuilder.java | 106 --- .../hateoas/reactive/WebFluxLinkBuilder.java | 250 ++++++ .../ControllerLinkBuilderFactoryUnitTest.java | 1 + ...erLinkBuilderOutsideSpringMvcUnitTest.java | 1 + .../mvc/ControllerLinkBuilderUnitTest.java | 1 + .../mvc/DummyInvocationUtilsUnitTest.java | 10 +- ...iableResourceAssemblerSupportUnitTest.java | 2 +- .../MultiMediaTypeWebMvcIntegrationTest.java | 2 +- .../mvc/WebMvcLinkBuilderFactoryUnitTest.java | 208 +++++ ...vcLinkBuilderOutsideSpringMvcUnitTest.java | 54 ++ .../mvc/WebMvcLinkBuilderUnitTest.java | 739 ++++++++++++++++++ .../reactive/HypermediaWebFilterTest.java | 7 +- ...rTest.java => WebFluxLinkBuilderTest.java} | 96 ++- .../support/WebFluxEmployeeController.java | 78 +- .../support/WebMvcEmployeeController.java | 16 +- 25 files changed, 1798 insertions(+), 210 deletions(-) rename src/main/java/org/springframework/hateoas/{mvc => core}/UriTemplateFactory.java (94%) create mode 100644 src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilder.java create mode 100644 src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactory.java delete mode 100644 src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java create mode 100644 src/main/java/org/springframework/hateoas/reactive/WebFluxLinkBuilder.java create mode 100644 src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactoryUnitTest.java create mode 100644 src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderOutsideSpringMvcUnitTest.java create mode 100644 src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderUnitTest.java rename src/test/java/org/springframework/hateoas/reactive/{ReactiveLinkBuilderTest.java => WebFluxLinkBuilderTest.java} (72%) diff --git a/src/main/java/org/springframework/hateoas/MethodLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/MethodLinkBuilderFactory.java index 1f990b98..4671ff43 100644 --- a/src/main/java/org/springframework/hateoas/MethodLinkBuilderFactory.java +++ b/src/main/java/org/springframework/hateoas/MethodLinkBuilderFactory.java @@ -18,7 +18,7 @@ package org.springframework.hateoas; import java.lang.reflect.Method; import org.springframework.hateoas.core.DummyInvocationUtils; -import org.springframework.hateoas.mvc.ControllerLinkBuilder; +import org.springframework.hateoas.mvc.WebMvcLinkBuilder; /** * Extension of {@link LinkBuilderFactory} for implementations that also support creating {@link LinkBuilder}s by @@ -52,9 +52,9 @@ public interface MethodLinkBuilderFactory extends LinkBui /** * Returns a {@link LinkBuilder} pointing to the URI mapped to the method the result is handed into this method. Use * {@link DummyInvocationUtils#methodOn(Class, Object...)} to obtain a dummy instance of a controller to record a - * dummy method invocation on. See {@link ControllerLinkBuilder#linkTo(Object)} for an example. - * - * @see ControllerLinkBuilder#linkTo(Object) + * dummy method invocation on. See {@link WebMvcLinkBuilder#linkTo(Object)} for an example. + * + * @see WebMvcLinkBuilder#linkTo(Object) * @param methodInvocationResult must not be {@literal null}. * @return */ diff --git a/src/main/java/org/springframework/hateoas/config/EntityLinksConfiguration.java b/src/main/java/org/springframework/hateoas/config/EntityLinksConfiguration.java index 3e61f525..4128a17a 100644 --- a/src/main/java/org/springframework/hateoas/config/EntityLinksConfiguration.java +++ b/src/main/java/org/springframework/hateoas/config/EntityLinksConfiguration.java @@ -22,7 +22,7 @@ import org.springframework.context.annotation.Primary; import org.springframework.hateoas.EntityLinks; import org.springframework.hateoas.core.ControllerEntityLinksFactoryBean; import org.springframework.hateoas.core.DelegatingEntityLinks; -import org.springframework.hateoas.mvc.ControllerLinkBuilderFactory; +import org.springframework.hateoas.mvc.WebMvcLinkBuilderFactory; import org.springframework.plugin.core.PluginRegistry; import org.springframework.plugin.core.support.PluginRegistryFactoryBean; import org.springframework.stereotype.Controller; @@ -54,7 +54,7 @@ class EntityLinksConfiguration { } @Bean - ControllerEntityLinksFactoryBean controllerEntityLinks(ControllerLinkBuilderFactory controllerLinkBuilderFactory) { + ControllerEntityLinksFactoryBean controllerEntityLinks(WebMvcLinkBuilderFactory controllerLinkBuilderFactory) { ControllerEntityLinksFactoryBean factory = new ControllerEntityLinksFactoryBean(); factory.setAnnotation(Controller.class); @@ -64,7 +64,7 @@ class EntityLinksConfiguration { } @Bean - ControllerLinkBuilderFactory controllerLinkBuilderFactoryBean() { - return new ControllerLinkBuilderFactory(); + WebMvcLinkBuilderFactory webMvcLinkBuilderFactoryBean() { + return new WebMvcLinkBuilderFactory(); } } diff --git a/src/main/java/org/springframework/hateoas/mvc/UriTemplateFactory.java b/src/main/java/org/springframework/hateoas/core/UriTemplateFactory.java similarity index 94% rename from src/main/java/org/springframework/hateoas/mvc/UriTemplateFactory.java rename to src/main/java/org/springframework/hateoas/core/UriTemplateFactory.java index 80eb3959..06404ef4 100644 --- a/src/main/java/org/springframework/hateoas/mvc/UriTemplateFactory.java +++ b/src/main/java/org/springframework/hateoas/core/UriTemplateFactory.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.hateoas.mvc; +package org.springframework.hateoas.core; import java.util.Map; @@ -27,7 +27,7 @@ import org.springframework.web.util.UriTemplate; * @author Michal Stochmialek * @author Oliver Drotbohm */ -class UriTemplateFactory { +public class UriTemplateFactory { private static final Map CACHE = new ConcurrentReferenceHashMap(); diff --git a/src/main/java/org/springframework/hateoas/core/WebHandler.java b/src/main/java/org/springframework/hateoas/core/WebHandler.java index 2016043b..98f2e3cb 100644 --- a/src/main/java/org/springframework/hateoas/core/WebHandler.java +++ b/src/main/java/org/springframework/hateoas/core/WebHandler.java @@ -42,7 +42,6 @@ import org.springframework.hateoas.Affordance; import org.springframework.hateoas.LinkBuilder; import org.springframework.hateoas.TemplateVariable; import org.springframework.hateoas.TemplateVariables; -import org.springframework.hateoas.mvc.ControllerLinkBuilder; import org.springframework.util.Assert; import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.MultiValueMap; @@ -57,13 +56,14 @@ import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriTemplate; /** - * Utility for taking a method invocation and extracting a {@link ControllerLinkBuilder}. + * Utility for taking a method invocation and extracting a {@link LinkBuilder}. * * @author Greg Turnquist */ public class WebHandler { - private static final MappingDiscoverer DISCOVERER = new AnnotationMappingDiscoverer(RequestMapping.class); + private static final MappingDiscoverer DISCOVERER = CachingMappingDiscoverer + .of(new AnnotationMappingDiscoverer(RequestMapping.class)); private static final AnnotatedParametersParameterAccessor PATH_VARIABLE_ACCESSOR // = new AnnotatedParametersParameterAccessor(new AnnotationAttribute(PathVariable.class)); private static final AnnotatedParametersParameterAccessor REQUEST_PARAM_ACCESSOR // @@ -90,7 +90,7 @@ public class WebHandler { String mapping = DISCOVERER.getMapping(invocation.getTargetType(), invocation.getMethod()); UriComponentsBuilder builder = mappingToUriComponentsBuilder.apply(mapping); - UriTemplate template = new UriTemplate(mapping == null ? "/" : mapping); + UriTemplate template = UriTemplateFactory.templateFor(mapping == null ? "/" : mapping); Map values = new HashMap<>(); Iterator names = template.getVariableNames().iterator(); diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java index ffabd33a..dc82d68e 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilder.java @@ -29,6 +29,7 @@ import org.springframework.hateoas.core.CachingMappingDiscoverer; import org.springframework.hateoas.core.DummyInvocationUtils; import org.springframework.hateoas.core.MappingDiscoverer; import org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport; +import org.springframework.hateoas.core.UriTemplateFactory; import org.springframework.util.Assert; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.context.request.RequestContextHolder; @@ -47,7 +48,9 @@ import org.springframework.web.util.UriTemplate; * @author Andrew Naydyonock * @author Oliver Trosien * @author Greg Turnquist + * @deprecated use {@link WebMvcLinkBuilder} instead. */ +@Deprecated public class ControllerLinkBuilder extends TemplateVariableAwareLinkBuilderSupport { private static final MappingDiscoverer DISCOVERER = CachingMappingDiscoverer diff --git a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java index a7197b68..c1e4127f 100644 --- a/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java +++ b/src/main/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactory.java @@ -42,7 +42,9 @@ import org.springframework.hateoas.core.WebHandler; * @author Kevin Conaway * @author Andrew Naydyonock * @author Greg Turnquist + * @deprecated use {@link WebMvcLinkBuilderFactory} instead. */ +@Deprecated public class ControllerLinkBuilderFactory implements MethodLinkBuilderFactory { private List uriComponentsContributors = new ArrayList<>(); diff --git a/src/main/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupport.java b/src/main/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupport.java index fc8fa96e..66479366 100644 --- a/src/main/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupport.java +++ b/src/main/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupport.java @@ -15,7 +15,7 @@ */ package org.springframework.hateoas.mvc; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import java.util.Arrays; diff --git a/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java b/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java index 53982f27..5f8b72fa 100755 --- a/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java +++ b/src/main/java/org/springframework/hateoas/mvc/ResourceAssemblerSupport.java @@ -15,7 +15,7 @@ */ package org.springframework.hateoas.mvc; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilder.java b/src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilder.java new file mode 100644 index 00000000..35889d70 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilder.java @@ -0,0 +1,265 @@ +/* + * Copyright 2012-2019 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.mvc; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.TemplateVariables; +import org.springframework.hateoas.core.AnnotationMappingDiscoverer; +import org.springframework.hateoas.core.CachingMappingDiscoverer; +import org.springframework.hateoas.core.DummyInvocationUtils; +import org.springframework.hateoas.core.MappingDiscoverer; +import org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport; +import org.springframework.hateoas.core.UriTemplateFactory; +import org.springframework.util.Assert; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.util.DefaultUriTemplateHandler; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriTemplate; + +/** + * Builder to ease building {@link Link} instances pointing to Spring MVC controllers. + * + * @author Oliver Gierke + * @author Kamill Sokol + * @author Greg Turnquist + * @author Kevin Conaway + * @author Andrew Naydyonock + * @author Oliver Trosien + * @author Greg Turnquist + */ +public class WebMvcLinkBuilder extends TemplateVariableAwareLinkBuilderSupport { + + private static final MappingDiscoverer DISCOVERER = CachingMappingDiscoverer + .of(new AnnotationMappingDiscoverer(RequestMapping.class)); + private static final WebMvcLinkBuilderFactory FACTORY = new WebMvcLinkBuilderFactory(); + private static final CustomUriTemplateHandler HANDLER = new CustomUriTemplateHandler(); + + /** + * Creates a new {@link WebMvcLinkBuilder} using the given {@link UriComponentsBuilder}. + * + * @param builder must not be {@literal null}. + */ + WebMvcLinkBuilder(UriComponentsBuilder builder) { + this(builder, TemplateVariables.NONE, Collections.emptyList()); + } + + WebMvcLinkBuilder(UriComponentsBuilder builder, TemplateVariables variables, List affordances) { + super(builder, variables, affordances); + } + + WebMvcLinkBuilder(UriComponents uriComponents, TemplateVariables variables, List affordances) { + super(uriComponents, variables, affordances); + } + + /** + * Creates a new {@link WebMvcLinkBuilder} with a base of the mapping annotated to the given controller class. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @return + */ + public static WebMvcLinkBuilder linkTo(Class controller) { + return linkTo(controller, new Object[0]); + } + + /** + * Creates a new {@link WebMvcLinkBuilder} with a base of the mapping annotated to the given controller class. The + * additional parameters are used to fill up potentially available path variables in the class scop request mapping. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be + * {@literal null}. + * @return + */ + public static WebMvcLinkBuilder linkTo(Class controller, Object... parameters) { + + Assert.notNull(controller, "Controller must not be null!"); + Assert.notNull(parameters, "Parameters must not be null!"); + + String mapping = DISCOVERER.getMapping(controller); + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(mapping == null ? "/" : mapping); + UriComponents uriComponents = HANDLER.expandAndEncode(builder, parameters); + + return new WebMvcLinkBuilder(UriComponentsBuilderFactory.getBuilder()).slash(uriComponents, true); + } + + /** + * Creates a new {@link WebMvcLinkBuilder} with a base of the mapping annotated to the given controller class. + * Parameter map is used to fill up potentially available path variables in the class scope request mapping. + * + * @param controller the class to discover the annotation on, must not be {@literal null}. + * @param parameters additional parameters to bind to the URI template declared in the annotation, must not be + * {@literal null}. + * @return + */ + public static WebMvcLinkBuilder linkTo(Class controller, Map parameters) { + + Assert.notNull(controller, "Controller must not be null!"); + Assert.notNull(parameters, "Parameters must not be null!"); + + String mapping = DISCOVERER.getMapping(controller); + + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(mapping == null ? "/" : mapping); + UriComponents uriComponents = HANDLER.expandAndEncode(builder, parameters); + + return new WebMvcLinkBuilder(UriComponentsBuilderFactory.getBuilder()).slash(uriComponents, true); + } + + /* + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Method, Object...) + */ + public static WebMvcLinkBuilder linkTo(Method method, Object... parameters) { + return linkTo(method.getDeclaringClass(), method, parameters); + } + + /* + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(Class, Method, Object...) + */ + public static WebMvcLinkBuilder linkTo(Class controller, Method method, Object... parameters) { + + Assert.notNull(controller, "Controller type must not be null!"); + Assert.notNull(method, "Method must not be null!"); + + String mapping = DISCOVERER.getMapping(controller, method); + UriTemplate template = UriTemplateFactory.templateFor(mapping); + URI uri = template.expand(parameters); + + return new WebMvcLinkBuilder(UriComponentsBuilderFactory.getBuilder()).slash(uri); + } + + /** + * Creates a {@link WebMvcLinkBuilder} pointing to a controller method. Hand in a dummy method invocation result you + * can create via {@link #methodOn(Class, Object...)} or {@link DummyInvocationUtils#methodOn(Class, Object...)}. + * + *
+	 * @RequestMapping("/customers")
+	 * class CustomerController {
+	 *
+	 *   @RequestMapping("/{id}/addresses")
+	 *   HttpEntity<Addresses> showAddresses(@PathVariable Long id) { … }
+	 * }
+	 *
+	 * Link link = linkTo(methodOn(CustomerController.class).showAddresses(2L)).withRel("addresses");
+	 * 
+ * + * The resulting {@link Link} instance will point to {@code /customers/2/addresses} and have a rel of + * {@code addresses}. For more details on the method invocation constraints, see + * {@link DummyInvocationUtils#methodOn(Class, Object...)}. + * + * @param invocationValue + * @return + */ + public static WebMvcLinkBuilder linkTo(Object invocationValue) { + return FACTORY.linkTo(invocationValue); + } + + /** + * Extract a {@link Link} from the {@link WebMvcLinkBuilder} and look up the related {@link Affordance}. Should only + * be one. + * + *
+	 * Link findOneLink = linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel()
+	 * 		.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id)));
+	 * 
+ * + * This takes a link and adds an {@link Affordance} based on another Spring MVC handler method. + * + * @param invocationValue + * @return + */ + public static Affordance afford(Object invocationValue) { + + WebMvcLinkBuilder linkBuilder = linkTo(invocationValue); + + Assert.isTrue(linkBuilder.getAffordances().size() == 1, "A base can only have one affordance, itself"); + + return linkBuilder.getAffordances().get(0); + } + + /** + * Wrapper for {@link DummyInvocationUtils#methodOn(Class, Object...)} to be available in case you work with static + * imports of {@link WebMvcLinkBuilder}. + * + * @param controller must not be {@literal null}. + * @param parameters parameters to extend template variables in the type level mapping. + * @return + */ + public static T methodOn(Class controller, Object... parameters) { + return DummyInvocationUtils.methodOn(controller, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.UriComponentsLinkBuilder#getThis() + */ + @Override + protected WebMvcLinkBuilder getThis() { + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport#createNewInstance(org.springframework.web.util.UriComponentsBuilder, java.util.List, org.springframework.hateoas.TemplateVariables) + */ + @Override + protected WebMvcLinkBuilder createNewInstance(UriComponentsBuilder builder, List affordances, + TemplateVariables variables) { + return new WebMvcLinkBuilder(builder, variables, affordances); + } + + /** + * Returns a {@link UriComponentsBuilder} to continue to build the already built URI in a more fine grained way. + * + * @return + */ + public UriComponentsBuilder toUriComponentsBuilder() { + return UriComponentsBuilder.fromUri(toUri()); + } + + 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/WebMvcLinkBuilderFactory.java b/src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactory.java new file mode 100644 index 00000000..389064a5 --- /dev/null +++ b/src/main/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactory.java @@ -0,0 +1,135 @@ +/* + * Copyright 2012-2019 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.mvc; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.core.MethodParameter; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.MethodLinkBuilderFactory; +import org.springframework.hateoas.core.LinkBuilderSupport; +import org.springframework.hateoas.core.MethodParameters; +import org.springframework.hateoas.core.WebHandler; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Factory for {@link LinkBuilderSupport} instances based on the request mapping annotated on the given controller. + * + * @author Ricardo Gladwell + * @author Oliver Gierke + * @author Dietrich Schulten + * @author Kamill Sokol + * @author Ross Turner + * @author Oemer Yildiz + * @author Kevin Conaway + * @author Andrew Naydyonock + * @author Greg Turnquist + */ +public class WebMvcLinkBuilderFactory implements MethodLinkBuilderFactory { + + private List uriComponentsContributors = new ArrayList<>(); + + /** + * Configures the {@link UriComponentsContributor} to be used when building {@link Link} instances from method + * invocations. + * + * @see #linkTo(Object) + * @param uriComponentsContributors the uriComponentsContributors to set + */ + public void setUriComponentsContributors(List uriComponentsContributors) { + this.uriComponentsContributors = Collections.unmodifiableList(uriComponentsContributors); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.LinkBuilderFactory#linkTo(java.lang.Class) + */ + @Override + public WebMvcLinkBuilder linkTo(Class controller) { + return WebMvcLinkBuilder.linkTo(controller); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.LinkBuilderFactory#linkTo(java.lang.Class, java.lang.Object[]) + */ + @Override + public WebMvcLinkBuilder linkTo(Class controller, Object... parameters) { + return WebMvcLinkBuilder.linkTo(controller, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.LinkBuilderFactory#linkTo(java.lang.Class, java.util.Map) + */ + @Override + public WebMvcLinkBuilder linkTo(Class controller, Map parameters) { + return WebMvcLinkBuilder.linkTo(controller, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(java.lang.Class, java.lang.reflect.Method, java.lang.Object[]) + */ + @Override + public WebMvcLinkBuilder linkTo(Class controller, Method method, Object... parameters) { + return WebMvcLinkBuilder.linkTo(controller, method, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(java.lang.Object) + */ + @Override + public WebMvcLinkBuilder linkTo(Object invocationValue) { + + Function builderFactory = mapping -> UriComponentsBuilderFactory.getBuilder() + .path(mapping); + + return WebHandler.linkTo(invocationValue, builderFactory, WebMvcLinkBuilder::new, (builder, invocation) -> { + + MethodParameters parameters = new MethodParameters(invocation.getMethod()); + Iterator parameterValues = Arrays.asList(invocation.getArguments()).iterator(); + + for (MethodParameter parameter : parameters.getParameters()) { + + Object parameterValue = parameterValues.next(); + + uriComponentsContributors.stream() // + .filter(it -> it.supportsParameter(parameter)) // + .forEach(it -> it.enhance(builder, parameter, parameterValue)); + } + + return builder; + }); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(java.lang.reflect.Method, java.lang.Object[]) + */ + @Override + public WebMvcLinkBuilder linkTo(Method method, Object... parameters) { + return WebMvcLinkBuilder.linkTo(method, parameters); + } +} diff --git a/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java b/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java deleted file mode 100644 index 1b44d7f1..00000000 --- a/src/main/java/org/springframework/hateoas/reactive/ReactiveLinkBuilder.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2019 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.reactive; - -import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; - -import reactor.core.publisher.Mono; - -import java.util.List; - -import org.springframework.hateoas.Affordance; -import org.springframework.hateoas.Link; -import org.springframework.hateoas.TemplateVariables; -import org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport; -import org.springframework.hateoas.core.WebHandler; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.util.UriComponents; -import org.springframework.web.util.UriComponentsBuilder; - -/** - * Utility for building reactive {@link Link}s. - * - * @author Greg Turnquist - * @since 1.0 - */ -public class ReactiveLinkBuilder extends TemplateVariableAwareLinkBuilderSupport { - - private ReactiveLinkBuilder(UriComponentsBuilder builder, TemplateVariables variables, List affordances) { - super(builder, variables, affordances); - } - - private ReactiveLinkBuilder(UriComponents components, TemplateVariables variables, List affordances) { - super(components, variables, affordances); - } - - /** - * Create a {@link ReactiveLinkBuilder} by checking if the Reactor Context contains a {@link ServerWebExchange} and - * using that combined with the Spring Web annotations to build a full URI. If there is no exchange, then fall back to - * relative URIs. - * - * @param invocationValue - */ - public static Mono linkTo(Object invocationValue) { - - return Mono.subscriberContext() - .map(context -> linkTo(invocationValue, context.getOrDefault(SERVER_WEB_EXCHANGE, null))); - } - - /** - * Create a {@link ReactiveLinkBuilder} using an explicitly defined {@link ServerWebExchange}. This is possible if - * your WebFlux method includes the exchange and you want to pass it straight in. - * - * @param invocationValue - * @param exchange - */ - public static ReactiveLinkBuilder linkTo(Object invocationValue, ServerWebExchange exchange) { - - return WebHandler.linkTo(invocationValue, // - path -> getBuilder(exchange).replacePath(path == null ? "/" : path), // - ReactiveLinkBuilder::new); - } - - /** - * Returns a {@link UriComponentsBuilder} obtained from the {@link ServerWebExchange}. - * - * @param exchange - */ - private static UriComponentsBuilder getBuilder(ServerWebExchange exchange) { - - return exchange == null // - ? UriComponentsBuilder.fromPath("/") // - : UriComponentsBuilder.fromHttpRequest(exchange.getRequest()); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport#createNewInstance(org.springframework.web.util.UriComponentsBuilder, java.util.List, org.springframework.hateoas.TemplateVariables) - */ - @Override - protected ReactiveLinkBuilder createNewInstance(UriComponentsBuilder builder, List affordances, - TemplateVariables variables) { - return new ReactiveLinkBuilder(builder, variables, affordances); - } - - /* - * (non-Javadoc) - * @see org.springframework.hateoas.core.LinkBuilderSupport#getThis() - */ - @Override - protected ReactiveLinkBuilder getThis() { - return this; - } -} diff --git a/src/main/java/org/springframework/hateoas/reactive/WebFluxLinkBuilder.java b/src/main/java/org/springframework/hateoas/reactive/WebFluxLinkBuilder.java new file mode 100644 index 00000000..7720251c --- /dev/null +++ b/src/main/java/org/springframework/hateoas/reactive/WebFluxLinkBuilder.java @@ -0,0 +1,250 @@ +/* + * Copyright 2019 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.reactive; + +import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.function.Function; + +import org.springframework.hateoas.Affordance; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.LinkRelation; +import org.springframework.hateoas.TemplateVariables; +import org.springframework.hateoas.core.DummyInvocationUtils; +import org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport; +import org.springframework.hateoas.core.WebHandler; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Utility for building reactive {@link Link}s. + * + * @author Greg Turnquist + * @author Oliver Drotbohm + * @since 1.0 + */ +public class WebFluxLinkBuilder extends TemplateVariableAwareLinkBuilderSupport { + + private WebFluxLinkBuilder(UriComponentsBuilder builder, TemplateVariables variables, List affordances) { + super(builder, variables, affordances); + } + + private WebFluxLinkBuilder(UriComponents components, TemplateVariables variables, List affordances) { + super(components, variables, affordances); + } + + /** + * Create a {@link WebFluxLinkBuilder} by checking if the Reactor Context contains a {@link ServerWebExchange} and + * using that combined with the Spring Web annotations to build a full URI. If there is no exchange, then fall back to + * relative URIs. Usually used with {@link #methodOn(Class, Object...)} to refer to a method invocation. + * + * @param invocation must not be {@literal null}. + * @see #methodOn(Class, Object...) + */ + public static WebFluxBuilder linkTo(Object invocation) { + + Assert.notNull(invocation, "Invocation must not be null!"); + + return new WebFluxBuilder(linkToInternal(invocation)); + } + + /** + * Create a {@link WebFluxLinkBuilder} using an explicitly defined {@link ServerWebExchange}. This is possible if your + * WebFlux method includes the exchange and you want to pass it straight in. + * + * @param invocation must not be {@literal null}. + * @param exchange must not be {@literal null}. + */ + public static WebFluxBuilder linkTo(Object invocation, ServerWebExchange exchange) { + return new WebFluxBuilder(linkToInternal(invocation, exchange)); + } + + /** + * Wrapper for {@link DummyInvocationUtils#methodOn(Class, Object...)} to be available in case you work with static + * imports of {@link WebFluxLinkBuilder}. + * + * @param controller must not be {@literal null}. + * @param parameters parameters to extend template variables in the type level mapping. + * @return + */ + public static T methodOn(Class controller, Object... parameters) { + return DummyInvocationUtils.methodOn(controller, parameters); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.core.TemplateVariableAwareLinkBuilderSupport#createNewInstance(org.springframework.web.util.UriComponentsBuilder, java.util.List, org.springframework.hateoas.TemplateVariables) + */ + @Override + protected WebFluxLinkBuilder createNewInstance(UriComponentsBuilder builder, List affordances, + TemplateVariables variables) { + return new WebFluxLinkBuilder(builder, variables, affordances); + } + + /* + * (non-Javadoc) + * @see org.springframework.hateoas.core.LinkBuilderSupport#getThis() + */ + @Override + protected WebFluxLinkBuilder getThis() { + return this; + } + + @RequiredArgsConstructor + public static class WebFluxBuilder { + + private final Mono builder; + + /** + * Creates a new {@link WebFluxLink} for the {@link Link} with the given {@link LinkRelation} + * + * @param relation must not be {@literal null}. + * @return + */ + public WebFluxLink withRel(LinkRelation relation) { + return new WebFluxLink(builder.map(it -> it.withRel(relation))); + } + + /** + * Creates a new {@link WebFluxLink} for the {@link Link} with the given link relation. + * + * @param relation must not be {@literal null}. + * @return + */ + public WebFluxLink withRel(String relation) { + return new WebFluxLink(builder.map(it -> it.withRel(relation))); + } + + /** + * Creates a new {@link WebFluxLink} for the {@link Link} with the {@link IanaLinkRelations#SELF}. + * + * @return + */ + public WebFluxLink withSelfRel() { + return new WebFluxLink(builder.map(WebFluxLinkBuilder::withSelfRel)); + } + + /** + * General callback to produce a {@link Link} from the given {@link WebFluxLinkBuilder}. + * + * @param finisher must not be {@literal null}. + * @return + */ + public WebFluxLink toLink(Function> finisher) { + + Assert.notNull(finisher, "Finisher must not be null!"); + + return new WebFluxLink(builder.flatMap(finisher)); + } + } + + /** + * Intermediate representation of a {@link Link} within a reactive pipeline to easily add {@link Affordance}s from + * method invocations. + * + * @author Oliver Gierke + */ + @RequiredArgsConstructor + public static class WebFluxLink { + + private final Mono link; + + /** + * Adds the affordance created by the given virtual method invocation. + * + * @param invocation must not be {@literal null}. + * @return + * @see WebFluxLinkBuilder#methodOn(Class, Object...) + */ + public WebFluxLink andAffordance(Object invocation) { + + Assert.notNull(invocation, "Invocation must not be null!"); + + return new WebFluxLink(link.flatMap(it -> linkToInternal(invocation) // + .flatMapIterable(WebFluxLinkBuilder::getAffordances) // + .singleOrEmpty() // + .map(it::andAffordance))); + } + + /** + * Creates a new {@link WebFluxLink} with the current {@link Link} instance transformed using the given mapper. + * + * @param mapper must not be {@literal null}. + * @return + */ + public WebFluxLink map(Function mapper) { + + Assert.notNull(mapper, "Function must not be null!"); + + return new WebFluxLink(link.map(mapper)); + } + + /** + * Returns the underlying {@link Mono} of {@link Link} for further handling within a reactive pipeline. + * + * @return + */ + public Mono toMono() { + return link; + } + + /** + * Returns a {@link Mono} of {@link Link} with the current one augmented by the given {@link Function}. Allows + * immediate customization of the {@link Link} instance and immediately return to a general reactive API. + * + * @param finisher must not be {@literal null}. + * @return + */ + public Mono toMono(Function finisher) { + + Assert.notNull(finisher, "Function must not be null!"); + + return link.map(finisher); + } + } + + /** + * Returns a {@link UriComponentsBuilder} obtained from the {@link ServerWebExchange}. + * + * @param exchange + */ + private static UriComponentsBuilder getBuilder(ServerWebExchange exchange) { + + return exchange == null // + ? UriComponentsBuilder.fromPath("/") // + : UriComponentsBuilder.fromHttpRequest(exchange.getRequest()); + } + + private static Mono linkToInternal(Object invocation) { + + return Mono.subscriberContext() // + .flatMap(context -> linkToInternal(invocation, context.getOrDefault(SERVER_WEB_EXCHANGE, null))); + } + + private static Mono linkToInternal(Object invocation, ServerWebExchange exchange) { + + return Mono.just(WebHandler.linkTo(invocation, // + path -> getBuilder(exchange).replacePath(path == null ? "/" : path), // + WebFluxLinkBuilder::new)); + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactoryUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactoryUnitTest.java index 9f7da7e6..f5c40b14 100755 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactoryUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderFactoryUnitTest.java @@ -51,6 +51,7 @@ import org.springframework.web.util.UriComponentsBuilder; * @author Kamill Sokol * @author Ross Turner */ +@Deprecated public class ControllerLinkBuilderFactoryUnitTest extends TestUtils { ControllerLinkBuilderFactory factory = new ControllerLinkBuilderFactory(); diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderOutsideSpringMvcUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderOutsideSpringMvcUnitTest.java index b09f4ea8..c8ca9982 100755 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderOutsideSpringMvcUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderOutsideSpringMvcUnitTest.java @@ -28,6 +28,7 @@ import org.springframework.web.context.request.RequestContextHolder; * * @author Greg Turnquist */ +@Deprecated public class ControllerLinkBuilderOutsideSpringMvcUnitTest { /** diff --git a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java index a7b2b8ff..275a4b0d 100755 --- a/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/ControllerLinkBuilderUnitTest.java @@ -56,6 +56,7 @@ import org.springframework.web.util.UriComponentsBuilder; * @author Oliver Trosien * @author Greg Turnquist */ +@Deprecated public class ControllerLinkBuilderUnitTest extends TestUtils { public @Rule ExpectedException exception = ExpectedException.none(); diff --git a/src/test/java/org/springframework/hateoas/mvc/DummyInvocationUtilsUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/DummyInvocationUtilsUnitTest.java index e222dcfd..c74a0c1f 100755 --- a/src/test/java/org/springframework/hateoas/mvc/DummyInvocationUtilsUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/DummyInvocationUtilsUnitTest.java @@ -16,11 +16,11 @@ package org.springframework.hateoas.mvc; import static org.assertj.core.api.Assertions.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import org.junit.Test; import org.springframework.hateoas.Link; import org.springframework.hateoas.TestUtils; -import org.springframework.hateoas.core.DummyInvocationUtils; import org.springframework.http.HttpEntity; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -35,16 +35,16 @@ public class DummyInvocationUtilsUnitTest extends TestUtils { @Test public void pathVariableWithDefaultParameter() { - Link link = ControllerLinkBuilder.linkTo(DummyInvocationUtils.methodOn(SampleController.class).someMethod(1L)) - .withSelfRel(); + Link link = linkTo(methodOn(SampleController.class).someMethod(1L)).withSelfRel(); + assertThat(link.getHref()).isEqualTo("http://localhost/sample/1/foo"); } @Test public void pathVariableWithNameParameter() { - Link link = ControllerLinkBuilder.linkTo(DummyInvocationUtils.methodOn(SampleController.class).someOtherMethod(2L)) - .withSelfRel(); + Link link = linkTo(methodOn(SampleController.class).someOtherMethod(2L)).withSelfRel(); + assertThat(link.getHref()).isEqualTo("http://localhost/sample/2/bar"); } diff --git a/src/test/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupportUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupportUnitTest.java index 8b54d63f..085ed833 100755 --- a/src/test/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupportUnitTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/IdentifiableResourceAssemblerSupportUnitTest.java @@ -16,7 +16,7 @@ package org.springframework.hateoas.mvc; import static org.assertj.core.api.Assertions.*; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import java.util.Arrays; import java.util.List; diff --git a/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java b/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java index 999cf99b..4a3b6b13 100644 --- a/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java +++ b/src/test/java/org/springframework/hateoas/mvc/MultiMediaTypeWebMvcIntegrationTest.java @@ -17,7 +17,7 @@ package org.springframework.hateoas.mvc; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.collection.IsCollectionWithSize.*; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*; diff --git a/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactoryUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactoryUnitTest.java new file mode 100644 index 00000000..6a318361 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderFactoryUnitTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2012-2019 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.mvc; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.joda.time.DateTime; +import org.joda.time.format.ISODateTimeFormat; +import org.junit.Test; +import org.springframework.core.MethodParameter; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.format.annotation.DateTimeFormat.ISO; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.TestUtils; +import org.springframework.hateoas.mvc.ControllerLinkBuilderUnitTest.ControllerWithMethods; +import org.springframework.hateoas.mvc.ControllerLinkBuilderUnitTest.PersonControllerImpl; +import org.springframework.hateoas.mvc.ControllerLinkBuilderUnitTest.PersonsAddressesController; +import org.springframework.http.HttpEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Unit tests for {@link WebMvcLinkBuilderFactory}. + * + * @author Ricardo Gladwell + * @author Oliver Gierke + * @author Kamill Sokol + * @author Ross Turner + */ +public class WebMvcLinkBuilderFactoryUnitTest extends TestUtils { + + WebMvcLinkBuilderFactory factory = new WebMvcLinkBuilderFactory(); + + @Test + public void createsLinkToControllerRoot() { + + Link link = factory.linkTo(PersonControllerImpl.class).withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people"); + } + + @Test + public void createsLinkToParameterizedControllerRoot() { + + Link link = factory.linkTo(PersonsAddressesController.class, 15).withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/15/addresses"); + } + + @Test + public void appliesParameterValueIfContributorConfigured() { + + WebMvcLinkBuilderFactory factory = new WebMvcLinkBuilderFactory(); + factory.setUriComponentsContributors(Collections.singletonList(new SampleUriComponentsContributor())); + + SpecialType specialType = new SpecialType(); + specialType.parameterValue = "value"; + + Link link = factory.linkTo(methodOn(SampleController.class).sampleMethod(1L, specialType)).withSelfRel(); + assertPointsToMockServer(link); + assertThat(link.getHref()).endsWith("/sample/1?foo=value"); + } + + /** + * @see #57 + */ + @Test + public void usesDateTimeFormatForUriBinding() { + + DateTime now = DateTime.now(); + + WebMvcLinkBuilderFactory factory = new WebMvcLinkBuilderFactory(); + Link link = factory.linkTo(methodOn(SampleController.class).sampleMethod(now)).withSelfRel(); + assertThat(link.getHref()).endsWith("/sample/" + ISODateTimeFormat.date().print(now)); + } + + /** + * @see #96 + */ + @Test + public void linksToMethodWithPathVariableContainingBlank() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithPathVariable("with blank")).withSelfRel(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/something/with%20blank/foo"); + } + + /** + * @see #96 + */ + @Test + public void createsLinkToParameterizedControllerRootContainingBlank() { + + Link link = factory.linkTo(PersonsAddressesController.class, "with blank").withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/with%20blank/addresses"); + } + + /** + * @see #209 + */ + @Test + public void createsLinkToControllerMethodWithMapRequestParam() { + + Map queryParams = new LinkedHashMap<>(); + queryParams.put("firstKey", "firstValue"); + queryParams.put("secondKey", "secondValue"); + + Link link = factory.linkTo(methodOn(SampleController.class).sampleMethodWithMap(queryParams)).withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/sample/mapsupport?firstKey=firstValue&secondKey=secondValue"); + } + + /** + * @see #209 + */ + @Test + public void createsLinkToControllerMethodWithMultiValueMapRequestParam() { + + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.put("key1", Arrays.asList("value1a", "value1b")); + queryParams.put("key2", Arrays.asList("value2a", "value2b")); + + Link link = factory.linkTo(methodOn(SampleController.class).sampleMethodWithMap(queryParams)).withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()) // + .endsWith("/sample/multivaluemapsupport?key1=value1a&key1=value1b&key2=value2a&key2=value2b"); + } + + /** + * @see #372 + */ + @Test + public void createsLinkToParameterizedControllerRootWithParameterMap() { + + Link link = factory.linkTo(PersonsAddressesController.class, Collections.singletonMap("id", "17")).withSelfRel(); + + assertPointsToMockServer(link); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/17/addresses"); + } + + interface SampleController { + + @RequestMapping("/sample/{id}") + HttpEntity sampleMethod(@PathVariable("id") Long id, SpecialType parameter); + + @RequestMapping("/sample/{time}") + HttpEntity sampleMethod(@PathVariable("time") @DateTimeFormat(iso = ISO.DATE) DateTime time); + + @RequestMapping("/sample/mapsupport") + HttpEntity sampleMethodWithMap(@RequestParam Map queryParams); + + @RequestMapping("/sample/multivaluemapsupport") + HttpEntity sampleMethodWithMap(@RequestParam MultiValueMap queryParams); + } + + static class SampleUriComponentsContributor implements UriComponentsContributor { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return SpecialType.class.equals(parameter.getParameterType()); + } + + @Override + public void enhance(UriComponentsBuilder builder, MethodParameter parameter, Object value) { + builder.queryParam("foo", ((SpecialType) value).parameterValue); + } + } + + static class SpecialType { + String parameterValue; + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderOutsideSpringMvcUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderOutsideSpringMvcUnitTest.java new file mode 100644 index 00000000..7ad175d8 --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderOutsideSpringMvcUnitTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2019 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.mvc; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.hateoas.Link; +import org.springframework.web.context.request.RequestContextHolder; + +/** + * Test cases for {@link ControllerLinkBuilder} that are NOT inside an existing Spring MVC request + * + * @author Greg Turnquist + * @author Oliver Drotbohm + */ +public class WebMvcLinkBuilderOutsideSpringMvcUnitTest { + + /** + * Clear out any existing request attributes left behind by other tests + */ + @Before + public void setUp() { + RequestContextHolder.setRequestAttributes(null); + } + + /** + * @see #408 + */ + @Test + public void requestingLinkOutsideWebRequest() { + + Link link = linkTo( + methodOn(WebMvcLinkBuilderUnitTest.PersonsAddressesController.class, 15).getAddressesForCountry("DE")) + .withSelfRel(); + + assertThat(link).isEqualTo(new Link("/people/15/addresses/DE").withSelfRel()); + } +} diff --git a/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderUnitTest.java b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderUnitTest.java new file mode 100644 index 00000000..780c334a --- /dev/null +++ b/src/test/java/org/springframework/hateoas/mvc/WebMvcLinkBuilderUnitTest.java @@ -0,0 +1,739 @@ +/* + * Copyright 2012-2019 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.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mockito; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Identifiable; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.TemplateVariable; +import org.springframework.hateoas.TemplateVariable.VariableType; +import org.springframework.hateoas.TestUtils; +import org.springframework.http.HttpEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Unit tests for {@link ControllerLinkBuilder}. + * + * @author Oliver Gierke + * @author Dietrich Schulten + * @author Kamill Sokol + * @author Oemer Yildiz + * @author Greg Turnquist + * @author Kevin Conaway + * @author Oliver Trosien + * @author Greg Turnquist + */ +public class WebMvcLinkBuilderUnitTest extends TestUtils { + + public @Rule ExpectedException exception = ExpectedException.none(); + + @Test + public void createsLinkToControllerRoot() { + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people"); + } + + @Test + public void createsLinkToParameterizedControllerRoot() { + + Link link = linkTo(PersonsAddressesController.class, 15).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/15/addresses"); + } + + /** + * @see #70 + */ + @Test + public void createsLinkToMethodOnParameterizedControllerRoot() { + + Link link = linkTo(methodOn(PersonsAddressesController.class, 15).getAddressesForCountry("DE")).withSelfRel(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/15/addresses/DE"); + } + + @Test + public void createsLinkToSubResource() { + + Link link = linkTo(PersonControllerImpl.class).slash("something").withSelfRel(); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/people/something"); + } + + @Test + public void createsLinkWithCustomRel() { + + Link link = linkTo(PersonControllerImpl.class).withRel(IanaLinkRelations.NEXT); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.NEXT); + assertThat(link.getHref()).endsWith("/people"); + } + + /** + * @see #186 + */ + @Test + public void usesFirstMappingInCaseMultipleOnesAreDefined() { + assertThat(linkTo(InvalidController.class).withSelfRel().getHref()).endsWith("/persons"); + } + + @Test + public void createsLinkToUnmappedController() { + + Link link = linkTo(UnmappedController.class).withSelfRel(); + assertThat(link.getHref()).isEqualTo("http://localhost"); + } + + @Test + @SuppressWarnings("unchecked") + public void usesIdOfIdentifyableForPathSegment() { + + Identifiable identifyable = Mockito.mock(Identifiable.class); + Mockito.when(identifyable.getId()).thenReturn(Optional.of(10L)); + + Link link = linkTo(PersonControllerImpl.class).slash(identifyable).withSelfRel(); + assertThat(link.getHref()).endsWith("/people/10"); + } + + @Test + public void appendingNullIsANoOp() { + + Link link = linkTo(PersonControllerImpl.class).slash(null).withSelfRel(); + assertThat(link.getHref()).endsWith("/people"); + + link = linkTo(PersonControllerImpl.class).slash((Object) null).withSelfRel(); + assertThat(link.getHref()).endsWith("/people"); + } + + @Test + public void linksToMethod() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).myMethod(null)).withSelfRel(); + assertPointsToMockServer(link); + assertThat(link.getHref()).endsWith("/something/else"); + } + + @Test + public void linksToMethodWithPathVariable() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithPathVariable("1")).withSelfRel(); + assertPointsToMockServer(link); + assertThat(link.getHref()).endsWith("/something/1/foo"); + } + + /** + * @see #33 + */ + @Test + public void usesForwardedHostAsHostIfHeaderIsSet() { + + request.addHeader("X-Forwarded-Host", "somethingDifferent"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("http://somethingDifferent")); + } + + /** + * @see #112 + */ + @Test + public void usesForwardedSslIfHeaderIsSet() { + + request.addHeader("X-Forwarded-Ssl", "on"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("https://")); + } + + /** + * @see #112 + */ + @Test + public void usesForwardedSslIfHeaderIsSetOff() { + + request.addHeader("X-Forwarded-Ssl", "off"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("http://")); + } + + /** + * @see #112 + */ + @Test + public void usesForwardedSslAndHostIfHeaderIsSet() { + + request.addHeader("X-Forwarded-Host", "somethingDifferent"); + request.addHeader("X-Forwarded-Ssl", "on"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("https://somethingDifferent")); + } + + /** + * @see #26, #39 + */ + @Test + public void addsRequestParametersHandedIntoSlashCorrectly() { + + Link link = linkTo(PersonController.class).slash("?foo=bar").withSelfRel(); + + UriComponents components = toComponents(link); + assertThat(components.getQuery()).isEqualTo("foo=bar"); + } + + /** + * @see #26, #39 + */ + @Test + public void linksToMethodWithPathVariableAndRequestParams() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodForNextPage("1", 10, 5)).withSelfRel(); + + UriComponents components = toComponents(link); + assertThat(components.getPath()).isEqualTo("/something/1/foo"); + + MultiValueMap queryParams = components.getQueryParams(); + assertThat(queryParams.get("limit"), contains("5")); + assertThat(queryParams.get("offset"), contains("10")); + } + + /** + * @see #26, #39 + */ + @Test + public void linksToMethodWithPathVariableAndMultiValueRequestParams() { + + Link link = linkTo( + methodOn(ControllerWithMethods.class).methodWithMultiValueRequestParams("1", Arrays.asList(3, 7), 5)) + .withSelfRel(); + + UriComponents components = toComponents(link); + assertThat(components.getPath()).isEqualTo("/something/1/foo"); + + MultiValueMap queryParams = components.getQueryParams(); + assertThat(queryParams.get("limit"), contains("5")); + assertThat(queryParams.get("items"), containsInAnyOrder("3", "7")); + } + + /** + * @see #26, #39 + */ + @Test + public void returnsUriComponentsBuilder() { + + UriComponents components = linkTo(PersonController.class).slash("something?foo=bar").toUriComponentsBuilder() + .build(); + + assertThat(components.getPath()).isEqualTo("/people/something"); + assertThat(components.getQuery()).isEqualTo("foo=bar"); + } + + /** + * @see #90 + */ + @Test + public void usesForwardedHostAndPortFromHeader() { + + request.addHeader("X-Forwarded-Host", "foobar:8088"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("http://foobar:8088")); + } + + /** + * @see #90 + */ + @Test + public void usesFirstHostOfXForwardedHost() { + + request.addHeader("X-Forwarded-Host", "barfoo:8888, localhost:8088"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("http://barfoo:8888")); + } + + /** + * @see #122, #169 + */ + @Test + public void appendsOptionalParameterVariableForUnsetParameter() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodForOptionalNextPage(null)).withSelfRel(); + + assertThat(link.getVariables(), contains(new TemplateVariable("offset", VariableType.REQUEST_PARAM))); + assertThat(link.expand().getHref()).endsWith("/foo"); + } + + /** + * @see #122, #169 + */ + @Test + public void rejectsMissingPathVariable() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithPathVariable(null))// + .withSelfRel(); + + exception.expect(IllegalArgumentException.class); + + link.expand(); + } + + /** + * @see #122, #169 + */ + @Test(expected = IllegalArgumentException.class) + public void rejectsMissingRequiredRequestParam() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithRequestParam(null)).withSelfRel(); + + assertThat(link.getVariableNames(), contains("id")); + + link.expand(); + } + + /** + * @see #170 + */ + @Test + public void usesForwardedPortFromHeader() { + + request.addHeader("X-Forwarded-Host", "foobarhost"); + request.addHeader("X-Forwarded-Port", "9090"); + request.setServerPort(8080); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + + assertThat(link.getHref(), startsWith("http://foobarhost:9090/")); + } + + /** + * @see #170 + */ + @Test + public void usesForwardedHostFromHeaderWithDefaultPort() { + + request.addHeader("X-Forwarded-Host", "foobarhost"); + request.setServerPort(8080); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("http://foobarhost/")); + } + + /** + * @see #114 + */ + @Test + public void discoversParentClassTypeMappingForInvocation() { + + Link link = linkTo(methodOn(ChildController.class).myMethod()).withSelfRel(); + assertThat(link.getHref()).endsWith("/parent/child"); + } + + /** + * @see #114 + */ + @Test + public void includesTypeMappingFromChildClass() { + + Link link = linkTo(methodOn(ChildWithTypeMapping.class).myMethod()).withSelfRel(); + assertThat(link.getHref()).endsWith("/child/parent"); + } + + /** + * @see #96 + */ + @Test + public void linksToMethodWithPathVariableContainingBlank() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithPathVariable("with blank")).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/something/with%20blank/foo"); + } + + /** + * @see #192 + */ + @Test + public void usesRootMappingOfTargetClassForMethodsOfParentClass() { + + Link link = linkTo(methodOn(ChildControllerWithRootMapping.class) // + .someEmptyMappedMethod()) // + .withSelfRel(); + + assertThat(link.getHref()).endsWith("/root"); + } + + /** + * @see #192 + */ + @Test + public void usesRootMappingOfTargetClassForMethodsOfParent() throws Exception { + + Method method = ParentControllerWithoutRootMapping.class.getMethod("someEmptyMappedMethod"); + + Link link = linkTo(ChildControllerWithRootMapping.class, method).withSelfRel(); + assertThat(link.getHref()).endsWith("/root"); + } + + /** + * @see #257, #107 + */ + @Test + public void usesXForwardedProtoHeaderAsLinkSchema() { + + for (String proto : Arrays.asList("http", "https")) { + + setUp(); + request.addHeader("X-Forwarded-Proto", proto); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith(proto + "://")); + } + } + + /** + * @see #257, #107 + */ + @Test + public void usesProtoValueFromForwardedHeaderAsLinkSchema() { + + for (String proto : Arrays.asList("http", "https")) { + + setUp(); + request.addHeader("Forwarded", new String[] { "proto=" + proto }); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith(proto.concat("://"))); + } + } + + /** + * @see #257, #107 + */ + @Test + public void favorsStandardForwardHeaderOverXForwardedProto() { + + request.addHeader("X-Forwarded-Proto", "foo"); + request.addHeader("Forwarded", "proto=bar"); + + adaptRequestFromForwardedHeaders(); + + Link link = linkTo(PersonControllerImpl.class).withSelfRel(); + assertThat(link.getHref(), startsWith("bar://")); + } + + /** + * @see #331 + */ + @Test + public void linksToMethodWithRequestParamImplicitlySetToFalse() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodForOptionalSizeWithDefaultValue(null)).withSelfRel(); + + assertThat(link.getHref()).endsWith("/bar"); + } + + /** + * @see #398 + */ + @Test + public void encodesRequestParameterWithSpecialValue() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithRequestParam("Spring#\n")).withSelfRel(); + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).endsWith("/something/foo?id=Spring%23%0A"); + } + + /** + * @see #169 + */ + @Test + public void createsPartiallyExpandedLink() { + + Link link = linkTo(methodOn(PersonsAddressesController.class, "some id").getAddressesForCountry(null)) + .withSelfRel(); + + assertThat(link.isTemplated()).isTrue(); + assertThat(link.getHref()).contains("some%20id"); + } + + /** + * @see #169 + */ + @Test + public void addsRequestParameterVariablesForMissingRequiredParameter() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodForNextPage("1", 10, null)).withSelfRel(); + + assertThat(link.getVariableNames(), contains("limit")); + + exception.expect(IllegalArgumentException.class); + exception.expectMessage("limit"); + + link.expand(); + } + + /** + * @see #169 + */ + @Test + public void addsOptionalRequestParameterTemplateForMissingValue() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodForNextPage("1", null, 5)).withSelfRel(); + + assertThat(link.getVariables(), contains(new TemplateVariable("offset", VariableType.REQUEST_PARAM_CONTINUED))); + + UriComponents components = toComponents(link); + + assertThat(components.getQueryParams().get("query")).isNull(); + } + + /** + * @see #509 + */ + @Test + public void supportsTwoProxiesAddingXForwardedPort() { + + request.addHeader("X-Forwarded-Port", "1443,8443"); + request.addHeader("X-Forwarded-Host", "proxy1,proxy2"); + + adaptRequestFromForwardedHeaders(); + + assertThat(linkTo(PersonControllerImpl.class).withSelfRel().getHref(), startsWith("http://proxy1:1443")); + } + + /** + * @see #509 + */ + @Test + public void resolvesAmbiguousXForwardedHeaders() { + + request.addHeader("X-Forwarded-Proto", "http"); + request.addHeader("X-Forwarded-Ssl", "on"); + + assertThat(linkTo(PersonControllerImpl.class).withSelfRel().getHref(), startsWith("http://")); + } + + /** + * @see #527 + */ + @Test + public void createsLinkRelativeToContextRoot() { + + request.setContextPath("/ctx"); + request.setServletPath("/foo"); + request.setRequestURI("/ctx/foo"); + + assertThat(linkTo(PersonControllerImpl.class).withSelfRel().getHref()).endsWith("/ctx/people"); + } + + /** + * @see #639 + */ + @Test + public void considersEmptyOptionalMethodParameterOptional() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithJdk8Optional(Optional.empty())).withSelfRel(); + + assertThat(link.isTemplated()).isTrue(); + assertThat(link.getVariableNames(), contains("value")); + } + + /** + * @see #639 + */ + @Test + public void considersOptionalWithValueMethodParameterOptional() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithJdk8Optional(Optional.of(1))).withSelfRel(); + + assertThat(link.isTemplated()).isFalse(); + assertThat(link.getHref()).endsWith("?value=1"); + } + + /** + * @see #617 + */ + @Test + public void alternativePathVariableParameter() { + + Link link = linkTo(methodOn(ControllerWithMethods.class).methodWithAlternatePathVariable("bar")).withSelfRel(); + assertThat(link.getHref()).isEqualTo("http://localhost/something/bar/foo"); + } + + private static UriComponents toComponents(Link link) { + return UriComponentsBuilder.fromUriString(link.expand().getHref()).build(); + } + + static class Person implements Identifiable { + + Long id; + + @Override + public Optional getId() { + return Optional.ofNullable(id); + } + } + + @RequestMapping("/people") + interface PersonController {} + + class PersonControllerImpl implements PersonController {} + + @RequestMapping("/people/{id}/addresses") + static class PersonsAddressesController { + + @RequestMapping("/{country}") + public HttpEntity getAddressesForCountry(@PathVariable String country) { + return null; + } + } + + @RequestMapping({ "/persons", "/people" }) + class InvalidController { + + } + + class UnmappedController { + + } + + @RequestMapping("/something") + static class ControllerWithMethods { + + @RequestMapping("/else") + HttpEntity myMethod(@RequestBody Object payload) { + return null; + } + + @RequestMapping("/{id}/foo") + HttpEntity methodWithPathVariable(@PathVariable String id) { + return null; + } + + @RequestMapping("/foo") + HttpEntity methodWithRequestParam(@RequestParam String id) { + return null; + } + + @RequestMapping(value = "/{id}/foo") + HttpEntity methodForNextPage(@PathVariable String id, @RequestParam(required = false) Integer offset, + @RequestParam Integer limit) { + return null; + } + + @RequestMapping(value = "/{id}/foo") + HttpEntity methodWithMultiValueRequestParams(@PathVariable String id, @RequestParam List items, + @RequestParam Integer limit) { + return null; + } + + @RequestMapping(value = "/{id}/foo") + HttpEntity methodWithAlternatePathVariable(@PathVariable(name = "id") String otherId) { + return null; + } + + @RequestMapping(value = "/foo") + HttpEntity methodForOptionalNextPage(@RequestParam(required = false) Integer offset) { + return null; + } + + @RequestMapping(value = "/bar") + HttpEntity methodForOptionalSizeWithDefaultValue(@RequestParam(defaultValue = "10") Integer size) { + return null; + } + + @RequestMapping + HttpEntity methodWithJdk8Optional(@RequestParam Optional value) { + return null; + } + } + + @RequestMapping("/parent") + interface ParentController {} + + interface ChildController extends ParentController { + + @RequestMapping("/child") + Object myMethod(); + } + + interface ParentWithMethod { + + @RequestMapping("/parent") + Object myMethod(); + } + + @RequestMapping("/child") + interface ChildWithTypeMapping extends ParentWithMethod {} + + interface ParentControllerWithoutRootMapping { + + @RequestMapping + Object someEmptyMappedMethod(); + } + + @RequestMapping("/root") + interface ChildControllerWithRootMapping extends ParentControllerWithoutRootMapping { + + } +} diff --git a/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java index 99eb54f7..785cdf76 100644 --- a/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java +++ b/src/test/java/org/springframework/hateoas/reactive/HypermediaWebFilterTest.java @@ -17,8 +17,7 @@ package org.springframework.hateoas.reactive; import static org.assertj.core.api.AssertionsForInterfaceTypes.*; import static org.springframework.hateoas.config.EnableHypermediaSupport.HypermediaType.*; -import static org.springframework.hateoas.core.DummyInvocationUtils.*; -import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.*; +import static org.springframework.hateoas.reactive.WebFluxLinkBuilder.*; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -89,9 +88,7 @@ public class HypermediaWebFilterTest { @GetMapping Mono root() { - - return linkTo(methodOn(TestController.class).root()).map(ReactiveLinkBuilder::withSelfRel) - .map(ResourceSupport::new); + return linkTo(methodOn(TestController.class).root()).withSelfRel().toMono().map(ResourceSupport::new); } } diff --git a/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java b/src/test/java/org/springframework/hateoas/reactive/WebFluxLinkBuilderTest.java similarity index 72% rename from src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java rename to src/test/java/org/springframework/hateoas/reactive/WebFluxLinkBuilderTest.java index d45787db..65b36242 100644 --- a/src/test/java/org/springframework/hateoas/reactive/ReactiveLinkBuilderTest.java +++ b/src/test/java/org/springframework/hateoas/reactive/WebFluxLinkBuilderTest.java @@ -17,9 +17,9 @@ package org.springframework.hateoas.reactive; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.methodOn; import static org.springframework.hateoas.reactive.HypermediaWebFilter.*; -import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.linkTo; +import static org.springframework.hateoas.reactive.WebFluxLinkBuilder.linkTo; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -33,7 +33,6 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.hateoas.IanaLinkRelations; -import org.springframework.hateoas.Link; import org.springframework.http.HttpHeaders; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.web.bind.annotation.GetMapping; @@ -42,10 +41,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ServerWebExchange; /** + * Unit tests for {@link WebFluxLinkBuilder}. + * * @author Greg Turnquist + * @author Oliver Drotbohm */ @RunWith(MockitoJUnitRunner.class) -public class ReactiveLinkBuilderTest { +public class WebFluxLinkBuilderTest { @Mock ServerWebExchange exchange; @Mock ServerHttpRequest request; @@ -57,14 +59,18 @@ public class ReactiveLinkBuilderTest { public void linkAtSameLevelAsExplicitServerExchangeShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); + linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel().toMono() // + .as(StepVerifier::create).expectNextMatches(link -> { - assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); - assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + + return true; + + }).verifyComplete(); } /** @@ -74,13 +80,12 @@ public class ReactiveLinkBuilderTest { public void linkAtSameLevelAsContextProvidedServerExchangeShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - linkTo(methodOn(TestController.class).root()).map(ReactiveLinkBuilder::withSelfRel) - .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)).as(StepVerifier::create) - .expectNextMatches(link -> { + linkTo(methodOn(TestController.class).root()).withSelfRel().toMono() // + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) // + .as(StepVerifier::create).expectNextMatches(link -> { assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); @@ -100,10 +105,16 @@ public class ReactiveLinkBuilderTest { when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api/employees")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - Link link = linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel(); + linkTo(methodOn(TestController.class).root(), this.exchange).withSelfRel().toMono() // + .as(StepVerifier::create).expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); + + return true; + + }).verifyComplete(); - assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); - assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); } /** @@ -113,18 +124,18 @@ public class ReactiveLinkBuilderTest { public void shallowLinkFromDeepContextProvidedServerExchangeShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api/employees")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - linkTo(methodOn(TestController.class).root()).map(ReactiveLinkBuilder::withSelfRel) - .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)).as(StepVerifier::create) - .expectNextMatches(link -> { + linkTo(methodOn(TestController.class).root()).withSelfRel().toMono() // + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) // + .as(StepVerifier::create).expectNextMatches(link -> { assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api"); return true; + }).verifyComplete(); } @@ -135,14 +146,18 @@ public class ReactiveLinkBuilderTest { public void deepLinkFromShallowExplicitServerExchangeShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - Link link = linkTo(methodOn(TestController.class).deep(), this.exchange).withSelfRel(); + linkTo(methodOn(TestController.class).deep(), this.exchange).withSelfRel().toMono() // + .as(StepVerifier::create).expectNextMatches(link -> { - assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); - assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); + + return true; + + }).verifyComplete(); } /** @@ -152,18 +167,18 @@ public class ReactiveLinkBuilderTest { public void deepLinkFromShallowContextProvidedServerExchangeShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/api")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - linkTo(methodOn(TestController.class).deep()).map(ReactiveLinkBuilder::withSelfRel) - .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)).as(StepVerifier::create) - .expectNextMatches(link -> { + linkTo(methodOn(TestController.class).deep()).withSelfRel().toMono() // + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) // + .as(StepVerifier::create).expectNextMatches(link -> { assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/api/employees"); return true; + }).verifyComplete(); } @@ -174,18 +189,18 @@ public class ReactiveLinkBuilderTest { public void linkToRouteWithNoMappingShouldWork() throws URISyntaxException { when(this.exchange.getRequest()).thenReturn(this.request); - when(this.request.getURI()).thenReturn(new URI("http://localhost:8080/")); when(this.request.getHeaders()).thenReturn(new HttpHeaders()); - linkTo(methodOn(TestController2.class).root()).map(ReactiveLinkBuilder::withSelfRel) - .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)).as(StepVerifier::create) - .expectNextMatches(link -> { + linkTo(methodOn(TestController2.class).root()).withSelfRel().toMono() // + .subscriberContext(Context.of(SERVER_WEB_EXCHANGE, this.exchange)) // + .as(StepVerifier::create).expectNextMatches(link -> { assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("http://localhost:8080/"); return true; + }).verifyComplete(); } @@ -195,15 +210,14 @@ public class ReactiveLinkBuilderTest { @Test public void linkToRouteWithNoExchangeInTheContextShouldFallbackToRelativeUris() throws URISyntaxException { - linkTo(methodOn(TestController2.class).root())// - .map(ReactiveLinkBuilder::withSelfRel) // - .as(StepVerifier::create) // - .expectNextMatches(link -> { + linkTo(methodOn(TestController2.class).root()).withSelfRel().toMono() // + .as(StepVerifier::create).expectNextMatches(link -> { assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); assertThat(link.getHref()).isEqualTo("/"); return true; + }).verifyComplete(); } @@ -213,10 +227,16 @@ public class ReactiveLinkBuilderTest { @Test public void linkToRouteWithExplictExchangeBeingNullShouldFallbackToRelativeUris() throws URISyntaxException { - Link link = linkTo(methodOn(TestController2.class).root(), null).withSelfRel(); + linkTo(methodOn(TestController2.class).root(), null).withSelfRel().toMono() // + .as(StepVerifier::create).expectNextMatches(link -> { + + assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); + assertThat(link.getHref()).isEqualTo("/"); + + return true; + + }).verifyComplete(); - assertThat(link.getRel()).isEqualTo(IanaLinkRelations.SELF); - assertThat(link.getHref()).isEqualTo("/"); } @RestController diff --git a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java index e3c707be..453ff264 100644 --- a/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebFluxEmployeeController.java @@ -15,8 +15,7 @@ */ package org.springframework.hateoas.support; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; -import static org.springframework.hateoas.reactive.ReactiveLinkBuilder.linkTo; +import static org.springframework.hateoas.reactive.WebFluxLinkBuilder.*; import static reactor.function.TupleUtils.*; import reactor.core.publisher.Flux; @@ -27,10 +26,13 @@ import java.util.Map; import java.util.Optional; import java.util.TreeMap; +import org.springframework.hateoas.Affordance; import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.hateoas.Link; +import org.springframework.hateoas.Links; import org.springframework.hateoas.Resource; import org.springframework.hateoas.Resources; -import org.springframework.hateoas.reactive.ReactiveLinkBuilder; +import org.springframework.hateoas.reactive.WebFluxLinkBuilder; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -42,7 +44,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** + * Sample controller using {@link WebFluxLinkBuilder} to create {@link Affordance}s. + * * @author Greg Turnquist + * @author Oliver Drotbohm */ @RestController public class WebFluxEmployeeController { @@ -60,44 +65,61 @@ public class WebFluxEmployeeController { @GetMapping("/employees") public Mono>> all() { - return Flux.fromIterable(EMPLOYEES.keySet()).flatMap(id -> findOne(id)).collectList().flatMap( - resources -> linkTo(methodOn(WebFluxEmployeeController.class).all()).map(ReactiveLinkBuilder::withSelfRel) - .map(link -> link.andAffordance(afford(methodOn(WebFluxEmployeeController.class).newEmployee(null))) - .andAffordance(afford(methodOn(WebFluxEmployeeController.class).search(null, null)))) + Class controller = WebFluxEmployeeController.class; + + return Flux.fromIterable(EMPLOYEES.keySet()) // + .flatMap(id -> findOne(id)) // + .collectList() // + .flatMap(resources -> linkTo(methodOn(controller).all()).withSelfRel() // + .andAffordance(methodOn(controller).newEmployee(null)) // + .andAffordance(methodOn(controller).search(null, null)) // + .toMono() // .map(selfLink -> new Resources<>(resources, selfLink))); } @GetMapping("/employees/search") - public Mono>> search(@RequestParam(value = "name", required = false) String name, - @RequestParam(value = "role", required = false) String role) { + public Mono>> search( // + @RequestParam Optional name, // + @RequestParam Optional role) { - return Flux.fromIterable(EMPLOYEES.keySet()).flatMap(id -> findOne(id)).filter(resource -> { + return Flux.fromIterable(EMPLOYEES.keySet()) // + .flatMap(id -> findOne(id)) // + .filter(resource -> { - boolean nameMatches = Optional.ofNullable(name).map(s -> resource.getContent().getName().contains(s)) - .orElse(true); + boolean nameMatches = name // + .map(s -> resource.getContent().getName().contains(s)) // + .orElse(true); - boolean roleMatches = Optional.ofNullable(role).map(s -> resource.getContent().getRole().contains(s)) - .orElse(true); + boolean roleMatches = name.map(s -> resource.getContent().getRole().contains(s)) // + .orElse(true); - return nameMatches && roleMatches; - }).collectList().flatMap( - resources -> linkTo(methodOn(WebFluxEmployeeController.class).all()).map(ReactiveLinkBuilder::withSelfRel) - .map(link -> link.andAffordance(afford(methodOn(WebFluxEmployeeController.class).newEmployee(null))) - .andAffordance(afford(methodOn(WebFluxEmployeeController.class).search(null, null)))) - .map(selfLink -> new Resources<>(resources, selfLink))); + return nameMatches && roleMatches; + }).collectList().flatMap(resources -> { + + Class controller = WebFluxEmployeeController.class; + + return linkTo(methodOn(controller).all()).withSelfRel() // + .andAffordance(methodOn(controller).newEmployee(null)) // + .andAffordance(methodOn(controller).search(null, null)) // + .toMono() // + .map(selfLink -> new Resources<>(resources, selfLink)); + }); } @GetMapping("/employees/{id}") public Mono> findOne(@PathVariable Integer id) { - return linkTo(methodOn(WebFluxEmployeeController.class).findOne(id)) // - .map(ReactiveLinkBuilder::withSelfRel) // - .zipWith(linkTo(methodOn(WebFluxEmployeeController.class).all()) // - .map(reactiveLinkBuilder -> reactiveLinkBuilder.withRel("employees"))) - .map(function((selfLink, employeesLink) -> new Resource<>(EMPLOYEES.get(id), - selfLink.andAffordance(afford(methodOn(WebFluxEmployeeController.class).updateEmployee(null, id))) // - .andAffordance(afford(methodOn(WebFluxEmployeeController.class).partiallyUpdateEmployee(null, id))), - employeesLink))); + Mono selfLink = linkTo(methodOn(WebFluxEmployeeController.class).findOne(id)).withSelfRel() // + .andAffordance(methodOn(WebFluxEmployeeController.class).updateEmployee(null, id)) // + .andAffordance(methodOn(WebFluxEmployeeController.class).partiallyUpdateEmployee(null, id)) // + .toMono(); + + Mono employeesLink = linkTo(methodOn(WebFluxEmployeeController.class).all()).withRel("employees") // + .toMono(); + + return selfLink.zipWith(employeesLink) // + .map(function((left, right) -> Links.of(left, right))) // + .map(links -> new Resource<>(EMPLOYEES.get(id), links)); } @PostMapping("/employees") diff --git a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java index 22520f50..dc54e8f8 100644 --- a/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java +++ b/src/test/java/org/springframework/hateoas/support/WebMvcEmployeeController.java @@ -15,7 +15,7 @@ */ package org.springframework.hateoas.support; -import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*; +import static org.springframework.hateoas.mvc.WebMvcLinkBuilder.*; import java.net.URI; import java.net.URISyntaxException; @@ -24,6 +24,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.springframework.hateoas.IanaLinkRelations; import org.springframework.hateoas.Link; @@ -58,21 +60,15 @@ public class WebMvcEmployeeController { @GetMapping("/employees") public Resources> all() { - // Create a list of Resource's to return - List> employees = new ArrayList<>(); - - // Fetch each Resource using the controller's findOne method. - for (int i = 0; i < EMPLOYEES.size(); i++) { - employees.add(findOne(i)); - } - // Generate an "Affordance" based on this method (the "self" link) Link selfLink = linkTo(methodOn(WebMvcEmployeeController.class).all()).withSelfRel() // .andAffordance(afford(methodOn(WebMvcEmployeeController.class).newEmployee(null))) // .andAffordance(afford(methodOn(WebMvcEmployeeController.class).search(null, null))); // Return the collection of employee resources along with the composite affordance - return new Resources<>(employees, selfLink); + return IntStream.range(0, EMPLOYEES.size()) // + .mapToObj(this::findOne) // + .collect(Collectors.collectingAndThen(Collectors.toList(), it -> new Resources<>(it, selfLink))); } @GetMapping("/employees/search")