From 7df664d30689de2010f645f2c631e55c709c102c Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Sun, 17 Mar 2019 08:08:13 +0000 Subject: [PATCH] Add support for @EventHeader annotation - Add @EventHeader which can be used to bound as single event headers instead of all headers @EventHeaders. - Fixes #638 --- docs/src/reference/asciidoc/sm.adoc | 6 ++ .../statemachine/annotation/EventHeader.java | 62 +++++++++++++++++ .../statemachine/annotation/EventHeaders.java | 10 ++- .../StateMachineMethodInvokerHelper.java | 69 +++++++++++++++++-- .../annotation/MethodAnnotationTests.java | 30 +++++++- .../docs/DocsConfigurationSampleTests4.java | 5 +- 6 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeader.java diff --git a/docs/src/reference/asciidoc/sm.adoc b/docs/src/reference/asciidoc/sm.adoc index 07ca3637..4ff11607 100644 --- a/docs/src/reference/asciidoc/sm.adoc +++ b/docs/src/reference/asciidoc/sm.adoc @@ -1300,6 +1300,12 @@ Number of parameters or order of those doesn't matter. include::samples/DocsConfigurationSampleTests4.java[tags=snippetBB] ---- +[NOTE] +==== +Instead of getting all event headers with `@EventHeaders` you can use +`@EventHeader` which can bound to a single header. +==== + === Transition Annotations Annotations for transitions are `OnTransition`, `OnTransitionStart` and `OnTransitionEnd`. diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeader.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeader.java new file mode 100644 index 00000000..7c6747a2 --- /dev/null +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeader.java @@ -0,0 +1,62 @@ +/* + * Copyright 2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.statemachine.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation which indicates that a method parameter should be bound to a event header. + * + * @author Janne Valkealahti + * + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface EventHeader { + + /** + * Alias for {@link #name}. + * + * @return the header name + */ + @AliasFor("name") + String value() default ""; + + /** + * The name of the request header to bind to. + * + * @return the header name + */ + @AliasFor("value") + String name() default ""; + + /** + * Whether the header is required. + *

