diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/PathVariable.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/PathVariable.java new file mode 100644 index 00000000000..00b21e9a745 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/PathVariable.java @@ -0,0 +1,48 @@ +/* + * 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.messaging.handler.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation which indicates that a method parameter should be bound to a path template + * variable. Supported for {@link org.springframework.messaging.simp.annotation.SubscribeEvent}, + * {@link org.springframework.messaging.simp.annotation.UnsubscribeEvent}, + * {@link org.springframework.messaging.handler.annotation.MessageMapping} + * annotated handler methods. + * + *

A {@code @PathVariable} template variable is always required and does not have + * a default value to fall back on. + * + * @author Brian Clozel + * @see org.springframework.messaging.simp.annotation.SubscribeEvent + * @see org.springframework.messaging.simp.annotation.UnsubscribeEvent + * @see org.springframework.messaging.handler.annotation.MessageMapping + * @since 4.0 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface PathVariable { + + /** The path template variable to bind to. */ + String value() default ""; +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolver.java new file mode 100644 index 00000000000..d0952119132 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolver.java @@ -0,0 +1,77 @@ +/* + * 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.messaging.handler.annotation.support; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.PathVariable; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.messaging.simp.handler.AnnotationMethodMessageHandler; + +import java.util.Map; + +/** + * Resolves method parameters annotated with {@link PathVariable @PathVariable}. + * + *

A @{@link PathVariable} is a named value that gets resolved from a path + * template variable that matches the Message destination header. + * It is always required and does not have a default value to fall back on. + * + * @author Brian Clozel + * @see org.springframework.messaging.handler.annotation.PathVariable + * @see org.springframework.messaging.MessageHeaders + * @since 4.0 + */ +public class PathVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + public PathVariableMethodArgumentResolver(ConversionService cs, ConfigurableBeanFactory beanFactory) { + super(cs, beanFactory); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(PathVariable.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + PathVariable annotation = parameter.getParameterAnnotation(PathVariable.class); + return new PathVariableNamedValueInfo(annotation); + } + + @Override + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) throws Exception { + Map pathTemplateVars = + (Map) message.getHeaders().get(AnnotationMethodMessageHandler.PATH_TEMPLATE_VARIABLES_HEADER); + return (pathTemplateVars != null) ? pathTemplateVars.get(name) : null; + } + + @Override + protected void handleMissingValue(String name, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, "Missing path template variable '" + name + + "' for method parameter type [" + parameter.getParameterType() + "]"); + } + + private static class PathVariableNamedValueInfo extends NamedValueInfo { + + private PathVariableNamedValueInfo(PathVariable annotation) { + super(annotation.value(), true, ValueConstants.DEFAULT_NONE); + } + } +} \ No newline at end of file diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandler.java index df54a075578..51d846124e2 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandler.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandler.java @@ -21,6 +21,8 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -50,6 +52,7 @@ import org.springframework.messaging.handler.annotation.support.HeaderMethodArgu import org.springframework.messaging.handler.annotation.support.HeadersMethodArgumentResolver; import org.springframework.messaging.handler.annotation.support.MessageMethodArgumentResolver; import org.springframework.messaging.handler.annotation.support.PayloadArgumentResolver; +import org.springframework.messaging.handler.annotation.support.PathVariableMethodArgumentResolver; import org.springframework.messaging.handler.method.HandlerMethod; import org.springframework.messaging.handler.method.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.method.HandlerMethodArgumentResolverComposite; @@ -67,25 +70,35 @@ import org.springframework.messaging.simp.annotation.support.PrincipalMethodArgu import org.springframework.messaging.simp.annotation.support.SendToMethodReturnValueHandler; import org.springframework.messaging.simp.annotation.support.SubscriptionMethodReturnValueHandler; import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.messaging.support.converter.ByteArrayMessageConverter; import org.springframework.messaging.support.converter.CompositeMessageConverter; import org.springframework.messaging.support.converter.MessageConverter; import org.springframework.messaging.support.converter.StringMessageConverter; import org.springframework.stereotype.Controller; +import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; +import org.springframework.util.PathMatcher; import org.springframework.util.ReflectionUtils.MethodFilter; /** * @author Rossen Stoyanchev + * @author Brian Clozel * @since 4.0 */ public class AnnotationMethodMessageHandler implements MessageHandler, ApplicationContextAware, InitializingBean { + public static final String PATH_TEMPLATE_VARIABLES_HEADER = "Spring-PathTemplateVariables"; + + public static final String BEST_MATCHING_PATTERN_HEADER = "Spring-BestMatchingPattern"; + private static final Log logger = LogFactory.getLog(AnnotationMethodMessageHandler.class); + private final PathMatcher pathMatcher = new AntPathMatcher(); + private final SimpMessageSendingOperations brokerTemplate; private final SimpMessageSendingOperations webSocketResponseTemplate; @@ -242,6 +255,7 @@ public class AnnotationMethodMessageHandler implements MessageHandler, Applicati // Annotation-based argument resolution this.argumentResolvers.addResolver(new HeaderMethodArgumentResolver(this.conversionService, beanFactory)); this.argumentResolvers.addResolver(new HeadersMethodArgumentResolver()); + this.argumentResolvers.addResolver(new PathVariableMethodArgumentResolver(this.conversionService, beanFactory)); // Type-based argument resolution this.argumentResolvers.addResolver(new PrincipalMethodArgumentResolver()); @@ -363,7 +377,7 @@ public class AnnotationMethodMessageHandler implements MessageHandler, Applicati return; } - HandlerMethod match = getHandlerMethod(lookupPath, handlerMethods); + MappingInfoMatch match = matchMappingInfo(lookupPath, handlerMethods); if (match == null) { if (logger.isTraceEnabled()) { logger.trace("No matching method, lookup path " + lookupPath); @@ -371,13 +385,16 @@ public class AnnotationMethodMessageHandler implements MessageHandler, Applicati return; } - HandlerMethod handlerMethod = match.createWithResolvedBean(); + HandlerMethod handlerMethod = match.handlerMethod.createWithResolvedBean(); InvocableHandlerMethod invocableHandlerMethod = new InvocableHandlerMethod(handlerMethod); invocableHandlerMethod.setMessageMethodArgumentResolvers(this.argumentResolvers); try { headers.setDestination(lookupPath); + headers.setHeader(BEST_MATCHING_PATTERN_HEADER,match.mappingDestination); + headers.setHeader(PATH_TEMPLATE_VARIABLES_HEADER, + pathMatcher.extractUriTemplateVariables(match.mappingDestination,lookupPath)); message = MessageBuilder.withPayload(message.getPayload()).setHeaders(headers).build(); Object returnValue = invocableHandlerMethod.invoke(message); @@ -444,17 +461,52 @@ public class AnnotationMethodMessageHandler implements MessageHandler, Applicati } } - protected HandlerMethod getHandlerMethod(String destination, Map handlerMethods) { + protected MappingInfoMatch matchMappingInfo(String destination, + Map handlerMethods) { + + List matches = new ArrayList(4); for (MappingInfo key : handlerMethods.keySet()) { for (String mappingDestination : key.getDestinations()) { - if (destination.equals(mappingDestination)) { - return handlerMethods.get(key); + if (this.pathMatcher.match(mappingDestination, destination)) { + matches.add(new MappingInfoMatch(mappingDestination, + handlerMethods.get(key))); + } + } + } + if(!matches.isEmpty()) { + Comparator comparator = getMappingInfoMatchComparator(destination, this.pathMatcher); + Collections.sort(matches, comparator); + + if (logger.isTraceEnabled()) { + logger.trace("Found " + matches.size() + " matching mapping(s) for [" + destination + "] : " + matches); + } + + MappingInfoMatch bestMatch = matches.get(0); + if (matches.size() > 1) { + MappingInfoMatch secondBestMatch = matches.get(1); + if (comparator.compare(bestMatch, secondBestMatch) == 0) { + Method m1 = bestMatch.handlerMethod.getMethod(); + Method m2 = secondBestMatch.handlerMethod.getMethod(); + throw new IllegalStateException( + "Ambiguous handler methods mapped for Message destination '" + destination + "': {" + + m1 + ", " + m2 + "}"); } } + return bestMatch; } return null; } + private Comparator getMappingInfoMatchComparator(String destination, + PathMatcher pathMatcher) { + return new Comparator() { + @Override + public int compare(MappingInfoMatch one, MappingInfoMatch other) { + Comparator patternComparator = pathMatcher.getPatternComparator(destination); + return patternComparator.compare(one.mappingDestination,other.mappingDestination); + } + }; + } private static class MappingInfo { @@ -493,4 +545,14 @@ public class AnnotationMethodMessageHandler implements MessageHandler, Applicati } } + private static class MappingInfoMatch { + + private final String mappingDestination; + private final HandlerMethod handlerMethod; + + public MappingInfoMatch(String destination, HandlerMethod handlerMethod) { + this.mappingDestination = destination; + this.handlerMethod = handlerMethod; + } + } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolverTests.java new file mode 100644 index 00000000000..840b6e39ace --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/PathVariableMethodArgumentResolverTests.java @@ -0,0 +1,101 @@ +/* + * 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.messaging.handler.annotation.support; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.GenericTypeResolver; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.PathVariable; +import org.springframework.messaging.simp.handler.AnnotationMethodMessageHandler; +import org.springframework.messaging.support.MessageBuilder; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Test fixture for {@link PathVariableMethodArgumentResolver} tests. + * @author Brian Clozel + */ +public class PathVariableMethodArgumentResolverTests { + + private PathVariableMethodArgumentResolver resolver; + + private MethodParameter paramAnnotated; + private MethodParameter paramAnnotatedValue; + private MethodParameter paramNotAnnotated; + + @Before + public void setup() throws Exception { + GenericApplicationContext cxt = new GenericApplicationContext(); + cxt.refresh(); + this.resolver = new PathVariableMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); + + Method method = getClass().getDeclaredMethod("handleMessage", + String.class, String.class, String.class); + this.paramAnnotated = new MethodParameter(method, 0); + this.paramAnnotatedValue = new MethodParameter(method, 1); + this.paramNotAnnotated = new MethodParameter(method, 2); + + this.paramAnnotated.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + GenericTypeResolver.resolveParameterType(this.paramAnnotated, PathVariableMethodArgumentResolver.class); + this.paramAnnotatedValue.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + GenericTypeResolver.resolveParameterType(this.paramAnnotatedValue, PathVariableMethodArgumentResolver.class); + } + + @Test + public void supportsParameter() { + assertTrue(resolver.supportsParameter(paramAnnotated)); + assertTrue(resolver.supportsParameter(paramAnnotatedValue)); + assertFalse(resolver.supportsParameter(paramNotAnnotated)); + } + + @Test + public void resolveArgument() throws Exception { + Map pathParams = new HashMap(); + pathParams.put("foo","bar"); + pathParams.put("name","value"); + Message message = MessageBuilder.withPayload(new byte[0]) + .setHeader(AnnotationMethodMessageHandler.PATH_TEMPLATE_VARIABLES_HEADER, pathParams).build(); + Object result = this.resolver.resolveArgument(this.paramAnnotated, message); + assertEquals("bar",result); + result = this.resolver.resolveArgument(this.paramAnnotatedValue, message); + assertEquals("value",result); + } + + @Test(expected = MessageHandlingException.class) + public void resolveArgumentNotFound() throws Exception { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + this.resolver.resolveArgument(this.paramAnnotated, message); + } + + @SuppressWarnings("unused") + private void handleMessage( + @PathVariable String foo, + @PathVariable(value = "name") String param1, + String param3) { + } +} \ No newline at end of file diff --git a/spring-messaging/src/test/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandlerTests.java index ab31216a2bf..d36c2c18576 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/simp/handler/AnnotationMethodMessageHandlerTests.java @@ -28,9 +28,12 @@ import org.springframework.messaging.MessageChannel; import org.springframework.messaging.handler.annotation.Header; import org.springframework.messaging.handler.annotation.Headers; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.PathVariable; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SubscribeEvent; import org.springframework.messaging.support.MessageBuilder; import org.springframework.stereotype.Controller; @@ -40,6 +43,7 @@ import static org.junit.Assert.*; /** * Test fixture for {@link AnnotationMethodMessageHandler}. * @author Rossen Stoyanchev + * @author Brian Clozel */ public class AnnotationMethodMessageHandlerTests { @@ -80,6 +84,63 @@ public class AnnotationMethodMessageHandlerTests { this.messageHandler.registerHandler(new DuplicateMappingController()); } + @Test + public void messageMappingPathVariableResolution() { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(); + headers.setDestination("/message/bar/value"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + this.messageHandler.handleMessage(message); + + assertEquals("messageMappingPathVariable", this.testController.method); + assertEquals("bar", this.testController.arguments.get("foo")); + assertEquals("value", this.testController.arguments.get("name")); + } + + @Test + public void subscribeEventPathVariableResolution() { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.SUBSCRIBE); + headers.setDestination("/sub/bar/value"); + Message message = MessageBuilder.withPayload(new byte[0]) + .copyHeaders(headers.toMap()).build(); + this.messageHandler.handleMessage(message); + + assertEquals("subscribeEventPathVariable", this.testController.method); + assertEquals("bar", this.testController.arguments.get("foo")); + assertEquals("value", this.testController.arguments.get("name")); + } + + @Test + public void antPatchMatchWildcard() { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(); + headers.setDestination("/pathmatch/wildcard/test"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + this.messageHandler.handleMessage(message); + + assertEquals("pathMatchWildcard", this.testController.method); + } + + @Test + public void bestMatchWildcard() { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(); + headers.setDestination("/bestmatch/bar/path"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + this.messageHandler.handleMessage(message); + + assertEquals("bestMatch", this.testController.method); + assertEquals("bar", this.testController.arguments.get("foo")); + } + + @Test + public void simpleBinding() { + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(); + headers.setDestination("/binding/id/12"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + this.messageHandler.handleMessage(message); + + assertEquals("simpleBinding", this.testController.method); + assertTrue("should be bound to type long", this.testController.arguments.get("id") instanceof Long); + assertEquals(12L, this.testController.arguments.get("id")); + } private static class TestAnnotationMethodMessageHandler extends AnnotationMethodMessageHandler { @@ -109,6 +170,44 @@ public class AnnotationMethodMessageHandlerTests { this.arguments.put("foo", foo); this.arguments.put("headers", headers); } + + @MessageMapping("/message/{foo}/{name}") + public void messageMappingPathVariable(@PathVariable("foo") String param1, + @PathVariable("name") String param2) { + this.method = "messageMappingPathVariable"; + this.arguments.put("foo", param1); + this.arguments.put("name", param2); + } + + @SubscribeEvent("/sub/{foo}/{name}") + public void subscribeEventPathVariable(@PathVariable("foo") String param1, + @PathVariable("name") String param2) { + this.method = "subscribeEventPathVariable"; + this.arguments.put("foo", param1); + this.arguments.put("name", param2); + } + + @MessageMapping("/pathmatch/wildcard/**") + public void pathMatchWildcard() { + this.method = "pathMatchWildcard"; + } + + @MessageMapping("/bestmatch/{foo}/path") + public void bestMatch(@PathVariable("foo") String param1) { + this.method = "bestMatch"; + this.arguments.put("foo", param1); + } + + @MessageMapping("/bestmatch/**") + public void otherMatch() { + this.method = "otherMatch"; + } + + @MessageMapping("/binding/id/{id}") + public void simpleBinding(@PathVariable("id") Long id) { + this.method = "simpleBinding"; + this.arguments.put("id", id); + } } @Controller