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")