Default is {@code true}, leading to an exception if the header is + * missing. Switch this to {@code false} if you prefer a {@code null} + * value in case of a header missing. + * + * @return the required flag + */ + boolean required() default true; +} diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeaders.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeaders.java index e5111acb..65bec928 100644 --- a/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeaders.java +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/annotation/EventHeaders.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2015-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. @@ -21,6 +21,14 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Annotation which indicates that a method parameter should be bound to the + * event headers of a message. The annotated parameter must be assignable to + * {@link java.util.Map} with String keys and Object values. + * + * @author Janne Valkealahti + * + */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/processor/StateMachineMethodInvokerHelper.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/processor/StateMachineMethodInvokerHelper.java index e35774fc..ccb81877 100644 --- a/spring-statemachine-core/src/main/java/org/springframework/statemachine/processor/StateMachineMethodInvokerHelper.java +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/processor/StateMachineMethodInvokerHelper.java @@ -29,7 +29,9 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.aop.framework.Advised; import org.springframework.aop.support.AopUtils; +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.AnnotationAttributes; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.convert.TypeDescriptor; @@ -42,6 +44,8 @@ import org.springframework.messaging.Message; import org.springframework.statemachine.ExtendedState; import org.springframework.statemachine.StateContext; import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.StateMachineException; +import org.springframework.statemachine.annotation.EventHeader; import org.springframework.statemachine.annotation.EventHeaders; import org.springframework.statemachine.annotation.ExtendedStateVariable; import org.springframework.statemachine.support.AbstractExpressionEvaluator; @@ -52,6 +56,7 @@ import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils.MethodCallback; import org.springframework.util.ReflectionUtils.MethodFilter; +import org.springframework.util.StringUtils; /** * A helper class using spel to execute target methods. @@ -134,7 +139,11 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression this.handlerMethods = null; this.handlerMessageMethods = null; this.handlerMethodsList = null; - this.prepareEvaluationContext(this.getEvaluationContext(false), method, annotationType); + try { + this.prepareEvaluationContext(this.getEvaluationContext(false), method, annotationType); + } catch (Exception e) { + throw new StateMachineException("Unable to prepare evaluation context", e); + } this.setDisplayString(targetObject, method); } @@ -169,7 +178,11 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression this.handlerMethodsList.add(this.handlerMethods); this.handlerMethodsList.add(this.handlerMessageMethods); } - this.prepareEvaluationContext(this.getEvaluationContext(false), methodName, annotationType); + try { + this.prepareEvaluationContext(this.getEvaluationContext(false), methodName, annotationType); + } catch (Exception e) { + throw new StateMachineException("Unable to prepare evaluation context", e); + } this.setDisplayString(targetObject, methodName); } @@ -184,7 +197,7 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression } private void prepareEvaluationContext(StandardEvaluationContext context, Object method, - Class annotationType) { + Class annotationType) throws Exception { Class targetType = AopUtils.getTargetClass(this.targetObject); if (method instanceof Method) { context.registerMethodFilter(targetType, new FixedMethodFilter((Method) method)); @@ -203,6 +216,8 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression context.registerMethodFilter(targetType, filter); } context.setVariable("target", targetObject); + context.registerFunction("requiredHeader", ParametersWrapper.class.getDeclaredMethod("getHeader", + Map.class, String.class)); } private boolean canReturnExpectedType(AnnotatedMethodFilter filter, Class targetType, TypeConverter typeConverter) { @@ -382,7 +397,7 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression private static final SpelExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); -// private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new LocalVariableTableParameterNameDiscoverer(); + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new LocalVariableTableParameterNameDiscoverer(); private final Method method; @@ -399,7 +414,6 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression this.expression = this.generateExpression(method); } - Expression getExpression() { return this.expression; } @@ -435,6 +449,8 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression if (annotationType.equals(EventHeaders.class)) { sb.append("headers"); + } else if (annotationType.equals(EventHeader.class)) { + sb.append(this.determineHeaderExpression(mappingAnnotation, methodParameter)); } else if (annotationType.equals(ExtendedStateVariable.class)) { AnnotationAttributes annotationAttributes = AnnotationAttributes .fromMap(AnnotationUtils.getAnnotationAttributes(mappingAnnotation)); @@ -483,6 +499,8 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression + annotation.annotationType().getName() + "]"); } match = annotation; + } else if (type.equals(EventHeader.class)) { + match = annotation; } else if (type.equals(ExtendedStateVariable.class)) { if (match != null) { throw new IllegalArgumentException( @@ -496,12 +514,43 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression return match; } + private String determineHeaderExpression(Annotation headerAnnotation, MethodParameter methodParameter) { + methodParameter.initParameterNameDiscovery(PARAMETER_NAME_DISCOVERER); + String headerName = null; + String relativeExpression = ""; + AnnotationAttributes annotationAttributes = (AnnotationAttributes) AnnotationUtils + .getAnnotationAttributes(headerAnnotation); + String valueAttribute = annotationAttributes.getString(AnnotationUtils.VALUE); + if (!StringUtils.hasText(valueAttribute)) { + headerName = methodParameter.getParameterName(); + } else if (valueAttribute.indexOf('.') != -1) { + String[] tokens = valueAttribute.split("\\.", 2); + headerName = tokens[0]; + if (StringUtils.hasText(tokens[1])) { + relativeExpression = "." + tokens[1]; + } + } else { + headerName = valueAttribute; + } + Assert.notNull(headerName, "Cannot determine header name. Possible reasons: -debug is " + + "disabled or header name is not explicitly provided via @EventHeader annotation."); + String headerRetrievalExpression = "headers['" + headerName + "']"; + String fullHeaderExpression = headerRetrievalExpression + relativeExpression; + if (annotationAttributes.getBoolean("required") + && !methodParameter.getParameterType().getName().equals("java.util.Optional")) { + return "#requiredHeader(headers, '" + headerName + "')" + relativeExpression; + } else if (!StringUtils.hasLength(relativeExpression)) { + return headerRetrievalExpression + " ?: null"; + } else { + return headerRetrievalExpression + " != null ? " + fullHeaderExpression + " : null"; + } + } } /** * Wrapping everything we need to work with spel. */ - public class ParametersWrapper { + public static class ParametersWrapper { private final StateContext stateContext; @@ -513,6 +562,14 @@ public class StateMachineMethodInvokerHelper extends AbstractExpression return stateContext; } + public static Object getHeader(Map headers, String header) { + Object object = headers.get(header); + if (object == null) { + throw new IllegalArgumentException("required header not available: " + header); + } + return object; + } + public Map getHeaders() { return stateContext.getMessageHeaders(); } diff --git a/spring-statemachine-core/src/test/java/org/springframework/statemachine/annotation/MethodAnnotationTests.java b/spring-statemachine-core/src/test/java/org/springframework/statemachine/annotation/MethodAnnotationTests.java index 19fcc3c5..14205a2b 100644 --- a/spring-statemachine-core/src/test/java/org/springframework/statemachine/annotation/MethodAnnotationTests.java +++ b/spring-statemachine-core/src/test/java/org/springframework/statemachine/annotation/MethodAnnotationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2015-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. @@ -177,7 +177,12 @@ public class MethodAnnotationTests extends AbstractStateMachineTests { Bean2 bean2 = context.getBean(Bean2.class); // this event should cause 'method1' to get called - machine.sendEvent(MessageBuilder.withPayload(TestEvents.E1).setHeader("foo", "jee").build()); + machine.sendEvent(MessageBuilder.withPayload(TestEvents.E1) + .setHeader("foo", "jee") + .setHeader("bar1", "jee1") + .setHeader("bar2", "jee2") + .setHeader("bar3", "jee3") + .build()); machine.sendEvent(MessageBuilder.withPayload(TestEvents.E2).build()); assertThat(bean2.onMethod1Latch.await(2, TimeUnit.SECONDS), is(true)); @@ -188,6 +193,11 @@ public class MethodAnnotationTests extends AbstractStateMachineTests { assertThat(bean2.onMethod2Latch.await(2, TimeUnit.SECONDS), is(true)); assertThat(bean2.variable, notNullValue()); assertThat((String)bean2.variable, is("jee")); + + assertThat((String)bean2.fooHeader, is("jee")); + assertThat((String)bean2.bar1Header, is("jee1")); + assertThat((String)bean2.bar2Header, is("jee2")); + assertThat(bean2.bar3Header, is("jee3")); } @Test @@ -460,6 +470,10 @@ public class MethodAnnotationTests extends AbstractStateMachineTests { Map headers; ExtendedState extendedState; Object variable; + Object fooHeader; + Object bar1Header; + Object bar2Header; + String bar3Header; @OnTransition(source = "S1", target = "S2") public void method1(@EventHeaders Map headers, ExtendedState extendedState) { @@ -475,6 +489,18 @@ public class MethodAnnotationTests extends AbstractStateMachineTests { onMethod2Latch.countDown(); } + @OnTransition(source = "S1", target = "S2") + public void method3(@EventHeader(name = "foo") Object header) { + this.fooHeader = header; + } + + @OnTransition(source = "S1", target = "S2") + public void method4(@EventHeader(name = "bar1") Object header1, @EventHeader(value = "bar2") Object header2, + @EventHeader(value = "bar3") String header3) { + this.bar1Header = header1; + this.bar2Header = header2; + this.bar3Header = header3; + } } @WithStateMachine diff --git a/spring-statemachine-core/src/test/java/org/springframework/statemachine/docs/DocsConfigurationSampleTests4.java b/spring-statemachine-core/src/test/java/org/springframework/statemachine/docs/DocsConfigurationSampleTests4.java index 4f8ba008..cae2b2ed 100644 --- a/spring-statemachine-core/src/test/java/org/springframework/statemachine/docs/DocsConfigurationSampleTests4.java +++ b/spring-statemachine-core/src/test/java/org/springframework/statemachine/docs/DocsConfigurationSampleTests4.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2015-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. @@ -27,6 +27,7 @@ import org.springframework.statemachine.AbstractStateMachineTests; import org.springframework.statemachine.ExtendedState; import org.springframework.statemachine.StateContext; import org.springframework.statemachine.StateMachine; +import org.springframework.statemachine.annotation.EventHeader; import org.springframework.statemachine.annotation.EventHeaders; import org.springframework.statemachine.annotation.OnEventNotAccepted; import org.springframework.statemachine.annotation.OnExtendedStateChanged; @@ -131,6 +132,8 @@ public class DocsConfigurationSampleTests4 extends AbstractStateMachineTests { @OnTransition public void anyTransition( @EventHeaders Map headers, + @EventHeader("myheader1") Object myheader1, + @EventHeader(name = "myheader2", required = false) String myheader2, ExtendedState extendedState, StateMachine stateMachine, Message message,