From 4fd27b12fceaadec5181a641439ac246fa7f7756 Mon Sep 17 00:00:00 2001 From: Oliver Gierke Date: Fri, 2 Aug 2013 19:19:48 +0200 Subject: [PATCH] Introduced MvcUriComponentsBuilder to create URIs pointing to controller methods. MvcUriComponentsBuilder allows creating URIs that point to Spring MVC controller methods annotated with @RequestMapping. It builds them by exposing a mock method invocation API similar to Mockito, records the method invocations and thus builds up the URI by inspecting the mapping annotations and the parameters handed into the method invocations. Introduced a new SPI UriComponentsContributor that should be implemented by HandlerMethodArgumentResolvers that actually contribute path segments or query parameters to a URI. While the newly introduced MvcUriComponentsBuilder looks up those UriComponentsContributor instances from the MVC configuration. The MvcUriComponentsBuilderFactory (name to be discussed - MvcUris maybe?) prevents the multiple lookups by keeping the UriComponentsBuilder instances in an instance variable. So an instance of the factory could be exposed as Spring bean or through a HandlerMethodArgumentResolver to be injected into Controller methods. Issue: SPR-10665, SPR-8826 --- build.gradle | 1 + .../core/AnnotationAttribute.java | 125 +++++++ .../core/MethodParameters.java | 170 +++++++++ .../core/AnnotationAttributeUnitTests.java | 72 ++++ .../core/MethodParametersUnitTests.java | 58 ++++ .../web/util/UriComponentsBuilder.java | 31 +- .../AnnotatedParametersParameterAccessor.java | 160 +++++++++ .../AnnotationMappingDiscoverer.java | 121 +++++++ .../hypermedia/MvcUriComponentsBuilder.java | 328 ++++++++++++++++++ .../MvcUriComponentsBuilderFactory.java | 55 +++ .../web/servlet/hypermedia/MvcUris.java | 36 ++ .../hypermedia/RecordedInvocationUtils.java | 240 +++++++++++++ .../hypermedia/UriComponentsContributor.java | 51 +++ .../AnnotationMappingDiscovererUnitTests.java | 112 ++++++ ...cUriComponentsBuilderFactoryUnitTests.java | 131 +++++++ .../MvcUriComponentsBuilderUnitTests.java | 250 +++++++++++++ .../RecordedInvocationUtilsUnitTests.java | 47 +++ .../web/servlet/hypermedia/TestUtils.java | 49 +++ 18 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 spring-core/src/main/java/org/springframework/core/AnnotationAttribute.java create mode 100644 spring-core/src/main/java/org/springframework/core/MethodParameters.java create mode 100644 spring-core/src/test/java/org/springframework/core/AnnotationAttributeUnitTests.java create mode 100644 spring-core/src/test/java/org/springframework/core/MethodParametersUnitTests.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotatedParametersParameterAccessor.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscoverer.java create mode 100755 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilder.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactory.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUris.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtils.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/UriComponentsContributor.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscovererUnitTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactoryUnitTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderUnitTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtilsUnitTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/TestUtils.java diff --git a/build.gradle b/build.gradle index 75c51e2e02..1aa68b418f 100644 --- a/build.gradle +++ b/build.gradle @@ -640,6 +640,7 @@ project("spring-webmvc") { dependencies { compile(project(":spring-core")) + compile(files(project(":spring-core").objenesisRepackJar)) compile(project(":spring-expression")) compile(project(":spring-beans")) compile(project(":spring-web")) diff --git a/spring-core/src/main/java/org/springframework/core/AnnotationAttribute.java b/spring-core/src/main/java/org/springframework/core/AnnotationAttribute.java new file mode 100644 index 0000000000..96e44189fa --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/AnnotationAttribute.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2013 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.core; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; + +/** + * Simply helper to reference a dedicated attribute of an {@link Annotation}. + * + * @author Oliver Gierke + */ +public class AnnotationAttribute { + + private final Class annotationType; + + private final String attributeName; + + /** + * Creates a new {@link AnnotationAttribute} to the {@code value} attribute of the + * given {@link Annotation} type. + * + * @param annotationType must not be {@literal null}. + */ + public AnnotationAttribute(Class annotationType) { + this(annotationType, null); + } + + /** + * Creates a new {@link AnnotationAttribute} for the given {@link Annotation} type and + * annotation attribute name. + * + * @param annotationType must not be {@literal null}. + * @param attributeName can be {@literal null}, defaults to {@code value}. + */ + public AnnotationAttribute(Class annotationType, + String attributeName) { + + Assert.notNull(annotationType); + + this.annotationType = annotationType; + this.attributeName = attributeName; + } + + /** + * Returns the annotation type. + * + * @return the annotationType + */ + public Class getAnnotationType() { + return annotationType; + } + + /** + * Reads the {@link Annotation} attribute's value from the given + * {@link MethodParameter}. + * + * @param parameter must not be {@literal null}. + * @return + */ + public Object getValueFrom(MethodParameter parameter) { + + Assert.notNull(parameter, "MethodParameter must not be null!"); + Annotation annotation = parameter.getParameterAnnotation(annotationType); + return annotation == null ? null : getValueFrom(annotation); + } + + /** + * Reads the {@link Annotation} attribute's value from the given type. + * + * @param type must not be {@literal null}. + * @return + */ + public Object findValueOn(Class type) { + + Assert.notNull(type, "Type must not be null!"); + Annotation annotation = AnnotationUtils.findAnnotation(type, annotationType); + return annotation == null ? null : getValueFrom(annotation); + } + + public Object findValueOn(Method method) { + + Assert.notNull(method, "Method must nor be null!"); + Annotation annotation = AnnotationUtils.findAnnotation(method, annotationType); + return annotation == null ? null : getValueFrom(annotation); + } + + public Object getValueFrom(AnnotatedElement element) { + + Assert.notNull(element, "Annotated element must not be null!"); + Annotation annotation = AnnotationUtils.getAnnotation(element, annotationType); + return annotation == null ? null : getValueFrom(annotation); + } + + /** + * Returns the {@link Annotation} attribute's value from the given {@link Annotation}. + * + * @param annotation must not be {@literal null}. + * @return + */ + public Object getValueFrom(Annotation annotation) { + + Assert.notNull(annotation, "Annotation must not be null!"); + return attributeName == null ? AnnotationUtils.getValue(annotation) + : AnnotationUtils.getValue(annotation, attributeName); + } +} diff --git a/spring-core/src/main/java/org/springframework/core/MethodParameters.java b/spring-core/src/main/java/org/springframework/core/MethodParameters.java new file mode 100644 index 0000000000..61f36f219d --- /dev/null +++ b/spring-core/src/main/java/org/springframework/core/MethodParameters.java @@ -0,0 +1,170 @@ +/* + * Copyright 2012-2013 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.core; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Value object to represent {@link MethodParameters} to allow to easily find the ones + * with a given annotation. + * + * @author Oliver Gierke + */ +public class MethodParameters { + + private static final ParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer(); + + private final List parameters; + + /** + * Creates a new {@link MethodParameters} from the given {@link Method}. + * + * @param method must not be {@literal null}. + */ + public MethodParameters(Method method) { + this(method, null); + } + + /** + * Creates a new {@link MethodParameters} for the given {@link Method} and + * {@link AnnotationAttribute}. If the latter is given, method parameter names will be + * looked up from the annotation attribute if present. + * + * @param method must not be {@literal null}. + * @param namingAnnotation can be {@literal null}. + */ + public MethodParameters(Method method, AnnotationAttribute namingAnnotation) { + + Assert.notNull(method); + this.parameters = new ArrayList(); + + for (int i = 0; i < method.getParameterTypes().length; i++) { + + MethodParameter parameter = new AnnotationNamingMethodParameter(method, i, + namingAnnotation); + parameter.initParameterNameDiscovery(DISCOVERER); + parameters.add(parameter); + } + } + + /** + * Returns all {@link MethodParameter}s. + * + * @return + */ + public List getParameters() { + return parameters; + } + + /** + * Returns the {@link MethodParameter} with the given name or {@literal null} if none + * found. + * + * @param name must not be {@literal null} or empty. + * @return + */ + public MethodParameter getParameter(String name) { + + Assert.hasText(name, "Parameter name must not be null!"); + + for (MethodParameter parameter : parameters) { + if (name.equals(parameter.getParameterName())) { + return parameter; + } + } + + return null; + } + + /** + * Returns all {@link MethodParameter}s annotated with the given annotation type. + * + * @param annotation must not be {@literal null}. + * @return + */ + public List getParametersWith(Class annotation) { + + Assert.notNull(annotation); + List result = new ArrayList(); + + for (MethodParameter parameter : getParameters()) { + if (parameter.hasParameterAnnotation(annotation)) { + result.add(parameter); + } + } + + return result; + } + + /** + * Custom {@link MethodParameter} extension that will favor the name configured in the + * {@link AnnotationAttribute} if set over discovering it. + * + * @author Oliver Gierke + */ + private static class AnnotationNamingMethodParameter extends MethodParameter { + + private final AnnotationAttribute attribute; + + private String name; + + /** + * Creates a new {@link AnnotationNamingMethodParameter} for the given + * {@link Method}'s parameter with the given index. + * + * @param method must not be {@literal null}. + * @param parameterIndex + * @param attribute can be {@literal null} + */ + public AnnotationNamingMethodParameter(Method method, int parameterIndex, + AnnotationAttribute attribute) { + + super(method, parameterIndex); + this.attribute = attribute; + + } + + /* + * (non-Javadoc) + * + * @see org.springframework.core.MethodParameter#getParameterName() + */ + @Override + public String getParameterName() { + + if (name != null) { + return name; + } + + if (attribute != null) { + Object foundName = attribute.getValueFrom(this); + if (foundName != null) { + name = foundName.toString(); + return name; + } + } + + name = super.getParameterName(); + return name; + } + } +} diff --git a/spring-core/src/test/java/org/springframework/core/AnnotationAttributeUnitTests.java b/spring-core/src/test/java/org/springframework/core/AnnotationAttributeUnitTests.java new file mode 100644 index 0000000000..a4dd43f055 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/AnnotationAttributeUnitTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2013 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.core; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link AnnotationAttribute}. + * + * @author Oliver Gierke + */ +public class AnnotationAttributeUnitTests { + + AnnotationAttribute valueAttribute = new AnnotationAttribute(MyAnnotation.class); + + AnnotationAttribute nonValueAttribute = new AnnotationAttribute(MyAnnotation.class, + "nonValue"); + + @Test + public void readsAttributesFromType() { + + assertThat(valueAttribute.findValueOn(Sample.class), is((Object) "foo")); + assertThat(nonValueAttribute.findValueOn(Sample.class), is((Object) "bar")); + } + + @Test + public void findsAttributesFromSubType() { + assertThat(valueAttribute.findValueOn(SampleSub.class), is((Object) "foo")); + } + + @Test + public void doesNotGetValueFromSubTyp() { + assertThat(valueAttribute.getValueFrom(SampleSub.class), is(nullValue())); + } + + @Retention(RetentionPolicy.RUNTIME) + public static @interface MyAnnotation { + + String value() default ""; + + String nonValue() default ""; + } + + @MyAnnotation(value = "foo", nonValue = "bar") + static class Sample { + + } + + static class SampleSub extends Sample { + + } +} diff --git a/spring-core/src/test/java/org/springframework/core/MethodParametersUnitTests.java b/spring-core/src/test/java/org/springframework/core/MethodParametersUnitTests.java new file mode 100644 index 0000000000..1517cb1059 --- /dev/null +++ b/spring-core/src/test/java/org/springframework/core/MethodParametersUnitTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2013 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.core; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; + +import org.junit.Test; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link MethodParameters}. + * + * @author Oliver Gierke + */ +public class MethodParametersUnitTests { + + @Test + public void prefersAnnotatedParameterOverDiscovered() throws Exception { + + Method method = Sample.class.getMethod("method", String.class, String.class); + MethodParameters parameters = new MethodParameters(method, + new AnnotationAttribute(Qualifier.class)); + + assertThat(parameters.getParameter("param"), is(notNullValue())); + assertThat(parameters.getParameter("foo"), is(notNullValue())); + assertThat(parameters.getParameter("another"), is(nullValue())); + } + + @Retention(RetentionPolicy.RUNTIME) + public static @interface Qualifier { + + String value() default ""; + } + + static class Sample { + + public void method(String param, @Qualifier("foo") String another) { + } + } +} diff --git a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java index fa5e7aeba0..d3a4c93faa 100644 --- a/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java +++ b/spring-web/src/main/java/org/springframework/web/util/UriComponentsBuilder.java @@ -554,13 +554,42 @@ public class UriComponentsBuilder { return this; } + public UriComponentsBuilder with(UriComponentsBuilder builder) { + + UriComponents components = builder.build().normalize(); + + if (StringUtils.hasText(components.getScheme())) { + scheme(components.getScheme()); + } + + if (StringUtils.hasText(components.getHost())) { + host(components.getHost()); + } + + if (components.getPort() != -1) { + port(components.getPort()); + } + + if (StringUtils.hasText(components.getPath())) { + path(components.getPath()); + } + + if (StringUtils.hasText(components.getQuery())) { + query(components.getQuery()); + } + + if (StringUtils.hasText(components.getFragment())) { + fragment(components.getFragment()); + } + + return this; + } private interface PathComponentBuilder { PathComponent build(); } - private static class CompositePathComponentBuilder implements PathComponentBuilder { private final LinkedList componentBuilders = new LinkedList(); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotatedParametersParameterAccessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotatedParametersParameterAccessor.java new file mode 100644 index 0000000000..78d74766da --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotatedParametersParameterAccessor.java @@ -0,0 +1,160 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.AnnotationAttribute; +import org.springframework.core.MethodParameter; +import org.springframework.core.MethodParameters; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.hypermedia.RecordedInvocationUtils.RecordedMethodInvocation; +import org.springframework.web.util.UriTemplate; + +/** + * Value object to allow accessing {@link RecordedMethodInvocation} parameters with the + * configured {@link AnnotationAttribute}. + * + * @author Oliver Gierke + */ +class AnnotatedParametersParameterAccessor { + + private final AnnotationAttribute attribute; + + /** + * Creates a new {@link AnnotatedParametersParameterAccessor} using the given + * {@link AnnotationAttribute}. + * + * @param attribute must not be {@literal null}. + */ + public AnnotatedParametersParameterAccessor(AnnotationAttribute attribute) { + + Assert.notNull(attribute); + this.attribute = attribute; + } + + /** + * Returns {@link BoundMethodParameter}s contained in the given + * {@link RecordedMethodInvocation}. + * + * @param invocation must not be {@literal null}. + * @return + */ + public List getBoundParameters(RecordedMethodInvocation invocation) { + + Assert.notNull(invocation, "RecordedMethodInvocation must not be null!"); + + MethodParameters parameters = new MethodParameters(invocation.getMethod()); + Object[] arguments = invocation.getArguments(); + List result = new ArrayList(); + + for (MethodParameter parameter : parameters.getParametersWith(attribute.getAnnotationType())) { + result.add(new BoundMethodParameter(parameter, + arguments[parameter.getParameterIndex()], attribute)); + } + + return result; + } + + /** + * Represents a {@link MethodParameter} alongside the value it has been bound to. + * + * @author Oliver Gierke + */ + static class BoundMethodParameter { + + private static final ConversionService CONVERSION_SERVICE = new DefaultFormattingConversionService(); + + private static final TypeDescriptor STRING_DESCRIPTOR = TypeDescriptor.valueOf(String.class); + + private final MethodParameter parameter; + + private final Object value; + + private final AnnotationAttribute attribute; + + private final TypeDescriptor parameterTypeDecsriptor; + + /** + * Creates a new {@link BoundMethodParameter} + * + * @param parameter + * @param value + * @param attribute + */ + public BoundMethodParameter(MethodParameter parameter, Object value, + AnnotationAttribute attribute) { + + Assert.notNull(parameter, "MethodParameter must not be null!"); + + this.parameter = parameter; + this.value = value; + this.attribute = attribute; + this.parameterTypeDecsriptor = TypeDescriptor.nested(parameter, 0); + } + + /** + * Returns the name of the {@link UriTemplate} variable to be bound. The name will + * be derived from the configured {@link AnnotationAttribute} or the + * {@link MethodParameter} name as fallback. + * + * @return + */ + public String getVariableName() { + + if (attribute == null) { + return parameter.getParameterName(); + } + + Annotation annotation = parameter.getParameterAnnotation(attribute.getAnnotationType()); + String annotationAttributeValue = attribute.getValueFrom(annotation).toString(); + return StringUtils.hasText(annotationAttributeValue) ? annotationAttributeValue + : parameter.getParameterName(); + } + + /** + * Returns the raw value bound to the {@link MethodParameter}. + * + * @return + */ + public Object getValue() { + return value; + } + + /** + * Returns the bound value converted into a {@link String} based on default + * conversion service setup. + * + * @return + */ + public String asString() { + + if (value == null) { + return null; + } + + return (String) CONVERSION_SERVICE.convert(value, parameterTypeDecsriptor, + STRING_DESCRIPTOR); + } + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscoverer.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscoverer.java new file mode 100644 index 0000000000..26e87a3d53 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscoverer.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012 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.web.servlet.hypermedia; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + +import org.springframework.core.AnnotationAttribute; +import org.springframework.util.Assert; + +/** + * {@link MappingDiscoverer} implementation that inspects mappings from a particular + * annotation. + * + * @author Oliver Gierke + */ +class AnnotationMappingDiscoverer { + + private final AnnotationAttribute attribute; + + /** + * Creates an {@link AnnotationMappingDiscoverer} for the given annotation type. Will + * lookup the {@code value} attribute by default. + * + * @param annotation must not be {@literal null}. + */ + public AnnotationMappingDiscoverer(Class annotation) { + this(new AnnotationAttribute(annotation)); + } + + /** + * Creates an {@link AnnotationMappingDiscoverer} for the given annotation type and + * attribute name. + * + * @param annotation must not be {@literal null}. + * @param mappingAttributeName if {@literal null}, it defaults to {@code value}. + */ + public AnnotationMappingDiscoverer(AnnotationAttribute attribute) { + + Assert.notNull(attribute); + this.attribute = attribute; + } + + /** + * Returns the mapping associated with the given type. + * + * @param type must not be {@literal null}. + * @return the type-level mapping or {@literal null} in case none is present. + */ + public String getMapping(Class type) { + + String[] mapping = getMappingFrom(attribute.findValueOn(type)); + + if (mapping.length > 1) { + throw new IllegalStateException(String.format( + "Multiple class level mappings defined on class %s!", type.getName())); + } + + return mapping.length == 0 ? null : mapping[0]; + } + + /** + * Returns the mapping associated with the given {@link Method}. This will include the + * type-level mapping. + * + * @param method must not be {@literal null}. + * @return the method mapping including the type-level one or {@literal null} if + * neither of them present. + */ + public String getMapping(Method method) { + + String[] mapping = getMappingFrom(attribute.findValueOn(method)); + + if (mapping.length > 1) { + throw new IllegalStateException(String.format( + "Multiple method level mappings defined on method %s!", + method.toString())); + } + + String typeMapping = getMapping(method.getDeclaringClass()); + + if (mapping == null || mapping.length == 0) { + return typeMapping; + } + + return typeMapping == null || "/".equals(typeMapping) ? mapping[0] : typeMapping + + mapping[0]; + } + + private String[] getMappingFrom(Object annotationValue) { + + if (annotationValue instanceof String) { + return new String[] { (String) annotationValue }; + } + else if (annotationValue instanceof String[]) { + return (String[]) annotationValue; + } + else if (annotationValue == null) { + return new String[0]; + } + + throw new IllegalStateException( + String.format( + "Unsupported type for the mapping attribute! Support String and String[] but got %s!", + annotationValue.getClass())); + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilder.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilder.java new file mode 100755 index 0000000000..0fd76c6ab5 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilder.java @@ -0,0 +1,328 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.lang.reflect.Method; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.AnnotationAttribute; +import org.springframework.core.MethodParameter; +import org.springframework.core.MethodParameters; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +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.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.context.support.SpringBeanAutowiringSupport; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.hypermedia.AnnotatedParametersParameterAccessor.BoundMethodParameter; +import org.springframework.web.servlet.hypermedia.RecordedInvocationUtils.LastInvocationAware; +import org.springframework.web.servlet.hypermedia.RecordedInvocationUtils.RecordedMethodInvocation; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponents; +//import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriTemplate; + +import static org.springframework.web.servlet.hypermedia.RecordedInvocationUtils.*; + +/** + * Builder to ease building {@link URI} instances pointing to Spring MVC controllers. + * + * @author Oliver Gierke + */ +public class MvcUriComponentsBuilder extends UriComponentsBuilder { + + private static final AnnotationMappingDiscoverer DISCOVERER = new AnnotationMappingDiscoverer( + RequestMapping.class); + + private static final AnnotatedParametersParameterAccessor PATH_VARIABLE_ACCESSOR = new AnnotatedParametersParameterAccessor( + new AnnotationAttribute(PathVariable.class)); + + private static final AnnotatedParametersParameterAccessor REQUEST_PARAM_ACCESSOR = new AnnotatedParametersParameterAccessor( + new AnnotationAttribute(RequestParam.class)); + + private final List contributors; + + @Autowired(required = false) + private RequestMappingHandlerAdapter adapter; + + /** + * Creates a new {@link LinkBuilderSupport} to grab the + * {@link UriComponentsContributor}s registered in the + * {@link RequestMappingHandlerAdapter}. + * + * @param builder must not be {@literal null}. + */ + MvcUriComponentsBuilder() { + + SpringBeanAutowiringSupport.processInjectionBasedOnCurrentContext(this); + List contributors = new ArrayList(); + + if (adapter != null) { + for (HandlerMethodArgumentResolver resolver : adapter.getArgumentResolvers()) { + if (resolver instanceof UriComponentsContributor) { + contributors.add((UriComponentsContributor) resolver); + } + } + } + + this.contributors = contributors; + } + + /** + * Creates a new {@link MvcUriComponentsBuilder} 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 UriComponentsBuilder from(Class controller) { + return from(controller, new Object[0]); + } + + /** + * Creates a new {@link MvcUriComponentsBuilder} 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 UriComponentsBuilder from(Class controller, Object... parameters) { + + Assert.notNull(controller); + + String mapping = DISCOVERER.getMapping(controller); + UriTemplate template = new UriTemplate(mapping == null ? "/" : mapping); + UriComponentsBuilder builder = UriComponentsBuilder.fromUri(template.expand(parameters)); + return getRootBuilder().with(builder); + } + + public static UriComponentsBuilder from(Method method, Object... parameters) { + MvcUriComponentsBuilder builder = new MvcUriComponentsBuilder(); + return from(method, parameters, builder.contributors); + } + + static UriComponentsBuilder from(Method method, Object[] parameters, + List contributors) { + + UriTemplate template = new UriTemplate(DISCOVERER.getMapping(method)); + UriComponentsBuilder builder = UriComponentsBuilder.fromUri(template.expand(parameters)); + + RecordedMethodInvocation invocation = getInvocation(method, parameters); + UriComponentsBuilder appender = applyUriComponentsContributer(invocation, + builder, contributors); + + return getRootBuilder().with(appender); + } + + /** + * Creates a {@link MvcUriComponentsBuilder} pointing to a controller method. Hand in + * a dummy method invocation result you can create via + * {@link #methodOn(Class, Object...)} or + * {@link RecordedInvocationUtils#methodOn(Class, Object...)}. + * + *
+	 * @RequestMapping("/customers")
+	 * class CustomerController {
+	 * 
+	 *   @RequestMapping("/{id}/addresses")
+	 *   HttpEntity<Addresses> showAddresses(@PathVariable Long id) { … } 
+	 * }
+	 * 
+	 * URI uri = linkTo(methodOn(CustomerController.class).showAddresses(2L)).toURI();
+	 * 
+ * + * The resulting {@link URI} instance will point to {@code /customers/2/addresses}. + * For more details on the method invocation constraints, see + * {@link RecordedInvocationUtils#methodOn(Class, Object...)}. + * + * @param invocationValue + * @return + */ + /* + * (non-Javadoc) + * + * @see org.springframework.hateoas.MethodLinkBuilderFactory#linkTo(java.lang.Object) + */ + public static UriComponentsBuilder from(Object invocationValue) { + + MvcUriComponentsBuilder builder = new MvcUriComponentsBuilder(); + return from(invocationValue, builder.contributors); + } + + static UriComponentsBuilder from(Object invocationValue, + List contributors) { + + Assert.isInstanceOf(LastInvocationAware.class, invocationValue); + LastInvocationAware invocations = (LastInvocationAware) invocationValue; + + RecordedMethodInvocation invocation = invocations.getLastInvocation(); + Iterator classMappingParameters = invocations.getObjectParameters(); + Method method = invocation.getMethod(); + + String mapping = DISCOVERER.getMapping(method); + UriComponentsBuilder builder = getRootBuilder().path(mapping); + + UriTemplate template = new UriTemplate(mapping); + Map values = new HashMap(); + + Iterator names = template.getVariableNames().iterator(); + while (classMappingParameters.hasNext()) { + values.put(names.next(), classMappingParameters.next()); + } + + for (BoundMethodParameter parameter : PATH_VARIABLE_ACCESSOR.getBoundParameters(invocation)) { + values.put(parameter.getVariableName(), parameter.asString()); + } + + for (BoundMethodParameter parameter : REQUEST_PARAM_ACCESSOR.getBoundParameters(invocation)) { + + Object value = parameter.getValue(); + String key = parameter.getVariableName(); + + if (value instanceof Collection) { + for (Object element : (Collection) value) { + builder.queryParam(key, element); + } + } + else { + builder.queryParam(key, parameter.asString()); + } + } + + UriComponents components = applyUriComponentsContributer(invocation, builder, + contributors).buildAndExpand(values); + return UriComponentsBuilder.fromUri(components.toUri()); + } + + /** + * Wrapper for {@link RecordedInvocationUtils#methodOn(Class, Object...)} to be + * available in case you work with static imports of {@link MvcUriComponentsBuilder}. + * + * @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 RecordedInvocationUtils.methodOn(controller, parameters); + } + + /** + * Returns a {@link UriComponentsBuilder} obtained from the current servlet mapping + * with the host tweaked in case the request contains an {@code X-Forwarded-Host} + * header. + * + * @return + */ + static UriComponentsBuilder getRootBuilder() { + + HttpServletRequest request = getCurrentRequest(); + UriComponentsBuilder builder = ServletUriComponentsBuilder.fromServletMapping(request); + + String header = request.getHeader("X-Forwarded-Host"); + + if (!StringUtils.hasText(header)) { + return builder; + } + + String[] hosts = StringUtils.commaDelimitedListToStringArray(header); + String hostToUse = hosts[0]; + + if (hostToUse.contains(":")) { + + String[] hostAndPort = StringUtils.split(hostToUse, ":"); + + builder.host(hostAndPort[0]); + builder.port(Integer.parseInt(hostAndPort[1])); + + } + else { + builder.host(hostToUse); + } + + return builder; + } + + /** + * Applies the configured {@link UriComponentsContributor}s to the given + * {@link UriComponentsBuilder}. + * + * @param builder will never be {@literal null}. + * @param invocation will never be {@literal null}. + * @return + */ + private static UriComponentsBuilder applyUriComponentsContributer( + RecordedMethodInvocation invocation, UriComponentsBuilder builder, + Collection contributors) { + + if (contributors.isEmpty()) { + return builder; + } + + MethodParameters parameters = new MethodParameters(invocation.getMethod()); + Iterator parameterValues = Arrays.asList(invocation.getArguments()).iterator(); + + for (MethodParameter parameter : parameters.getParameters()) { + Object parameterValue = parameterValues.next(); + for (UriComponentsContributor contributor : contributors) { + if (contributor.supportsParameter(parameter)) { + contributor.enhance(builder, parameter, parameterValue); + } + } + } + + return builder; + } + + /** + * Copy of {@link ServletUriComponentsBuilder#getCurrentRequest()} until SPR-10110 + * gets fixed. + * + * @return + */ + private static HttpServletRequest getCurrentRequest() { + + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + Assert.state(requestAttributes != null, + "Could not find current request via RequestContextHolder"); + Assert.isInstanceOf(ServletRequestAttributes.class, requestAttributes); + HttpServletRequest servletRequest = ((ServletRequestAttributes) requestAttributes).getRequest(); + Assert.state(servletRequest != null, "Could not find current HttpServletRequest"); + return servletRequest; + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactory.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactory.java new file mode 100644 index 0000000000..5ee28624e6 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2013 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.web.servlet.hypermedia; + +import java.lang.reflect.Method; +import java.util.List; + +import org.springframework.web.util.UriComponentsBuilder; + +/** + * + * @author olivergierke + */ +public class MvcUriComponentsBuilderFactory implements MvcUris { + + private final List contributors; + + /** + * @param contributors + */ + public MvcUriComponentsBuilderFactory( + List contributors) { + this.contributors = contributors; + } + + public UriComponentsBuilder from(Class controller) { + return from(controller, new Object[0]); + } + + public UriComponentsBuilder from(Class controller, Object... parameters) { + return MvcUriComponentsBuilder.from(controller, parameters); + } + + public UriComponentsBuilder from(Object invocationValue) { + return MvcUriComponentsBuilder.from(invocationValue, contributors); + } + + public UriComponentsBuilder from(Method method, Object... parameters) { + return MvcUriComponentsBuilder.from(method, parameters, contributors); + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUris.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUris.java new file mode 100644 index 0000000000..6815547f0b --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/MvcUris.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2013 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.web.servlet.hypermedia; + +import java.lang.reflect.Method; + +import org.springframework.web.util.UriComponentsBuilder; + +/** + * + * @author olivergierke + */ +public interface MvcUris { + + UriComponentsBuilder from(Class controller); + + UriComponentsBuilder from(Class controller, Object... parameters); + + UriComponentsBuilder from(Object invocationValue); + + UriComponentsBuilder from(Method method, Object... parameters); +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtils.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtils.java new file mode 100644 index 0000000000..bdf9a42111 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtils.java @@ -0,0 +1,240 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Iterator; + +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.target.EmptyTargetSource; +import org.springframework.cglib.proxy.Callback; +import org.springframework.cglib.proxy.Enhancer; +import org.springframework.cglib.proxy.Factory; +import org.springframework.cglib.proxy.MethodProxy; +import org.springframework.objenesis.ObjenesisStd; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Utility methods to capture dummy method invocations. + * + * @author Oliver Gierke + */ +class RecordedInvocationUtils { + + private static ObjenesisStd OBJENESIS = new ObjenesisStd(true); + + public interface LastInvocationAware { + + Iterator getObjectParameters(); + + RecordedMethodInvocation getLastInvocation(); + } + + /** + * Method interceptor that records the last method invocation and creates a proxy for + * the return value that exposes the method invocation. + * + * @author Oliver Gierke + */ + private static class InvocationRecordingMethodInterceptor implements + MethodInterceptor, LastInvocationAware, + org.springframework.cglib.proxy.MethodInterceptor { + + private static final Method GET_INVOCATIONS; + + private static final Method GET_OBJECT_PARAMETERS; + + private final Object[] objectParameters; + + private RecordedMethodInvocation invocation; + + static { + GET_INVOCATIONS = ReflectionUtils.findMethod(LastInvocationAware.class, + "getLastInvocation"); + GET_OBJECT_PARAMETERS = ReflectionUtils.findMethod(LastInvocationAware.class, + "getObjectParameters"); + } + + /** + * Creates a new {@link InvocationRecordingMethodInterceptor} carrying the given + * parameters forward that might be needed to populate the class level mapping. + * + * @param parameters + */ + public InvocationRecordingMethodInterceptor(Object... parameters) { + this.objectParameters = parameters.clone(); + } + + /* + * (non-Javadoc) + * + * @see + * org.springframework.cglib.proxy.MethodInterceptor#intercept(java.lang.Object, + * java.lang.reflect.Method, java.lang.Object[], + * org.springframework.cglib.proxy.MethodProxy) + */ + public Object intercept(Object obj, Method method, Object[] args, + MethodProxy proxy) { + + if (GET_INVOCATIONS.equals(method)) { + return getLastInvocation(); + } + else if (GET_OBJECT_PARAMETERS.equals(method)) { + return getObjectParameters(); + } + else if (ReflectionUtils.isObjectMethod(method)) { + return ReflectionUtils.invokeMethod(method, obj, args); + } + + this.invocation = new SimpleRecordedMethodInvocation(method, args); + + Class returnType = method.getReturnType(); + return returnType.cast(getProxyWithInterceptor(returnType, this)); + } + + /* + * (non-Javadoc) + * + * @see + * org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept + * .MethodInvocation) + */ + @Override + public Object invoke(org.aopalliance.intercept.MethodInvocation invocation) + throws Throwable { + return intercept(invocation.getThis(), invocation.getMethod(), + invocation.getArguments(), null); + } + + /* + * (non-Javadoc) + * + * @see org.springframework.hateoas.core.DummyInvocationUtils.LastInvocationAware# + * getLastInvocation() + */ + @Override + public RecordedMethodInvocation getLastInvocation() { + return invocation; + } + + /* + * (non-Javadoc) + * + * @see org.springframework.hateoas.core.DummyInvocationUtils.LastInvocationAware# + * getObjectParameters() + */ + @Override + public Iterator getObjectParameters() { + return Arrays.asList(objectParameters).iterator(); + } + } + + /** + * Returns a proxy of the given type, backed by an {@link EmptyTargetSource} to simply + * drop method invocations but equips it with an + * {@link InvocationRecordingMethodInterceptor}. The interceptor records the last + * invocation and returns a proxy of the return type that also implements + * {@link LastInvocationAware} so that the last method invocation can be inspected. + * Parameters passed to the subsequent method invocation are generally neglected + * except the ones that might be mapped into the URI translation eventually, e.g. + * {@linke PathVariable} in the case of Spring MVC. + * + * @param type must not be {@literal null}. + * @param parameters parameters to extend template variables in the type level + * mapping. + * @return + */ + public static T methodOn(Class type, Object... parameters) { + + Assert.notNull(type, "Given type must not be null!"); + + InvocationRecordingMethodInterceptor interceptor = new InvocationRecordingMethodInterceptor( + parameters); + return getProxyWithInterceptor(type, interceptor); + } + + static RecordedMethodInvocation getInvocation(Method method, Object[] parameters) { + return new SimpleRecordedMethodInvocation(method, parameters); + } + + @SuppressWarnings("unchecked") + private static T getProxyWithInterceptor(Class type, + InvocationRecordingMethodInterceptor interceptor) { + + if (type.isInterface()) { + + ProxyFactory factory = new ProxyFactory(EmptyTargetSource.INSTANCE); + factory.addInterface(type); + factory.addInterface(LastInvocationAware.class); + factory.addAdvice(interceptor); + + return (T) factory.getProxy(); + } + + Enhancer enhancer = new Enhancer(); + enhancer.setSuperclass(type); + enhancer.setInterfaces(new Class[] { LastInvocationAware.class }); + enhancer.setCallbackType(org.springframework.cglib.proxy.MethodInterceptor.class); + + Factory factory = (Factory) OBJENESIS.newInstance(enhancer.createClass()); + factory.setCallbacks(new Callback[] { interceptor }); + return (T) factory; + } + + public interface RecordedMethodInvocation { + + Object[] getArguments(); + + Method getMethod(); + } + + static class SimpleRecordedMethodInvocation implements RecordedMethodInvocation { + + private final Method method; + + private final Object[] arguments; + + /** + * Creates a new {@link SimpleRecordedMethodInvocation} for the given + * {@link Method} and arguments. + * + * @param method must not be {@literal null}. + * @param arguments must not be {@literal null}. + */ + private SimpleRecordedMethodInvocation(Method method, Object[] arguments) { + + Assert.notNull(method, "Method must not be null!"); + Assert.notNull(arguments, "Arguments must not be null!"); + + this.arguments = arguments; + this.method = method; + } + + @Override + public Object[] getArguments() { + return arguments; + } + + @Override + public Method getMethod() { + return method; + } + } +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/UriComponentsContributor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/UriComponentsContributor.java new file mode 100644 index 0000000000..796d2692f0 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/hypermedia/UriComponentsContributor.java @@ -0,0 +1,51 @@ +/* + * Copyright 2013 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.web.servlet.hypermedia; + +import org.springframework.core.MethodParameter; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * SPI callback to enhance a {@link UriComponentsBuilder} when referring to a method + * through a dummy method invocation. Will usually be implemented in implementations of + * {@link HandlerMethodArgumentResolver} as they represent exactly the same functionality + * inverted. + * + * @see MvcUriComponentsBuilderFactory#from(Object) + * @author Oliver Gierke + */ +public interface UriComponentsContributor { + + /** + * Returns whether the {@link UriComponentsBuilder} supports the given + * {@link MethodParameter}. + * + * @param parameter will never be {@literal null}. + * @return + */ + boolean supportsParameter(MethodParameter parameter); + + /** + * Enhance the given {@link UriComponentsBuilder} with the given value. + * + * @param builder will never be {@literal null}. + * @param parameter will never be {@literal null}. + * @param value can be {@literal null}. + */ + void enhance(UriComponentsBuilder builder, MethodParameter parameter, Object value); +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscovererUnitTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscovererUnitTests.java new file mode 100644 index 0000000000..4e68cbcf45 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/AnnotationMappingDiscovererUnitTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; + +import org.junit.Test; +import org.springframework.core.AnnotationAttribute; +import org.springframework.web.bind.annotation.RequestMapping; + +import static org.hamcrest.CoreMatchers.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link AnnotationMappingDiscoverer}. + * + * @author Oliver Gierke + */ +public class AnnotationMappingDiscovererUnitTests { + + AnnotationMappingDiscoverer discoverer = new AnnotationMappingDiscoverer( + RequestMapping.class); + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullAnnotationType() { + new AnnotationMappingDiscoverer((Class) null); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNullAnnotationAttribute() { + new AnnotationMappingDiscoverer((AnnotationAttribute) null); + } + + @Test + public void discoversTypeLevelMapping() { + assertThat(discoverer.getMapping(MyController.class), is("/type")); + } + + @Test + public void discoversMethodLevelMapping() throws Exception { + Method method = MyController.class.getMethod("method"); + assertThat(discoverer.getMapping(method), is("/type/method")); + } + + @Test + public void returnsNullForNonExistentTypeLevelMapping() { + assertThat(discoverer.getMapping(ControllerWithoutTypeLevelMapping.class), + is(nullValue())); + } + + @Test + public void resolvesMethodLevelMappingWithoutTypeLevelMapping() throws Exception { + + Method method = ControllerWithoutTypeLevelMapping.class.getMethod("method"); + assertThat(discoverer.getMapping(method), is("/method")); + } + + @Test + public void resolvesMethodLevelMappingWithSlashRootMapping() throws Exception { + + Method method = SlashRootMapping.class.getMethod("method"); + assertThat(discoverer.getMapping(method), is("/method")); + } + + /** + * @see #46 + */ + @Test + public void treatsMissingMethodMappingAsEmptyMapping() throws Exception { + + Method method = MyController.class.getMethod("noMethodMapping"); + assertThat(discoverer.getMapping(method), is("/type")); + } + + @RequestMapping("/type") + interface MyController { + + @RequestMapping("/method") + void method(); + + @RequestMapping + void noMethodMapping(); + } + + interface ControllerWithoutTypeLevelMapping { + + @RequestMapping("/method") + void method(); + } + + @RequestMapping("/") + interface SlashRootMapping { + + @RequestMapping("/method") + void method(); + } +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactoryUnitTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactoryUnitTests.java new file mode 100644 index 0000000000..5e52559e99 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderFactoryUnitTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +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.http.HttpEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.hypermedia.MvcUriComponentsBuilderUnitTests.PersonControllerImpl; +import org.springframework.web.servlet.hypermedia.MvcUriComponentsBuilderUnitTests.PersonsAddressesController; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.web.servlet.hypermedia.MvcUriComponentsBuilder.*; + +/** + * Unit tests for {@link MvcUriComponentsBuilderFactory}. + * + * @author Ricardo Gladwell + * @author Oliver Gierke + */ +public class MvcUriComponentsBuilderFactoryUnitTests extends TestUtils { + + List contributors = Collections.emptyList(); + + MvcUriComponentsBuilderFactory factory = new MvcUriComponentsBuilderFactory( + contributors); + + @Test + public void createsLinkToControllerRoot() { + + URI link = factory.from(PersonControllerImpl.class).build().toUri(); + + assertPointsToMockServer(link); + assertThat(link.toString(), endsWith("/people")); + } + + @Test + public void createsLinkToParameterizedControllerRoot() { + + URI link = factory.from(PersonsAddressesController.class, 15).build().toUri(); + + assertPointsToMockServer(link); + assertThat(link.toString(), endsWith("/people/15/addresses")); + } + + @Test + public void appliesParameterValueIfContributorConfigured() { + + List contributors = Arrays.asList(new SampleUriComponentsContributor()); + MvcUriComponentsBuilderFactory factory = new MvcUriComponentsBuilderFactory( + contributors); + + SpecialType specialType = new SpecialType(); + specialType.parameterValue = "value"; + + URI link = factory.from( + methodOn(SampleController.class).sampleMethod(1L, specialType)).build().toUri(); + assertPointsToMockServer(link); + assertThat(link.toString(), endsWith("/sample/1?foo=value")); + } + + /** + * @see #57 + */ + @Test + public void usesDateTimeFormatForUriBinding() { + + DateTime now = DateTime.now(); + + MvcUriComponentsBuilderFactory factory = new MvcUriComponentsBuilderFactory( + contributors); + URI link = factory.from(methodOn(SampleController.class).sampleMethod(now)).build().toUri(); + assertThat(link.toString(), + endsWith("/sample/" + ISODateTimeFormat.date().print(now))); + } + + static 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); + } + + 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/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderUnitTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderUnitTests.java new file mode 100644 index 0000000000..f3ec765d32 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/MvcUriComponentsBuilderUnitTests.java @@ -0,0 +1,250 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +import org.hamcrest.Matchers; +import org.junit.Test; +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; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; +import static org.springframework.web.servlet.hypermedia.MvcUriComponentsBuilder.*; + +/** + * Unit tests for {@link MvcUriComponentsBuilder}. + * + * @author Oliver Gierke + * @author Dietrich Schulten + */ +public class MvcUriComponentsBuilderUnitTests extends TestUtils { + + @Test + public void createsLinkToControllerRoot() { + + URI link = from(PersonControllerImpl.class).build().toUri(); + assertThat(link.toString(), Matchers.endsWith("/people")); + } + + @Test + public void createsLinkToParameterizedControllerRoot() { + + URI link = from(PersonsAddressesController.class, 15).build().toUri(); + assertThat(link.toString(), endsWith("/people/15/addresses")); + } + + /** + * @see #70 + */ + @Test + public void createsLinkToMethodOnParameterizedControllerRoot() { + + URI link = from( + methodOn(PersonsAddressesController.class, 15).getAddressesForCountry( + "DE")).build().toUri(); + assertThat(link.toString(), endsWith("/people/15/addresses/DE")); + } + + @Test + public void createsLinkToSubResource() { + + URI link = from(PersonControllerImpl.class).pathSegment("something").build().toUri(); + assertThat(link.toString(), endsWith("/people/something")); + } + + @Test(expected = IllegalStateException.class) + public void rejectsControllerWithMultipleMappings() { + from(InvalidController.class); + } + + @Test + public void createsLinkToUnmappedController() { + + URI link = from(UnmappedController.class).build().toUri(); + assertThat(link.toString(), is("http://localhost/")); + } + + @Test + public void appendingNullIsANoOp() { + + URI link = from(PersonControllerImpl.class).path(null).build().toUri(); + assertThat(link.toString(), endsWith("/people")); + } + + @Test + public void linksToMethod() { + + URI link = from(methodOn(ControllerWithMethods.class).myMethod(null)).build().toUri(); + assertPointsToMockServer(link); + assertThat(link.toString(), endsWith("/something/else")); + } + + @Test + public void linksToMethodWithPathVariable() { + + URI link = from(methodOn(ControllerWithMethods.class).methodWithPathVariable("1")).build().toUri(); + assertPointsToMockServer(link); + assertThat(link.toString(), endsWith("/something/1/foo")); + } + + /** + * @see #33 + */ + @Test + public void usesForwardedHostAsHostIfHeaderIsSet() { + + request.addHeader("X-Forwarded-Host", "somethingDifferent"); + + URI link = from(PersonControllerImpl.class).build().toUri(); + assertThat(link.toString(), startsWith("http://somethingDifferent")); + } + + /** + * @see #26, #39 + */ + @Test + public void linksToMethodWithPathVariableAndRequestParams() { + + URI link = from( + methodOn(ControllerWithMethods.class).methodForNextPage("1", 10, 5)).build().toUri(); + + UriComponents components = toComponents(link); + assertThat(components.getPath(), is("/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() { + + URI link = from( + methodOn(ControllerWithMethods.class).methodWithMultiValueRequestParams( + "1", Arrays.asList(3, 7), 5)).build().toUri(); + + UriComponents components = toComponents(link); + assertThat(components.getPath(), is("/something/1/foo")); + + MultiValueMap queryParams = components.getQueryParams(); + assertThat(queryParams.get("limit"), contains("5")); + assertThat(queryParams.get("items"), containsInAnyOrder("3", "7")); + } + + /** + * @see #90 + */ + @Test + public void usesForwardedHostAndPortFromHeader() { + + request.addHeader("X-Forwarded-Host", "foobar:8088"); + + URI link = from(PersonControllerImpl.class).build().toUri(); + assertThat(link.toString(), startsWith("http://foobar:8088")); + } + + /** + * @see #90 + */ + @Test + public void usesFirstHostOfXForwardedHost() { + + request.addHeader("X-Forwarded-Host", "barfoo:8888, localhost:8088"); + + URI link = from(PersonControllerImpl.class).build().toUri(); + assertThat(link.toString(), startsWith("http://barfoo:8888")); + } + + private static UriComponents toComponents(URI link) { + return UriComponentsBuilder.fromUri(link).build(); + } + + static class Person { + + Long id; + + public Long getId() { + return 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(value = "/{id}/foo") + HttpEntity methodForNextPage(@PathVariable String id, + @RequestParam Integer offset, @RequestParam Integer limit) { + return null; + } + + @RequestMapping(value = "/{id}/foo") + HttpEntity methodWithMultiValueRequestParams(@PathVariable String id, + @RequestParam List items, @RequestParam Integer limit) { + return null; + } + } +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtilsUnitTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtilsUnitTests.java new file mode 100644 index 0000000000..9e24f124f1 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/RecordedInvocationUtilsUnitTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012 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.web.servlet.hypermedia; + +import org.junit.Test; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * @author Oliver Gierke + */ +public class RecordedInvocationUtilsUnitTests extends TestUtils { + + @Test + public void test() { + + MvcUriComponentsBuilder.from(RecordedInvocationUtils.methodOn(SampleController.class).someMethod( + 1L)); + + } + + @RequestMapping("/sample") + static class SampleController { + + @RequestMapping("/{id}/foo") + HttpEntity someMethod(@PathVariable("id") Long id) { + return new ResponseEntity(HttpStatus.OK); + } + } +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/TestUtils.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/TestUtils.java new file mode 100644 index 0000000000..742707decf --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/hypermedia/TestUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2013 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.web.servlet.hypermedia; + +import java.net.URI; + +import org.junit.Before; +import org.springframework.mock.web.test.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; + +/** + * Utility class to ease tesing. + * + * @author Oliver Gierke + */ +public class TestUtils { + + protected MockHttpServletRequest request; + + @Before + public void setUp() { + + request = new MockHttpServletRequest(); + ServletRequestAttributes requestAttributes = new ServletRequestAttributes(request); + RequestContextHolder.setRequestAttributes(requestAttributes); + } + + protected void assertPointsToMockServer(URI link) { + assertThat(link.toString(), startsWith("http://localhost")); + } +}