diff --git a/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java b/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java index 27bd39d756..8a66e528c2 100644 --- a/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java +++ b/spring-core/src/test/java/org/springframework/core/io/buffer/AbstractDataBufferAllocatingTestCase.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -105,13 +105,15 @@ public abstract class AbstractDataBufferAllocatingTestCase { */ protected void waitForDataBufferRelease(Duration duration) throws InterruptedException { Instant start = Instant.now(); - while (Instant.now().isBefore(start.plus(duration))) { + while (true) { try { verifyAllocations(); break; } catch (AssertionError ex) { - // ignore; + if (Instant.now().isAfter(start.plus(duration))) { + throw ex; + } } Thread.sleep(50); } diff --git a/spring-messaging/spring-messaging.gradle b/spring-messaging/spring-messaging.gradle index b8e29c0960..fd469281c6 100644 --- a/spring-messaging/spring-messaging.gradle +++ b/spring-messaging/spring-messaging.gradle @@ -7,12 +7,15 @@ dependencyManagement { } } +def rsocketVersion = "0.11.17" + dependencies { compile(project(":spring-beans")) compile(project(":spring-core")) optional(project(":spring-context")) optional(project(":spring-oxm")) optional("io.projectreactor.netty:reactor-netty") + optional("io.rsocket:rsocket-core:${rsocketVersion}") optional("com.fasterxml.jackson.core:jackson-databind:${jackson2Version}") optional("javax.xml.bind:jaxb-api:2.3.1") testCompile("javax.inject:javax.inject-tck:1") @@ -24,6 +27,9 @@ dependencies { exclude group: "org.springframework", module: "spring-context" } testCompile("org.apache.activemq:activemq-stomp:5.8.0") + testCompile("io.projectreactor:reactor-test") + testCompile "io.reactivex.rxjava2:rxjava:${rxjava2Version}" + testCompile("io.rsocket:rsocket-transport-netty:${rsocketVersion}") testCompile("org.jetbrains.kotlin:kotlin-reflect:${kotlinVersion}") testCompile("org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}") testCompile("org.xmlunit:xmlunit-matchers:2.6.2") diff --git a/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java new file mode 100644 index 0000000000..32c1d50e9e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/ReactiveMessageHandler.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging; + +import reactor.core.publisher.Mono; + +/** + * Reactive contract for handling a {@link Message}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +@FunctionalInterface +public interface ReactiveMessageHandler { + + /** + * Handle the given message. + * @param message the message to be handled + * @return a completion {@link Mono} for the result of the message handling. + */ + Mono handleMessage(Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java new file mode 100644 index 0000000000..23c61faaaa --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/CompositeMessageCondition.java @@ -0,0 +1,160 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * Composite {@link MessageCondition} that delegates to other message conditions. + * + *

For {@link #combine} and {@link #compareTo} it is expected that the "other" + * composite contains the same number, type, and order of message conditions. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class CompositeMessageCondition implements MessageCondition { + + private final List> messageConditions; + + + public CompositeMessageCondition(MessageCondition... messageConditions) { + this(Arrays.asList(messageConditions)); + } + + private CompositeMessageCondition(List> messageConditions) { + Assert.notEmpty(messageConditions, "No message conditions"); + this.messageConditions = messageConditions; + } + + + public List> getMessageConditions() { + return this.messageConditions; + } + + @SuppressWarnings("unchecked") + public > T getCondition(Class messageConditionType) { + for (MessageCondition condition : this.messageConditions) { + if (messageConditionType.isAssignableFrom(condition.getClass())) { + return (T) condition; + } + } + throw new IllegalStateException("No condition of type: " + messageConditionType); + } + + + @Override + public CompositeMessageCondition combine(CompositeMessageCondition other) { + checkCompatible(other); + List> result = new ArrayList<>(this.messageConditions.size()); + for (int i = 0; i < this.messageConditions.size(); i++) { + result.add(combine(getMessageConditions().get(i), other.getMessageConditions().get(i))); + } + return new CompositeMessageCondition(result); + } + + @SuppressWarnings("unchecked") + private > T combine(MessageCondition first, MessageCondition second) { + return ((T) first).combine((T) second); + } + + @Override + public CompositeMessageCondition getMatchingCondition(Message message) { + List> result = new ArrayList<>(this.messageConditions.size()); + for (MessageCondition condition : this.messageConditions) { + MessageCondition matchingCondition = (MessageCondition) condition.getMatchingCondition(message); + if (matchingCondition == null) { + return null; + } + result.add(matchingCondition); + } + return new CompositeMessageCondition(result); + } + + @Override + public int compareTo(CompositeMessageCondition other, Message message) { + checkCompatible(other); + List> otherConditions = other.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + int result = compare (this.messageConditions.get(i), otherConditions.get(i), message); + if (result != 0) { + return result; + } + } + return 0; + } + + @SuppressWarnings("unchecked") + private > int compare( + MessageCondition first, MessageCondition second, Message message) { + + return ((T) first).compareTo((T) second, message); + } + + private void checkCompatible(CompositeMessageCondition other) { + List> others = other.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + if (i < others.size()) { + if (this.messageConditions.get(i).getClass().equals(others.get(i).getClass())) { + continue; + } + } + throw new IllegalArgumentException("Mismatched CompositeMessageCondition: " + + this.messageConditions + " vs " + others); + } + } + + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CompositeMessageCondition)) { + return false; + } + CompositeMessageCondition otherComposite = (CompositeMessageCondition) other; + checkCompatible(otherComposite); + List> otherConditions = otherComposite.getMessageConditions(); + for (int i = 0; i < this.messageConditions.size(); i++) { + if (!this.messageConditions.get(i).equals(otherConditions.get(i))) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int hashCode = 0; + for (MessageCondition condition : this.messageConditions) { + hashCode += condition.hashCode() * 31; + } + return hashCode; + } + + @Override + public String toString() { + return this.messageConditions.stream().map(Object::toString).collect(Collectors.joining(",", "{", "}")); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java index 0be63540cd..30230a2c30 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/HandlerMethod.java @@ -298,7 +298,7 @@ public class HandlerMethod { */ public String getShortLogMessage() { int args = this.method.getParameterCount(); - return getBeanType().getName() + "#" + this.method.getName() + "[" + args + " args]"; + return getBeanType().getSimpleName() + "#" + this.method.getName() + "[" + args + " args]"; } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java index dc8e877c58..4d51886797 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/AbstractNamedValueMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2017 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,7 +26,6 @@ import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.MethodParameter; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; -import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.handler.annotation.ValueConstants; @@ -34,24 +33,20 @@ import org.springframework.messaging.handler.invocation.HandlerMethodArgumentRes import org.springframework.util.ClassUtils; /** - * Abstract base class for resolving method arguments from a named value. Message headers, - * and path variables are examples of named values. Each may have a name, a required flag, - * and a default value. + * Abstract base class to resolve method arguments from a named value, e.g. + * message headers or destination variables. Named values could have one or more + * of a name, a required flag, and a default value. * - *

Subclasses define how to do the following: - *

+ *

Subclasses only need to define specific steps such as how to obtain named + * value details from a method parameter, how to resolve to argument values, or + * how to handle missing values. * - *

A default value string can contain ${...} placeholders and Spring Expression - * Language {@code #{...}} expressions. For this to work a {@link ConfigurableBeanFactory} - * must be supplied to the class constructor. + *

A default value string can contain ${...} placeholders and Spring + * Expression Language {@code #{...}} expressions which will be resolved if a + * {@link ConfigurableBeanFactory} is supplied to the class constructor. * - *

A {@link ConversionService} may be used to apply type conversion to the resolved - * argument value if it doesn't match the method parameter type. + *

A {@link ConversionService} is used to to convert resolved String argument + * value to the expected target method parameter type. * * @author Rossen Stoyanchev * @author Juergen Hoeller @@ -61,8 +56,10 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle private final ConversionService conversionService; + @Nullable private final ConfigurableBeanFactory configurableBeanFactory; + @Nullable private final BeanExpressionContext expressionContext; private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); @@ -70,28 +67,27 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle /** * Constructor with a {@link ConversionService} and a {@link BeanFactory}. - * @param cs conversion service for converting values to match the - * target method parameter type - * @param beanFactory a bean factory to use for resolving {@code ${...}} placeholder - * and {@code #{...}} SpEL expressions in default values, or {@code null} if default - * values are not expected to contain expressions + * @param conversionService conversion service for converting String values + * to the target method parameter type + * @param beanFactory a bean factory for resolving {@code ${...}} + * placeholders and {@code #{...}} SpEL expressions in default values */ - protected AbstractNamedValueMethodArgumentResolver(ConversionService cs, + protected AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { - this.conversionService = (cs != null ? cs : DefaultConversionService.getSharedInstance()); + this.conversionService = conversionService; this.configurableBeanFactory = beanFactory; this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); } @Override - @Nullable public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); MethodParameter nestedParameter = parameter.nestedIfOptional(); - Object resolvedName = resolveStringValue(namedValueInfo.name); + Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name); if (resolvedName == null) { throw new IllegalArgumentException( "Specified name must not resolve to null: [" + namedValueInfo.name + "]"); @@ -100,7 +96,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle Object arg = resolveArgumentInternal(nestedParameter, message, resolvedName.toString()); if (arg == null) { if (namedValueInfo.defaultValue != null) { - arg = resolveStringValue(namedValueInfo.defaultValue); + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } else if (namedValueInfo.required && !nestedParameter.isOptional()) { handleMissingValue(namedValueInfo.name, nestedParameter, message); @@ -108,7 +104,7 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); } else if ("".equals(arg) && namedValueInfo.defaultValue != null) { - arg = resolveStringValue(namedValueInfo.defaultValue); + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); } if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) { @@ -134,35 +130,40 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle } /** - * Create the {@link NamedValueInfo} object for the given method parameter. Implementations typically - * retrieve the method annotation by means of {@link MethodParameter#getParameterAnnotation(Class)}. + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. * @param parameter the method parameter * @return the named value information */ protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); /** - * Create a new NamedValueInfo based on the given NamedValueInfo with sanitized values. + * Fall back on the parameter name from the class file if necessary and + * replace {@link ValueConstants#DEFAULT_NONE} with null. */ private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { String name = info.name; if (info.name.isEmpty()) { name = parameter.getParameterName(); if (name == null) { - throw new IllegalArgumentException("Name for argument type [" + parameter.getParameterType().getName() + - "] not available, and parameter name information not found in class file either."); + Class type = parameter.getParameterType(); + throw new IllegalArgumentException( + "Name for argument of type [" + type.getName() + "] not specified, " + + "and parameter name information not found in class file either."); } } - String defaultValue = (ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); - return new NamedValueInfo(name, info.required, defaultValue); + return new NamedValueInfo(name, info.required, + ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); } /** * Resolve the given annotation-specified value, * potentially containing placeholders and expressions. */ - private Object resolveStringValue(String value) { - if (this.configurableBeanFactory == null) { + @Nullable + private Object resolveEmbeddedValuesAndExpressions(String value) { + if (this.configurableBeanFactory == null || this.expressionContext == null) { return value; } String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); @@ -186,19 +187,21 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle throws Exception; /** - * Invoked when a named value is required, but - * {@link #resolveArgumentInternal(MethodParameter, Message, String)} returned {@code null} and - * there is no default value. Subclasses typically throw an exception in this case. + * Invoked when a value is required, but {@link #resolveArgumentInternal} + * returned {@code null} and there is no default value. Sub-classes can + * throw an appropriate exception for this case. * @param name the name for the value - * @param parameter the method parameter + * @param parameter the target method parameter * @param message the message being processed */ protected abstract void handleMissingValue(String name, MethodParameter parameter, Message message); /** - * A {@code null} results in a {@code false} value for {@code boolean}s or an - * exception for other primitives. + * One last chance to handle a possible null value. + * Specifically for booleans method parameters, use {@link Boolean#FALSE}. + * Also raise an ISE for primitive types. */ + @Nullable private Object handleNullValue(String name, @Nullable Object value, Class paramType) { if (value == null) { if (Boolean.TYPE.equals(paramType)) { @@ -221,13 +224,13 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle * @param parameter the argument parameter type * @param message the message */ - protected void handleResolvedValue(Object arg, String name, MethodParameter parameter, Message message) { + protected void handleResolvedValue( + @Nullable Object arg, String name, MethodParameter parameter, Message message) { } /** - * Represents the information about a named value, including name, whether it's - * required and a default value. + * Represents a named value declaration. */ protected static class NamedValueInfo { @@ -235,9 +238,10 @@ public abstract class AbstractNamedValueMethodArgumentResolver implements Handle private final boolean required; + @Nullable private final String defaultValue; - protected NamedValueInfo(String name, boolean required, String defaultValue) { + protected NamedValueInfo(String name, boolean required, @Nullable String defaultValue) { this.name = name; this.required = required; this.defaultValue = defaultValue; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java index 3b33d97096..43d6cbd29c 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DefaultMessageHandlerMethodFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,11 +26,13 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.core.convert.ConversionService; import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.lang.Nullable; import org.springframework.messaging.converter.GenericMessageConverter; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolverComposite; import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; +import org.springframework.util.Assert; import org.springframework.validation.Validator; /** @@ -60,15 +62,19 @@ public class DefaultMessageHandlerMethodFactory private ConversionService conversionService = new DefaultFormattingConversionService(); + @Nullable private MessageConverter messageConverter; + @Nullable private Validator validator; + @Nullable private List customArgumentResolvers; private final HandlerMethodArgumentResolverComposite argumentResolvers = new HandlerMethodArgumentResolverComposite(); + @Nullable private BeanFactory beanFactory; @@ -114,6 +120,7 @@ public class DefaultMessageHandlerMethodFactory * the ones configured by default. This is an advanced option. For most use cases * it should be sufficient to use {@link #setCustomArgumentResolvers(java.util.List)}. */ + @SuppressWarnings("ConstantConditions") public void setArgumentResolvers(List argumentResolvers) { if (argumentResolvers == null) { this.argumentResolvers.clear(); @@ -151,11 +158,11 @@ public class DefaultMessageHandlerMethodFactory protected List initArgumentResolvers() { List resolvers = new ArrayList<>(); - ConfigurableBeanFactory cbf = (this.beanFactory instanceof ConfigurableBeanFactory ? + ConfigurableBeanFactory beanFactory = (this.beanFactory instanceof ConfigurableBeanFactory ? (ConfigurableBeanFactory) this.beanFactory : null); // Annotation-based argument resolution - resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, cbf)); + resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, beanFactory)); resolvers.add(new HeadersMethodArgumentResolver()); // Type-based argument resolution @@ -164,6 +171,8 @@ public class DefaultMessageHandlerMethodFactory if (this.customArgumentResolvers != null) { resolvers.addAll(this.customArgumentResolvers); } + + Assert.notNull(this.messageConverter, "MessageConverter not configured"); resolvers.add(new PayloadArgumentResolver(this.messageConverter, this.validator)); return resolvers; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java index b1c3b6b34d..6fccd661e1 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,28 +23,26 @@ import org.springframework.core.convert.ConversionService; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.ValueConstants; import org.springframework.util.Assert; /** - * Resolves method parameters annotated with - * {@link org.springframework.messaging.handler.annotation.DestinationVariable @DestinationVariable}. + * Resolve for {@link DestinationVariable @DestinationVariable} method parameters. * * @author Brian Clozel * @since 4.0 */ public class DestinationVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { - /** - * The name of the header used to for template variables. - */ + /** The name of the header used to for template variables. */ public static final String DESTINATION_TEMPLATE_VARIABLES_HEADER = DestinationVariableMethodArgumentResolver.class.getSimpleName() + ".templateVariables"; - public DestinationVariableMethodArgumentResolver(ConversionService cs) { - super(cs, null); + public DestinationVariableMethodArgumentResolver(ConversionService conversionService) { + super(conversionService, null); } @@ -55,26 +53,24 @@ public class DestinationVariableMethodArgumentResolver extends AbstractNamedValu @Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { - DestinationVariable annotation = parameter.getParameterAnnotation(DestinationVariable.class); - Assert.state(annotation != null, "No DestinationVariable annotation"); - return new DestinationVariableNamedValueInfo(annotation); + DestinationVariable annot = parameter.getParameterAnnotation(DestinationVariable.class); + Assert.state(annot != null, "No DestinationVariable annotation"); + return new DestinationVariableNamedValueInfo(annot); } @Override @Nullable - protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) - throws Exception { - - @SuppressWarnings("unchecked") - Map vars = - (Map) message.getHeaders().get(DESTINATION_TEMPLATE_VARIABLES_HEADER); - return (vars != null ? vars.get(name) : null); + @SuppressWarnings("unchecked") + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + MessageHeaders headers = message.getHeaders(); + Map vars = (Map) headers.get(DESTINATION_TEMPLATE_VARIABLES_HEADER); + return vars != null ? vars.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() + "]"); + throw new MessageHandlingException(message, "Missing path template variable '" + name + "' " + + "for method parameter type [" + parameter.getParameterType() + "]"); } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java index 679db8c9b0..613991d739 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,18 +33,25 @@ import org.springframework.messaging.support.NativeMessageHeaderAccessor; import org.springframework.util.Assert; /** - * Resolves method parameters annotated with {@link Header @Header}. + * Resolver for {@link Header @Header} arguments. Headers are resolved from + * either the top-level header map or the nested + * {@link NativeMessageHeaderAccessor native} header map. * * @author Rossen Stoyanchev * @since 4.0 + * + * @see HeadersMethodArgumentResolver + * @see NativeMessageHeaderAccessor */ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { private static final Log logger = LogFactory.getLog(HeaderMethodArgumentResolver.class); - public HeaderMethodArgumentResolver(ConversionService cs, ConfigurableBeanFactory beanFactory) { - super(cs, beanFactory); + public HeaderMethodArgumentResolver( + ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); } @@ -55,9 +62,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume @Override protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { - Header annotation = parameter.getParameterAnnotation(Header.class); - Assert.state(annotation != null, "No Header annotation"); - return new HeaderNamedValueInfo(annotation); + Header annot = parameter.getParameterAnnotation(Header.class); + Assert.state(annot != null, "No Header annotation"); + return new HeaderNamedValueInfo(annot); } @Override @@ -70,10 +77,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume if (headerValue != null && nativeHeaderValue != null) { if (logger.isDebugEnabled()) { - logger.debug("Message headers contain two values for the same header '" + name + "', " + - "one in the top level header map and a second in the nested map with native headers. " + - "Using the value from top level map. " + - "Use 'nativeHeader.myHeader' to resolve to the value from the nested native header map."); + logger.debug("A value was found for '" + name + "', in both the top level header map " + + "and also in the nested map for native headers. Using the value from top level map. " + + "Use 'nativeHeader.myHeader' to resolve the native header."); } } @@ -94,9 +100,9 @@ public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgume } @SuppressWarnings("unchecked") + @Nullable private Map> getNativeHeaders(Message message) { - return (Map>) message.getHeaders().get( - NativeMessageHeaderAccessor.NATIVE_HEADERS); + return (Map>) message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS); } @Override diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java index 2d4a517946..7d257f17ca 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,12 +29,11 @@ import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.ReflectionUtils; /** - * {@link HandlerMethodArgumentResolver} for header method parameters. Resolves the - * following method parameters: + * Argument resolver for headers. Resolves the following method parameters: *

* * @author Rossen Stoyanchev @@ -58,7 +57,7 @@ public class HeadersMethodArgumentResolver implements HandlerMethodArgumentResol } else if (MessageHeaderAccessor.class == paramType) { MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); - return (accessor != null ? accessor : new MessageHeaderAccessor(message)); + return accessor != null ? accessor : new MessageHeaderAccessor(message); } else if (MessageHeaderAccessor.class.isAssignableFrom(paramType)) { MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); @@ -75,9 +74,8 @@ public class HeadersMethodArgumentResolver implements HandlerMethodArgumentResol } } else { - throw new IllegalStateException( - "Unexpected method parameter type " + paramType + "in method " + parameter.getMethod() + ". " - + "@Headers method arguments must be assignable to java.util.Map."); + throw new IllegalStateException("Unexpected parameter of type " + paramType + + " in method " + parameter.getMethod() + ". "); } } diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java index d92af195ae..337d966bc8 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/MessageMethodArgumentResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -43,6 +43,7 @@ import org.springframework.util.StringUtils; */ public class MessageMethodArgumentResolver implements HandlerMethodArgumentResolver { + @Nullable private final MessageConverter converter; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java index 022441df4d..a8913b2752 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/PayloadArgumentResolver.java @@ -55,6 +55,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { private final MessageConverter converter; + @Nullable private final Validator validator; private final boolean useDefaultResolution; @@ -76,7 +77,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { * @param messageConverter the MessageConverter to use (required) * @param validator the Validator to use (optional) */ - public PayloadArgumentResolver(MessageConverter messageConverter, Validator validator) { + public PayloadArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator) { this(messageConverter, validator, true); } @@ -89,7 +90,7 @@ public class PayloadArgumentResolver implements HandlerMethodArgumentResolver { * all parameters; if "false" then only arguments with the {@code @Payload} * annotation are supported. */ - public PayloadArgumentResolver(MessageConverter messageConverter, Validator validator, + public PayloadArgumentResolver(MessageConverter messageConverter, @Nullable Validator validator, boolean useDefaultResolution) { Assert.notNull(messageConverter, "MessageConverter must not be null"); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java index 7b106d109c..494657e5c6 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/package-info.java @@ -1,4 +1,9 @@ /** * Support classes for working with annotated message-handling methods. */ +@NonNullApi +@NonNullFields package org.springframework.messaging.handler.annotation.support; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java new file mode 100644 index 0000000000..9184e41f36 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/AbstractNamedValueMethodArgumentResolver.java @@ -0,0 +1,235 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.messaging.handler.invocation.reactive.SyncHandlerMethodArgumentResolver; +import org.springframework.util.ClassUtils; + +/** + * Abstract base class to resolve method arguments from a named value, e.g. + * message headers or destination variables. Named values could have one or more + * of a name, a required flag, and a default value. + * + *

Subclasses only need to define specific steps such as how to obtain named + * value details from a method parameter, how to resolve to argument values, or + * how to handle missing values. + * + *

A default value string can contain ${...} placeholders and Spring + * Expression Language {@code #{...}} expressions which will be resolved if a + * {@link ConfigurableBeanFactory} is supplied to the class constructor. + * + *

A {@link ConversionService} is used to to convert resolved String argument + * value to the expected target method parameter type. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public abstract class AbstractNamedValueMethodArgumentResolver implements SyncHandlerMethodArgumentResolver { + + private final ConversionService conversionService; + + @Nullable + private final ConfigurableBeanFactory configurableBeanFactory; + + @Nullable + private final BeanExpressionContext expressionContext; + + private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); + + + /** + * Constructor with a {@link ConversionService} and a {@link BeanFactory}. + * @param conversionService conversion service for converting String values + * to the target method parameter type + * @param beanFactory a bean factory for resolving {@code ${...}} + * placeholders and {@code #{...}} SpEL expressions in default values + */ + protected AbstractNamedValueMethodArgumentResolver(ConversionService conversionService, + @Nullable ConfigurableBeanFactory beanFactory) { + + this.conversionService = conversionService; + this.configurableBeanFactory = beanFactory; + this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); + } + + + @Override + public Object resolveArgumentValue(MethodParameter parameter, Message message) { + + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); + MethodParameter nestedParameter = parameter.nestedIfOptional(); + + Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name); + if (resolvedName == null) { + throw new IllegalArgumentException( + "Specified name must not resolve to null: [" + namedValueInfo.name + "]"); + } + + Object arg = resolveArgumentInternal(nestedParameter, message, resolvedName.toString()); + if (arg == null) { + if (namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + else if (namedValueInfo.required && !nestedParameter.isOptional()) { + handleMissingValue(namedValueInfo.name, nestedParameter, message); + } + arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType()); + } + else if ("".equals(arg) && namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + + if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) { + arg = this.conversionService.convert(arg, TypeDescriptor.forObject(arg), new TypeDescriptor(parameter)); + } + + return arg; + } + + /** + * Obtain the named value for the given method parameter. + */ + private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { + NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter); + if (namedValueInfo == null) { + namedValueInfo = createNamedValueInfo(parameter); + namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo); + this.namedValueInfoCache.put(parameter, namedValueInfo); + } + return namedValueInfo; + } + + /** + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. + * @param parameter the method parameter + * @return the named value information + */ + protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); + + /** + * Fall back on the parameter name from the class file if necessary and + * replace {@link ValueConstants#DEFAULT_NONE} with null. + */ + private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { + String name = info.name; + if (info.name.isEmpty()) { + name = parameter.getParameterName(); + if (name == null) { + Class type = parameter.getParameterType(); + throw new IllegalArgumentException( + "Name for argument of type [" + type.getName() + "] not specified, " + + "and parameter name information not found in class file either."); + } + } + return new NamedValueInfo(name, info.required, + ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); + } + + /** + * Resolve the given annotation-specified value, + * potentially containing placeholders and expressions. + */ + @Nullable + private Object resolveEmbeddedValuesAndExpressions(String value) { + if (this.configurableBeanFactory == null || this.expressionContext == null) { + return value; + } + String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); + BeanExpressionResolver exprResolver = this.configurableBeanFactory.getBeanExpressionResolver(); + if (exprResolver == null) { + return value; + } + return exprResolver.evaluate(placeholdersResolved, this.expressionContext); + } + + /** + * Resolves the given parameter type and value name into an argument value. + * @param parameter the method parameter to resolve to an argument value + * @param message the current request + * @param name the name of the value being resolved + * @return the resolved argument. May be {@code null} + */ + @Nullable + protected abstract Object resolveArgumentInternal(MethodParameter parameter, Message message, String name); + + /** + * Invoked when a value is required, but {@link #resolveArgumentInternal} + * returned {@code null} and there is no default value. Sub-classes can + * throw an appropriate exception for this case. + * @param name the name for the value + * @param parameter the target method parameter + * @param message the message being processed + */ + protected abstract void handleMissingValue(String name, MethodParameter parameter, Message message); + + /** + * One last chance to handle a possible null value. + * Specifically for booleans method parameters, use {@link Boolean#FALSE}. + * Also raise an ISE for primitive types. + */ + @Nullable + private Object handleNullValue(String name, @Nullable Object value, Class paramType) { + if (value == null) { + if (Boolean.TYPE.equals(paramType)) { + return Boolean.FALSE; + } + else if (paramType.isPrimitive()) { + throw new IllegalStateException("Optional " + paramType + " parameter '" + name + + "' is present but cannot be translated into a null value due to being " + + "declared as a primitive type. Consider declaring it as object wrapper " + + "for the corresponding primitive type."); + } + } + return value; + } + + + /** + * Represents a named value declaration. + */ + protected static class NamedValueInfo { + + private final String name; + + private final boolean required; + + @Nullable + private final String defaultValue; + + protected NamedValueInfo(String name, boolean required, @Nullable String defaultValue) { + this.name = name; + this.required = required; + this.defaultValue = defaultValue; + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java new file mode 100644 index 0000000000..b8c413072d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolver.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.util.Map; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.util.Assert; + +/** + * Resolve for {@link DestinationVariable @DestinationVariable} method parameters. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class DestinationVariableMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + /** The name of the header used to for template variables. */ + public static final String DESTINATION_TEMPLATE_VARIABLES_HEADER = + DestinationVariableMethodArgumentResolver.class.getSimpleName() + ".templateVariables"; + + + public DestinationVariableMethodArgumentResolver(ConversionService conversionService) { + super(conversionService, null); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(DestinationVariable.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + DestinationVariable annot = parameter.getParameterAnnotation(DestinationVariable.class); + Assert.state(annot != null, "No DestinationVariable annotation"); + return new DestinationVariableNamedValueInfo(annot); + } + + @Override + @Nullable + @SuppressWarnings("unchecked") + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + MessageHeaders headers = message.getHeaders(); + Map vars = (Map) headers.get(DESTINATION_TEMPLATE_VARIABLES_HEADER); + return vars != null ? vars.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 final class DestinationVariableNamedValueInfo extends NamedValueInfo { + + private DestinationVariableNamedValueInfo(DestinationVariable annotation) { + super(annotation.value(), true, ValueConstants.DEFAULT_NONE); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java new file mode 100644 index 0000000000..a50d38e377 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolver.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; +import org.springframework.util.Assert; + +/** + * Resolver for {@link Header @Header} arguments. Headers are resolved from + * either the top-level header map or the nested + * {@link NativeMessageHeaderAccessor native} header map. + * + * @author Rossen Stoyanchev + * @since 5.2 + * + * @see HeadersMethodArgumentResolver + * @see NativeMessageHeaderAccessor + */ +public class HeaderMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver { + + private static final Log logger = LogFactory.getLog(HeaderMethodArgumentResolver.class); + + + public HeaderMethodArgumentResolver( + ConversionService conversionService, @Nullable ConfigurableBeanFactory beanFactory) { + + super(conversionService, beanFactory); + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Header.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + Header annot = parameter.getParameterAnnotation(Header.class); + Assert.state(annot != null, "No Header annotation"); + return new HeaderNamedValueInfo(annot); + } + + @Override + @Nullable + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, String name) { + + Object headerValue = message.getHeaders().get(name); + Object nativeHeaderValue = getNativeHeaderValue(message, name); + + if (headerValue != null && nativeHeaderValue != null) { + if (logger.isDebugEnabled()) { + logger.debug("A value was found for '" + name + "', in both the top level header map " + + "and also in the nested map for native headers. Using the value from top level map. " + + "Use 'nativeHeader.myHeader' to resolve the native header."); + } + } + + return (headerValue != null ? headerValue : nativeHeaderValue); + } + + @Nullable + private Object getNativeHeaderValue(Message message, String name) { + Map> nativeHeaders = getNativeHeaders(message); + if (name.startsWith("nativeHeaders.")) { + name = name.substring("nativeHeaders.".length()); + } + if (nativeHeaders == null || !nativeHeaders.containsKey(name)) { + return null; + } + List nativeHeaderValues = nativeHeaders.get(name); + return (nativeHeaderValues.size() == 1 ? nativeHeaderValues.get(0) : nativeHeaderValues); + } + + @SuppressWarnings("unchecked") + @Nullable + private Map> getNativeHeaders(Message message) { + return (Map>) message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS); + } + + @Override + protected void handleMissingValue(String headerName, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, "Missing header '" + headerName + + "' for method parameter type [" + parameter.getParameterType() + "]"); + } + + + private static final class HeaderNamedValueInfo extends NamedValueInfo { + + private HeaderNamedValueInfo(Header annotation) { + super(annotation.name(), annotation.required(), annotation.defaultValue()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java new file mode 100644 index 0000000000..e2aa858540 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.lang.reflect.Method; +import java.util.Map; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.reactive.SyncHandlerMethodArgumentResolver; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.ReflectionUtils; + +/** + * Argument resolver for headers. Resolves the following method parameters: + *

+ * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class HeadersMethodArgumentResolver implements SyncHandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class paramType = parameter.getParameterType(); + return ((parameter.hasParameterAnnotation(Headers.class) && Map.class.isAssignableFrom(paramType)) || + MessageHeaders.class == paramType || MessageHeaderAccessor.class.isAssignableFrom(paramType)); + } + + @Override + @Nullable + public Object resolveArgumentValue(MethodParameter parameter, Message message) { + Class paramType = parameter.getParameterType(); + if (Map.class.isAssignableFrom(paramType)) { + return message.getHeaders(); + } + else if (MessageHeaderAccessor.class == paramType) { + MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); + return accessor != null ? accessor : new MessageHeaderAccessor(message); + } + else if (MessageHeaderAccessor.class.isAssignableFrom(paramType)) { + MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, MessageHeaderAccessor.class); + if (accessor != null && paramType.isAssignableFrom(accessor.getClass())) { + return accessor; + } + else { + Method method = ReflectionUtils.findMethod(paramType, "wrap", Message.class); + if (method == null) { + throw new IllegalStateException( + "Cannot create accessor of type " + paramType + " for message " + message); + } + return ReflectionUtils.invokeMethod(method, null, message); + } + } + else { + throw new IllegalStateException("Unexpected parameter of type " + paramType + + " in method " + parameter.getMethod() + ". "); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java new file mode 100644 index 0000000000..7bdad79d5a --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandler.java @@ -0,0 +1,313 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.codec.Decoder; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.CompositeMessageCondition; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.support.AnnotationExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; +import org.springframework.messaging.handler.invocation.reactive.AbstractMethodMessageHandler; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.stereotype.Controller; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; +import org.springframework.util.StringValueResolver; +import org.springframework.validation.Validator; + +/** + * Extension of {@link AbstractMethodMessageHandler} for reactive, non-blocking + * handling of messages via {@link MessageMapping @MessageMapping} methods. + * By default such methods are detected in {@code @Controller} Spring beans but + * that can be changed via {@link #setHandlerPredicate(Predicate)}. + * + *

Payloads for incoming messages are decoded through the configured + * {@link #setDecoders(List)} decoders, with the help of + * {@link PayloadMethodArgumentResolver}. + * + *

There is no default handling for return values but + * {@link #setReturnValueHandlerConfigurer} can be used to configure custom + * return value handlers. Sub-classes may also override + * {@link #initReturnValueHandlers()} to set up default return value handlers. + * + * @author Rossen Stoyanchev + * @since 5.2 + * @see AbstractEncoderMethodReturnValueHandler + */ +public class MessageMappingMessageHandler extends AbstractMethodMessageHandler + implements EmbeddedValueResolverAware { + + @Nullable + private Predicate> handlerPredicate = + beanType -> AnnotatedElementUtils.hasAnnotation(beanType, Controller.class); + + private final List> decoders = new ArrayList<>(); + + @Nullable + private Validator validator; + + private PathMatcher pathMatcher; + + private ConversionService conversionService = new DefaultFormattingConversionService(); + + @Nullable + private StringValueResolver valueResolver; + + + public MessageMappingMessageHandler() { + this.pathMatcher = new AntPathMatcher(); + ((AntPathMatcher) this.pathMatcher).setPathSeparator("."); + } + + + /** + * Manually configure handlers to check for {@code @MessageMapping} methods. + *

Note: the given handlers are not required to be + * annotated with {@code @Controller}. Consider also using + * {@link #setAutoDetectDisabled()} if the intent is to use these handlers + * instead of, and not in addition to {@code @Controller} classes. Or + * alternatively use {@link #setHandlerPredicate(Predicate)} to select a + * different set of beans based on a different criteria. + * @param handlers the handlers to register + * @see #setAutoDetectDisabled() + * @see #setHandlerPredicate(Predicate) + */ + public void setHandlers(List handlers) { + for (Object handler : handlers) { + detectHandlerMethods(handler); + } + // Disable auto-detection.. + this.handlerPredicate = null; + } + + /** + * Configure the predicate to use for selecting which Spring beans to check + * for {@code @MessageMapping} methods. When set to {@code null}, + * auto-detection is turned off which is what + * {@link #setAutoDetectDisabled()} does internally. + *

The predicate used by default selects {@code @Controller} classes. + * @see #setHandlers(List) + * @see #setAutoDetectDisabled() + */ + public void setHandlerPredicate(@Nullable Predicate> handlerPredicate) { + this.handlerPredicate = handlerPredicate; + } + + /** + * Return the {@link #setHandlerPredicate configured} handler predicate. + */ + @Nullable + public Predicate> getHandlerPredicate() { + return this.handlerPredicate; + } + + /** + * Disable auto-detection of {@code @MessageMapping} methods, e.g. in + * {@code @Controller}s, by setting {@link #setHandlerPredicate(Predicate) + * setHandlerPredicate(null)}. + */ + public void setAutoDetectDisabled() { + this.handlerPredicate = null; + } + + /** + * Configure the decoders to use for incoming payloads. + */ + public void setDecoders(List> decoders) { + this.decoders.addAll(decoders); + } + + /** + * Return the configured decoders. + */ + public List> getDecoders() { + return this.decoders; + } + + /** + * Set the Validator instance used for validating {@code @Payload} arguments. + * @see org.springframework.validation.annotation.Validated + * @see PayloadMethodArgumentResolver + */ + public void setValidator(@Nullable Validator validator) { + this.validator = validator; + } + + /** + * Return the configured Validator instance. + */ + @Nullable + public Validator getValidator() { + return this.validator; + } + + /** + * Set the PathMatcher implementation to use for matching destinations + * against configured destination patterns. + *

By default, {@link AntPathMatcher} is used with separator set to ".". + */ + public void setPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "PathMatcher must not be null"); + this.pathMatcher = pathMatcher; + } + + /** + * Return the PathMatcher implementation to use for matching destinations. + */ + public PathMatcher getPathMatcher() { + return this.pathMatcher; + } + + /** + * Configure a {@link ConversionService} to use for type conversion of + * String based values, e.g. in destination variables or headers. + *

By default {@link DefaultFormattingConversionService} is used. + * @param conversionService the conversion service to use + */ + public void setConversionService(ConversionService conversionService) { + this.conversionService = conversionService; + } + + /** + * Return the configured ConversionService. + */ + public ConversionService getConversionService() { + return this.conversionService; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.valueResolver = resolver; + } + + + @Override + protected List initArgumentResolvers() { + List resolvers = new ArrayList<>(); + + ApplicationContext context = getApplicationContext(); + ConfigurableBeanFactory beanFactory = (context instanceof ConfigurableApplicationContext ? + ((ConfigurableApplicationContext) context).getBeanFactory() : null); + + // Annotation-based resolvers + resolvers.add(new HeaderMethodArgumentResolver(this.conversionService, beanFactory)); + resolvers.add(new HeadersMethodArgumentResolver()); + resolvers.add(new DestinationVariableMethodArgumentResolver(this.conversionService)); + + // Custom resolvers + resolvers.addAll(getArgumentResolverConfigurer().getCustomResolvers()); + + // Catch-all + resolvers.add(new PayloadMethodArgumentResolver( + this.decoders, this.validator, getReactiveAdapterRegistry(), true)); + + return resolvers; + } + + @Override + protected List initReturnValueHandlers() { + return Collections.emptyList(); + } + + @Override + protected Predicate> initHandlerPredicate() { + return this.handlerPredicate; + } + + + @Override + protected CompositeMessageCondition getMappingForMethod(Method method, Class handlerType) { + CompositeMessageCondition methodCondition = getCondition(method); + if (methodCondition != null) { + CompositeMessageCondition typeCondition = getCondition(handlerType); + if (typeCondition != null) { + return typeCondition.combine(methodCondition); + } + } + return methodCondition; + } + + @Nullable + private CompositeMessageCondition getCondition(AnnotatedElement element) { + MessageMapping annot = AnnotatedElementUtils.findMergedAnnotation(element, MessageMapping.class); + if (annot == null || annot.value().length == 0) { + return null; + } + String[] destinations = annot.value(); + if (this.valueResolver != null) { + destinations = Arrays.stream(annot.value()) + .map(s -> this.valueResolver.resolveStringValue(s)) + .toArray(String[]::new); + } + return new CompositeMessageCondition(new DestinationPatternsMessageCondition(destinations, this.pathMatcher)); + } + + @Override + protected Set getDirectLookupMappings(CompositeMessageCondition mapping) { + Set result = new LinkedHashSet<>(); + for (String pattern : mapping.getCondition(DestinationPatternsMessageCondition.class).getPatterns()) { + if (!this.pathMatcher.isPattern(pattern)) { + result.add(pattern); + } + } + return result; + } + + @Override + protected String getDestination(Message message) { + return (String) message.getHeaders().get(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER); + } + + @Override + protected CompositeMessageCondition getMatchingMapping(CompositeMessageCondition mapping, Message message) { + return mapping.getMatchingCondition(message); + } + + @Override + protected Comparator getMappingComparator(Message message) { + return (info1, info2) -> info1.compareTo(info2, message); + } + + @Override + protected AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType) { + return new AnnotationExceptionHandlerMethodResolver(beanType); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java new file mode 100644 index 0000000000..e6885e21ad --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolver.java @@ -0,0 +1,302 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.Conventions; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.DecodingException; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.SmartValidator; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; + +/** + * A resolver to extract and decode the payload of a message using a + * {@link Decoder}, where the payload is expected to be a {@link Publisher} of + * {@link DataBuffer DataBuffer}. + * + *

Validation is applied if the method argument is annotated with + * {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link MethodArgumentNotValidException}. + * + *

This resolver should be ordered last if {@link #useDefaultResolution} is + * set to {@code true} since in that case it supports all types and does not + * require the presence of {@link Payload}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class PayloadMethodArgumentResolver implements HandlerMethodArgumentResolver { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List> decoders; + + @Nullable + private final Validator validator; + + private final ReactiveAdapterRegistry adapterRegistry; + + private final boolean useDefaultResolution; + + + public PayloadMethodArgumentResolver(List> decoders, @Nullable Validator validator, + @Nullable ReactiveAdapterRegistry registry, boolean useDefaultResolution) { + + Assert.isTrue(!CollectionUtils.isEmpty(decoders), "At least one Decoder is required."); + this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders)); + this.validator = validator; + this.adapterRegistry = registry != null ? registry : ReactiveAdapterRegistry.getSharedInstance(); + this.useDefaultResolution = useDefaultResolution; + } + + + /** + * Return a read-only list of the configured decoders. + */ + public List> getDecoders() { + return this.decoders; + } + + /** + * Return the configured validator, if any. + */ + @Nullable + public Validator getValidator() { + return this.validator; + } + + /** + * Return the configured {@link ReactiveAdapterRegistry}. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + /** + * Whether this resolver is configured to use default resolution, i.e. + * works for any argument type regardless of whether {@code @Payload} is + * present or not. + */ + public boolean isUseDefaultResolution() { + return this.useDefaultResolution; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Payload.class) || this.useDefaultResolution; + } + + + /** + * Decode the content of the given message payload through a compatible + * {@link Decoder}. + * + *

Validation is applied if the method argument is annotated with + * {@code @javax.validation.Valid} or + * {@link org.springframework.validation.annotation.Validated}. Validation + * failure results in an {@link MethodArgumentNotValidException}. + * + * @param parameter the target method argument that we are decoding to + * @param message the message from which the content was extracted + * @return a Mono with the result of argument resolution + * + * @see #extractContent(MethodParameter, Message) + * @see #getMimeType(Message) + */ + @Override + public final Mono resolveArgument(MethodParameter parameter, Message message) { + + Payload ann = parameter.getParameterAnnotation(Payload.class); + if (ann != null && StringUtils.hasText(ann.expression())) { + throw new IllegalStateException("@Payload SpEL expressions not supported by this resolver"); + } + + MimeType mimeType = getMimeType(message); + mimeType = mimeType != null ? mimeType : MimeTypeUtils.APPLICATION_OCTET_STREAM; + + Flux content = extractContent(parameter, message); + return decodeContent(parameter, message, ann == null || ann.required(), content, mimeType); + } + + @SuppressWarnings("unchecked") + private Flux extractContent(MethodParameter parameter, Message message) { + Object payload = message.getPayload(); + if (payload instanceof DataBuffer) { + return Flux.just((DataBuffer) payload); + } + if (payload instanceof Publisher) { + return Flux.from((Publisher) payload).map(value -> { + if (value instanceof DataBuffer) { + return (DataBuffer) value; + } + String className = value.getClass().getName(); + throw getUnexpectedPayloadError(message, parameter, "Publisher<" + className + ">"); + }); + } + return Flux.error(getUnexpectedPayloadError(message, parameter, payload.getClass().getName())); + } + + private MethodArgumentResolutionException getUnexpectedPayloadError( + Message message, MethodParameter parameter, String actualType) { + + return new MethodArgumentResolutionException(message, parameter, + "Expected DataBuffer or Publisher for the Message payload, actual: " + actualType); + } + + /** + * Return the mime type for the content. By default this method checks the + * {@link MessageHeaders#CONTENT_TYPE} header expecting to find a + * {@link MimeType} value or a String to parse to a {@link MimeType}. + * @param message the input message + */ + @Nullable + protected MimeType getMimeType(Message message) { + Object headerValue = message.getHeaders().get(MessageHeaders.CONTENT_TYPE); + if (headerValue == null) { + return null; + } + else if (headerValue instanceof String) { + return MimeTypeUtils.parseMimeType((String) headerValue); + } + else if (headerValue instanceof MimeType) { + return (MimeType) headerValue; + } + else { + throw new IllegalArgumentException("Unexpected MimeType value: " + headerValue); + } + } + + private Mono decodeContent(MethodParameter parameter, Message message, + boolean isContentRequired, Flux content, MimeType mimeType) { + + ResolvableType targetType = ResolvableType.forMethodParameter(parameter); + Class resolvedType = targetType.resolve(); + ReactiveAdapter adapter = (resolvedType != null ? getAdapterRegistry().getAdapter(resolvedType) : null); + ResolvableType elementType = (adapter != null ? targetType.getGeneric() : targetType); + isContentRequired = isContentRequired || (adapter != null && !adapter.supportsEmpty()); + Consumer validator = getValidator(message, parameter); + + Map hints = Collections.emptyMap(); + + for (Decoder decoder : this.decoders) { + if (decoder.canDecode(elementType, mimeType)) { + if (adapter != null && adapter.isMultiValue()) { + Flux flux = content + .concatMap(buffer -> decoder.decode(Mono.just(buffer), elementType, mimeType, hints)) + .onErrorResume(ex -> Flux.error(handleReadError(parameter, message, ex))); + if (isContentRequired) { + flux = flux.switchIfEmpty(Flux.error(() -> handleMissingBody(parameter, message))); + } + if (validator != null) { + flux = flux.doOnNext(validator::accept); + } + return Mono.just(adapter.fromPublisher(flux)); + } + else { + // Single-value (with or without reactive type wrapper) + Mono mono = decoder + .decodeToMono(content.next(), targetType, mimeType, hints) + .onErrorResume(ex -> Mono.error(handleReadError(parameter, message, ex))); + if (isContentRequired) { + mono = mono.switchIfEmpty(Mono.error(() -> handleMissingBody(parameter, message))); + } + if (validator != null) { + mono = mono.doOnNext(validator::accept); + } + return (adapter != null ? Mono.just(adapter.fromPublisher(mono)) : Mono.from(mono)); + } + } + } + + return Mono.error(new MethodArgumentResolutionException( + message, parameter, "Cannot decode to [" + targetType + "]" + message)); + } + + private Throwable handleReadError(MethodParameter parameter, Message message, Throwable ex) { + return ex instanceof DecodingException ? + new MethodArgumentResolutionException(message, parameter, "Failed to read HTTP message", ex) : ex; + } + + private MethodArgumentResolutionException handleMissingBody(MethodParameter param, Message message) { + return new MethodArgumentResolutionException(message, param, + "Payload content is missing: " + param.getExecutable().toGenericString()); + } + + @Nullable + private Consumer getValidator(Message message, MethodParameter parameter) { + if (this.validator == null) { + return null; + } + for (Annotation ann : parameter.getParameterAnnotations()) { + Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class); + if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { + Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann)); + Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); + String name = Conventions.getVariableNameForParameter(parameter); + return target -> { + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, name); + if (!ObjectUtils.isEmpty(validationHints) && this.validator instanceof SmartValidator) { + ((SmartValidator) this.validator).validate(target, bindingResult, validationHints); + } + else { + this.validator.validate(target, bindingResult); + } + if (bindingResult.hasErrors()) { + throw new MethodArgumentNotValidException(message, parameter, bindingResult); + } + }; + } + } + return null; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java new file mode 100644 index 0000000000..41f18a41cd --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/annotation/support/reactive/package-info.java @@ -0,0 +1,10 @@ +/** + * Support classes for working with annotated message-handling methods with + * non-blocking, reactive contracts. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.handler.annotation.support.reactive; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java index 82d07e260d..836458adbe 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/AbstractExceptionHandlerMethodResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -85,7 +85,7 @@ public abstract class AbstractExceptionHandlerMethodResolver { * @return a Method to handle the exception, or {@code null} if none found */ @Nullable - public Method resolveMethod(Exception exception) { + public Method resolveMethod(Throwable exception) { Method method = resolveMethodByExceptionType(exception.getClass()); if (method == null) { Throwable cause = exception.getCause(); diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java index 0d03ea2c98..a8302bd48b 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/MethodArgumentResolutionException.java @@ -17,6 +17,7 @@ package org.springframework.messaging.handler.invocation; import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessagingException; @@ -51,6 +52,17 @@ public class MethodArgumentResolutionException extends MessagingException { this.parameter = parameter; } + /** + * Create a new instance providing the invalid {@code MethodParameter}, + * prepared description, and a cause. + */ + public MethodArgumentResolutionException( + Message message, MethodParameter parameter, String description, @Nullable Throwable cause) { + + super(message, getMethodParameterMessage(parameter) + ": " + description, cause); + this.parameter = parameter; + } + /** * Return the MethodParameter that was rejected. diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java new file mode 100644 index 0000000000..28387c292d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractEncoderMethodReturnValueHandler.java @@ -0,0 +1,218 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.MessagingException; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Base class for a return value handler that encodes return values to + * {@code Flux} through the configured {@link Encoder}s. + * + *

Sub-classes must implement the abstract method + * {@link #handleEncodedContent} to handle the resulting encoded content. + * + *

This handler should be ordered last since its {@link #supportsReturnType} + * returns {@code true} for any method parameter type. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public abstract class AbstractEncoderMethodReturnValueHandler implements HandlerMethodReturnValueHandler { + + private static final ResolvableType VOID_RESOLVABLE_TYPE = ResolvableType.forClass(Void.class); + + private static final ResolvableType OBJECT_RESOLVABLE_TYPE = ResolvableType.forClass(Object.class); + + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List> encoders; + + private final ReactiveAdapterRegistry adapterRegistry; + + private DataBufferFactory defaultBufferFactory = new DefaultDataBufferFactory(); + + + protected AbstractEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + Assert.notEmpty(encoders, "At least one Encoder is required"); + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.encoders = Collections.unmodifiableList(encoders); + this.adapterRegistry = registry; + } + + + /** + * The configured encoders. + */ + public List> getEncoders() { + return this.encoders; + } + + /** + * The configured adapter registry. + */ + public ReactiveAdapterRegistry getAdapterRegistry() { + return this.adapterRegistry; + } + + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + // We could check canEncode but we're probably last in order anyway + return true; + } + + @Override + public Mono handleReturnValue( + @Nullable Object returnValue, MethodParameter returnType, Message message) { + + if (returnValue == null) { + return handleNoContent(returnType, message); + } + + DataBufferFactory bufferFactory = (DataBufferFactory) message.getHeaders() + .getOrDefault(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, this.defaultBufferFactory); + + MimeType mimeType = (MimeType) message.getHeaders().get(MessageHeaders.CONTENT_TYPE); + + Flux encodedContent = encodeContent( + returnValue, returnType, bufferFactory, mimeType, Collections.emptyMap()); + + return new ChannelSendOperator<>(encodedContent, publisher -> + handleEncodedContent(Flux.from(publisher), returnType, message)); + } + + @SuppressWarnings("unchecked") + private Flux encodeContent( + @Nullable Object content, MethodParameter returnType, DataBufferFactory bufferFactory, + @Nullable MimeType mimeType, Map hints) { + + ResolvableType returnValueType = ResolvableType.forMethodParameter(returnType); + ReactiveAdapter adapter = getAdapterRegistry().getAdapter(returnValueType.resolve(), content); + + Publisher publisher; + ResolvableType elementType; + if (adapter != null) { + publisher = adapter.toPublisher(content); + ResolvableType genericType = returnValueType.getGeneric(); + elementType = getElementType(adapter, genericType); + } + else { + publisher = Mono.justOrEmpty(content); + elementType = returnValueType.toClass() == Object.class && content != null ? + ResolvableType.forInstance(content) : returnValueType; + } + + if (elementType.resolve() == void.class || elementType.resolve() == Void.class) { + return Flux.from(publisher).cast(DataBuffer.class); + } + + Encoder encoder = getEncoder(elementType, mimeType); + + return Flux.from((Publisher) publisher).concatMap(value -> + encodeValue(value, elementType, encoder, bufferFactory, mimeType, hints)); + } + + private ResolvableType getElementType(ReactiveAdapter adapter, ResolvableType type) { + if (adapter.isNoValue()) { + return VOID_RESOLVABLE_TYPE; + } + else if (type != ResolvableType.NONE) { + return type; + } + else { + return OBJECT_RESOLVABLE_TYPE; + } + } + + @Nullable + @SuppressWarnings("unchecked") + private Encoder getEncoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Encoder encoder : getEncoders()) { + if (encoder.canEncode(elementType, mimeType)) { + return (Encoder) encoder; + } + } + return null; + } + + @SuppressWarnings("unchecked") + private Mono encodeValue( + Object element, ResolvableType elementType, @Nullable Encoder encoder, + DataBufferFactory bufferFactory, @Nullable MimeType mimeType, + @Nullable Map hints) { + + if (encoder == null) { + encoder = getEncoder(ResolvableType.forInstance(element), mimeType); + if (encoder == null) { + return Mono.error(new MessagingException( + "No encoder for " + elementType + ", current value type is " + element.getClass())); + } + } + Mono mono = Mono.just((T) element); + Flux dataBuffers = encoder.encode(mono, bufferFactory, elementType, mimeType, hints); + return DataBufferUtils.join(dataBuffers); + } + + /** + * Sub-classes implement this method to handle encoded values in some way + * such as creating and sending messages. + * + * @param encodedContent the encoded content; each {@code DataBuffer} + * represents the fully-aggregated, encoded content for one value + * (i.e. payload) returned from the HandlerMethod. + * @param returnType return type of the handler method that produced the data + * @param message the input message handled by the handler method + * @return completion {@code Mono} for the handling + */ + protected abstract Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message); + + /** + * Invoked for a {@code null} return value, which could mean a void method + * or method returning an async type parameterized by void. + * @param returnType return type of the handler method that produced the data + * @param message the input message handled by the handler method + * @return completion {@code Mono} for the handling + */ + protected abstract Mono handleNoContent(MethodParameter returnType, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java new file mode 100644 index 0000000000..f4231151f0 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/AbstractMethodMessageHandler.java @@ -0,0 +1,517 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.BeanNameAware; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.ReactiveMessageHandler; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.MessagingAdviceBean; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; + +/** + * Abstract base class for reactive HandlerMethod-based message handling. + * Provides most of the logic required to discover handler methods at startup, + * find a matching handler method at runtime for a given message and invoke it. + * + *

Also supports discovering and invoking exception handling methods to process + * exceptions raised during message handling. + * + * @author Rossen Stoyanchev + * @since 5.2 + * @param the type of the Object that contains information mapping information + */ +public abstract class AbstractMethodMessageHandler + implements ReactiveMessageHandler, ApplicationContextAware, InitializingBean, BeanNameAware { + + /** + * Bean name prefix for target beans behind scoped proxies. Used to exclude those + * targets from handler method detection, in favor of the corresponding proxies. + *

We're not checking the autowire-candidate status here, which is how the + * proxy target filtering problem is being handled at the autowiring level, + * since autowire-candidate may have been turned to {@code false} for other + * reasons, while still expecting the bean to be eligible for handler methods. + *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} + * but duplicated here to avoid a hard dependency on the spring-aop module. + */ + private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + + + protected final Log logger = LogFactory.getLog(getClass()); + + + private ArgumentResolverConfigurer argumentResolverConfigurer = new ArgumentResolverConfigurer(); + + private ReturnValueHandlerConfigurer returnValueHandlerConfigurer = new ReturnValueHandlerConfigurer(); + + private final InvocableHelper invocableHelper = new InvocableHelper(this::createExceptionMethodResolverFor); + + @Nullable + private ApplicationContext applicationContext; + + @Nullable + private String beanName; + + private final Map handlerMethods = new LinkedHashMap<>(64); + + private final MultiValueMap destinationLookup = new LinkedMultiValueMap<>(64); + + + /** + * Configure custom resolvers for handler method arguments. + */ + public void setArgumentResolverConfigurer(ArgumentResolverConfigurer configurer) { + Assert.notNull(configurer, "HandlerMethodArgumentResolver is required."); + this.argumentResolverConfigurer = configurer; + } + + /** + * Return the configured custom resolvers for handler method arguments. + */ + public ArgumentResolverConfigurer getArgumentResolverConfigurer() { + return this.argumentResolverConfigurer; + } + + /** + * Configure custom return value handlers for handler metohds. + */ + public void setReturnValueHandlerConfigurer(ReturnValueHandlerConfigurer configurer) { + Assert.notNull(configurer, "ReturnValueHandlerConfigurer is required."); + this.returnValueHandlerConfigurer = configurer; + } + + /** + * Return the configured return value handlers. + */ + public ReturnValueHandlerConfigurer getReturnValueHandlerConfigurer() { + return this.returnValueHandlerConfigurer; + } + + /** + * Configure the registry for adapting various reactive types. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + this.invocableHelper.setReactiveAdapterRegistry(registry); + } + + /** + * Return the configured registry for adapting reactive types. + */ + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return this.invocableHelper.getReactiveAdapterRegistry(); + } + + @Override + public void setApplicationContext(@Nullable ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Nullable + public ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + @Override + public void setBeanName(String name) { + this.beanName = name; + } + + public String getBeanName() { + return this.beanName != null ? this.beanName : + getClass().getSimpleName() + "@" + ObjectUtils.getIdentityHexString(this); + } + + /** + * Subclasses can invoke this method to populate the MessagingAdviceBean cache + * (e.g. to support "global" {@code @MessageExceptionHandler}). + */ + protected void registerExceptionHandlerAdvice( + MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { + + this.invocableHelper.registerExceptionHandlerAdvice(bean, resolver); + } + + /** + * Return a read-only map with all handler methods and their mappings. + */ + public Map getHandlerMethods() { + return Collections.unmodifiableMap(this.handlerMethods); + } + + /** + * Return a read-only multi-value map with a direct lookup of mappings, + * (e.g. for non-pattern destinations). + */ + public MultiValueMap getDestinationLookup() { + return CollectionUtils.unmodifiableMultiValueMap(this.destinationLookup); + } + + + @Override + public void afterPropertiesSet() { + + List resolvers = initArgumentResolvers(); + if (resolvers.isEmpty()) { + resolvers = new ArrayList<>(this.argumentResolverConfigurer.getCustomResolvers()); + } + this.invocableHelper.addArgumentResolvers(resolvers); + + List handlers = initReturnValueHandlers(); + if (handlers.isEmpty()) { + handlers = new ArrayList<>(this.returnValueHandlerConfigurer.getCustomHandlers()); + } + this.invocableHelper.addReturnValueHandlers(handlers); + + initHandlerMethods(); + } + + /** + * Return the list of argument resolvers to use. + *

Subclasses should also take into account custom argument types configured via + * {@link #setArgumentResolverConfigurer}. + */ + protected abstract List initArgumentResolvers(); + + /** + * Return the list of return value handlers to use. + *

Subclasses should also take into account custom return value types configured + * via {@link #setReturnValueHandlerConfigurer}. + */ + protected abstract List initReturnValueHandlers(); + + + private void initHandlerMethods() { + if (this.applicationContext == null) { + logger.warn("No ApplicationContext available for detecting beans with message handling methods."); + return; + } + Predicate> handlerPredicate = initHandlerPredicate(); + if (handlerPredicate == null) { + logger.warn("[" + getBeanName() + "] No auto-detection of handler methods (e.g. in @Controller)."); + return; + } + for (String beanName : this.applicationContext.getBeanNamesForType(Object.class)) { + if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { + Class beanType = null; + try { + beanType = this.applicationContext.getType(beanName); + } + catch (Throwable ex) { + // An unresolvable bean type, probably from a lazy bean - let's ignore it. + if (logger.isDebugEnabled()) { + logger.debug("Could not resolve target class for bean with name '" + beanName + "'", ex); + } + } + if (beanType != null && handlerPredicate.test(beanType)) { + detectHandlerMethods(beanName); + } + } + } + } + + /** + * Return the predicate to use to check whether a given Spring bean should + * be introspected for message handling methods. If {@code null} is + * returned, auto-detection is effectively disabled. + */ + @Nullable + protected abstract Predicate> initHandlerPredicate(); + + /** + * Detect if the given handler has any methods that can handle messages and if + * so register it with the extracted mapping information. + *

Note: This method is protected and can be invoked by + * sub-classes, but this should be done on startup only as documented in + * {@link #registerHandlerMethod}. + * @param handler the handler to check, either an instance of a Spring bean name + */ + protected final void detectHandlerMethods(Object handler) { + Class handlerType; + if (handler instanceof String) { + ApplicationContext context = getApplicationContext(); + Assert.state(context != null, "ApplicationContext is required for resolving handler bean names"); + handlerType = context.getType((String) handler); + } + else { + handlerType = handler.getClass(); + } + if (handlerType != null) { + final Class userType = ClassUtils.getUserClass(handlerType); + Map methods = MethodIntrospector.selectMethods(userType, + (MethodIntrospector.MetadataLookup) method -> getMappingForMethod(method, userType)); + if (logger.isDebugEnabled()) { + logger.debug(methods.size() + " message handler methods found on " + userType + ": " + methods); + } + methods.forEach((key, value) -> registerHandlerMethod(handler, key, value)); + } + } + + /** + * Obtain the mapping for the given method, if any. + * @param method the method to check + * @param handlerType the handler type, possibly a sub-type of the method's declaring class + * @return the mapping, or {@code null} if the method is not mapped + */ + @Nullable + protected abstract T getMappingForMethod(Method method, Class handlerType); + + /** + * Register a handler method and its unique mapping. + *

Note: This method is protected and can be invoked by + * sub-classes. Keep in mind however that the registration is not protected + * for concurrent use, and is expected to be done on startup. + * @param handler the bean name of the handler or the handler instance + * @param method the method to register + * @param mapping the mapping conditions associated with the handler method + * @throws IllegalStateException if another method was already registered + * under the same mapping + */ + protected final void registerHandlerMethod(Object handler, Method method, T mapping) { + Assert.notNull(mapping, "Mapping must not be null"); + HandlerMethod newHandlerMethod = createHandlerMethod(handler, method); + HandlerMethod oldHandlerMethod = this.handlerMethods.get(mapping); + + if (oldHandlerMethod != null && !oldHandlerMethod.equals(newHandlerMethod)) { + throw new IllegalStateException("Ambiguous mapping found. Cannot map '" + newHandlerMethod.getBean() + + "' bean method \n" + newHandlerMethod + "\nto " + mapping + ": There is already '" + + oldHandlerMethod.getBean() + "' bean method\n" + oldHandlerMethod + " mapped."); + } + + this.handlerMethods.put(mapping, newHandlerMethod); + if (logger.isTraceEnabled()) { + logger.trace("Mapped \"" + mapping + "\" onto " + newHandlerMethod); + } + + for (String pattern : getDirectLookupMappings(mapping)) { + this.destinationLookup.add(pattern, mapping); + } + } + + /** + * Create a HandlerMethod instance from an Object handler that is either a handler + * instance or a String-based bean name. + */ + private HandlerMethod createHandlerMethod(Object handler, Method method) { + HandlerMethod handlerMethod; + if (handler instanceof String) { + ApplicationContext context = getApplicationContext(); + Assert.state(context != null, "ApplicationContext is required for resolving handler bean names"); + String beanName = (String) handler; + handlerMethod = new HandlerMethod(beanName, context.getAutowireCapableBeanFactory(), method); + } + else { + handlerMethod = new HandlerMethod(handler, method); + } + return handlerMethod; + } + + /** + * Return String-based destinations for the given mapping, if any, that can + * be used to find matches with a direct lookup (i.e. non-patterns). + *

Note: This is completely optional. The mapping + * metadata for a sub-class may support neither direct lookups, nor String + * based destinations. + */ + protected abstract Set getDirectLookupMappings(T mapping); + + + @Override + public Mono handleMessage(Message message) throws MessagingException { + Match match = getHandlerMethod(message); + if (match == null) { + // handleNoMatch would have been invoked already + return Mono.empty(); + } + HandlerMethod handlerMethod = match.getHandlerMethod().createWithResolvedBean(); + return this.invocableHelper.handleMessage(handlerMethod, message); + } + + @Nullable + private Match getHandlerMethod(Message message) { + List> matches = new ArrayList<>(); + + String destination = getDestination(message); + List mappingsByUrl = destination != null ? this.destinationLookup.get(destination) : null; + if (mappingsByUrl != null) { + addMatchesToCollection(mappingsByUrl, message, matches); + } + if (matches.isEmpty()) { + // No direct hits, go through all mappings + Set allMappings = this.handlerMethods.keySet(); + addMatchesToCollection(allMappings, message, matches); + } + if (matches.isEmpty()) { + handleNoMatch(destination, message); + return null; + } + Comparator> comparator = new MatchComparator(getMappingComparator(message)); + matches.sort(comparator); + if (logger.isTraceEnabled()) { + logger.trace("Found " + matches.size() + " handler methods: " + matches); + } + Match bestMatch = matches.get(0); + if (matches.size() > 1) { + Match secondBestMatch = matches.get(1); + if (comparator.compare(bestMatch, secondBestMatch) == 0) { + HandlerMethod m1 = bestMatch.handlerMethod; + HandlerMethod m2 = secondBestMatch.handlerMethod; + throw new IllegalStateException("Ambiguous handler methods mapped for destination '" + + destination + "': {" + m1.getShortLogMessage() + ", " + m2.getShortLogMessage() + "}"); + } + } + return bestMatch; + } + + /** + * Extract a String-based destination, if any, that can be used to perform + * a direct look up into the registered mappings. + *

Note: This is completely optional. The mapping + * metadata for a sub-class may support neither direct lookups, nor String + * based destinations. + * @see #getDirectLookupMappings(Object) + */ + @Nullable + protected abstract String getDestination(Message message); + + private void addMatchesToCollection( + Collection mappingsToCheck, Message message, List> matches) { + + for (T mapping : mappingsToCheck) { + T match = getMatchingMapping(mapping, message); + if (match != null) { + matches.add(new Match(match, this.handlerMethods.get(mapping))); + } + } + } + + /** + * Check if a mapping matches the current message and return a possibly + * new mapping with conditions relevant to the current request. + * @param mapping the mapping to get a match for + * @param message the message being handled + * @return the match or {@code null} if there is no match + */ + @Nullable + protected abstract T getMatchingMapping(T mapping, Message message); + + /** + * Return a comparator for sorting matching mappings. + * The returned comparator should sort 'better' matches higher. + * @param message the current Message + * @return the comparator, never {@code null} + */ + protected abstract Comparator getMappingComparator(Message message); + + /** + * Invoked when no matching handler is found. + * @param destination the destination + * @param message the message + */ + @Nullable + protected void handleNoMatch(@Nullable String destination, Message message) { + logger.debug("No handlers for destination '" + destination + "'"); + } + + /** + * Create a concrete instance of {@link AbstractExceptionHandlerMethodResolver} + * that finds exception handling methods based on some criteria, e.g. based + * on the presence of {@code @MessageExceptionHandler}. + * @param beanType the class in which an exception occurred during handling + * @return the resolver to use + */ + protected abstract AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType); + + + /** + * Container for matched mapping and HandlerMethod. Used for best match + * comparison and for access to mapping information. + */ + private static class Match { + + private final T mapping; + + private final HandlerMethod handlerMethod; + + + Match(T mapping, HandlerMethod handlerMethod) { + this.mapping = mapping; + this.handlerMethod = handlerMethod; + } + + + public T getMapping() { + return this.mapping; + } + + public HandlerMethod getHandlerMethod() { + return this.handlerMethod; + } + + + @Override + public String toString() { + return this.mapping.toString(); + } + } + + + private class MatchComparator implements Comparator> { + + private final Comparator comparator; + + + MatchComparator(Comparator comparator) { + this.comparator = comparator; + } + + + @Override + public int compare(Match match1, Match match2) { + return this.comparator.compare(match1.mapping, match2.mapping); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java new file mode 100644 index 0000000000..41c2fe77b9 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ArgumentResolverConfigurer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Assist with configuration for handler method argument resolvers. + * At present, it supports only providing a list of custom resolvers. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class ArgumentResolverConfigurer { + + private final List customResolvers = new ArrayList<>(8); + + + /** + * Configure resolvers for custom handler method arguments. + * @param resolver the resolvers to add + */ + public void addCustomResolver(HandlerMethodArgumentResolver... resolver) { + Assert.notNull(resolver, "'resolvers' must not be null"); + this.customResolvers.addAll(Arrays.asList(resolver)); + } + + + public List getCustomResolvers() { + return this.customResolvers; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java new file mode 100644 index 0000000000..b89f5f96fe --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ChannelSendOperator.java @@ -0,0 +1,410 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.Scannable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Operators; +import reactor.util.context.Context; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * ---------------------- + *

NOTE: This class was copied from + * {@code org.springframework.http.server.reactive.ChannelSendOperator} and is + * identical to it. It's used for the same purpose, i.e. the ability to switch to + * alternate handling via annotated exception handler methods if the output + * publisher starts with an error. + *

----------------------
+ * + *

Given a write function that accepts a source {@code Publisher} to write + * with and returns {@code Publisher} for the result, this operator helps + * to defer the invocation of the write function, until we know if the source + * publisher will begin publishing without an error. If the first emission is + * an error, the write function is bypassed, and the error is sent directly + * through the result publisher. Otherwise the write function is invoked. + * + * @author Rossen Stoyanchev + * @author Stephane Maldini + * @since 5.2 + * @param the type of element signaled + */ +class ChannelSendOperator extends Mono implements Scannable { + + private final Function, Publisher> writeFunction; + + private final Flux source; + + + public ChannelSendOperator(Publisher source, Function, Publisher> writeFunction) { + this.source = Flux.from(source); + this.writeFunction = writeFunction; + } + + + @Override + @Nullable + @SuppressWarnings("rawtypes") + public Object scanUnsafe(Attr key) { + if (key == Attr.PREFETCH) { + return Integer.MAX_VALUE; + } + if (key == Attr.PARENT) { + return this.source; + } + return null; + } + + @Override + public void subscribe(CoreSubscriber actual) { + this.source.subscribe(new WriteBarrier(actual)); + } + + + private enum State { + + /** No emissions from the upstream source yet. */ + NEW, + + /** + * At least one signal of any kind has been received; we're ready to + * call the write function and proceed with actual writing. + */ + FIRST_SIGNAL_RECEIVED, + + /** + * The write subscriber has subscribed and requested; we're going to + * emit the cached signals. + */ + EMITTING_CACHED_SIGNALS, + + /** + * The write subscriber has subscribed, and cached signals have been + * emitted to it; we're ready to switch to a simple pass-through mode + * for all remaining signals. + **/ + READY_TO_WRITE + + } + + + /** + * A barrier inserted between the write source and the write subscriber + * (i.e. the HTTP server adapter) that pre-fetches and waits for the first + * signal before deciding whether to hook in to the write subscriber. + * + *

Acts as: + *

    + *
  • Subscriber to the write source. + *
  • Subscription to the write subscriber. + *
  • Publisher to the write subscriber. + *
+ * + *

Also uses {@link WriteCompletionBarrier} to communicate completion + * and detect cancel signals from the completion subscriber. + */ + private class WriteBarrier implements CoreSubscriber, Subscription, Publisher { + + /* Bridges signals to and from the completionSubscriber */ + private final WriteCompletionBarrier writeCompletionBarrier; + + /* Upstream write source subscription */ + @Nullable + private Subscription subscription; + + /** Cached data item before readyToWrite. */ + @Nullable + private T item; + + /** Cached error signal before readyToWrite. */ + @Nullable + private Throwable error; + + /** Cached onComplete signal before readyToWrite. */ + private boolean completed = false; + + /** Recursive demand while emitting cached signals. */ + private long demandBeforeReadyToWrite; + + /** Current state. */ + private State state = State.NEW; + + /** The actual writeSubscriber from the HTTP server adapter. */ + @Nullable + private Subscriber writeSubscriber; + + + WriteBarrier(CoreSubscriber completionSubscriber) { + this.writeCompletionBarrier = new WriteCompletionBarrier(completionSubscriber, this); + } + + + // Subscriber methods (we're the subscriber to the write source).. + + @Override + public final void onSubscribe(Subscription s) { + if (Operators.validate(this.subscription, s)) { + this.subscription = s; + this.writeCompletionBarrier.connect(); + s.request(1); + } + } + + @Override + public final void onNext(T item) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + return; + } + //FIXME revisit in case of reentrant sync deadlock + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onNext(item); + } + else if (this.state == State.NEW) { + this.item = item; + this.state = State.FIRST_SIGNAL_RECEIVED; + writeFunction.apply(this).subscribe(this.writeCompletionBarrier); + } + else { + if (this.subscription != null) { + this.subscription.cancel(); + } + this.writeCompletionBarrier.onError(new IllegalStateException("Unexpected item.")); + } + } + } + + private Subscriber requiredWriteSubscriber() { + Assert.state(this.writeSubscriber != null, "No write subscriber"); + return this.writeSubscriber; + } + + @Override + public final void onError(Throwable ex) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + return; + } + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onError(ex); + } + else if (this.state == State.NEW) { + this.state = State.FIRST_SIGNAL_RECEIVED; + this.writeCompletionBarrier.onError(ex); + } + else { + this.error = ex; + } + } + } + + @Override + public final void onComplete() { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + return; + } + synchronized (this) { + if (this.state == State.READY_TO_WRITE) { + requiredWriteSubscriber().onComplete(); + } + else if (this.state == State.NEW) { + this.completed = true; + this.state = State.FIRST_SIGNAL_RECEIVED; + writeFunction.apply(this).subscribe(this.writeCompletionBarrier); + } + else { + this.completed = true; + } + } + } + + @Override + public Context currentContext() { + return this.writeCompletionBarrier.currentContext(); + } + + + // Subscription methods (we're the Subscription to the writeSubscriber).. + + @Override + public void request(long n) { + Subscription s = this.subscription; + if (s == null) { + return; + } + if (this.state == State.READY_TO_WRITE) { + s.request(n); + return; + } + synchronized (this) { + if (this.writeSubscriber != null) { + if (this.state == State.EMITTING_CACHED_SIGNALS) { + this.demandBeforeReadyToWrite = n; + return; + } + try { + this.state = State.EMITTING_CACHED_SIGNALS; + if (emitCachedSignals()) { + return; + } + n = n + this.demandBeforeReadyToWrite - 1; + if (n == 0) { + return; + } + } + finally { + this.state = State.READY_TO_WRITE; + } + } + } + s.request(n); + } + + private boolean emitCachedSignals() { + if (this.item != null) { + requiredWriteSubscriber().onNext(this.item); + } + if (this.error != null) { + requiredWriteSubscriber().onError(this.error); + return true; + } + if (this.completed) { + requiredWriteSubscriber().onComplete(); + return true; + } + return false; + } + + @Override + public void cancel() { + Subscription s = this.subscription; + if (s != null) { + this.subscription = null; + s.cancel(); + } + } + + + // Publisher methods (we're the Publisher to the writeSubscriber).. + + @Override + public void subscribe(Subscriber writeSubscriber) { + synchronized (this) { + Assert.state(this.writeSubscriber == null, "Only one write subscriber supported"); + this.writeSubscriber = writeSubscriber; + if (this.error != null || this.completed) { + this.writeSubscriber.onSubscribe(Operators.emptySubscription()); + emitCachedSignals(); + } + else { + this.writeSubscriber.onSubscribe(this); + } + } + } + } + + + /** + * We need an extra barrier between the WriteBarrier itself and the actual + * completion subscriber. + * + *

The completionSubscriber is subscribed initially to the WriteBarrier. + * Later after the first signal is received, we need one more subscriber + * instance (per spec can only subscribe once) to subscribe to the write + * function and switch to delegating completion signals from it. + */ + private class WriteCompletionBarrier implements CoreSubscriber, Subscription { + + /* Downstream write completion subscriber */ + private final CoreSubscriber completionSubscriber; + + private final WriteBarrier writeBarrier; + + @Nullable + private Subscription subscription; + + + public WriteCompletionBarrier(CoreSubscriber subscriber, WriteBarrier writeBarrier) { + this.completionSubscriber = subscriber; + this.writeBarrier = writeBarrier; + } + + + /** + * Connect the underlying completion subscriber to this barrier in order + * to track cancel signals and pass them on to the write barrier. + */ + public void connect() { + this.completionSubscriber.onSubscribe(this); + } + + // Subscriber methods (we're the subscriber to the write function).. + + @Override + public void onSubscribe(Subscription subscription) { + this.subscription = subscription; + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Void aVoid) { + } + + @Override + public void onError(Throwable ex) { + this.completionSubscriber.onError(ex); + } + + @Override + public void onComplete() { + this.completionSubscriber.onComplete(); + } + + @Override + public Context currentContext() { + return this.completionSubscriber.currentContext(); + } + + + @Override + public void request(long n) { + // Ignore: we don't produce data + } + + @Override + public void cancel() { + this.writeBarrier.cancel(); + Subscription subscription = this.subscription; + if (subscription != null) { + subscription.cancel(); + } + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java new file mode 100644 index 0000000000..8edf9be8a9 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; + +/** + * Strategy interface for resolving method parameters into argument values + * in the context of a given {@link Message}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface HandlerMethodArgumentResolver { + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by this resolver. + * @param parameter the method parameter to check + * @return {@code true} if this resolver supports the supplied parameter; + * {@code false} otherwise + */ + boolean supportsParameter(MethodParameter parameter); + + /** + * Resolves a method parameter into an argument value from a given message. + * @param parameter the method parameter to resolve. + * This parameter must have previously been passed to + * {@link #supportsParameter(org.springframework.core.MethodParameter)} + * which must have returned {@code true}. + * @param message the currently processed message + * @return {@code Mono} for the argument value, possibly empty + */ + Mono resolveArgument(MethodParameter parameter, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java new file mode 100644 index 0000000000..8263ceb67e --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodArgumentResolverComposite.java @@ -0,0 +1,142 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Resolves method parameters by delegating to a list of registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * Previously resolved method parameters are cached for faster lookups. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final List argumentResolvers = new LinkedList<>(); + + private final Map argumentResolverCache = + new ConcurrentHashMap<>(256); + + + /** + * Add the given {@link HandlerMethodArgumentResolver}. + */ + public HandlerMethodArgumentResolverComposite addResolver(HandlerMethodArgumentResolver resolver) { + this.argumentResolvers.add(resolver); + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + */ + public HandlerMethodArgumentResolverComposite addResolvers(@Nullable HandlerMethodArgumentResolver... resolvers) { + if (resolvers != null) { + Collections.addAll(this.argumentResolvers, resolvers); + } + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + */ + public HandlerMethodArgumentResolverComposite addResolvers( + @Nullable List resolvers) { + + if (resolvers != null) { + this.argumentResolvers.addAll(resolvers); + } + return this; + } + + /** + * Return a read-only list with the contained resolvers, or an empty list. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.argumentResolvers); + } + + /** + * Clear the list of configured resolvers. + */ + public void clear() { + this.argumentResolvers.clear(); + } + + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by any registered {@link HandlerMethodArgumentResolver}. + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return getArgumentResolver(parameter) != null; + } + + /** + * Iterate over registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} and + * invoke the one that supports it. + * @throws IllegalStateException if no suitable + * {@link HandlerMethodArgumentResolver} is found. + */ + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter); + if (resolver == null) { + throw new IllegalArgumentException( + "Unsupported parameter type [" + parameter.getParameterType().getName() + "]." + + " supportsParameter should be called first."); + } + return resolver.resolveArgument(parameter, message); + } + + /** + * Find a registered {@link HandlerMethodArgumentResolver} that supports + * the given method parameter. + */ + @Nullable + private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { + HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); + if (result == null) { + for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) { + if (methodArgumentResolver.supportsParameter(parameter)) { + result = methodArgumentResolver; + this.argumentResolverCache.put(parameter, result); + break; + } + } + } + return result; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java new file mode 100644 index 0000000000..f220d81995 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandler.java @@ -0,0 +1,57 @@ +/* + * Copyright 2002-2017 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.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Handle the return value from the invocation of an annotated {@link Message} + * handling method. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface HandlerMethodReturnValueHandler { + + /** Header containing a DataBufferFactory for use in return value handling. */ + String DATA_BUFFER_FACTORY_HEADER = "dataBufferFactory"; + + + /** + * Whether the given {@linkplain MethodParameter method return type} is + * supported by this handler. + * @param returnType the method return type to check + * @return {@code true} if this handler supports the supplied return type; + * {@code false} otherwise + */ + boolean supportsReturnType(MethodParameter returnType); + + /** + * Handle the given return value. + * @param returnValue the value returned from the handler method + * @param returnType the type of the return value. This type must have previously + * been passed to {@link #supportsReturnType(MethodParameter)} + * and it must have returned {@code true}. + * @return {@code Mono} to indicate when handling is complete. + */ + Mono handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java new file mode 100644 index 0000000000..480484d456 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/HandlerMethodReturnValueHandlerComposite.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * A HandlerMethodReturnValueHandler that wraps and delegates to others. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class HandlerMethodReturnValueHandlerComposite implements HandlerMethodReturnValueHandler { + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final List returnValueHandlers = new ArrayList<>(); + + + /** + * Return a read-only list with the configured handlers. + */ + public List getReturnValueHandlers() { + return Collections.unmodifiableList(this.returnValueHandlers); + } + + /** + * Clear the list of configured handlers. + */ + public void clear() { + this.returnValueHandlers.clear(); + } + + /** + * Add the given {@link HandlerMethodReturnValueHandler}. + */ + public HandlerMethodReturnValueHandlerComposite addHandler(HandlerMethodReturnValueHandler returnValueHandler) { + this.returnValueHandlers.add(returnValueHandler); + return this; + } + + /** + * Add the given {@link HandlerMethodReturnValueHandler HandlerMethodReturnValueHandlers}. + */ + public HandlerMethodReturnValueHandlerComposite addHandlers( + @Nullable List handlers) { + + if (handlers != null) { + this.returnValueHandlers.addAll(handlers); + } + return this; + } + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return getReturnValueHandler(returnType) != null; + } + + @Override + public Mono handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, Message message) { + HandlerMethodReturnValueHandler handler = getReturnValueHandler(returnType); + if (handler == null) { + throw new IllegalStateException("No handler for return value type: " + returnType.getParameterType()); + } + if (logger.isTraceEnabled()) { + logger.trace("Processing return value with " + handler); + } + return handler.handleReturnValue(returnValue, returnType, message); + } + + @SuppressWarnings("ForLoopReplaceableByForEach") + @Nullable + private HandlerMethodReturnValueHandler getReturnValueHandler(MethodParameter returnType) { + for (int i = 0; i < this.returnValueHandlers.size(); i++) { + HandlerMethodReturnValueHandler handler = this.returnValueHandlers.get(i); + if (handler.supportsReturnType(returnType)) { + return handler; + } + } + return null; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java new file mode 100644 index 0000000000..0ab85b55fc --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethod.java @@ -0,0 +1,213 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; + +/** + * Extension of {@link HandlerMethod} that invokes the underlying method with + * argument values resolved from the current HTTP request through a list of + * {@link HandlerMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Mono EMPTY_ARGS = Mono.just(new Object[0]); + + private static final Object NO_ARG_VALUE = new Object(); + + + private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + + /** + * Create an instance from a {@code HandlerMethod}. + */ + public InvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + /** + * Create an instance from a bean instance and a method. + */ + public InvocableHandlerMethod(Object bean, Method method) { + super(bean, method); + } + + + /** + * Configure the argument resolvers to use to use for resolving method + * argument values against a {@code ServerWebExchange}. + */ + public void setArgumentResolvers(List resolvers) { + this.resolvers.addResolvers(resolvers); + } + + /** + * Return the configured argument resolvers. + */ + public List getResolvers() { + return this.resolvers.getResolvers(); + } + + /** + * Set the ParameterNameDiscoverer for resolving parameter names when needed + * (e.g. default request attribute name). + *

Default is a {@link DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer nameDiscoverer) { + this.parameterNameDiscoverer = nameDiscoverer; + } + + /** + * Return the configured parameter name discoverer. + */ + public ParameterNameDiscoverer getParameterNameDiscoverer() { + return this.parameterNameDiscoverer; + } + + /** + * Configure a reactive registry. This is needed for cases where the response + * is fully handled within the controller in combination with an async void + * return value. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + * @param registry the registry to use + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + this.reactiveAdapterRegistry = registry; + } + + + /** + * Invoke the method for the given exchange. + * @param message the current message + * @param providedArgs optional list of argument values to match by type + * @return a Mono with the result from the invocation. + */ + public Mono invoke(Message message, Object... providedArgs) { + + return getMethodArgumentValues(message, providedArgs).flatMap(args -> { + Object value; + try { + ReflectionUtils.makeAccessible(getBridgedMethod()); + value = getBridgedMethod().invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(getBridgedMethod(), getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + return Mono.error(new IllegalStateException(formatInvokeError(text, args), ex)); + } + catch (InvocationTargetException ex) { + return Mono.error(ex.getTargetException()); + } + catch (Throwable ex) { + // Unlikely to ever get here, but it must be handled... + return Mono.error(new IllegalStateException(formatInvokeError("Invocation failure", args), ex)); + } + + MethodParameter returnType = getReturnType(); + ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(returnType.getParameterType()); + return isAsyncVoidReturnType(returnType, adapter) ? + Mono.from(adapter.toPublisher(value)) : Mono.justOrEmpty(value); + }); + } + + private Mono getMethodArgumentValues(Message message, Object... providedArgs) { + if (ObjectUtils.isEmpty(getMethodParameters())) { + return EMPTY_ARGS; + } + MethodParameter[] parameters = getMethodParameters(); + List> argMonos = new ArrayList<>(parameters.length); + for (MethodParameter parameter : parameters) { + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + Object providedArg = findProvidedArgument(parameter, providedArgs); + if (providedArg != null) { + argMonos.add(Mono.just(providedArg)); + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + return Mono.error(new MethodArgumentResolutionException( + message, parameter, formatArgumentError(parameter, "No suitable resolver"))); + } + try { + argMonos.add(this.resolvers.resolveArgument(parameter, message) + .defaultIfEmpty(NO_ARG_VALUE) + .doOnError(cause -> logArgumentErrorIfNecessary(parameter, cause))); + } + catch (Exception ex) { + logArgumentErrorIfNecessary(parameter, ex); + argMonos.add(Mono.error(ex)); + } + } + return Mono.zip(argMonos, values -> + Stream.of(values).map(o -> o != NO_ARG_VALUE ? o : null).toArray()); + } + + private void logArgumentErrorIfNecessary(MethodParameter parameter, Throwable cause) { + // Leave stack trace for later, if error is not handled.. + String causeMessage = cause.getMessage(); + if (!causeMessage.contains(parameter.getExecutable().toGenericString())) { + if (logger.isDebugEnabled()) { + logger.debug(formatArgumentError(parameter, causeMessage)); + } + } + } + + private boolean isAsyncVoidReturnType(MethodParameter returnType, @Nullable ReactiveAdapter reactiveAdapter) { + if (reactiveAdapter != null && reactiveAdapter.supportsEmpty()) { + if (reactiveAdapter.isNoValue()) { + return true; + } + Type parameterType = returnType.getGenericParameterType(); + if (parameterType instanceof ParameterizedType) { + ParameterizedType type = (ParameterizedType) parameterType; + if (type.getActualTypeArguments().length == 1) { + return Void.class.equals(type.getActualTypeArguments()[0]); + } + } + } + return false; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java new file mode 100644 index 0000000000..84e5e766e4 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/InvocableHelper.java @@ -0,0 +1,208 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.MessagingAdviceBean; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.util.Assert; + +/** + * Help to initialize and invoke an {@link InvocableHandlerMethod}, and to then + * apply return value handling and exception handling. Holds all necessary + * configuration necessary to do so. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class InvocableHelper { + + private static Log logger = LogFactory.getLog(InvocableHelper.class); + + + private final HandlerMethodArgumentResolverComposite argumentResolvers = + new HandlerMethodArgumentResolverComposite(); + + private final HandlerMethodReturnValueHandlerComposite returnValueHandlers = + new HandlerMethodReturnValueHandlerComposite(); + + private ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + private final Function, AbstractExceptionHandlerMethodResolver> exceptionMethodResolverFactory; + + private final Map, AbstractExceptionHandlerMethodResolver> exceptionHandlerCache = + new ConcurrentHashMap<>(64); + + private final Map exceptionHandlerAdviceCache = + new LinkedHashMap<>(64); + + + public InvocableHelper( + Function, AbstractExceptionHandlerMethodResolver> exceptionMethodResolverFactory) { + + this.exceptionMethodResolverFactory = exceptionMethodResolverFactory; + } + + /** + * Add the arguments resolvers to use for message handling and exception + * handling methods. + */ + public void addArgumentResolvers(List resolvers) { + this.argumentResolvers.addResolvers(resolvers); + } + + /** + * Add the return value handlers to use for message handling and exception + * handling methods. + */ + public void addReturnValueHandlers(List handlers) { + this.returnValueHandlers.addHandlers(handlers); + } + + /** + * Configure the registry for adapting various reactive types. + *

By default this is an instance of {@link ReactiveAdapterRegistry} with + * default settings. + */ + public void setReactiveAdapterRegistry(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.reactiveAdapterRegistry = registry; + } + + /** + * Return the configured registry for adapting reactive types. + */ + public ReactiveAdapterRegistry getReactiveAdapterRegistry() { + return this.reactiveAdapterRegistry; + } + + /** + * Method to populate the MessagingAdviceBean cache (e.g. to support "global" + * {@code @MessageExceptionHandler}). + */ + public void registerExceptionHandlerAdvice( + MessagingAdviceBean bean, AbstractExceptionHandlerMethodResolver resolver) { + + this.exceptionHandlerAdviceCache.put(bean, resolver); + } + + + /** + * Create {@link InvocableHandlerMethod} with the configured arg resolvers. + * @param handlerMethod the target handler method to invoke + * @return the created instance + */ + + public InvocableHandlerMethod initMessageMappingMethod(HandlerMethod handlerMethod) { + InvocableHandlerMethod invocable = new InvocableHandlerMethod(handlerMethod); + invocable.setArgumentResolvers(this.argumentResolvers.getResolvers()); + return invocable; + } + + /** + * Find an exception handling method for the given exception. + *

The default implementation searches methods in the class hierarchy of + * the HandlerMethod first and if not found, it continues searching for + * additional handling methods registered via + * {@link #registerExceptionHandlerAdvice}. + * @param handlerMethod the method where the exception was raised + * @param ex the exception raised or signaled + * @return a method to handle the exception, or {@code null} + */ + @Nullable + public InvocableHandlerMethod initExceptionHandlerMethod(HandlerMethod handlerMethod, Throwable ex) { + if (logger.isDebugEnabled()) { + logger.debug("Searching for methods to handle " + ex.getClass().getSimpleName()); + } + Class beanType = handlerMethod.getBeanType(); + AbstractExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.get(beanType); + if (resolver == null) { + resolver = this.exceptionMethodResolverFactory.apply(beanType); + this.exceptionHandlerCache.put(beanType, resolver); + } + InvocableHandlerMethod exceptionHandlerMethod = null; + Method method = resolver.resolveMethod(ex); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(handlerMethod.getBean(), method); + } + else { + for (MessagingAdviceBean advice : this.exceptionHandlerAdviceCache.keySet()) { + if (advice.isApplicableToBeanType(beanType)) { + resolver = this.exceptionHandlerAdviceCache.get(advice); + method = resolver.resolveMethod(ex); + if (method != null) { + exceptionHandlerMethod = new InvocableHandlerMethod(advice.resolveBean(), method); + break; + } + } + } + } + if (exceptionHandlerMethod != null) { + logger.debug("Found exception handler " + exceptionHandlerMethod.getShortLogMessage()); + exceptionHandlerMethod.setArgumentResolvers(this.argumentResolvers.getResolvers()); + } + else { + logger.error("No exception handling method", ex); + } + return exceptionHandlerMethod; + } + + + public Mono handleMessage(HandlerMethod handlerMethod, Message message) { + InvocableHandlerMethod invocable = initMessageMappingMethod(handlerMethod); + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + invocable.getShortLogMessage()); + } + return invocable.invoke(message) + .switchIfEmpty(Mono.defer(() -> handleReturnValue(null, invocable, message))) + .flatMap(returnValue -> handleReturnValue(returnValue, invocable, message)) + .onErrorResume(ex -> { + InvocableHandlerMethod exHandler = initExceptionHandlerMethod(handlerMethod, ex); + if (exHandler == null) { + return Mono.error(ex); + } + if (logger.isDebugEnabled()) { + logger.debug("Invoking " + exHandler.getShortLogMessage()); + } + return exHandler.invoke(message, ex) + .switchIfEmpty(Mono.defer(() -> handleReturnValue(null, exHandler, message))) + .flatMap(returnValue -> handleReturnValue(returnValue, exHandler, message)); + }); + } + + private Mono handleReturnValue( + @Nullable Object returnValue, HandlerMethod handlerMethod, Message message) { + + MethodParameter returnType = handlerMethod.getReturnType(); + return this.returnValueHandlers.handleReturnValue(returnValue, returnType, message); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java new file mode 100644 index 0000000000..c466162635 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/ReturnValueHandlerConfigurer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Assist with configuration for handler method return value handlers. + * At present, it supports only providing a list of custom handlers. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class ReturnValueHandlerConfigurer { + + private final List customHandlers = new ArrayList<>(8); + + + /** + * Configure custom return value handlers for handler methods. + * @param handlers the handlers to add + */ + public void addCustomHandler(HandlerMethodReturnValueHandler... handlers) { + Assert.notNull(handlers, "'handlers' must not be null"); + this.customHandlers.addAll(Arrays.asList(handlers)); + } + + + public List getCustomHandlers() { + return this.customHandlers; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java new file mode 100644 index 0000000000..01727c43de --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/SyncHandlerMethodArgumentResolver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * An extension of {@link HandlerMethodArgumentResolver} for implementations + * that are synchronous in nature and do not block to resolve values. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface SyncHandlerMethodArgumentResolver extends HandlerMethodArgumentResolver { + + /** + * {@inheritDoc} + *

By default this simply delegates to {@link #resolveArgumentValue} for + * synchronous resolution. + */ + @Override + default Mono resolveArgument(MethodParameter parameter, Message message) { + return Mono.justOrEmpty(resolveArgumentValue(parameter, message)); + } + + /** + * Resolve the value for the method parameter synchronously. + * @param parameter the method parameter + * @param message the currently processed message + * @return the resolved value, if any + */ + @Nullable + Object resolveArgumentValue(MethodParameter parameter, Message message); + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java new file mode 100644 index 0000000000..27a85c242d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/handler/invocation/reactive/package-info.java @@ -0,0 +1,10 @@ +/** + * Common infrastructure for invoking message handler methods with non-blocking, + * and reactive contracts. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.handler.invocation.reactive; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java new file mode 100644 index 0000000000..c59e99386f --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketRequester.java @@ -0,0 +1,272 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; + +import io.rsocket.Payload; +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; + +/** + * Default, package-private {@link RSocketRequester} implementation. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +final class DefaultRSocketRequester implements RSocketRequester { + + private static final Map EMPTY_HINTS = Collections.emptyMap(); + + + private final RSocket rsocket; + + @Nullable + private final MimeType dataMimeType; + + private final RSocketStrategies strategies; + + private DataBuffer emptyDataBuffer; + + + DefaultRSocketRequester(RSocket rsocket, @Nullable MimeType dataMimeType, RSocketStrategies strategies) { + Assert.notNull(rsocket, "RSocket is required"); + Assert.notNull(strategies, "RSocketStrategies is required"); + this.rsocket = rsocket; + this.dataMimeType = dataMimeType; + this.strategies = strategies; + this.emptyDataBuffer = this.strategies.dataBufferFactory().wrap(new byte[0]); + } + + + @Override + public RSocket rsocket() { + return this.rsocket; + } + + @Override + public RequestSpec route(String route) { + return new DefaultRequestSpec(route); + } + + + private static boolean isVoid(ResolvableType elementType) { + return Void.class.equals(elementType.resolve()) || void.class.equals(elementType.resolve()); + } + + + private class DefaultRequestSpec implements RequestSpec { + + private final String route; + + + DefaultRequestSpec(String route) { + this.route = route; + } + + + @Override + public ResponseSpec data(Object data) { + Assert.notNull(data, "'data' must not be null"); + return toResponseSpec(data, ResolvableType.NONE); + } + + @Override + public > ResponseSpec data(P publisher, Class dataType) { + Assert.notNull(publisher, "'publisher' must not be null"); + Assert.notNull(dataType, "'dataType' must not be null"); + return toResponseSpec(publisher, ResolvableType.forClass(dataType)); + } + + @Override + public > ResponseSpec data(P publisher, ParameterizedTypeReference dataTypeRef) { + Assert.notNull(publisher, "'publisher' must not be null"); + Assert.notNull(dataTypeRef, "'dataTypeRef' must not be null"); + return toResponseSpec(publisher, ResolvableType.forType(dataTypeRef)); + } + + private ResponseSpec toResponseSpec(Object input, ResolvableType dataType) { + ReactiveAdapter adapter = strategies.reactiveAdapterRegistry().getAdapter(input.getClass()); + Publisher publisher; + if (input instanceof Publisher) { + publisher = (Publisher) input; + } + else if (adapter != null) { + publisher = adapter.toPublisher(input); + } + else { + Mono payloadMono = encodeValue(input, ResolvableType.forInstance(input), null) + .map(this::firstPayload) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + if (isVoid(dataType) || (adapter != null && adapter.isNoValue())) { + Mono payloadMono = Mono.when(publisher).then(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + Encoder encoder = dataType != ResolvableType.NONE && !Object.class.equals(dataType.resolve()) ? + strategies.encoder(dataType, dataMimeType) : null; + + if (adapter != null && !adapter.isMultiValue()) { + Mono payloadMono = Mono.from(publisher) + .flatMap(value -> encodeValue(value, dataType, encoder)) + .map(this::firstPayload) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadMono); + } + + Flux payloadFlux = Flux.from(publisher) + .concatMap(value -> encodeValue(value, dataType, encoder)) + .switchOnFirst((signal, inner) -> { + DataBuffer data = signal.get(); + if (data != null) { + return Flux.concat( + Mono.just(firstPayload(data)), + inner.skip(1).map(PayloadUtils::createPayload)); + } + else { + return inner.map(PayloadUtils::createPayload); + } + }) + .switchIfEmpty(emptyPayload()); + return new DefaultResponseSpec(payloadFlux); + } + + @SuppressWarnings("unchecked") + private Mono encodeValue(T value, ResolvableType valueType, @Nullable Encoder encoder) { + if (encoder == null) { + encoder = strategies.encoder(ResolvableType.forInstance(value), dataMimeType); + } + return DataBufferUtils.join(((Encoder) encoder).encode( + Mono.just(value), strategies.dataBufferFactory(), valueType, dataMimeType, EMPTY_HINTS)); + } + + private Payload firstPayload(DataBuffer data) { + return PayloadUtils.createPayload(getMetadata(), data); + } + + private Mono emptyPayload() { + return Mono.fromCallable(() -> firstPayload(emptyDataBuffer)); + } + + private DataBuffer getMetadata() { + return strategies.dataBufferFactory().wrap(this.route.getBytes(StandardCharsets.UTF_8)); + } + } + + + private class DefaultResponseSpec implements ResponseSpec { + + @Nullable + private final Mono payloadMono; + + @Nullable + private final Flux payloadFlux; + + + DefaultResponseSpec(Mono payloadMono) { + this.payloadMono = payloadMono; + this.payloadFlux = null; + } + + DefaultResponseSpec(Flux payloadFlux) { + this.payloadMono = null; + this.payloadFlux = payloadFlux; + } + + + @Override + public Mono send() { + Assert.notNull(this.payloadMono, "No RSocket interaction model for one-way send with Flux."); + return this.payloadMono.flatMap(rsocket::fireAndForget); + } + + @Override + public Mono retrieveMono(Class dataType) { + return retrieveMono(ResolvableType.forClass(dataType)); + } + + @Override + public Mono retrieveMono(ParameterizedTypeReference dataTypeRef) { + return retrieveMono(ResolvableType.forType(dataTypeRef)); + } + + @Override + public Flux retrieveFlux(Class dataType) { + return retrieveFlux(ResolvableType.forClass(dataType)); + } + + @Override + public Flux retrieveFlux(ParameterizedTypeReference dataTypeRef) { + return retrieveFlux(ResolvableType.forType(dataTypeRef)); + } + + @SuppressWarnings("unchecked") + private Mono retrieveMono(ResolvableType elementType) { + Assert.notNull(this.payloadMono, + "No RSocket interaction model for Flux request to Mono response."); + + Mono payloadMono = this.payloadMono.flatMap(rsocket::requestResponse); + + if (isVoid(elementType)) { + return (Mono) payloadMono.then(); + } + + Decoder decoder = strategies.decoder(elementType, dataMimeType); + return (Mono) decoder.decodeToMono( + payloadMono.map(this::retainDataAndReleasePayload), elementType, dataMimeType, EMPTY_HINTS); + } + + @SuppressWarnings("unchecked") + private Flux retrieveFlux(ResolvableType elementType) { + + Flux payloadFlux = this.payloadMono != null ? + this.payloadMono.flatMapMany(rsocket::requestStream) : + rsocket.requestChannel(this.payloadFlux); + + if (isVoid(elementType)) { + return payloadFlux.thenMany(Flux.empty()); + } + + Decoder decoder = strategies.decoder(elementType, dataMimeType); + + return payloadFlux.map(this::retainDataAndReleasePayload).concatMap(dataBuffer -> + (Mono) decoder.decodeToMono(Mono.just(dataBuffer), elementType, dataMimeType, EMPTY_HINTS)); + } + + private DataBuffer retainDataAndReleasePayload(Payload payload) { + return PayloadUtils.retainDataAndReleasePayload(payload, strategies.dataBufferFactory()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java new file mode 100644 index 0000000000..a2a12293e9 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/DefaultRSocketStrategies.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Default, package-private {@link RSocketStrategies} implementation. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +final class DefaultRSocketStrategies implements RSocketStrategies { + + private final List> encoders; + + private final List> decoders; + + private final ReactiveAdapterRegistry adapterRegistry; + + private final DataBufferFactory bufferFactory; + + + private DefaultRSocketStrategies( + List> encoders, List> decoders, + ReactiveAdapterRegistry adapterRegistry, DataBufferFactory bufferFactory) { + + this.encoders = Collections.unmodifiableList(encoders); + this.decoders = Collections.unmodifiableList(decoders); + this.adapterRegistry = adapterRegistry; + this.bufferFactory = bufferFactory; + } + + + @Override + public List> encoders() { + return this.encoders; + } + + @Override + public List> decoders() { + return this.decoders; + } + + @Override + public ReactiveAdapterRegistry reactiveAdapterRegistry() { + return this.adapterRegistry; + } + + @Override + public DataBufferFactory dataBufferFactory() { + return this.bufferFactory; + } + + + /** + * Default RSocketStrategies.Builder implementation. + */ + static class DefaultRSocketStrategiesBuilder implements RSocketStrategies.Builder { + + private final List> encoders = new ArrayList<>(); + + private final List> decoders = new ArrayList<>(); + + private ReactiveAdapterRegistry adapterRegistry = ReactiveAdapterRegistry.getSharedInstance(); + + @Nullable + private DataBufferFactory dataBufferFactory; + + + @Override + public Builder encoder(Encoder... encoders) { + this.encoders.addAll(Arrays.asList(encoders)); + return this; + } + + @Override + public Builder decoder(Decoder... decoder) { + this.decoders.addAll(Arrays.asList(decoder)); + return this; + } + + @Override + public Builder encoders(Consumer>> consumer) { + consumer.accept(this.encoders); + return this; + } + + @Override + public Builder decoders(Consumer>> consumer) { + consumer.accept(this.decoders); + return this; + } + + @Override + public Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry) { + Assert.notNull(registry, "ReactiveAdapterRegistry is required"); + this.adapterRegistry = registry; + return this; + } + + @Override + public Builder dataBufferFactory(DataBufferFactory bufferFactory) { + this.dataBufferFactory = bufferFactory; + return this; + } + + @Override + public RSocketStrategies build() { + return new DefaultRSocketStrategies(this.encoders, this.decoders, this.adapterRegistry, + this.dataBufferFactory != null ? this.dataBufferFactory : new DefaultDataBufferFactory()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java new file mode 100644 index 0000000000..7ff4ec20ba --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessageHandlerAcceptor.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.function.Function; + +import io.rsocket.ConnectionSetupPayload; +import io.rsocket.RSocket; +import io.rsocket.SocketAcceptor; +import reactor.core.publisher.Mono; + +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.util.MimeType; + +/** + * Extension of {@link RSocketMessageHandler} that can be plugged directly into + * RSocket to receive connections either on the + * {@link io.rsocket.RSocketFactory.ClientRSocketFactory#acceptor(Function) client} or on the + * {@link io.rsocket.RSocketFactory.ServerRSocketFactory#acceptor(SocketAcceptor) server} + * side. Requests are handled by delegating to the "super" {@link #handleMessage(Message)}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public final class MessageHandlerAcceptor extends RSocketMessageHandler + implements SocketAcceptor, Function { + + @Nullable + private MimeType defaultDataMimeType; + + + /** + * Configure the default content type to use for data payloads. + *

By default this is not set. However a server acceptor will use the + * content type from the {@link ConnectionSetupPayload}, so this is typically + * required for clients but can also be used on servers as a fallback. + * @param defaultDataMimeType the MimeType to use + */ + public void setDefaultDataMimeType(@Nullable MimeType defaultDataMimeType) { + this.defaultDataMimeType = defaultDataMimeType; + } + + + @Override + public Mono accept(ConnectionSetupPayload setupPayload, RSocket sendingRSocket) { + MessagingRSocket rsocket = createRSocket(sendingRSocket); + // Allow handling of the ConnectionSetupPayload via @MessageMapping methods. + // However, if the handling is to make requests to the client, it's expected + // it will do so decoupled from the handling, e.g. via .subscribe(). + return rsocket.handleConnectionSetupPayload(setupPayload).then(Mono.just(rsocket)); + } + + @Override + public RSocket apply(RSocket sendingRSocket) { + return createRSocket(sendingRSocket); + } + + private MessagingRSocket createRSocket(RSocket rsocket) { + return new MessagingRSocket( + this::handleMessage, rsocket, this.defaultDataMimeType, getRSocketStrategies()); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java new file mode 100644 index 0000000000..a941566904 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/MessagingRSocket.java @@ -0,0 +1,195 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import io.rsocket.AbstractRSocket; +import io.rsocket.ConnectionSetupPayload; +import io.rsocket.Payload; +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.util.Assert; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link RSocket} that wraps incoming requests with a + * {@link Message}, delegates to a {@link Function} for handling, and then + * obtains the response from a "reply" header. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +class MessagingRSocket extends AbstractRSocket { + + private final Function, Mono> handler; + + private final RSocketRequester requester; + + @Nullable + private MimeType dataMimeType; + + private final RSocketStrategies strategies; + + + MessagingRSocket(Function, Mono> handler, RSocket sendingRSocket, + @Nullable MimeType defaultDataMimeType, RSocketStrategies strategies) { + + Assert.notNull(handler, "'handler' is required"); + Assert.notNull(sendingRSocket, "'sendingRSocket' is required"); + this.handler = handler; + this.requester = RSocketRequester.create(sendingRSocket, defaultDataMimeType, strategies); + this.dataMimeType = defaultDataMimeType; + this.strategies = strategies; + } + + + /** + * Wrap the {@link ConnectionSetupPayload} with a {@link Message} and + * delegate to {@link #handle(Payload)} for handling. + * @param payload the connection payload + * @return completion handle for success or error + */ + public Mono handleConnectionSetupPayload(ConnectionSetupPayload payload) { + if (StringUtils.hasText(payload.dataMimeType())) { + this.dataMimeType = MimeTypeUtils.parseMimeType(payload.dataMimeType()); + } + // frameDecoder does not apply to connectionSetupPayload + // so retain here since handle expects it.. + payload.retain(); + return handle(payload); + } + + + @Override + public Mono fireAndForget(Payload payload) { + return handle(payload); + } + + @Override + public Mono requestResponse(Payload payload) { + return handleAndReply(payload, Flux.just(payload)).next(); + } + + @Override + public Flux requestStream(Payload payload) { + return handleAndReply(payload, Flux.just(payload)); + } + + @Override + public Flux requestChannel(Publisher payloads) { + return Flux.from(payloads) + .switchOnFirst((signal, innerFlux) -> { + Payload firstPayload = signal.get(); + return firstPayload == null ? innerFlux : handleAndReply(firstPayload, innerFlux); + }); + } + + @Override + public Mono metadataPush(Payload payload) { + // Not very useful until createHeaders does more with metadata + return handle(payload); + } + + + private Mono handle(Payload payload) { + String destination = getDestination(payload); + MessageHeaders headers = createHeaders(destination, null); + DataBuffer dataBuffer = retainDataAndReleasePayload(payload); + int refCount = refCount(dataBuffer); + Message message = MessageBuilder.createMessage(dataBuffer, headers); + return Mono.defer(() -> this.handler.apply(message)) + .doFinally(s -> { + if (refCount(dataBuffer) == refCount) { + DataBufferUtils.release(dataBuffer); + } + }); + } + + private int refCount(DataBuffer dataBuffer) { + return dataBuffer instanceof NettyDataBuffer ? + ((NettyDataBuffer) dataBuffer).getNativeBuffer().refCnt() : 1; + } + + private Flux handleAndReply(Payload firstPayload, Flux payloads) { + MonoProcessor> replyMono = MonoProcessor.create(); + String destination = getDestination(firstPayload); + MessageHeaders headers = createHeaders(destination, replyMono); + + AtomicBoolean read = new AtomicBoolean(); + Flux buffers = payloads.map(this::retainDataAndReleasePayload).doOnSubscribe(s -> read.set(true)); + Message> message = MessageBuilder.createMessage(buffers, headers); + + return Mono.defer(() -> this.handler.apply(message)) + .doFinally(s -> { + // Subscription should have happened by now due to ChannelSendOperator + if (!read.get()) { + buffers.subscribe(DataBufferUtils::release); + } + }) + .thenMany(Flux.defer(() -> replyMono.isTerminated() ? + replyMono.flatMapMany(Function.identity()) : + Mono.error(new IllegalStateException("Something went wrong: reply Mono not set")))); + } + + private String getDestination(Payload payload) { + + // TODO: + // For now treat the metadata as a simple string with routing information. + // We'll have to get more sophisticated once the routing extension is completed. + // https://github.com/rsocket/rsocket-java/issues/568 + + return payload.getMetadataUtf8(); + } + + private DataBuffer retainDataAndReleasePayload(Payload payload) { + return PayloadUtils.retainDataAndReleasePayload(payload, this.strategies.dataBufferFactory()); + } + + private MessageHeaders createHeaders(String destination, @Nullable MonoProcessor replyMono) { + MessageHeaderAccessor headers = new MessageHeaderAccessor(); + headers.setHeader(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination); + if (this.dataMimeType != null) { + headers.setContentType(this.dataMimeType); + } + headers.setHeader(RSocketRequesterMethodArgumentResolver.RSOCKET_REQUESTER_HEADER, this.requester); + if (replyMono != null) { + headers.setHeader(RSocketPayloadReturnValueHandler.RESPONSE_HEADER, replyMono); + } + DataBufferFactory bufferFactory = this.strategies.dataBufferFactory(); + headers.setHeader(HandlerMethodReturnValueHandler.DATA_BUFFER_FACTORY_HEADER, bufferFactory); + return headers.getMessageHeaders(); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java new file mode 100644 index 0000000000..ee25110161 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/PayloadUtils.java @@ -0,0 +1,107 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import io.netty.buffer.ByteBuf; +import io.rsocket.Frame; +import io.rsocket.Payload; +import io.rsocket.util.ByteBufPayload; +import io.rsocket.util.DefaultPayload; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.util.Assert; + +/** + * Static utility methods to create {@link Payload} from {@link DataBuffer}s + * and vice versa. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +abstract class PayloadUtils { + + /** + * Use this method to slice, retain and wrap the data portion of the + * {@code Payload}, and also to release the {@code Payload}. This assumes + * the Payload metadata has been read by now and ensures downstream code + * need only be aware of {@code DataBuffer}s. + * @param payload the payload to process + * @param bufferFactory the DataBufferFactory to wrap with + * @return the created {@code DataBuffer} instance + */ + public static DataBuffer retainDataAndReleasePayload(Payload payload, DataBufferFactory bufferFactory) { + try { + if (bufferFactory instanceof NettyDataBufferFactory) { + ByteBuf byteBuf = payload.sliceData().retain(); + return ((NettyDataBufferFactory) bufferFactory).wrap(byteBuf); + } + + Assert.isTrue(!(payload instanceof ByteBufPayload) && !(payload instanceof Frame), + "NettyDataBufferFactory expected, actual: " + bufferFactory.getClass().getSimpleName()); + + return bufferFactory.wrap(payload.getData()); + } + finally { + if (payload.refCnt() > 0) { + payload.release(); + } + } + } + + /** + * Create a Payload from the given metadata and data. + * @param metadata the metadata part for the payload + * @param data the data part for the payload + * @return the created Payload + */ + public static Payload createPayload(DataBuffer metadata, DataBuffer data) { + if (metadata instanceof NettyDataBuffer && data instanceof NettyDataBuffer) { + return ByteBufPayload.create( + ((NettyDataBuffer) data).getNativeBuffer(), + ((NettyDataBuffer) metadata).getNativeBuffer()); + } + else if (metadata instanceof DefaultDataBuffer && data instanceof DefaultDataBuffer) { + return DefaultPayload.create( + ((DefaultDataBuffer) data).getNativeBuffer(), + ((DefaultDataBuffer) metadata).getNativeBuffer()); + } + else { + return DefaultPayload.create(data.asByteBuffer(), metadata.asByteBuffer()); + } + } + + /** + * Create a Payload from the given data. + * @param data the data part for the payload + * @return the created Payload + */ + public static Payload createPayload(DataBuffer data) { + if (data instanceof NettyDataBuffer) { + return ByteBufPayload.create(((NettyDataBuffer) data).getNativeBuffer()); + } + else if (data instanceof DefaultDataBuffer) { + return DefaultPayload.create(((DefaultDataBuffer) data).getNativeBuffer()); + } + else { + return DefaultPayload.create(data.asByteBuffer()); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java new file mode 100644 index 0000000000..3e038598ac --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketMessageHandler.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageDeliveryException; +import org.springframework.messaging.handler.annotation.support.reactive.MessageMappingMessageHandler; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodReturnValueHandler; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * RSocket-specific extension of {@link MessageMappingMessageHandler}. + * + *

The configured {@link #setEncoders(List) encoders} are used to encode the + * return values from handler methods, with the help of + * {@link RSocketPayloadReturnValueHandler}. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class RSocketMessageHandler extends MessageMappingMessageHandler { + + private final List> encoders = new ArrayList<>(); + + @Nullable + private RSocketStrategies rsocketStrategies; + + + /** + * Configure the encoders to use for encoding handler method return values. + */ + public void setEncoders(List> encoders) { + this.encoders.addAll(encoders); + } + + /** + * Return the configured {@link #setEncoders(List) encoders}. + */ + public List> getEncoders() { + return this.encoders; + } + + /** + * Provide configuration in the form of {@link RSocketStrategies}. This is + * an alternative to using {@link #setEncoders(List)}, + * {@link #setDecoders(List)}, and others directly. It is convenient when + * you also configuring an {@link RSocketRequester} in which case the + * {@link RSocketStrategies} encapsulates required configuration for re-use. + * @param rsocketStrategies the strategies to use + */ + public void setRSocketStrategies(RSocketStrategies rsocketStrategies) { + Assert.notNull(rsocketStrategies, "RSocketStrategies must not be null"); + this.rsocketStrategies = rsocketStrategies; + setDecoders(rsocketStrategies.decoders()); + setEncoders(rsocketStrategies.encoders()); + setReactiveAdapterRegistry(rsocketStrategies.reactiveAdapterRegistry()); + } + + /** + * Return the {@code RSocketStrategies} instance provided via + * {@link #setRSocketStrategies rsocketStrategies}, or + * otherwise initialize it with the configured {@link #setEncoders(List) + * encoders}, {@link #setDecoders(List) decoders}, and others. + */ + public RSocketStrategies getRSocketStrategies() { + if (this.rsocketStrategies == null) { + this.rsocketStrategies = RSocketStrategies.builder() + .decoder(getDecoders().toArray(new Decoder[0])) + .encoder(getEncoders().toArray(new Encoder[0])) + .reactiveAdapterStrategy(getReactiveAdapterRegistry()) + .build(); + } + return this.rsocketStrategies; + } + + + @Override + public void afterPropertiesSet() { + getArgumentResolverConfigurer().addCustomResolver(new RSocketRequesterMethodArgumentResolver()); + super.afterPropertiesSet(); + } + + @Override + protected List initReturnValueHandlers() { + List handlers = new ArrayList<>(); + handlers.add(new RSocketPayloadReturnValueHandler(this.encoders, getReactiveAdapterRegistry())); + handlers.addAll(getReturnValueHandlerConfigurer().getCustomHandlers()); + return handlers; + } + + @Override + protected void handleNoMatch(@Nullable String destination, Message message) { + + // MessagingRSocket will raise an error anyway if reply Mono is expected + // Here we raise a more helpful message a destination is present + + // It is OK if some messages (ConnectionSetupPayload, metadataPush) are not handled + // We need a better way to avoid raising errors for those + + if (StringUtils.hasText(destination)) { + throw new MessageDeliveryException("No handler for destination '" + destination + "'"); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java new file mode 100644 index 0000000000..4c671c4e13 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketPayloadReturnValueHandler.java @@ -0,0 +1,86 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.List; + +import io.rsocket.Payload; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.reactive.AbstractEncoderMethodReturnValueHandler; +import org.springframework.util.Assert; + +/** + * Extension of {@link AbstractEncoderMethodReturnValueHandler} that + * {@link #handleEncodedContent handles} encoded content by wrapping data buffers + * as RSocket payloads and by passing those to the {@link MonoProcessor} + * from the {@link #RESPONSE_HEADER} header. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class RSocketPayloadReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + /** + * Message header name that is expected to have a {@link MonoProcessor} + * which will receive the {@code Flux} that represents the response. + */ + public static final String RESPONSE_HEADER = "rsocketResponse"; + + + public RSocketPayloadReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + + @Override + @SuppressWarnings("unchecked") + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + MonoProcessor> replyMono = getReplyMono(message); + Assert.notNull(replyMono, "Missing '" + RESPONSE_HEADER + "'"); + replyMono.onNext(encodedContent.map(PayloadUtils::createPayload)); + replyMono.onComplete(); + return Mono.empty(); + } + + @Override + protected Mono handleNoContent(MethodParameter returnType, Message message) { + MonoProcessor> replyMono = getReplyMono(message); + if (replyMono != null) { + replyMono.onComplete(); + } + return Mono.empty(); + } + + @Nullable + @SuppressWarnings("unchecked") + private MonoProcessor> getReplyMono(Message message) { + Object headerValue = message.getHeaders().get(RESPONSE_HEADER); + Assert.state(headerValue == null || headerValue instanceof MonoProcessor, "Expected MonoProcessor"); + return (MonoProcessor>) headerValue; + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java new file mode 100644 index 0000000000..968f0e693d --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequester.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import io.rsocket.RSocket; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; + +/** + * A thin wrapper around a sending {@link RSocket} with a fluent API accepting + * and returning higher level Objects for input and for output, along with + * methods specify routing and other metadata. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface RSocketRequester { + + + /** + * Return the underlying RSocket used to make requests. + */ + RSocket rsocket(); + + + /** + * Create a new {@code RSocketRequester} from the given {@link RSocket} and + * strategies for encoding and decoding request and response payloads. + * @param rsocket the sending RSocket to use + * @param dataMimeType the MimeType for data (from the SETUP frame) + * @param strategies encoders, decoders, and others + * @return the created RSocketRequester wrapper + */ + static RSocketRequester create(RSocket rsocket, @Nullable MimeType dataMimeType, RSocketStrategies strategies) { + return new DefaultRSocketRequester(rsocket, dataMimeType, strategies); + } + + + // For now we treat metadata as a simple string that is the route. + // This will change after the resolution of: + // https://github.com/rsocket/rsocket-java/issues/568 + + /** + * Entry point to prepare a new request to the given route. + * + *

For requestChannel interactions, i.e. Flux-to-Flux the metadata is + * attached to the first request payload. + * + * @param route the routing destination + * @return a spec for further defining and executing the reuqest + */ + RequestSpec route(String route); + + + /** + * Contract to provide input data for an RSocket request. + */ + interface RequestSpec { + + /** + * Provide request payload data. The given Object may be a synchronous + * value, or a {@link Publisher} of values, or another async type that's + * registered in the configured {@link ReactiveAdapterRegistry}. + *

For multivalued Publishers, prefer using + * {@link #data(Publisher, Class)} or + * {@link #data(Publisher, ParameterizedTypeReference)} since that makes + * it possible to find a compatible {@code Encoder} up front vs looking + * it up on every value. + * @param data the Object to use for payload data + * @return spec for declaring the expected response + */ + ResponseSpec data(Object data); + + /** + * Provide a {@link Publisher} of value(s) for request payload data. + *

Publisher semantics determined through the configured + * {@link ReactiveAdapterRegistry} influence which of the 4 RSocket + * interactions to use. Publishers with unknown semantics are treated + * as multivalued. Consider registering a reactive type adapter, or + * passing {@code Mono.from(publisher)}. + *

If the publisher completes empty, possibly {@code Publisher}, + * the request will have an empty data Payload. + * @param publisher source of payload data value(s) + * @param dataType the type of values to be published + * @param the type of element values + * @param

the type of publisher + * @return spec for declaring the expected response + */ + > ResponseSpec data(P publisher, Class dataType); + + /** + * Variant of {@link #data(Publisher, Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + > ResponseSpec data(P publisher, ParameterizedTypeReference dataTypeRef); + } + + + /** + * Contract to declare the expected RSocket response. + */ + interface ResponseSpec { + + /** + * Perform {@link RSocket#fireAndForget fireAndForget}. + */ + Mono send(); + + /** + * Perform {@link RSocket#requestResponse requestResponse}. If the + * expected data type is {@code Void.class}, the returned {@code Mono} + * will complete after all data is consumed. + *

Note: Use of this method will raise an error if + * the request payload is a multivalued {@link Publisher} as + * determined through the configured {@link ReactiveAdapterRegistry}. + * @param dataType the expected data type for the response + * @param parameter for the expected data type + * @return the decoded response + */ + Mono retrieveMono(Class dataType); + + /** + * Variant of {@link #retrieveMono(Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + Mono retrieveMono(ParameterizedTypeReference dataTypeRef); + + /** + * Perform {@link RSocket#requestStream requestStream} or + * {@link RSocket#requestChannel requestChannel} depending on whether + * the request input consists of a single or multiple payloads. + * If the expected data type is {@code Void.class}, the returned + * {@code Flux} will complete after all data is consumed. + * @param dataType the expected type for values in the response + * @param parameterize the expected type of values + * @return the decoded response + */ + Flux retrieveFlux(Class dataType); + + /** + * Variant of {@link #retrieveFlux(Class)} for when the dataType has + * to have a generic type. See {@link ParameterizedTypeReference}. + */ + Flux retrieveFlux(ParameterizedTypeReference dataTypeRef); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java new file mode 100644 index 0000000000..36fc075e46 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketRequesterMethodArgumentResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.rsocket; + +import io.rsocket.RSocket; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.reactive.HandlerMethodArgumentResolver; +import org.springframework.util.Assert; + +/** + * Resolves arguments of type {@link RSocket} that can be used for making + * requests to the remote peer. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public class RSocketRequesterMethodArgumentResolver implements HandlerMethodArgumentResolver { + + /** + * Message header name that is expected to have the {@link RSocket} to + * initiate new interactions to the remote peer with. + */ + public static final String RSOCKET_REQUESTER_HEADER = "rsocketRequester"; + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class type = parameter.getParameterType(); + return RSocketRequester.class.equals(type) || RSocket.class.isAssignableFrom(type); + } + + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + + Object headerValue = message.getHeaders().get(RSOCKET_REQUESTER_HEADER); + Assert.notNull(headerValue, "Missing '" + RSOCKET_REQUESTER_HEADER + "'"); + Assert.isInstanceOf(RSocketRequester.class, headerValue, "Expected header value of type RSocketRequester"); + + RSocketRequester requester = (RSocketRequester) headerValue; + + Class type = parameter.getParameterType(); + if (RSocketRequester.class.equals(type)) { + return Mono.just(requester); + } + else if (RSocket.class.isAssignableFrom(type)) { + return Mono.just(requester.rsocket()); + } + else { + return Mono.error(new IllegalArgumentException("Unexpected parameter type: " + parameter)); + } + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java new file mode 100644 index 0000000000..7d6f64f560 --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/RSocketStrategies.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.List; +import java.util.function.Consumer; + +import io.netty.buffer.PooledByteBufAllocator; + +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; + +/** + * Access to strategies for use by RSocket requester and responder components. + * + * @author Rossen Stoyanchev + * @since 5.2 + */ +public interface RSocketStrategies { + + /** + * Return the configured {@link Builder#encoder(Encoder[]) encoders}. + * @see #encoder(ResolvableType, MimeType) + */ + List> encoders(); + + /** + * Find a compatible Encoder for the given element type. + * @param elementType the element type to match + * @param mimeType the MimeType to match + * @param for casting the Encoder to the expected element type + * @return the matching Encoder + * @throws IllegalArgumentException if no matching Encoder is found + */ + @SuppressWarnings("unchecked") + default Encoder encoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Encoder encoder : encoders()) { + if (encoder.canEncode(elementType, mimeType)) { + return (Encoder) encoder; + } + } + throw new IllegalArgumentException("No encoder for " + elementType); + } + + /** + * Return the configured {@link Builder#decoder(Decoder[]) decoders}. + * @see #decoder(ResolvableType, MimeType) + */ + List> decoders(); + + /** + * Find a compatible Decoder for the given element type. + * @param elementType the element type to match + * @param mimeType the MimeType to match + * @param for casting the Decoder to the expected element type + * @return the matching Decoder + * @throws IllegalArgumentException if no matching Decoder is found + */ + @SuppressWarnings("unchecked") + default Decoder decoder(ResolvableType elementType, @Nullable MimeType mimeType) { + for (Decoder decoder : decoders()) { + if (decoder.canDecode(elementType, mimeType)) { + return (Decoder) decoder; + } + } + throw new IllegalArgumentException("No decoder for " + elementType); + } + + /** + * Return the configured + * {@link Builder#reactiveAdapterStrategy(ReactiveAdapterRegistry) reactiveAdapterRegistry}. + */ + ReactiveAdapterRegistry reactiveAdapterRegistry(); + + /** + * Return the configured + * {@link Builder#dataBufferFactory(DataBufferFactory) dataBufferFactory}. + */ + DataBufferFactory dataBufferFactory(); + + + /** + * Return a builder to build a new {@code RSocketStrategies} instance. + */ + static Builder builder() { + return new DefaultRSocketStrategies.DefaultRSocketStrategiesBuilder(); + } + + + /** + * The builder options for creating {@code RSocketStrategies}. + */ + interface Builder { + + /** + * Add encoders to use for serializing Objects. + *

By default this is empty. + */ + Builder encoder(Encoder... encoder); + + /** + * Add decoders for de-serializing Objects. + *

By default this is empty. + */ + Builder decoder(Decoder... decoder); + + /** + * Access and manipulate the list of configured {@link #encoder encoders}. + */ + Builder encoders(Consumer>> consumer); + + /** + * Access and manipulate the list of configured {@link #encoder decoders}. + */ + Builder decoders(Consumer>> consumer); + + /** + * Configure the registry for reactive type support. This can be used to + * to adapt to, and/or determine the semantics of a given + * {@link org.reactivestreams.Publisher Publisher}. + *

By default this {@link ReactiveAdapterRegistry#sharedInstance}. + * @param registry the registry to use + */ + Builder reactiveAdapterStrategy(ReactiveAdapterRegistry registry); + + /** + * Configure the DataBufferFactory to use for allocating buffers, for + * example when preparing requests or when responding. The choice here + * must be aligned with the frame decoder configured in + * {@link io.rsocket.RSocketFactory}. + *

By default this property is an instance of + * {@link org.springframework.core.io.buffer.DefaultDataBufferFactory + * DefaultDataBufferFactory} matching to the default frame decoder in + * {@link io.rsocket.RSocketFactory} which copies the payload. This + * comes at cost to performance but does not require reference counting + * and eliminates possibility for memory leaks. + *

To switch to a zero-copy strategy, + * configure RSocket + * accordingly, and then configure this property with an instance of + * {@link org.springframework.core.io.buffer.NettyDataBufferFactory + * NettyDataBufferFactory} with a pooled allocator such as + * {@link PooledByteBufAllocator#DEFAULT}. + * @param bufferFactory the DataBufferFactory to use + */ + Builder dataBufferFactory(DataBufferFactory bufferFactory); + + /** + * Builder the {@code RSocketStrategies} instance. + */ + RSocketStrategies build(); + } + +} diff --git a/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java new file mode 100644 index 0000000000..9cb5ed03ac --- /dev/null +++ b/spring-messaging/src/main/java/org/springframework/messaging/rsocket/package-info.java @@ -0,0 +1,9 @@ +/** + * Support for the RSocket protocol. + */ +@NonNullApi +@NonNullFields +package org.springframework.messaging.rsocket; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java index 317c24919b..e6d6b55994 100644 --- a/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java +++ b/spring-messaging/src/main/java/org/springframework/messaging/simp/SimpMessageMappingInfo.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package org.springframework.messaging.simp; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; +import org.springframework.messaging.handler.CompositeMessageCondition; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; import org.springframework.messaging.handler.MessageCondition; @@ -34,62 +35,44 @@ import org.springframework.messaging.handler.MessageCondition; */ public class SimpMessageMappingInfo implements MessageCondition { - private final SimpMessageTypeMessageCondition messageTypeMessageCondition; - - private final DestinationPatternsMessageCondition destinationConditions; + private final CompositeMessageCondition delegate; public SimpMessageMappingInfo(SimpMessageTypeMessageCondition messageTypeMessageCondition, DestinationPatternsMessageCondition destinationConditions) { - this.messageTypeMessageCondition = messageTypeMessageCondition; - this.destinationConditions = destinationConditions; + this.delegate = new CompositeMessageCondition(messageTypeMessageCondition, destinationConditions); + } + + private SimpMessageMappingInfo(CompositeMessageCondition delegate) { + this.delegate = delegate; } public SimpMessageTypeMessageCondition getMessageTypeMessageCondition() { - return this.messageTypeMessageCondition; + return this.delegate.getCondition(SimpMessageTypeMessageCondition.class); } public DestinationPatternsMessageCondition getDestinationConditions() { - return this.destinationConditions; + return this.delegate.getCondition(DestinationPatternsMessageCondition.class); } @Override public SimpMessageMappingInfo combine(SimpMessageMappingInfo other) { - SimpMessageTypeMessageCondition typeCond = - this.getMessageTypeMessageCondition().combine(other.getMessageTypeMessageCondition()); - DestinationPatternsMessageCondition destCond = - this.destinationConditions.combine(other.getDestinationConditions()); - return new SimpMessageMappingInfo(typeCond, destCond); + return new SimpMessageMappingInfo(this.delegate.combine(other.delegate)); } @Override @Nullable public SimpMessageMappingInfo getMatchingCondition(Message message) { - SimpMessageTypeMessageCondition typeCond = this.messageTypeMessageCondition.getMatchingCondition(message); - if (typeCond == null) { - return null; - } - DestinationPatternsMessageCondition destCond = this.destinationConditions.getMatchingCondition(message); - if (destCond == null) { - return null; - } - return new SimpMessageMappingInfo(typeCond, destCond); + CompositeMessageCondition condition = this.delegate.getMatchingCondition(message); + return condition != null ? new SimpMessageMappingInfo(condition) : null; } @Override public int compareTo(SimpMessageMappingInfo other, Message message) { - int result = this.messageTypeMessageCondition.compareTo(other.messageTypeMessageCondition, message); - if (result != 0) { - return result; - } - result = this.destinationConditions.compareTo(other.destinationConditions, message); - if (result != 0) { - return result; - } - return 0; + return this.delegate.compareTo(other.delegate, message); } @@ -101,19 +84,17 @@ public class SimpMessageMappingInfo implements MessageCondition { + + @Nullable + private String value; + + + public DestinationVariablePredicate value(@Nullable String name) { + this.value = name; + return this; + } + + public DestinationVariablePredicate noValue() { + this.value = ""; + return this; + } + + @Override + public boolean test(MethodParameter parameter) { + DestinationVariable annotation = parameter.getParameterAnnotation(DestinationVariable.class); + return annotation != null && (this.value == null || annotation.value().equals(this.value)); + } + } + + + public static class HeaderPredicate implements Predicate { + + @Nullable + private String name; + + @Nullable + private Boolean required; + + @Nullable + private String defaultValue; + + + public HeaderPredicate name(@Nullable String name) { + this.name = name; + return this; + } + + public HeaderPredicate noName() { + this.name = ""; + return this; + } + + public HeaderPredicate required(boolean required) { + this.required = required; + return this; + } + + public HeaderPredicate defaultValue(@Nullable String value) { + this.defaultValue = value; + return this; + } + + public HeaderPredicate noAttributes() { + this.name = ""; + this.required = true; + this.defaultValue = ValueConstants.DEFAULT_NONE; + return this; + } + + @Override + public boolean test(MethodParameter parameter) { + Header annotation = parameter.getParameterAnnotation(Header.class); + return annotation != null && + (this.name == null || annotation.name().equals(this.name)) && + (this.required == null || annotation.required() == this.required) && + (this.defaultValue == null || annotation.defaultValue().equals(this.defaultValue)); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java index 3af92e3b2e..ea4b8559e2 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/DestinationVariableMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,23 +16,21 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; -import org.junit.Before; import org.junit.Test; -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.MessageHandlingException; import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; /** * Test fixture for {@link DestinationVariableMethodArgumentResolver} tests. @@ -41,33 +39,17 @@ import static org.junit.Assert.*; */ public class DestinationVariableMethodArgumentResolverTests { - private DestinationVariableMethodArgumentResolver resolver; + private final DestinationVariableMethodArgumentResolver resolver = + new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); - private MethodParameter paramAnnotated; - private MethodParameter paramAnnotatedValue; - private MethodParameter paramNotAnnotated; + private final ResolvableMethod resolvable = + ResolvableMethod.on(getClass()).named("handleMessage").build(); - @Before - public void setup() throws Exception { - this.resolver = new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); - - 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, DestinationVariableMethodArgumentResolver.class); - this.paramAnnotatedValue.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); - GenericTypeResolver.resolveParameterType(this.paramAnnotatedValue, DestinationVariableMethodArgumentResolver.class); - } - @Test public void supportsParameter() { - assertTrue(resolver.supportsParameter(paramAnnotated)); - assertTrue(resolver.supportsParameter(paramAnnotatedValue)); - assertFalse(resolver.supportsParameter(paramNotAnnotated)); + assertTrue(resolver.supportsParameter(this.resolvable.annot(destinationVar().noValue()).arg())); + assertFalse(resolver.supportsParameter(this.resolvable.annotNotPresent(DestinationVariable.class).arg())); } @Test @@ -80,17 +62,19 @@ public class DestinationVariableMethodArgumentResolverTests { Message message = MessageBuilder.withPayload(new byte[0]).setHeader( DestinationVariableMethodArgumentResolver.DESTINATION_TEMPLATE_VARIABLES_HEADER, vars).build(); - Object result = this.resolver.resolveArgument(this.paramAnnotated, message); + MethodParameter param = this.resolvable.annot(destinationVar().noValue()).arg(); + Object result = this.resolver.resolveArgument(param, message); assertEquals("bar", result); - result = this.resolver.resolveArgument(this.paramAnnotatedValue, message); + param = this.resolvable.annot(destinationVar("name")).arg(); + result = this.resolver.resolveArgument(param, 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); + this.resolver.resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); } @SuppressWarnings("unused") diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java index 61bf8854f6..927809e4fe 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeaderMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,6 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; import java.util.List; import java.util.Map; import java.util.Optional; @@ -25,19 +24,17 @@ 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.annotation.SynthesizingMethodParameter; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHandlingException; import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.NativeMessageHeaderAccessor; -import org.springframework.util.ReflectionUtils; import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; /** * Test fixture for {@link HeaderMethodArgumentResolver} tests. @@ -50,46 +47,27 @@ public class HeaderMethodArgumentResolverTests { private HeaderMethodArgumentResolver resolver; - private MethodParameter paramRequired; - private MethodParameter paramNamedDefaultValueStringHeader; - private MethodParameter paramSystemPropertyDefaultValue; - private MethodParameter paramSystemPropertyName; - private MethodParameter paramNotAnnotated; - private MethodParameter paramOptional; - private MethodParameter paramNativeHeader; + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); @Before public void setup() { - @SuppressWarnings("resource") - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - - Method method = ReflectionUtils.findMethod(getClass(), "handleMessage", (Class[]) null); - this.paramRequired = new SynthesizingMethodParameter(method, 0); - this.paramNamedDefaultValueStringHeader = new SynthesizingMethodParameter(method, 1); - this.paramSystemPropertyDefaultValue = new SynthesizingMethodParameter(method, 2); - this.paramSystemPropertyName = new SynthesizingMethodParameter(method, 3); - this.paramNotAnnotated = new SynthesizingMethodParameter(method, 4); - this.paramOptional = new SynthesizingMethodParameter(method, 5); - this.paramNativeHeader = new SynthesizingMethodParameter(method, 6); - - this.paramRequired.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); - GenericTypeResolver.resolveParameterType(this.paramRequired, HeaderMethodArgumentResolver.class); + GenericApplicationContext context = new GenericApplicationContext(); + context.refresh(); + this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), context.getBeanFactory()); } @Test public void supportsParameter() { - assertTrue(resolver.supportsParameter(paramNamedDefaultValueStringHeader)); - assertFalse(resolver.supportsParameter(paramNotAnnotated)); + assertTrue(this.resolver.supportsParameter(this.resolvable.annot(headerPlain()).arg())); + assertFalse(this.resolver.supportsParameter(this.resolvable.annotNotPresent(Header.class).arg())); } @Test public void resolveArgument() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).setHeader("param1", "foo").build(); - Object result = this.resolver.resolveArgument(this.paramRequired, message); + Object result = this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); assertEquals("foo", result); } @@ -98,7 +76,7 @@ public class HeaderMethodArgumentResolverTests { TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); headers.setNativeHeader("param1", "foo"); Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); - assertEquals("foo", this.resolver.resolveArgument(this.paramRequired, message)); + assertEquals("foo", this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message)); } @Test @@ -108,20 +86,23 @@ public class HeaderMethodArgumentResolverTests { headers.setNativeHeader("param1", "native-foo"); Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); - assertEquals("foo", this.resolver.resolveArgument(this.paramRequired, message)); - assertEquals("native-foo", this.resolver.resolveArgument(this.paramNativeHeader, message)); + assertEquals("foo", this.resolver.resolveArgument( + this.resolvable.annot(headerPlain()).arg(), message)); + + assertEquals("native-foo", this.resolver.resolveArgument( + this.resolvable.annot(header("nativeHeaders.param1")).arg(), message)); } @Test(expected = MessageHandlingException.class) public void resolveArgumentNotFound() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).build(); - this.resolver.resolveArgument(this.paramRequired, message); + this.resolver.resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); } @Test public void resolveArgumentDefaultValue() throws Exception { Message message = MessageBuilder.withPayload(new byte[0]).build(); - Object result = this.resolver.resolveArgument(this.paramNamedDefaultValueStringHeader, message); + Object result = this.resolver.resolveArgument(this.resolvable.annot(header("name", "bar")).arg(), message); assertEquals("bar", result); } @@ -130,7 +111,8 @@ public class HeaderMethodArgumentResolverTests { System.setProperty("systemProperty", "sysbar"); try { Message message = MessageBuilder.withPayload(new byte[0]).build(); - Object result = resolver.resolveArgument(paramSystemPropertyDefaultValue, message); + MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); + Object result = resolver.resolveArgument(param, message); assertEquals("sysbar", result); } finally { @@ -143,7 +125,8 @@ public class HeaderMethodArgumentResolverTests { System.setProperty("systemProperty", "sysbar"); try { Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); - Object result = resolver.resolveArgument(paramSystemPropertyName, message); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + Object result = resolver.resolveArgument(param, message); assertEquals("foo", result); } finally { @@ -153,31 +136,22 @@ public class HeaderMethodArgumentResolverTests { @Test public void resolveOptionalHeaderWithValue() throws Exception { - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - - HeaderMethodArgumentResolver resolver = - new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - Message message = MessageBuilder.withPayload("foo").setHeader("foo", "bar").build(); - Object result = resolver.resolveArgument(paramOptional, message); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolver.resolveArgument(param, message); assertEquals(Optional.of("bar"), result); } @Test public void resolveOptionalHeaderAsEmpty() throws Exception { - GenericApplicationContext cxt = new GenericApplicationContext(); - cxt.refresh(); - - HeaderMethodArgumentResolver resolver = - new HeaderMethodArgumentResolver(new DefaultConversionService(), cxt.getBeanFactory()); - Message message = MessageBuilder.withPayload("foo").build(); - Object result = resolver.resolveArgument(paramOptional, message); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolver.resolveArgument(param, message); assertEquals(Optional.empty(), result); } + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) public void handleMessage( @Header String param1, @Header(name = "name", defaultValue = "bar") String param2, @@ -191,7 +165,7 @@ public class HeaderMethodArgumentResolverTests { public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { - protected TestMessageHeaderAccessor() { + TestMessageHeaderAccessor() { super((Map>) null); } } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java index e666d50787..592a01e4da 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/HeadersMethodArgumentResolverTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2016 the original author or authors. + * Copyright 2002-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,17 +16,16 @@ package org.springframework.messaging.handler.annotation.support; -import java.lang.reflect.Method; -import java.util.HashMap; +import java.util.Collections; import java.util.Map; -import org.junit.Before; import org.junit.Test; import org.springframework.core.MethodParameter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.ResolvableMethod; import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.messaging.support.NativeMessageHeaderAccessor; @@ -41,47 +40,31 @@ import static org.junit.Assert.*; */ public class HeadersMethodArgumentResolverTests { - private HeadersMethodArgumentResolver resolver; + private final HeadersMethodArgumentResolver resolver = new HeadersMethodArgumentResolver(); - private MethodParameter paramAnnotated; - private MethodParameter paramAnnotatedNotMap; - private MethodParameter paramMessageHeaders; - private MethodParameter paramMessageHeaderAccessor; - private MethodParameter paramMessageHeaderAccessorSubclass; + private Message message = + MessageBuilder.withPayload(new byte[0]).copyHeaders(Collections.singletonMap("foo", "bar")).build(); - private Message message; + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); - @Before - public void setup() throws Exception { - this.resolver = new HeadersMethodArgumentResolver(); - - Method method = getClass().getDeclaredMethod("handleMessage", Map.class, String.class, - MessageHeaders.class, MessageHeaderAccessor.class, TestMessageHeaderAccessor.class); - - this.paramAnnotated = new MethodParameter(method, 0); - this.paramAnnotatedNotMap = new MethodParameter(method, 1); - this.paramMessageHeaders = new MethodParameter(method, 2); - this.paramMessageHeaderAccessor = new MethodParameter(method, 3); - this.paramMessageHeaderAccessorSubclass = new MethodParameter(method, 4); - - Map headers = new HashMap<>(); - headers.put("foo", "bar"); - this.message = MessageBuilder.withPayload(new byte[0]).copyHeaders(headers).build(); - } - @Test public void supportsParameter() { - assertTrue(this.resolver.supportsParameter(this.paramAnnotated)); - assertFalse(this.resolver.supportsParameter(this.paramAnnotatedNotMap)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaders)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaderAccessor)); - assertTrue(this.resolver.supportsParameter(this.paramMessageHeaderAccessorSubclass)); + + assertTrue(this.resolver.supportsParameter( + this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class))); + + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaders.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaderAccessor.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(TestMessageHeaderAccessor.class))); + + assertFalse(this.resolver.supportsParameter(this.resolvable.annotPresent(Headers.class).arg(String.class))); } @Test public void resolveArgumentAnnotated() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramAnnotated, this.message); + MethodParameter param = this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof Map); @SuppressWarnings("unchecked") @@ -91,12 +74,12 @@ public class HeadersMethodArgumentResolverTests { @Test(expected = IllegalStateException.class) public void resolveArgumentAnnotatedNotMap() throws Exception { - this.resolver.resolveArgument(this.paramAnnotatedNotMap, this.message); + this.resolver.resolveArgument(this.resolvable.annotPresent(Headers.class).arg(String.class), this.message); } @Test public void resolveArgumentMessageHeaders() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaders, this.message); + Object resolved = this.resolver.resolveArgument(this.resolvable.arg(MessageHeaders.class), this.message); assertTrue(resolved instanceof MessageHeaders); MessageHeaders headers = (MessageHeaders) resolved; @@ -105,7 +88,8 @@ public class HeadersMethodArgumentResolverTests { @Test public void resolveArgumentMessageHeaderAccessor() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaderAccessor, this.message); + MethodParameter param = this.resolvable.arg(MessageHeaderAccessor.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof MessageHeaderAccessor); MessageHeaderAccessor headers = (MessageHeaderAccessor) resolved; @@ -114,7 +98,8 @@ public class HeadersMethodArgumentResolverTests { @Test public void resolveArgumentMessageHeaderAccessorSubclass() throws Exception { - Object resolved = this.resolver.resolveArgument(this.paramMessageHeaderAccessorSubclass, this.message); + MethodParameter param = this.resolvable.arg(TestMessageHeaderAccessor.class); + Object resolved = this.resolver.resolveArgument(param, this.message); assertTrue(resolved instanceof TestMessageHeaderAccessor); TestMessageHeaderAccessor headers = (TestMessageHeaderAccessor) resolved; @@ -124,7 +109,7 @@ public class HeadersMethodArgumentResolverTests { @SuppressWarnings("unused") private void handleMessage( - @Headers Map param1, + @Headers Map param1, @Headers String param2, MessageHeaders param3, MessageHeaderAccessor param4, @@ -134,7 +119,7 @@ public class HeadersMethodArgumentResolverTests { public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { - protected TestMessageHeaderAccessor(Message message) { + TestMessageHeaderAccessor(Message message) { super(message); } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java new file mode 100644 index 0000000000..e5aee3aa95 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/DestinationVariableMethodArgumentResolverTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; + +import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; + +/** + * Test fixture for {@link DestinationVariableMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class DestinationVariableMethodArgumentResolverTests { + + private final DestinationVariableMethodArgumentResolver resolver = + new DestinationVariableMethodArgumentResolver(new DefaultConversionService()); + + private final ResolvableMethod resolvable = + ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Test + public void supportsParameter() { + assertTrue(resolver.supportsParameter(this.resolvable.annot(destinationVar().noValue()).arg())); + assertFalse(resolver.supportsParameter(this.resolvable.annotNotPresent(DestinationVariable.class).arg())); + } + + @Test + public void resolveArgument() { + + Map vars = new HashMap<>(); + vars.put("foo", "bar"); + vars.put("name", "value"); + + Message message = MessageBuilder.withPayload(new byte[0]).setHeader( + DestinationVariableMethodArgumentResolver.DESTINATION_TEMPLATE_VARIABLES_HEADER, vars).build(); + + Object result = resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); + assertEquals("bar", result); + + result = resolveArgument(this.resolvable.annot(destinationVar("name")).arg(), message); + assertEquals("value", result); + } + + @Test(expected = MessageHandlingException.class) + public void resolveArgumentNotFound() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + resolveArgument(this.resolvable.annot(destinationVar().noValue()).arg(), message); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param, Message message) { + return (T) this.resolver.resolveArgument(param, message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings("unused") + private void handleMessage( + @DestinationVariable String foo, + @DestinationVariable(value = "name") String param1, + String param3) { + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bc2422ad27 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeaderMethodArgumentResolverTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import static org.junit.Assert.*; +import static org.springframework.messaging.handler.annotation.MessagingPredicates.*; + +/** + * Test fixture for {@link HeaderMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class HeaderMethodArgumentResolverTests { + + private HeaderMethodArgumentResolver resolver; + + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Before + public void setup() { + GenericApplicationContext context = new GenericApplicationContext(); + context.refresh(); + this.resolver = new HeaderMethodArgumentResolver(new DefaultConversionService(), context.getBeanFactory()); + } + + + @Test + public void supportsParameter() { + assertTrue(this.resolver.supportsParameter(this.resolvable.annot(headerPlain()).arg())); + assertFalse(this.resolver.supportsParameter(this.resolvable.annotNotPresent(Header.class).arg())); + } + + @Test + public void resolveArgument() { + Message message = MessageBuilder.withPayload(new byte[0]).setHeader("param1", "foo").build(); + Object result = resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); + assertEquals("foo", result); + } + + @Test // SPR-11326 + public void resolveArgumentNativeHeader() { + TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); + headers.setNativeHeader("param1", "foo"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + assertEquals("foo", resolveArgument(this.resolvable.annot(headerPlain()).arg(), message)); + } + + @Test + public void resolveArgumentNativeHeaderAmbiguity() { + TestMessageHeaderAccessor headers = new TestMessageHeaderAccessor(); + headers.setHeader("param1", "foo"); + headers.setNativeHeader("param1", "native-foo"); + Message message = MessageBuilder.withPayload(new byte[0]).setHeaders(headers).build(); + + assertEquals("foo", resolveArgument( + this.resolvable.annot(headerPlain()).arg(), message)); + + assertEquals("native-foo", resolveArgument( + this.resolvable.annot(header("nativeHeaders.param1")).arg(), message)); + } + + @Test(expected = MessageHandlingException.class) + public void resolveArgumentNotFound() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + resolveArgument(this.resolvable.annot(headerPlain()).arg(), message); + } + + @Test + public void resolveArgumentDefaultValue() { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + Object result = resolveArgument(this.resolvable.annot(header("name", "bar")).arg(), message); + assertEquals("bar", result); + } + + @Test + public void resolveDefaultValueSystemProperty() { + System.setProperty("systemProperty", "sysbar"); + try { + Message message = MessageBuilder.withPayload(new byte[0]).build(); + MethodParameter param = this.resolvable.annot(header("name", "#{systemProperties.systemProperty}")).arg(); + Object result = resolveArgument(param, message); + assertEquals("sysbar", result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveNameFromSystemProperty() { + System.setProperty("systemProperty", "sysbar"); + try { + Message message = MessageBuilder.withPayload(new byte[0]).setHeader("sysbar", "foo").build(); + MethodParameter param = this.resolvable.annot(header("#{systemProperties.systemProperty}")).arg(); + Object result = resolveArgument(param, message); + assertEquals("foo", result); + } + finally { + System.clearProperty("systemProperty"); + } + } + + @Test + public void resolveOptionalHeaderWithValue() { + Message message = MessageBuilder.withPayload("foo").setHeader("foo", "bar").build(); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolveArgument(param, message); + assertEquals(Optional.of("bar"), result); + } + + @Test + public void resolveOptionalHeaderAsEmpty() { + Message message = MessageBuilder.withPayload("foo").build(); + MethodParameter param = this.resolvable.annot(header("foo")).arg(Optional.class, String.class); + Object result = resolveArgument(param, message); + assertEquals(Optional.empty(), result); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param, Message message) { + return (T) this.resolver.resolveArgument(param, message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings({"unused", "OptionalUsedAsFieldOrParameterType"}) + public void handleMessage( + @Header String param1, + @Header(name = "name", defaultValue = "bar") String param2, + @Header(name = "name", defaultValue = "#{systemProperties.systemProperty}") String param3, + @Header(name = "#{systemProperties.systemProperty}") String param4, + String param5, + @Header("foo") Optional param6, + @Header("nativeHeaders.param1") String nativeHeaderParam1) { + } + + + public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { + + TestMessageHeaderAccessor() { + super((Map>) null); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java new file mode 100644 index 0000000000..bfbcd1e8db --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/HeadersMethodArgumentResolverTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; + +import org.junit.Test; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Headers; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.messaging.support.NativeMessageHeaderAccessor; + +import static org.junit.Assert.*; + +/** + * Test fixture for {@link HeadersMethodArgumentResolver} tests. + * @author Rossen Stoyanchev + */ +public class HeadersMethodArgumentResolverTests { + + private final HeadersMethodArgumentResolver resolver = new HeadersMethodArgumentResolver(); + + private Message message = + MessageBuilder.withPayload(new byte[0]).copyHeaders(Collections.singletonMap("foo", "bar")).build(); + + private final ResolvableMethod resolvable = ResolvableMethod.on(getClass()).named("handleMessage").build(); + + + @Test + public void supportsParameter() { + + assertTrue(this.resolver.supportsParameter( + this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class))); + + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaders.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(MessageHeaderAccessor.class))); + assertTrue(this.resolver.supportsParameter(this.resolvable.arg(TestMessageHeaderAccessor.class))); + + assertFalse(this.resolver.supportsParameter(this.resolvable.annotPresent(Headers.class).arg(String.class))); + } + + @Test + @SuppressWarnings("unchecked") + public void resolveArgumentAnnotated() { + MethodParameter param = this.resolvable.annotPresent(Headers.class).arg(Map.class, String.class, Object.class); + Map headers = resolveArgument(param); + assertEquals("bar", headers.get("foo")); + } + + @Test(expected = IllegalStateException.class) + public void resolveArgumentAnnotatedNotMap() { + resolveArgument(this.resolvable.annotPresent(Headers.class).arg(String.class)); + } + + @Test + public void resolveArgumentMessageHeaders() { + MessageHeaders headers = resolveArgument(this.resolvable.arg(MessageHeaders.class)); + assertEquals("bar", headers.get("foo")); + } + + @Test + public void resolveArgumentMessageHeaderAccessor() { + MessageHeaderAccessor headers = resolveArgument(this.resolvable.arg(MessageHeaderAccessor.class)); + assertEquals("bar", headers.getHeader("foo")); + } + + @Test + public void resolveArgumentMessageHeaderAccessorSubclass() { + TestMessageHeaderAccessor headers = resolveArgument(this.resolvable.arg(TestMessageHeaderAccessor.class)); + assertEquals("bar", headers.getHeader("foo")); + } + + @SuppressWarnings({"unchecked", "ConstantConditions"}) + private T resolveArgument(MethodParameter param) { + return (T) this.resolver.resolveArgument(param, this.message).block(Duration.ofSeconds(5)); + } + + + @SuppressWarnings("unused") + private void handleMessage( + @Headers Map param1, + @Headers String param2, + MessageHeaders param3, + MessageHeaderAccessor param4, + TestMessageHeaderAccessor param5) { + } + + + public static class TestMessageHeaderAccessor extends NativeMessageHeaderAccessor { + + TestMessageHeaderAccessor(Message message) { + super(message); + } + + public static TestMessageHeaderAccessor wrap(Message message) { + return new TestMessageHeaderAccessor(message); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java new file mode 100644 index 0000000000..af1a643de5 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/MessageMappingMessageHandlerTests.java @@ -0,0 +1,201 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.config.EmbeddedValueResolver; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.invocation.reactive.TestEncoderMethodReturnValueHandler; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.stereotype.Controller; + +import static java.nio.charset.StandardCharsets.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link MessageMappingMessageHandler}. + * @author Rossen Stoyanchev + */ +@SuppressWarnings("ALL") +public class MessageMappingMessageHandlerTests { + + private static final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + + + private TestEncoderMethodReturnValueHandler returnValueHandler; + + + @Test + public void handleString() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("string", "abcdef")).block(Duration.ofSeconds(5)); + verifyOutputContent(Collections.singletonList("abcdef::response")); + } + + @Test + public void handleMonoString() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("monoString", "abcdef")).block(Duration.ofSeconds(5)); + verifyOutputContent(Collections.singletonList("abcdef::response")); + } + + @Test + public void handleFluxString() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("fluxString", "abc\ndef\nghi")).block(Duration.ofSeconds(5)); + verifyOutputContent(Arrays.asList("abc::response", "def::response", "ghi::response")); + } + + @Test + public void handleWithPlaceholderInMapping() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("path123", "abcdef")).block(Duration.ofSeconds(5)); + verifyOutputContent(Collections.singletonList("abcdef::response")); + } + + @Test + public void handleException() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("exception", "abc")).block(Duration.ofSeconds(5)); + verifyOutputContent(Collections.singletonList("rejected::handled")); + } + + @Test + public void handleErrorSignal() { + MessageMappingMessageHandler messsageHandler = initMesssageHandler(); + messsageHandler.handleMessage(message("errorSignal", "abc")).block(Duration.ofSeconds(5)); + verifyOutputContent(Collections.singletonList("rejected::handled")); + } + + @Test + public void unhandledExceptionShouldFlowThrough() { + + GenericMessage message = new GenericMessage<>(new Object(), + Collections.singletonMap(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "string")); + + StepVerifier.create(initMesssageHandler().handleMessage(message)) + .expectErrorSatisfies(ex -> assertTrue( + "Actual: " + ex.getMessage(), + ex.getMessage().startsWith("Could not resolve method parameter at index 0"))) + .verify(Duration.ofSeconds(5)); + } + + + private MessageMappingMessageHandler initMesssageHandler() { + + List> decoders = Collections.singletonList(StringDecoder.allMimeTypes()); + List> encoders = Collections.singletonList(CharSequenceEncoder.allMimeTypes()); + + ReactiveAdapterRegistry registry = ReactiveAdapterRegistry.getSharedInstance(); + this.returnValueHandler = new TestEncoderMethodReturnValueHandler(encoders, registry); + + PropertySource source = new MapPropertySource("test", Collections.singletonMap("path", "path123")); + + StaticApplicationContext context = new StaticApplicationContext(); + context.getEnvironment().getPropertySources().addFirst(source); + context.registerSingleton("testController", TestController.class); + context.refresh(); + + MessageMappingMessageHandler messageHandler = new MessageMappingMessageHandler(); + messageHandler.getReturnValueHandlerConfigurer().addCustomHandler(this.returnValueHandler); + messageHandler.setApplicationContext(context); + messageHandler.setEmbeddedValueResolver(new EmbeddedValueResolver(context.getBeanFactory())); + messageHandler.setDecoders(decoders); + messageHandler.afterPropertiesSet(); + + return messageHandler; + } + + private Message message(String destination, String... content) { + return new GenericMessage<>( + Flux.fromIterable(Arrays.asList(content)).map(payload -> toDataBuffer(payload)), + Collections.singletonMap(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, destination)); + } + + private DataBuffer toDataBuffer(String payload) { + return bufferFactory.wrap(payload.getBytes(UTF_8)); + } + + private void verifyOutputContent(List expected) { + Flux result = this.returnValueHandler.getContentAsStrings(); + StepVerifier.create(result.collectList()).expectNext(expected).verifyComplete(); + } + + + @Controller + static class TestController { + + @MessageMapping("string") + String handleString(String payload) { + return payload + "::response"; + } + + @MessageMapping("monoString") + Mono handleMonoString(Mono payload) { + return payload.map(s -> s + "::response").delayElement(Duration.ofMillis(10)); + } + + @MessageMapping("fluxString") + Flux handleFluxString(Flux payload) { + return payload.map(s -> s + "::response").delayElements(Duration.ofMillis(10)); + } + + @MessageMapping("${path}") + String handleWithPlaceholder(String payload) { + return payload + "::response"; + } + + @MessageMapping("exception") + String handleAndThrow() { + throw new IllegalArgumentException("rejected"); + } + + @MessageMapping("errorSignal") + Mono handleAndSignalError() { + return Mono.delay(Duration.ofMillis(10)) + .flatMap(aLong -> Mono.error(new IllegalArgumentException("rejected"))); + } + + @MessageExceptionHandler + Mono handleException(IllegalArgumentException ex) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + "::handled"); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java new file mode 100644 index 0000000000..0e1e05d08a --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/annotation/support/reactive/PayloadMethodArgumentResolverTests.java @@ -0,0 +1,208 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.annotation.support.reactive; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.ResolvableMethod; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.MimeTypeUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import org.springframework.validation.annotation.Validated; + +import static org.junit.Assert.*; + + +/** + * Unit tests for {@link PayloadMethodArgumentResolver}. + * + * @author Rossen Stoyanchev + */ +public class PayloadMethodArgumentResolverTests { + + private final List> decoders = new ArrayList<>(); + + private final ResolvableMethod testMethod = ResolvableMethod.on(getClass()).named("handle").build(); + + + @Test + public void supportsParameter() { + + boolean useDefaultResolution = true; + PayloadMethodArgumentResolver resolver = createResolver(null, useDefaultResolution); + + assertTrue(resolver.supportsParameter(this.testMethod.annotPresent(Payload.class).arg())); + assertTrue(resolver.supportsParameter(this.testMethod.annotNotPresent(Payload.class).arg(String.class))); + + useDefaultResolution = false; + resolver = createResolver(null, useDefaultResolution); + + assertTrue(resolver.supportsParameter(this.testMethod.annotPresent(Payload.class).arg())); + assertFalse(resolver.supportsParameter(this.testMethod.annotNotPresent(Payload.class).arg(String.class))); + } + + @Test + public void emptyBodyWhenRequired() { + MethodParameter param = this.testMethod.arg(ResolvableType.forClassWithGenerics(Mono.class, String.class)); + Mono mono = resolveValue(param, Mono.empty(), null); + + StepVerifier.create(mono) + .consumeErrorWith(ex -> { + assertEquals(MethodArgumentResolutionException.class, ex.getClass()); + assertTrue(ex.getMessage(), ex.getMessage().contains("Payload content is missing")); + }) + .verify(); + } + + @Test + public void emptyBodyWhenNotRequired() { + MethodParameter param = this.testMethod.annotPresent(Payload.class).arg(); + assertNull(resolveValue(param, Mono.empty(), null)); + } + + @Test + public void stringMono() { + String body = "foo"; + MethodParameter param = this.testMethod.arg(ResolvableType.forClassWithGenerics(Mono.class, String.class)); + Mono mono = resolveValue(param, + Mono.delay(Duration.ofMillis(10)).map(aLong -> toDataBuffer(body)), null); + + assertEquals(body, mono.block()); + } + + @Test + public void stringFlux() { + List body = Arrays.asList("foo", "bar"); + ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Flux flux = resolveValue(param, + Flux.fromIterable(body).delayElements(Duration.ofMillis(10)).map(this::toDataBuffer), null); + + assertEquals(body, flux.collectList().block()); + } + + @Test + public void string() { + String body = "foo"; + MethodParameter param = this.testMethod.annotNotPresent(Payload.class).arg(String.class); + Object value = resolveValue(param, Mono.just(toDataBuffer(body)), null); + + assertEquals(body, value); + } + + @Test + public void validateStringMono() { + ResolvableType type = ResolvableType.forClassWithGenerics(Mono.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Mono mono = resolveValue(param, Mono.just(toDataBuffer("12345")), new TestValidator()); + + StepVerifier.create(mono).expectNextCount(0) + .expectError(MethodArgumentNotValidException.class).verify(); + } + + @Test + public void validateStringFlux() { + ResolvableType type = ResolvableType.forClassWithGenerics(Flux.class, String.class); + MethodParameter param = this.testMethod.arg(type); + Flux flux = resolveValue(param, Mono.just(toDataBuffer("12345678\n12345")), new TestValidator()); + + StepVerifier.create(flux) + .expectNext("12345678") + .expectError(MethodArgumentNotValidException.class) + .verify(); + } + + + private DataBuffer toDataBuffer(String value) { + return new DefaultDataBufferFactory().wrap(value.getBytes(StandardCharsets.UTF_8)); + } + + + @SuppressWarnings("unchecked") + @Nullable + private T resolveValue(MethodParameter param, Publisher content, Validator validator) { + + Message message = new GenericMessage<>(content, + Collections.singletonMap(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.TEXT_PLAIN)); + + Mono result = createResolver(validator, true).resolveArgument(param, message); + + Object value = result.block(Duration.ofSeconds(5)); + if (value != null) { + Class expectedType = param.getParameterType(); + assertTrue("Unexpected return value type: " + value, expectedType.isAssignableFrom(value.getClass())); + } + return (T) value; + } + + private PayloadMethodArgumentResolver createResolver(@Nullable Validator validator, boolean useDefaultResolution) { + if (this.decoders.isEmpty()) { + this.decoders.add(StringDecoder.allMimeTypes()); + } + List decoders = Collections.singletonList(StringDecoder.allMimeTypes()); + return new PayloadMethodArgumentResolver(decoders, validator, null, useDefaultResolution) {}; + } + + + @SuppressWarnings("unused") + private void handle( + @Validated Mono valueMono, + @Validated Flux valueFlux, + @Payload(required = false) String optionalValue, + String value) { + } + + + private static class TestValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return clazz.equals(String.class); + } + + @Override + public void validate(@Nullable Object target, Errors errors) { + if (target instanceof String && ((String) target).length() < 8) { + errors.reject("Invalid length"); + } + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java index fbb0350d1b..e0c44543e6 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethodTests.java @@ -23,7 +23,6 @@ import org.junit.Test; import org.springframework.core.MethodParameter; import org.springframework.lang.Nullable; import org.springframework.messaging.Message; -import org.springframework.messaging.handler.ResolvableMethod; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java index 54d6830be1..b65cf64793 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/MethodMessageHandlerTests.java @@ -20,7 +20,6 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -32,7 +31,6 @@ import org.junit.Before; import org.junit.Test; import org.springframework.context.support.StaticApplicationContext; -import org.springframework.core.MethodIntrospector; import org.springframework.messaging.Message; import org.springframework.messaging.converter.SimpleMessageConverter; import org.springframework.messaging.handler.DestinationPatternsMessageCondition; @@ -40,8 +38,8 @@ import org.springframework.messaging.handler.HandlerMethod; import org.springframework.messaging.handler.annotation.support.MessageMethodArgumentResolver; import org.springframework.messaging.support.MessageBuilder; import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; import org.springframework.util.PathMatcher; -import org.springframework.util.ReflectionUtils.MethodFilter; import static org.junit.Assert.*; @@ -90,7 +88,7 @@ public class MethodMessageHandlerTests { } @Test - public void antPatchMatchWildcard() throws Exception { + public void patternMatch() throws Exception { Method method = this.testController.getClass().getMethod("handlerPathMatchWildcard"); this.messageHandler.registerHandlerMethod(this.testController, method, "/handlerPathMatch*"); @@ -101,7 +99,7 @@ public class MethodMessageHandlerTests { } @Test - public void bestMatchWildcard() throws Exception { + public void bestMatch() throws Exception { Method method = this.testController.getClass().getMethod("bestMatch"); this.messageHandler.registerHandlerMethod(this.testController, method, "/bestmatch/{foo}/path"); @@ -124,7 +122,7 @@ public class MethodMessageHandlerTests { } @Test - public void exceptionHandled() { + public void handleException() { this.messageHandler.handleMessage(toDestination("/test/handlerThrowsExc")); @@ -166,7 +164,7 @@ public class MethodMessageHandlerTests { this.method = "secondBestMatch"; } - public void illegalStateException(IllegalStateException exception) { + public void handleIllegalStateException(IllegalStateException exception) { this.method = "illegalStateException"; this.arguments.put("exception", exception); } @@ -186,6 +184,7 @@ public class MethodMessageHandlerTests { private PathMatcher pathMatcher = new AntPathMatcher(); + public void registerHandler(Object handler) { super.detectHandlerMethods(handler); } @@ -239,55 +238,24 @@ public class MethodMessageHandlerTests { @Override protected String getMatchingMapping(String mapping, Message message) { - String destination = getLookupDestination(getDestination(message)); - if (mapping.equals(destination) || this.pathMatcher.match(mapping, destination)) { - return mapping; - } - return null; + Assert.notNull(destination, "No destination"); + return mapping.equals(destination) || this.pathMatcher.match(mapping, destination) ? mapping : null; } @Override protected Comparator getMappingComparator(final Message message) { - return new Comparator() { - @Override - public int compare(String info1, String info2) { - DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); - DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); - return cond1.compareTo(cond2, message); - } + return (info1, info2) -> { + DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); + DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); + return cond1.compareTo(cond2, message); }; } @Override protected AbstractExceptionHandlerMethodResolver createExceptionHandlerMethodResolverFor(Class beanType) { - return new TestExceptionHandlerMethodResolver(beanType); + return new TestExceptionResolver(beanType); } } - - private static class TestExceptionHandlerMethodResolver extends AbstractExceptionHandlerMethodResolver { - - public TestExceptionHandlerMethodResolver(Class handlerType) { - super(initExceptionMappings(handlerType)); - } - - private static Map, Method> initExceptionMappings(Class handlerType) { - Map, Method> result = new HashMap<>(); - for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHOD_FILTER)) { - for (Class exception : getExceptionsFromMethodSignature(method)) { - result.put(exception, method); - } - } - return result; - } - - public final static MethodFilter EXCEPTION_HANDLER_METHOD_FILTER = new MethodFilter() { - @Override - public boolean matches(Method method) { - return method.getName().contains("Exception"); - } - }; - } - } diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java similarity index 97% rename from spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java rename to spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java index 7f4a367370..dd360bd6e0 100644 --- a/spring-messaging/src/test/java/org/springframework/messaging/handler/ResolvableMethod.java +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/ResolvableMethod.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.messaging.handler; +package org.springframework.messaging.handler.invocation; import java.lang.annotation.Annotation; import java.lang.reflect.Method; @@ -57,7 +57,10 @@ import org.springframework.util.ReflectionUtils; import static java.util.stream.Collectors.*; /** - * Convenience class to resolve method parameters from hints. + * NOTE: This class is a replica of the same class in spring-web so it can + * be used for tests in spring-messaging. + * + *

Convenience class to resolve method parameters from hints. * *

Background

* @@ -120,7 +123,7 @@ import static java.util.stream.Collectors.*; * * * @author Rossen Stoyanchev - * @since 5.0 + * @since 5.2 */ public class ResolvableMethod { @@ -186,6 +189,7 @@ public class ResolvableMethod { /** * Filter on method arguments with annotation. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annot(Predicate... filter) { @@ -298,6 +302,7 @@ public class ResolvableMethod { /** * Filter on annotated methods. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final Builder annot(Predicate... filters) { @@ -308,6 +313,7 @@ public class ResolvableMethod { /** * Filter on methods annotated with the given annotation type. * @see #annot(Predicate[]) + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final Builder annotPresent(Class... annotationTypes) { @@ -524,6 +530,7 @@ public class ResolvableMethod { /** * Filter on method arguments with annotations. + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annot(Predicate... filters) { @@ -535,6 +542,7 @@ public class ResolvableMethod { * Filter on method arguments that have the given annotations. * @param annotationTypes the annotation types * @see #annot(Predicate[]) + * See {@link org.springframework.web.method.MvcAnnotationPredicates}. */ @SafeVarargs public final ArgResolver annotPresent(Class... annotationTypes) { diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java new file mode 100644 index 0000000000..8be81d0ecc --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/TestExceptionResolver.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.core.MethodIntrospector; +import org.springframework.util.ReflectionUtils; + +/** + * Sub-class for {@link AbstractExceptionHandlerMethodResolver} for testing. + * @author Rossen Stoyanchev + */ +public class TestExceptionResolver extends AbstractExceptionHandlerMethodResolver { + + private final static ReflectionUtils.MethodFilter EXCEPTION_HANDLER_METHOD_FILTER = + method -> method.getName().matches("handle[\\w]*Exception"); + + + public TestExceptionResolver(Class handlerType) { + super(initExceptionMappings(handlerType)); + } + + private static Map, Method> initExceptionMappings(Class handlerType) { + Map, Method> result = new HashMap<>(); + for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHOD_FILTER)) { + for (Class exception : getExceptionsFromMethodSignature(method)) { + result.put(exception, method); + } + } + return result; + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java new file mode 100644 index 0000000000..a8cb85b7ee --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/EncoderMethodReturnValueHandlerTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.util.Collections; + +import io.reactivex.Completable; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +import static org.springframework.messaging.handler.invocation.ResolvableMethod.*; + +/** + * Unit tests for {@link AbstractEncoderMethodReturnValueHandler}. + * + * @author Rossen Stoyanchev + */ +public class EncoderMethodReturnValueHandlerTests { + + private final TestEncoderMethodReturnValueHandler handler = new TestEncoderMethodReturnValueHandler( + Collections.singletonList(CharSequenceEncoder.textPlainOnly()), + ReactiveAdapterRegistry.getSharedInstance()); + + private final Message message = new GenericMessage<>("shouldn't matter"); + + + @Test + public void stringReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(String.class); + this.handler.handleReturnValue("foo", parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + + StepVerifier.create(result).expectNext("foo").verifyComplete(); + } + + @Test + public void objectReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); + this.handler.handleReturnValue("foo", parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + + StepVerifier.create(result).expectNext("foo").verifyComplete(); + } + + @Test + public void fluxStringReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Flux.class, String.class); + this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + + StepVerifier.create(result).expectNext("foo").expectNext("bar").verifyComplete(); + } + + @Test + public void fluxObjectReturnValue() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Flux.class, Object.class); + this.handler.handleReturnValue(Flux.just("foo", "bar"), parameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + + StepVerifier.create(result).expectNext("foo").expectNext("bar").verifyComplete(); + } + + @Test + public void voidReturnValue() { + testVoidReturnType(null, on(TestController.class).resolveReturnType(void.class)); + testVoidReturnType(Mono.empty(), on(TestController.class).resolveReturnType(Mono.class, Void.class)); + testVoidReturnType(Completable.complete(), on(TestController.class).resolveReturnType(Completable.class)); + } + + private void testVoidReturnType(@Nullable Object value, MethodParameter bodyParameter) { + this.handler.handleReturnValue(value, bodyParameter, this.message).block(); + Flux result = this.handler.getContentAsStrings(); + StepVerifier.create(result).expectComplete().verify(); + } + + @Test + public void noEncoder() { + MethodParameter parameter = on(TestController.class).resolveReturnType(Object.class); + StepVerifier.create(this.handler.handleReturnValue(new Object(), parameter, this.message)) + .expectErrorMessage("No encoder for java.lang.Object, current value type is class java.lang.Object") + .verify(); + } + + + @SuppressWarnings({"unused", "ConstantConditions"}) + private static class TestController { + + String string() { return null; } + + Object object() { return null; } + + Flux fluxString() { return null; } + + Flux fluxObject() { return null; } + + void voidReturn() { } + + Mono monoVoid() { return null; } + + Completable completable() { return null; } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java new file mode 100644 index 0000000000..c41cae403b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/InvocableHandlerMethodTests.java @@ -0,0 +1,237 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.MethodArgumentResolutionException; +import org.springframework.messaging.handler.invocation.ResolvableMethod; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link InvocableHandlerMethod}. + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + */ +public class InvocableHandlerMethodTests { + + private final Message message = mock(Message.class); + + private final List resolvers = new ArrayList<>(); + + + @Test + public void resolveArg() { + this.resolvers.add(new StubArgumentResolver(99)); + this.resolvers.add(new StubArgumentResolver("value")); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method); + + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals(1, getStubResolver(1).getResolvedParameters().size()); + assertEquals("99-value", value); + assertEquals("intArg", getStubResolver(0).getResolvedParameters().get(0).getParameterName()); + assertEquals("stringArg", getStubResolver(1).getResolvedParameters().get(0).getParameterName()); + } + + @Test + public void resolveNoArgValue() { + this.resolvers.add(new StubArgumentResolver(Integer.class)); + this.resolvers.add(new StubArgumentResolver(String.class)); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method); + + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals(1, getStubResolver(1).getResolvedParameters().size()); + assertEquals("null-null", value); + } + + @Test + public void cannotResolveArg() { + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (MethodArgumentResolutionException ex) { + assertNotNull(ex.getMessage()); + assertTrue(ex.getMessage().contains("Could not resolve parameter [0]")); + } + } + + @Test + public void resolveProvidedArg() { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method, 99, "value"); + + assertNotNull(value); + assertEquals(String.class, value.getClass()); + assertEquals("99-value", value); + } + + @Test + public void resolveProvidedArgFirst() { + this.resolvers.add(new StubArgumentResolver(1)); + this.resolvers.add(new StubArgumentResolver("value1")); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + Object value = invokeAndBlock(new Handler(), method, 2, "value2"); + + assertEquals("2-value2", value); + } + + @Test + public void exceptionInResolvingArg() { + this.resolvers.add(new InvocableHandlerMethodTests.ExceptionRaisingArgumentResolver()); + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (IllegalArgumentException ex) { + // expected - allow HandlerMethodArgumentResolver exceptions to propagate + } + } + + @Test + public void illegalArgumentException() { + this.resolvers.add(new StubArgumentResolver(Integer.class, "__not_an_int__")); + this.resolvers.add(new StubArgumentResolver("value")); + try { + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0, "")).method(); + invokeAndBlock(new Handler(), method); + fail("Expected exception"); + } + catch (IllegalStateException ex) { + assertNotNull("Exception not wrapped", ex.getCause()); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getMessage().contains("Endpoint [")); + assertTrue(ex.getMessage().contains("Method [")); + assertTrue(ex.getMessage().contains("with argument values:")); + assertTrue(ex.getMessage().contains("[0] [type=java.lang.String] [value=__not_an_int__]")); + assertTrue(ex.getMessage().contains("[1] [type=java.lang.String] [value=value")); + } + } + + @Test + public void invocationTargetException() { + Method method = ResolvableMethod.on(Handler.class).argTypes(Throwable.class).resolveMethod(); + + Throwable expected = new Throwable("error"); + Mono result = invoke(new Handler(), method, expected); + StepVerifier.create(result).expectErrorSatisfies(actual -> assertSame(expected, actual)).verify(); + } + + @Test + public void voidMethod() { + this.resolvers.add(new StubArgumentResolver(double.class, 5.25)); + Method method = ResolvableMethod.on(Handler.class).mockCall(c -> c.handle(0.0d)).method(); + Handler handler = new Handler(); + Object value = invokeAndBlock(handler, method); + + assertNull(value); + assertEquals(1, getStubResolver(0).getResolvedParameters().size()); + assertEquals("5.25", handler.getResult()); + assertEquals("amount", getStubResolver(0).getResolvedParameters().get(0).getParameterName()); + } + + @Test + public void voidMonoMethod() { + Method method = ResolvableMethod.on(Handler.class).mockCall(Handler::handleAsync).method(); + Handler handler = new Handler(); + Object value = invokeAndBlock(handler, method); + + assertNull(value); + assertEquals("success", handler.getResult()); + } + + + @Nullable + private Object invokeAndBlock(Object handler, Method method, Object... providedArgs) { + return invoke(handler, method, providedArgs).block(Duration.ofSeconds(5)); + } + + private Mono invoke(Object handler, Method method, Object... providedArgs) { + InvocableHandlerMethod handlerMethod = new InvocableHandlerMethod(handler, method); + handlerMethod.setArgumentResolvers(this.resolvers); + return handlerMethod.invoke(this.message, providedArgs); + } + + private StubArgumentResolver getStubResolver(int index) { + return (StubArgumentResolver) this.resolvers.get(index); + } + + + + @SuppressWarnings({"unused", "UnusedReturnValue", "SameParameterValue"}) + private static class Handler { + + private AtomicReference result = new AtomicReference<>(); + + + public String getResult() { + return this.result.get(); + } + + String handle(Integer intArg, String stringArg) { + return intArg + "-" + stringArg; + } + + void handle(double amount) { + this.result.set(String.valueOf(amount)); + } + + void handleWithException(Throwable ex) throws Throwable { + throw ex; + } + + Mono handleAsync() { + return Mono.delay(Duration.ofMillis(100)).thenEmpty(Mono.defer(() -> { + this.result.set("success"); + return Mono.empty(); + })); + } + } + + + private static class ExceptionRaisingArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return true; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + return Mono.error(new IllegalArgumentException("oops, can't read")); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java new file mode 100644 index 0000000000..6022876719 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/MethodMessageHandlerTests.java @@ -0,0 +1,263 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import org.hamcrest.Matchers; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.DestinationPatternsMessageCondition; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.messaging.handler.invocation.AbstractExceptionHandlerMethodResolver; +import org.springframework.messaging.handler.invocation.TestExceptionResolver; +import org.springframework.messaging.support.GenericMessage; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.PathMatcher; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link AbstractMethodMessageHandler}. + * @author Rossen Stoyanchev + */ +public class MethodMessageHandlerTests { + + + @Test(expected = IllegalStateException.class) + public void duplicateMapping() { + initMethodMessageHandler(DuplicateMappingsController.class); + } + + @Test + public void registeredMappings() { + TestMethodMessageHandler messageHandler = initMethodMessageHandler(TestController.class); + Map mappings = messageHandler.getHandlerMethods(); + + assertEquals(5, mappings.keySet().size()); + assertThat(mappings.keySet(), Matchers.containsInAnyOrder( + "/handleMessage", "/handleMessageWithArgument", "/handleMessageWithError", + "/handleMessageMatch1", "/handleMessageMatch2")); + } + + @Test + public void bestMatch() throws NoSuchMethodException { + TestMethodMessageHandler handler = new TestMethodMessageHandler(); + TestController controller = new TestController(); + handler.register(controller, TestController.class.getMethod("handleMessageMatch1"), "/bestmatch/{foo}/path"); + handler.register(controller, TestController.class.getMethod("handleMessageMatch2"), "/bestmatch/*/*"); + handler.afterPropertiesSet(); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/bestmatch/bar/path")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Publisher) handler.getLastReturnValue()) + .expectNext("handleMessageMatch1") + .verifyComplete(); + } + + @Test + public void argumentResolution() { + + ArgumentResolverConfigurer configurer = new ArgumentResolverConfigurer(); + configurer.addCustomResolver(new StubArgumentResolver(String.class, "foo")); + + TestMethodMessageHandler handler = initMethodMessageHandler( + theHandler -> theHandler.setArgumentResolverConfigurer(configurer), + TestController.class); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageWithArgument")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Publisher) handler.getLastReturnValue()) + .expectNext("handleMessageWithArgument,payload=foo") + .verifyComplete(); + } + + @Test + public void handleException() { + + TestMethodMessageHandler handler = initMethodMessageHandler(TestController.class); + + Message message = new GenericMessage<>("body", Collections.singletonMap( + DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER, "/handleMessageWithError")); + + handler.handleMessage(message).block(Duration.ofSeconds(5)); + + StepVerifier.create((Publisher) handler.getLastReturnValue()) + .expectNext("handleIllegalStateException,ex=rejected") + .verifyComplete(); + } + + + private TestMethodMessageHandler initMethodMessageHandler(Class... handlerTypes) { + return initMethodMessageHandler(handler -> {}, handlerTypes); + } + + private TestMethodMessageHandler initMethodMessageHandler( + Consumer customizer, Class... handlerTypes) { + + StaticApplicationContext context = new StaticApplicationContext(); + for (Class handlerType : handlerTypes) { + String beanName = ClassUtils.getShortNameAsProperty(handlerType); + context.registerPrototype(beanName, handlerType); + } + TestMethodMessageHandler messageHandler = new TestMethodMessageHandler(); + messageHandler.setApplicationContext(context); + customizer.accept(messageHandler); + messageHandler.afterPropertiesSet(); + return messageHandler; + } + + + @SuppressWarnings("unused") + private static class TestController { + + public Mono handleMessage() { + return delay("handleMessage"); + } + + @SuppressWarnings("rawtypes") + public Mono handleMessageWithArgument(String payload) { + return delay("handleMessageWithArgument,payload=" + payload); + } + + public Mono handleMessageWithError() { + return Mono.delay(Duration.ofMillis(10)) + .flatMap(aLong -> Mono.error(new IllegalStateException("rejected"))); + } + + public Mono handleMessageMatch1() { + return delay("handleMessageMatch1"); + } + + public Mono handleMessageMatch2() { + return delay("handleMessageMatch2"); + } + + public Mono handleIllegalStateException(IllegalStateException ex) { + return delay("handleIllegalStateException,ex=" + ex.getMessage()); + } + + private Mono delay(String value) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> value); + } + } + + + @SuppressWarnings("unused") + private static class DuplicateMappingsController { + + void handleMessageFoo() { } + + void handleMessageFoo(String foo) { } + } + + + private static class TestMethodMessageHandler extends AbstractMethodMessageHandler { + + private final TestReturnValueHandler returnValueHandler = new TestReturnValueHandler(); + + private PathMatcher pathMatcher = new AntPathMatcher(); + + + @Override + protected List initArgumentResolvers() { + return Collections.emptyList(); + } + + @Override + protected List initReturnValueHandlers() { + return Collections.singletonList(this.returnValueHandler); + } + + @Override + protected Predicate> initHandlerPredicate() { + return handlerType -> handlerType.getName().endsWith("Controller"); + } + + @Nullable + public Object getLastReturnValue() { + return this.returnValueHandler.getLastReturnValue(); + } + + public void register(Object handler, Method method, String mapping) { + super.registerHandlerMethod(handler, method, mapping); + } + + @Override + protected String getMappingForMethod(Method method, Class handlerType) { + String methodName = method.getName(); + if (methodName.startsWith("handleMessage")) { + return "/" + methodName; + } + return null; + } + + @Override + protected Set getDirectLookupMappings(String mapping) { + return Collections.singleton(mapping); + } + + @Override + @Nullable + protected String getDestination(Message message) { + return (String) message.getHeaders().get(DestinationPatternsMessageCondition.LOOKUP_DESTINATION_HEADER); + } + + @Override + protected String getMatchingMapping(String mapping, Message message) { + String destination = getDestination(message); + Assert.notNull(destination, "No destination"); + return mapping.equals(destination) || this.pathMatcher.match(mapping, destination) ? mapping : null; + } + + @Override + protected Comparator getMappingComparator(Message message) { + return (info1, info2) -> { + DestinationPatternsMessageCondition cond1 = new DestinationPatternsMessageCondition(info1); + DestinationPatternsMessageCondition cond2 = new DestinationPatternsMessageCondition(info2); + return cond1.compareTo(cond2, message); + }; + } + + @Override + protected AbstractExceptionHandlerMethodResolver createExceptionMethodResolverFor(Class beanType) { + return new TestExceptionResolver(beanType); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java new file mode 100644 index 0000000000..66832e3670 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/StubArgumentResolver.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2018 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.invocation.reactive; + +import java.util.ArrayList; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Stub resolver for a fixed value type and/or value. + * + * @author Rossen Stoyanchev + */ +public class StubArgumentResolver implements HandlerMethodArgumentResolver { + + private final Class valueType; + + @Nullable + private final Object value; + + private List resolvedParameters = new ArrayList<>(); + + + public StubArgumentResolver(Object value) { + this(value.getClass(), value); + } + + public StubArgumentResolver(Class valueType) { + this(valueType, null); + } + + public StubArgumentResolver(Class valueType, Object value) { + this.valueType = valueType; + this.value = value; + } + + + public List getResolvedParameters() { + return resolvedParameters; + } + + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterType().equals(this.valueType); + } + + @SuppressWarnings("unchecked") + @Override + public Mono resolveArgument(MethodParameter parameter, Message message) { + this.resolvedParameters.add(parameter); + return Mono.justOrEmpty(this.value); + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java new file mode 100644 index 0000000000..2d07a4ad86 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestEncoderMethodReturnValueHandler.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import java.util.List; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.support.DataBufferTestUtils; +import org.springframework.messaging.Message; + +import static java.nio.charset.StandardCharsets.*; + +/** + * Implementation of {@link AbstractEncoderMethodReturnValueHandler} for tests. + * "Handles" by storing encoded return values. + * + * @author Rossen Stoyanchev + */ +public class TestEncoderMethodReturnValueHandler extends AbstractEncoderMethodReturnValueHandler { + + private Flux encodedContent; + + + public TestEncoderMethodReturnValueHandler(List> encoders, ReactiveAdapterRegistry registry) { + super(encoders, registry); + } + + + public Flux getContent() { + return this.encodedContent; + } + + public Flux getContentAsStrings() { + return this.encodedContent.map(buffer -> DataBufferTestUtils.dumpString(buffer, UTF_8)); + } + + @Override + protected Mono handleEncodedContent( + Flux encodedContent, MethodParameter returnType, Message message) { + + this.encodedContent = encodedContent.cache(); + return this.encodedContent.then(); + } + + @Override + protected Mono handleNoContent(MethodParameter returnType, Message message) { + this.encodedContent = Flux.empty(); + return Mono.empty(); + } +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java new file mode 100644 index 0000000000..1133c13d73 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/handler/invocation/reactive/TestReturnValueHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.handler.invocation.reactive; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; + +/** + * Return value handler that simply stores the last return value. + * @author Rossen Stoyanchev + */ +public class TestReturnValueHandler implements HandlerMethodReturnValueHandler { + + @Nullable + private Object lastReturnValue; + + + @Nullable + public Object getLastReturnValue() { + return this.lastReturnValue; + } + + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public Mono handleReturnValue(@Nullable Object value, MethodParameter returnType, Message message) { + return value instanceof Publisher ? + new ChannelSendOperator((Publisher) value, this::saveValue) : + saveValue(value); + } + + private Mono saveValue(@Nullable Object value) { + this.lastReturnValue = value; + return Mono.empty(); + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java new file mode 100644 index 0000000000..13c3693f72 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/DefaultRSocketRequesterTests.java @@ -0,0 +1,275 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import io.reactivex.Completable; +import io.reactivex.Observable; +import io.reactivex.Single; +import io.rsocket.AbstractRSocket; +import io.rsocket.Payload; +import org.junit.Before; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.lang.Nullable; +import org.springframework.messaging.rsocket.RSocketRequester.RequestSpec; +import org.springframework.messaging.rsocket.RSocketRequester.ResponseSpec; +import org.springframework.util.MimeTypeUtils; + +import static java.util.concurrent.TimeUnit.*; +import static org.junit.Assert.*; + +/** + * Unit tests for {@link DefaultRSocketRequester}. + * + * @author Rossen Stoyanchev + */ +public class DefaultRSocketRequesterTests { + + private static final Duration MILLIS_10 = Duration.ofMillis(10); + + + private TestRSocket rsocket; + + private RSocketRequester requester; + + private final DefaultDataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + + + @Before + public void setUp() { + RSocketStrategies strategies = RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .build(); + this.rsocket = new TestRSocket(); + this.requester = RSocketRequester.create(rsocket, MimeTypeUtils.TEXT_PLAIN, strategies); + } + + + @Test + public void singlePayload() { + + // data(Object) + testSinglePayload(spec -> spec.data("bodyA"), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA")), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).then()), ""); + testSinglePayload(spec -> spec.data(Single.timer(10, MILLISECONDS).map(l -> "bodyA")), "bodyA"); + testSinglePayload(spec -> spec.data(Completable.complete()), ""); + + // data(Publisher, Class) + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA"), String.class), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).map(l -> "bodyA"), Object.class), "bodyA"); + testSinglePayload(spec -> spec.data(Mono.delay(MILLIS_10).then(), Void.class), ""); + } + + private void testSinglePayload(Function mapper, String expectedValue) { + mapper.apply(this.requester.route("toA")).send().block(Duration.ofSeconds(5)); + + assertEquals("fireAndForget", this.rsocket.getSavedMethodName()); + assertEquals("toA", this.rsocket.getSavedPayload().getMetadataUtf8()); + assertEquals(expectedValue, this.rsocket.getSavedPayload().getDataUtf8()); + } + + @Test + public void multiPayload() { + String[] values = new String[] {"bodyA", "bodyB", "bodyC"}; + Flux stringFlux = Flux.fromArray(values).delayElements(MILLIS_10); + + // data(Object) + testMultiPayload(spec -> spec.data(stringFlux), values); + testMultiPayload(spec -> spec.data(Flux.empty()), ""); + testMultiPayload(spec -> spec.data(Observable.fromArray(values).delay(10, MILLISECONDS)), values); + testMultiPayload(spec -> spec.data(Observable.empty()), ""); + + // data(Publisher, Class) + testMultiPayload(spec -> spec.data(stringFlux, String.class), values); + testMultiPayload(spec -> spec.data(stringFlux.cast(Object.class), Object.class), values); + } + + private void testMultiPayload(Function mapper, String... expectedValues) { + this.rsocket.reset(); + mapper.apply(this.requester.route("toA")).retrieveFlux(String.class).blockLast(Duration.ofSeconds(5)); + + assertEquals("requestChannel", this.rsocket.getSavedMethodName()); + List payloads = this.rsocket.getSavedPayloadFlux().collectList().block(Duration.ofSeconds(5)); + assertNotNull(payloads); + + if (Arrays.equals(new String[] {""}, expectedValues)) { + assertEquals(1, payloads.size()); + assertEquals("toA", payloads.get(0).getMetadataUtf8()); + assertEquals("", payloads.get(0).getDataUtf8()); + } + else { + assertArrayEquals(new String[] {"toA", "", ""}, + payloads.stream().map(Payload::getMetadataUtf8).toArray(String[]::new)); + assertArrayEquals(expectedValues, + payloads.stream().map(Payload::getDataUtf8).toArray(String[]::new)); + } + } + + @Test + public void send() { + String value = "bodyA"; + this.requester.route("toA").data(value).send().block(Duration.ofSeconds(5)); + + assertEquals("fireAndForget", this.rsocket.getSavedMethodName()); + assertEquals("toA", this.rsocket.getSavedPayload().getMetadataUtf8()); + assertEquals("bodyA", this.rsocket.getSavedPayload().getDataUtf8()); + } + + @Test + public void retrieveMono() { + String value = "bodyA"; + this.rsocket.setPayloadMonoToReturn(Mono.delay(MILLIS_10).thenReturn(toPayload(value))); + Mono response = this.requester.route("").data("").retrieveMono(String.class); + + StepVerifier.create(response).expectNext(value).expectComplete().verify(Duration.ofSeconds(5)); + assertEquals("requestResponse", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveMonoVoid() { + AtomicBoolean consumed = new AtomicBoolean(false); + Mono mono = Mono.delay(MILLIS_10).thenReturn(toPayload("bodyA")).doOnSuccess(p -> consumed.set(true)); + this.rsocket.setPayloadMonoToReturn(mono); + this.requester.route("").data("").retrieveMono(Void.class).block(Duration.ofSeconds(5)); + + assertTrue(consumed.get()); + assertEquals("requestResponse", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveFlux() { + String[] values = new String[] {"bodyA", "bodyB", "bodyC"}; + this.rsocket.setPayloadFluxToReturn(Flux.fromArray(values).delayElements(MILLIS_10).map(this::toPayload)); + Flux response = this.requester.route("").data("").retrieveFlux(String.class); + + StepVerifier.create(response).expectNext(values).expectComplete().verify(Duration.ofSeconds(5)); + assertEquals("requestStream", this.rsocket.getSavedMethodName()); + } + + @Test + public void retrieveFluxVoid() { + AtomicBoolean consumed = new AtomicBoolean(false); + Flux flux = Flux.just("bodyA", "bodyB") + .delayElements(MILLIS_10).map(this::toPayload).doOnComplete(() -> consumed.set(true)); + this.rsocket.setPayloadFluxToReturn(flux); + this.requester.route("").data("").retrieveFlux(Void.class).blockLast(Duration.ofSeconds(5)); + + assertTrue(consumed.get()); + assertEquals("requestStream", this.rsocket.getSavedMethodName()); + } + + @Test + public void rejectFluxToMono() { + try { + this.requester.route("").data(Flux.just("a", "b")).retrieveMono(String.class); + fail(); + } + catch (IllegalArgumentException ex) { + assertEquals("No RSocket interaction model for Flux request to Mono response.", ex.getMessage()); + } + } + + private Payload toPayload(String value) { + return PayloadUtils.createPayload(bufferFactory.wrap(value.getBytes(StandardCharsets.UTF_8))); + } + + + private static class TestRSocket extends AbstractRSocket { + + private Mono payloadMonoToReturn = Mono.empty(); + private Flux payloadFluxToReturn = Flux.empty(); + + @Nullable private volatile String savedMethodName; + @Nullable private volatile Payload savedPayload; + @Nullable private volatile Flux savedPayloadFlux; + + + void setPayloadMonoToReturn(Mono payloadMonoToReturn) { + this.payloadMonoToReturn = payloadMonoToReturn; + } + + void setPayloadFluxToReturn(Flux payloadFluxToReturn) { + this.payloadFluxToReturn = payloadFluxToReturn; + } + + @Nullable + String getSavedMethodName() { + return this.savedMethodName; + } + + @Nullable + Payload getSavedPayload() { + return this.savedPayload; + } + + @Nullable + Flux getSavedPayloadFlux() { + return this.savedPayloadFlux; + } + + public void reset() { + this.savedMethodName = null; + this.savedPayload = null; + this.savedPayloadFlux = null; + } + + + @Override + public Mono fireAndForget(Payload payload) { + this.savedMethodName = "fireAndForget"; + this.savedPayload = payload; + return Mono.empty(); + } + + @Override + public Mono requestResponse(Payload payload) { + this.savedMethodName = "requestResponse"; + this.savedPayload = payload; + return this.payloadMonoToReturn; + } + + @Override + public Flux requestStream(Payload payload) { + this.savedMethodName = "requestStream"; + this.savedPayload = payload; + return this.payloadFluxToReturn; + } + + @Override + public Flux requestChannel(Publisher publisher) { + this.savedMethodName = "requestChannel"; + this.savedPayloadFlux = Flux.from(publisher); + return this.payloadFluxToReturn; + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java new file mode 100644 index 0000000000..72a5d5906b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/FireAndForgetCountingInterceptor.java @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import io.rsocket.AbstractRSocket; +import io.rsocket.Payload; +import io.rsocket.RSocket; +import io.rsocket.plugins.RSocketInterceptor; +import io.rsocket.util.RSocketProxy; +import reactor.core.publisher.Mono; + +/** + * Intercept received RSockets and count successfully completed requests seen + * on the server side. This is useful for verifying fire-and-forget + * interactions. + * + * @author Rossen Stoyanchev + */ +class FireAndForgetCountingInterceptor extends AbstractRSocket implements RSocketInterceptor { + + private final List rsockets = new CopyOnWriteArrayList<>(); + + + public int getRSocketCount() { + return this.rsockets.size(); + } + + public int getFireAndForgetCount(int index) { + return this.rsockets.get(index).getFireAndForgetCount(); + } + + + @Override + public RSocket apply(RSocket rsocket) { + CountingDecorator decorator = new CountingDecorator(rsocket); + this.rsockets.add(decorator); + return decorator; + } + + + private static class CountingDecorator extends RSocketProxy { + + private final AtomicInteger fireAndForget = new AtomicInteger(0); + + + CountingDecorator(RSocket delegate) { + super(delegate); + } + + + public int getFireAndForgetCount() { + return this.fireAndForget.get(); + } + + @Override + public Mono fireAndForget(Payload payload) { + return super.fireAndForget(payload).doOnSuccess(aVoid -> this.fireAndForget.incrementAndGet()); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java new file mode 100644 index 0000000000..f12656ed1b --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketBufferLeakTests.java @@ -0,0 +1,466 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCounted; +import io.rsocket.AbstractRSocket; +import io.rsocket.Frame; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.plugins.RSocketInterceptor; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.CloseableChannel; +import io.rsocket.transport.netty.server.TcpServerTransport; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.ReplayProcessor; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.NettyDataBuffer; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.core.io.buffer.PooledDataBuffer; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; +import org.springframework.util.MimeTypeUtils; +import org.springframework.util.ObjectUtils; + +import static org.junit.Assert.*; + +/** + * Tests for scenarios that could lead to Payload and/or DataBuffer leaks. + * + * @author Rossen Stoyanchev + */ +public class RSocketBufferLeakTests { + + private static AnnotationConfigApplicationContext context; + + private static final PayloadInterceptor payloadInterceptor = new PayloadInterceptor(); + + private static CloseableChannel server; + + private static RSocket client; + + private static RSocketRequester requester; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(ServerConfig.class); + + server = RSocketFactory.receive() + .frameDecoder(Frame::retain) // zero copy + .addServerPlugin(payloadInterceptor) // intercept responding + .acceptor(context.getBean(MessageHandlerAcceptor.class)) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + + client = RSocketFactory.connect() + .frameDecoder(Frame::retain) // zero copy + .addClientPlugin(payloadInterceptor) // intercept outgoing requests + .dataMimeType(MimeTypeUtils.TEXT_PLAIN_VALUE) + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + + requester = RSocketRequester.create( + client, MimeTypeUtils.TEXT_PLAIN, context.getBean(RSocketStrategies.class)); + } + + @AfterClass + public static void tearDownOnce() { + client.dispose(); + server.dispose(); + } + + + @Before + public void setUp() { + getLeakAwareNettyDataBufferFactory().reset(); + payloadInterceptor.reset(); + } + + @After + public void tearDown() throws InterruptedException { + getLeakAwareNettyDataBufferFactory().checkForLeaks(Duration.ofSeconds(5)); + payloadInterceptor.checkForLeaks(); + } + + private LeakAwareNettyDataBufferFactory getLeakAwareNettyDataBufferFactory() { + return (LeakAwareNettyDataBufferFactory) context.getBean(RSocketStrategies.class).dataBufferFactory(); + } + + + @Test + public void assemblyTimeErrorForHandleAndReply() { + Mono result = requester.route("A.B").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectErrorMatches(ex -> { + String prefix = "Ambiguous handler methods mapped for destination 'A.B':"; + return ex.getMessage().startsWith(prefix); + }).verify(); + } + + @Test + public void subscriptionTimeErrorForHandleAndReply() { + Mono result = requester.route("not-decodable").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectErrorMatches(ex -> { + String prefix = "Cannot decode to [org.springframework.core.io.Resource]"; + return ex.getMessage().contains(prefix); + }).verify(); + } + + @Test + public void errorSignalWithExceptionHandler() { + Mono result = requester.route("error-signal").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectNext("Handled 'bad input'").verifyComplete(); + } + + @Test + public void ignoreInput() { + Flux result = requester.route("ignore-input").data("a").retrieveFlux(String.class); + StepVerifier.create(result).expectNext("bar").verifyComplete(); + } + + @Test + public void retrieveMonoFromFluxResponderMethod() { + Mono result = requester.route("request-stream").data("foo").retrieveMono(String.class); + StepVerifier.create(result).expectNext("foo-1").verifyComplete(); + } + + + @Controller + static class ServerController { + + @MessageMapping("A.*") + void ambiguousMatchA(String payload) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("*.B") + void ambiguousMatchB(String payload) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("not-decodable") + void notDecodable(@Payload Resource resource) { + throw new IllegalStateException("Unexpected call"); + } + + @MessageMapping("error-signal") + public Flux errorSignal(String payload) { + return Flux.error(new IllegalArgumentException("bad input")) + .delayElements(Duration.ofMillis(10)) + .cast(String.class); + } + + @MessageExceptionHandler + public String handleIllegalArgument(IllegalArgumentException ex) { + return "Handled '" + ex.getMessage() + "'"; + } + + @MessageMapping("ignore-input") + Mono ignoreInput() { + return Mono.delay(Duration.ofMillis(10)).map(l -> "bar"); + } + + @MessageMapping("request-stream") + Flux stream(String payload) { + return Flux.range(1,100).delayElements(Duration.ofMillis(10)).map(idx -> payload + "-" + idx); + } + } + + + @Configuration + static class ServerConfig { + + @Bean + public ServerController controller() { + return new ServerController(); + } + + @Bean + public MessageHandlerAcceptor messageHandlerAcceptor() { + MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor(); + acceptor.setRSocketStrategies(rsocketStrategies()); + return acceptor; + } + + @Bean + public RSocketStrategies rsocketStrategies() { + return RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .dataBufferFactory(new LeakAwareNettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) + .build(); + } + } + + + /** + * Similar {@link org.springframework.core.io.buffer.LeakAwareDataBufferFactory} + * but extends {@link NettyDataBufferFactory} rather than rely on + * decoration, since {@link PayloadUtils} does instanceof checks. + */ + private static class LeakAwareNettyDataBufferFactory extends NettyDataBufferFactory { + + private final List created = new ArrayList<>(); + + + LeakAwareNettyDataBufferFactory(ByteBufAllocator byteBufAllocator) { + super(byteBufAllocator); + } + + + void checkForLeaks(Duration duration) throws InterruptedException { + Instant start = Instant.now(); + while (true) { + try { + this.created.forEach(info -> { + if (((PooledDataBuffer) info.getDataBuffer()).isAllocated()) { + throw info.getError(); + } + }); + break; + } + catch (AssertionError ex) { + if (Instant.now().isAfter(start.plus(duration))) { + throw ex; + } + } + Thread.sleep(50); + } + } + + void reset() { + this.created.clear(); + } + + + @Override + public NettyDataBuffer allocateBuffer() { + return (NettyDataBuffer) record(super.allocateBuffer()); + } + + @Override + public NettyDataBuffer allocateBuffer(int initialCapacity) { + return (NettyDataBuffer) record(super.allocateBuffer(initialCapacity)); + } + + @Override + public NettyDataBuffer wrap(ByteBuf byteBuf) { + NettyDataBuffer dataBuffer = super.wrap(byteBuf); + if (byteBuf != Unpooled.EMPTY_BUFFER) { + record(dataBuffer); + } + return dataBuffer; + } + + @Override + public DataBuffer join(List dataBuffers) { + return record(super.join(dataBuffers)); + } + + private DataBuffer record(DataBuffer buffer) { + this.created.add(new DataBufferLeakInfo(buffer, new AssertionError(String.format( + "DataBuffer leak: {%s} {%s} not released.%nStacktrace at buffer creation: ", buffer, + ObjectUtils.getIdentityHexString(((NettyDataBuffer) buffer).getNativeBuffer()))))); + return buffer; + } + } + + + private static class DataBufferLeakInfo { + + private final DataBuffer dataBuffer; + + private final AssertionError error; + + + DataBufferLeakInfo(DataBuffer dataBuffer, AssertionError error) { + this.dataBuffer = dataBuffer; + this.error = error; + } + + DataBuffer getDataBuffer() { + return this.dataBuffer; + } + + AssertionError getError() { + return this.error; + } + } + + + /** + * Store all intercepted incoming and outgoing payloads and then use + * {@link #checkForLeaks()} at the end to check reference counts. + */ + private static class PayloadInterceptor extends AbstractRSocket implements RSocketInterceptor { + + private final List rsockets = new CopyOnWriteArrayList<>(); + + + void checkForLeaks() { + this.rsockets.stream().map(PayloadSavingDecorator::getPayloads) + .forEach(payloadInfoProcessor -> { + payloadInfoProcessor.onComplete(); + payloadInfoProcessor + .doOnNext(this::checkForLeak) + .blockLast(); + }); + } + + private void checkForLeak(PayloadLeakInfo info) { + Instant start = Instant.now(); + while (true) { + try { + int count = info.getReferenceCount(); + assertTrue("Leaked payload (refCnt=" + count + "): " + info, count == 0); + break; + } + catch (AssertionError ex) { + if (Instant.now().isAfter(start.plus(Duration.ofSeconds(5)))) { + throw ex; + } + } + try { + Thread.sleep(50); + } + catch (InterruptedException ex) { + // ignore + } + } + } + + public void reset() { + this.rsockets.forEach(PayloadSavingDecorator::reset); + } + + + @Override + public RSocket apply(RSocket rsocket) { + PayloadSavingDecorator decorator = new PayloadSavingDecorator(rsocket); + this.rsockets.add(decorator); + return decorator; + } + + + private static class PayloadSavingDecorator extends AbstractRSocket { + + private final RSocket delegate; + + private ReplayProcessor payloads = ReplayProcessor.create(); + + + PayloadSavingDecorator(RSocket delegate) { + this.delegate = delegate; + } + + + ReplayProcessor getPayloads() { + return this.payloads; + } + + void reset() { + this.payloads = ReplayProcessor.create(); + } + + @Override + public Mono fireAndForget(io.rsocket.Payload payload) { + return this.delegate.fireAndForget(addPayload(payload)); + } + + @Override + public Mono requestResponse(io.rsocket.Payload payload) { + return this.delegate.requestResponse(addPayload(payload)).doOnSuccess(this::addPayload); + } + + @Override + public Flux requestStream(io.rsocket.Payload payload) { + return this.delegate.requestStream(addPayload(payload)).doOnNext(this::addPayload); + } + + @Override + public Flux requestChannel(Publisher payloads) { + return this.delegate + .requestChannel(Flux.from(payloads).doOnNext(this::addPayload)) + .doOnNext(this::addPayload); + } + + private io.rsocket.Payload addPayload(io.rsocket.Payload payload) { + this.payloads.onNext(new PayloadLeakInfo(payload)); + return payload; + } + + @Override + public Mono metadataPush(io.rsocket.Payload payload) { + return this.delegate.metadataPush(addPayload(payload)); + } + } + } + + + private static class PayloadLeakInfo { + + private final String description; + + private final ReferenceCounted referenceCounted; + + + PayloadLeakInfo(io.rsocket.Payload payload) { + this.description = payload.toString(); + this.referenceCounted = payload; + } + + + int getReferenceCount() { + return this.referenceCounted.refCnt(); + } + + @Override + public String toString() { + return this.description; + } + } +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java new file mode 100644 index 0000000000..0e3b65a15c --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketClientToServerIntegrationTests.java @@ -0,0 +1,274 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.time.Duration; + +import io.netty.buffer.PooledByteBufAllocator; +import io.rsocket.Frame; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.CloseableChannel; +import io.rsocket.transport.netty.server.TcpServerTransport; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.ReplayProcessor; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; +import org.springframework.util.MimeTypeUtils; + +import static org.junit.Assert.*; + +/** + * Server-side handling of RSocket requests. + * + * @author Rossen Stoyanchev + */ +public class RSocketClientToServerIntegrationTests { + + private static AnnotationConfigApplicationContext context; + + private static CloseableChannel server; + + private static FireAndForgetCountingInterceptor interceptor = new FireAndForgetCountingInterceptor(); + + private static RSocket client; + + private static RSocketRequester requester; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(ServerConfig.class); + + server = RSocketFactory.receive() + .addServerPlugin(interceptor) + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy + .acceptor(context.getBean(MessageHandlerAcceptor.class)) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + + client = RSocketFactory.connect() + .dataMimeType(MimeTypeUtils.TEXT_PLAIN_VALUE) + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + + requester = RSocketRequester.create( + client, MimeTypeUtils.TEXT_PLAIN, context.getBean(RSocketStrategies.class)); + } + + @AfterClass + public static void tearDownOnce() { + client.dispose(); + server.dispose(); + } + + + @Test + public void fireAndForget() { + + Flux.range(1, 3) + .concatMap(i -> requester.route("receive").data("Hello " + i).send()) + .blockLast(); + + StepVerifier.create(context.getBean(ServerController.class).fireForgetPayloads) + .expectNext("Hello 1") + .expectNext("Hello 2") + .expectNext("Hello 3") + .thenCancel() + .verify(Duration.ofSeconds(5)); + + assertEquals(1, interceptor.getRSocketCount()); + assertEquals("Fire and forget requests did not actually complete handling on the server side", + 3, interceptor.getFireAndForgetCount(0)); + } + + @Test + public void echo() { + Flux result = Flux.range(1, 3).concatMap(i -> + requester.route("echo").data("Hello " + i).retrieveMono(String.class)); + + StepVerifier.create(result) + .expectNext("Hello 1").expectNext("Hello 2").expectNext("Hello 3") + .verifyComplete(); + } + + @Test + public void echoAsync() { + Flux result = Flux.range(1, 3).concatMap(i -> + requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); + + StepVerifier.create(result) + .expectNext("Hello 1 async").expectNext("Hello 2 async").expectNext("Hello 3 async") + .verifyComplete(); + } + + @Test + public void echoStream() { + Flux result = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); + + StepVerifier.create(result) + .expectNext("Hello 0").expectNextCount(6).expectNext("Hello 7") + .thenCancel() + .verify(); + } + + @Test + public void echoChannel() { + Flux result = requester.route("echo-channel") + .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) + .retrieveFlux(String.class); + + StepVerifier.create(result) + .expectNext("Hello 1 async").expectNextCount(8).expectNext("Hello 10 async") + .verifyComplete(); + } + + @Test + public void voidReturnValue() { + Flux result = requester.route("void-return-value").data("Hello").retrieveFlux(String.class); + StepVerifier.create(result).verifyComplete(); + } + + @Test + public void voidReturnValueFromExceptionHandler() { + Flux result = requester.route("void-return-value").data("bad").retrieveFlux(String.class); + StepVerifier.create(result).verifyComplete(); + } + + @Test + public void handleWithThrownException() { + Mono result = requester.route("thrown-exception").data("a").retrieveMono(String.class); + StepVerifier.create(result).expectNext("Invalid input error handled").verifyComplete(); + } + + @Test + public void handleWithErrorSignal() { + Mono result = requester.route("error-signal").data("a").retrieveMono(String.class); + StepVerifier.create(result).expectNext("Invalid input error handled").verifyComplete(); + } + + @Test + public void noMatchingRoute() { + Mono result = requester.route("invalid").data("anything").retrieveMono(String.class); + StepVerifier.create(result).verifyErrorMessage("No handler for destination 'invalid'"); + } + + + @Controller + static class ServerController { + + final ReplayProcessor fireForgetPayloads = ReplayProcessor.create(); + + + @MessageMapping("receive") + void receive(String payload) { + this.fireForgetPayloads.onNext(payload); + } + + @MessageMapping("echo") + String echo(String payload) { + return payload; + } + + @MessageMapping("echo-async") + Mono echoAsync(String payload) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async"); + } + + @MessageMapping("echo-stream") + Flux echoStream(String payload) { + return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong); + } + + @MessageMapping("echo-channel") + Flux echoChannel(Flux payloads) { + return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async"); + } + + @MessageMapping("thrown-exception") + Mono handleAndThrow(String payload) { + throw new IllegalArgumentException("Invalid input error"); + } + + @MessageMapping("error-signal") + Mono handleAndReturnError(String payload) { + return Mono.error(new IllegalArgumentException("Invalid input error")); + } + + @MessageMapping("void-return-value") + Mono voidReturnValue(String payload) { + return !payload.equals("bad") ? + Mono.delay(Duration.ofMillis(10)).then(Mono.empty()) : + Mono.error(new IllegalStateException("bad")); + } + + @MessageExceptionHandler + Mono handleException(IllegalArgumentException ex) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> ex.getMessage() + " handled"); + } + + @MessageExceptionHandler + Mono handleExceptionWithVoidReturnValue(IllegalStateException ex) { + return Mono.delay(Duration.ofMillis(10)).then(Mono.empty()); + } + } + + + @Configuration + static class ServerConfig { + + @Bean + public ServerController controller() { + return new ServerController(); + } + + @Bean + public MessageHandlerAcceptor messageHandlerAcceptor() { + MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor(); + acceptor.setRSocketStrategies(rsocketStrategies()); + return acceptor; + } + + @Bean + public RSocketStrategies rsocketStrategies() { + return RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) + .build(); + } + } + +} diff --git a/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java new file mode 100644 index 0000000000..e55ef09820 --- /dev/null +++ b/spring-messaging/src/test/java/org/springframework/messaging/rsocket/RSocketServerToClientIntegrationTests.java @@ -0,0 +1,285 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.messaging.rsocket; + +import java.time.Duration; +import java.util.Collections; + +import io.netty.buffer.PooledByteBufAllocator; +import io.rsocket.Closeable; +import io.rsocket.Frame; +import io.rsocket.RSocket; +import io.rsocket.RSocketFactory; +import io.rsocket.transport.netty.client.TcpClientTransport; +import io.rsocket.transport.netty.server.TcpServerTransport; +import io.rsocket.util.DefaultPayload; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoProcessor; +import reactor.core.publisher.ReplayProcessor; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.codec.CharSequenceEncoder; +import org.springframework.core.codec.StringDecoder; +import org.springframework.core.io.buffer.NettyDataBufferFactory; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +/** + * Client-side handling of requests initiated from the server side. + * + * @author Rossen Stoyanchev + */ +public class RSocketServerToClientIntegrationTests { + + private static AnnotationConfigApplicationContext context; + + private static Closeable server; + + + @BeforeClass + @SuppressWarnings("ConstantConditions") + public static void setupOnce() { + + context = new AnnotationConfigApplicationContext(RSocketConfig.class); + + server = RSocketFactory.receive() + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy + .acceptor(context.getBean("serverAcceptor", MessageHandlerAcceptor.class)) + .transport(TcpServerTransport.create("localhost", 7000)) + .start() + .block(); + } + + @AfterClass + public static void tearDownOnce() { + server.dispose(); + } + + + @Test + public void echo() { + connectAndVerify("connect.echo"); + } + + @Test + public void echoAsync() { + connectAndVerify("connect.echo-async"); + } + + @Test + public void echoStream() { + connectAndVerify("connect.echo-stream"); + } + + @Test + public void echoChannel() { + connectAndVerify("connect.echo-channel"); + } + + + private static void connectAndVerify(String destination) { + + ServerController serverController = context.getBean(ServerController.class); + serverController.reset(); + + RSocket rsocket = null; + try { + rsocket = RSocketFactory.connect() + .setupPayload(DefaultPayload.create("", destination)) + .dataMimeType("text/plain") + .frameDecoder(Frame::retain) // as per https://github.com/rsocket/rsocket-java#zero-copy + .acceptor(context.getBean("clientAcceptor", MessageHandlerAcceptor.class)) + .transport(TcpClientTransport.create("localhost", 7000)) + .start() + .block(); + + serverController.await(Duration.ofSeconds(5)); + } + finally { + if (rsocket != null) { + rsocket.dispose(); + } + } + } + + + @Controller + @SuppressWarnings({"unused", "NullableProblems"}) + static class ServerController { + + // Must be initialized by @Test method... + volatile MonoProcessor result; + + + public void reset() { + this.result = MonoProcessor.create(); + } + + public void await(Duration duration) { + this.result.block(duration); + } + + + @MessageMapping("connect.echo") + void echo(RSocketRequester requester) { + runTest(() -> { + Flux flux = Flux.range(1, 3).concatMap(i -> + requester.route("echo").data("Hello " + i).retrieveMono(String.class)); + + StepVerifier.create(flux) + .expectNext("Hello 1") + .expectNext("Hello 2") + .expectNext("Hello 3") + .verifyComplete(); + }); + } + + @MessageMapping("connect.echo-async") + void echoAsync(RSocketRequester requester) { + runTest(() -> { + Flux flux = Flux.range(1, 3).concatMap(i -> + requester.route("echo-async").data("Hello " + i).retrieveMono(String.class)); + + StepVerifier.create(flux) + .expectNext("Hello 1 async") + .expectNext("Hello 2 async") + .expectNext("Hello 3 async") + .verifyComplete(); + }); + } + + @MessageMapping("connect.echo-stream") + void echoStream(RSocketRequester requester) { + runTest(() -> { + Flux flux = requester.route("echo-stream").data("Hello").retrieveFlux(String.class); + + StepVerifier.create(flux) + .expectNext("Hello 0") + .expectNextCount(5) + .expectNext("Hello 6") + .expectNext("Hello 7") + .thenCancel() + .verify(); + }); + } + + @MessageMapping("connect.echo-channel") + void echoChannel(RSocketRequester requester) { + runTest(() -> { + Flux flux = requester.route("echo-channel") + .data(Flux.range(1, 10).map(i -> "Hello " + i), String.class) + .retrieveFlux(String.class); + + StepVerifier.create(flux) + .expectNext("Hello 1 async") + .expectNextCount(7) + .expectNext("Hello 9 async") + .expectNext("Hello 10 async") + .verifyComplete(); + }); + } + + + private void runTest(Runnable testEcho) { + Mono.fromRunnable(testEcho) + .doOnError(ex -> result.onError(ex)) + .doOnSuccess(o -> result.onComplete()) + .subscribeOn(Schedulers.elastic()) // StepVerifier will block + .subscribe(); + } + } + + + private static class ClientHandler { + + final ReplayProcessor fireForgetPayloads = ReplayProcessor.create(); + + + @MessageMapping("receive") + void receive(String payload) { + this.fireForgetPayloads.onNext(payload); + } + + @MessageMapping("echo") + String echo(String payload) { + return payload; + } + + @MessageMapping("echo-async") + Mono echoAsync(String payload) { + return Mono.delay(Duration.ofMillis(10)).map(aLong -> payload + " async"); + } + + @MessageMapping("echo-stream") + Flux echoStream(String payload) { + return Flux.interval(Duration.ofMillis(10)).map(aLong -> payload + " " + aLong); + } + + @MessageMapping("echo-channel") + Flux echoChannel(Flux payloads) { + return payloads.delayElements(Duration.ofMillis(10)).map(payload -> payload + " async"); + } + } + + + @Configuration + static class RSocketConfig { + + @Bean + public ClientHandler clientHandler() { + return new ClientHandler(); + } + + @Bean + public ServerController serverController() { + return new ServerController(); + } + + @Bean + public MessageHandlerAcceptor clientAcceptor() { + MessageHandlerAcceptor acceptor = new MessageHandlerAcceptor(); + acceptor.setHandlers(Collections.singletonList(clientHandler())); + acceptor.setAutoDetectDisabled(); + acceptor.setRSocketStrategies(rsocketStrategies()); + return acceptor; + } + + @Bean + public MessageHandlerAcceptor serverAcceptor() { + MessageHandlerAcceptor handler = new MessageHandlerAcceptor(); + handler.setRSocketStrategies(rsocketStrategies()); + return handler; + } + + @Bean + public RSocketStrategies rsocketStrategies() { + return RSocketStrategies.builder() + .decoder(StringDecoder.allMimeTypes()) + .encoder(CharSequenceEncoder.allMimeTypes()) + .dataBufferFactory(new NettyDataBufferFactory(PooledByteBufAllocator.DEFAULT)) + .build(); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java b/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java index edf2ab4ba7..14e4f7631b 100644 --- a/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java +++ b/spring-web/src/test/java/org/springframework/web/method/ResolvableMethod.java @@ -57,7 +57,9 @@ import org.springframework.util.ReflectionUtils; import static java.util.stream.Collectors.*; /** - * Convenience class to resolve method parameters from hints. + * Convenience class to resolve to a Method and method parameters. + * + *

Note that a replica of this class also exists in spring-messaging. * *

Background

*