From b2f2f81890fdc05751f893339ce6b16743da5b59 Mon Sep 17 00:00:00 2001 From: Soby Chacko Date: Mon, 11 May 2020 17:34:38 -0400 Subject: [PATCH] Migrate router-sink Resolves https://github.com/spring-cloud/stream-applications/issues/26 --- .../processor/groovy-processor/pom.xml | 2 +- applications/sink/pom.xml | 11 +- applications/sink/router-sink/README.adoc | 18 ++ applications/sink/router-sink/pom.xml | 122 +++++++++++ .../router/sink/RouterSinkConfiguration.java | 103 +++++++++ .../app/router/sink/RouterSinkProperties.java | 159 ++++++++++++++ ...onfiguration-metadata-whitelist.properties | 1 + .../sink/RouterSinkIntegrationTests.java | 198 ++++++++++++++++++ .../src/test/resources/routertest.groovy | 5 + .../src/test/resources/routertest.properties | 1 + 10 files changed, 614 insertions(+), 6 deletions(-) create mode 100644 applications/sink/router-sink/README.adoc create mode 100644 applications/sink/router-sink/pom.xml create mode 100644 applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkConfiguration.java create mode 100644 applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkProperties.java create mode 100644 applications/sink/router-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties create mode 100644 applications/sink/router-sink/src/test/java/org/springframework/cloud/stream/app/router/sink/RouterSinkIntegrationTests.java create mode 100644 applications/sink/router-sink/src/test/resources/routertest.groovy create mode 100644 applications/sink/router-sink/src/test/resources/routertest.properties diff --git a/applications/processor/groovy-processor/pom.xml b/applications/processor/groovy-processor/pom.xml index d300339d..6adfa06b 100644 --- a/applications/processor/groovy-processor/pom.xml +++ b/applications/processor/groovy-processor/pom.xml @@ -88,7 +88,7 @@ spring-cloud-stream-app-maven-plugin - groovy-transform + groovy processor ${project.version} diff --git a/applications/sink/pom.xml b/applications/sink/pom.xml index 679678d1..0d475725 100644 --- a/applications/sink/pom.xml +++ b/applications/sink/pom.xml @@ -10,13 +10,14 @@ pom - file-sink - rabbit-sink - redis-sink - log-sink cassandra-sink - mongodb-sink counter-sink + file-sink + log-sink + mongodb-sink jdbc-sink + redis-sink + rabbit-sink + router-sink diff --git a/applications/sink/router-sink/README.adoc b/applications/sink/router-sink/README.adoc new file mode 100644 index 00000000..5d85e5f0 --- /dev/null +++ b/applications/sink/router-sink/README.adoc @@ -0,0 +1,18 @@ +//tag::ref-doc[] += Log Sink + +The `log` sink uses the application logger to output the data for inspection. + +Please understand that `log` sink uses type-less handler, which affects how the actual logging will be performed. +This means that if the content-type is textual, then raw payload bytes will be converted to String, otherwise raw bytes will be logged. +Please see more info in the https://docs.spring.io/spring-cloud-stream/docs/Elmhurst.RELEASE/reference/htmlsingle/#_content_type_versus_argument_type[user-guide]. + +== Options + +The **$$log$$** $$sink$$ has the following options: + + +//tag::configuration-properties[] +//end::configuration-properties[] + +//end::ref-doc[] diff --git a/applications/sink/router-sink/pom.xml b/applications/sink/router-sink/pom.xml new file mode 100644 index 00000000..8b54366f --- /dev/null +++ b/applications/sink/router-sink/pom.xml @@ -0,0 +1,122 @@ + + + 4.0.0 + router-sink + 3.0.0-SNAPSHOT + router-sink + log sink apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + 2.4.0 + + + + + org.springframework.integration + spring-integration-core + + + org.springframework.integration + spring-integration-groovy + + + org.codehaus.groovy + groovy-json + + + org.codehaus.groovy + groovy-nio + + + org.codehaus.groovy + groovy-templates + + + org.codehaus.groovy + groovy-xml + + + org.codehaus.groovy + groovy-dateutil + + + org.codehaus.groovy + groovy-datetime + + + org.codehaus.groovy + groovy-jaxb + + + org.apache.ivy + ivy + ${apache-ivy.version} + + + org.springframework.cloud.fn + payload-converter-function + 1.0.0-SNAPSHOT + + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + router + sink + ${project.version} + org.springframework.cloud.stream.app.router.sink.RouterSinkConfiguration.class + + byteArrayTextToString|routerSinkConsumer + + + + org.springframework.cloud.stream.app + router-sink + ${project.version} + + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkConfiguration.java b/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkConfiguration.java new file mode 100644 index 00000000..38a18920 --- /dev/null +++ b/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkConfiguration.java @@ -0,0 +1,103 @@ +/* + * Copyright 2016-2020 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 + * + * https://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.cloud.stream.app.router.sink; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.stream.binding.BinderAwareChannelResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.support.PropertiesLoaderUtils; +import org.springframework.integration.groovy.GroovyScriptExecutingMessageProcessor; +import org.springframework.integration.router.AbstractMappingMessageRouter; +import org.springframework.integration.router.AbstractMessageRouter; +import org.springframework.integration.router.ExpressionEvaluatingRouter; +import org.springframework.integration.router.MessageRouter; +import org.springframework.integration.router.MethodInvokingRouter; +import org.springframework.integration.scripting.DefaultScriptVariableGenerator; +import org.springframework.integration.scripting.RefreshableResourceScriptSource; +import org.springframework.integration.scripting.ScriptVariableGenerator; +import org.springframework.messaging.Message; +import org.springframework.scripting.ScriptSource; +import org.springframework.util.CollectionUtils; + +/** + * A sink app that routes to one or more named channels. + * + * @author Gary Russell + * @author Artem Bilan + * @author Christian Tzolov + * @author Soby Chacko + */ +@Configuration +@EnableConfigurationProperties(RouterSinkProperties.class) +public class RouterSinkConfiguration { + + @Autowired + RouterSinkProperties properties; + + @Bean + public Consumer> routerSinkConsumer(MessageRouter router) { + return ((AbstractMessageRouter) router)::handleMessage; + } + + @Bean + public MessageRouter router(BinderAwareChannelResolver channelResolver, + ScriptVariableGenerator scriptVariableGenerator) { + AbstractMappingMessageRouter router; + if (properties.getScript() != null) { + router = new MethodInvokingRouter(scriptProcessor(scriptVariableGenerator, properties)); + } + else { + router = new ExpressionEvaluatingRouter(properties.getExpression()); + } + router.setDefaultOutputChannelName(properties.getDefaultOutputChannel()); + router.setResolutionRequired(properties.isResolutionRequired()); + if (properties.getDestinationMappings() != null) { + router.replaceChannelMappings(properties.getDestinationMappings()); + } + router.setChannelResolver(channelResolver); + return router; + } + + @Bean(name = "variableGenerator") + public ScriptVariableGenerator scriptVariableGenerator() throws IOException { + Map variables = new HashMap<>(); + CollectionUtils.mergePropertiesIntoMap(properties.getVariables(), variables); + if (properties.getVariablesLocation() != null) { + CollectionUtils.mergePropertiesIntoMap( + PropertiesLoaderUtils.loadProperties(properties.getVariablesLocation()), variables); + } + return new DefaultScriptVariableGenerator(variables); + } + + @Bean + @ConditionalOnProperty("router.script") + public GroovyScriptExecutingMessageProcessor scriptProcessor(ScriptVariableGenerator scriptVariableGenerator, + RouterSinkProperties properties) { + ScriptSource scriptSource = new RefreshableResourceScriptSource(properties.getScript(), + properties.getRefreshDelay()); + return new GroovyScriptExecutingMessageProcessor(scriptSource, scriptVariableGenerator); + } + +} diff --git a/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkProperties.java b/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkProperties.java new file mode 100644 index 00000000..a960330e --- /dev/null +++ b/applications/sink/router-sink/src/main/java/org/springframework/cloud/stream/app/router/sink/RouterSinkProperties.java @@ -0,0 +1,159 @@ +/* + * Copyright 2016-2020 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 + * + * https://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.cloud.stream.app.router.sink; + +import java.util.Properties; +import java.util.function.Function; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.NotNull; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.expression.Expression; +import org.springframework.integration.expression.FunctionExpression; +import org.springframework.messaging.Message; + +/** + * Properties for the Router Sink; the router can use an expression + * or groovy script to return either a channel name, or a key to + * the channel mappings map. + * + * @author Gary Russell + */ +@ConfigurationProperties("router") +public class RouterSinkProperties { + + /** + * Default SpEL expression. + */ + public static final Expression DEFAULT_EXPRESSION = new FunctionExpression<>((Function, Object>) message -> message.getHeaders().get("routeTo")); + + /** + * Variable bindings as a new line delimited string of name-value pairs, e.g. 'foo=bar\n baz=car'. + */ + private Properties variables; + + /** + * The location of a properties file containing custom script variable bindings. + */ + private Resource variablesLocation; + + /** + * The expression to be applied to the message to determine the channel(s) to route to. + * Note that the payload wire format for content types such as text, json or xml is byte[] not String!. + * Consult the documentation for how to handle byte array payload content. + */ + private Expression expression = DEFAULT_EXPRESSION; + + /** + * The location of a groovy script that returns channels or channel mapping + * resolution keys. + */ + private Resource script; + + /** + * How often to check for script changes in ms (if present); < 0 means don't refresh. + */ + private int refreshDelay = 60000; + + /** + * Where to send un-routable messages. + */ + private String defaultOutputChannel = "nullChannel"; + + /** + * Whether or not channel resolution is required. + */ + private boolean resolutionRequired = false; + + /** + * Destination mappings as a new line delimited string of name-value pairs, e.g. 'foo=bar\n baz=car'. + */ + private Properties destinationMappings; + + public Properties getVariables() { + return variables; + } + + public void setVariables(Properties variables) { + this.variables = variables; + } + + public Resource getVariablesLocation() { + return variablesLocation; + } + + public void setVariablesLocation(Resource variablesLocation) { + this.variablesLocation = variablesLocation; + } + + public Expression getExpression() { + return this.expression; + } + + public void setExpression(Expression expression) { + this.expression = expression; + } + + public Resource getScript() { + return script; + } + + public void setScript(Resource script) { + this.script = script; + } + + @NotNull + public String getDefaultOutputChannel() { + return this.defaultOutputChannel; + } + + public void setDefaultOutputChannel(String defaultOutputChannel) { + this.defaultOutputChannel = defaultOutputChannel; + } + + public int getRefreshDelay() { + return refreshDelay; + } + + public void setRefreshDelay(int refreshDelay) { + this.refreshDelay = refreshDelay; + } + + public boolean isResolutionRequired() { + return this.resolutionRequired; + } + + public void setResolutionRequired(boolean resolutionRequired) { + this.resolutionRequired = resolutionRequired; + } + + public Properties getDestinationMappings() { + return destinationMappings; + } + + public void setDestinationMappings(Properties destinationMappings) { + this.destinationMappings = destinationMappings; + } + + @AssertTrue(message = "'expression' and 'script' are mutually exclusive") + public boolean isExpressionOrScriptValid() { + return this.script == null || this.expression == DEFAULT_EXPRESSION; + } + +} diff --git a/applications/sink/router-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/sink/router-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..ab4592d7 --- /dev/null +++ b/applications/sink/router-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1 @@ +configuration-properties.classes=org.springframework.cloud.fn.consumer.log.LogConsumerProperties diff --git a/applications/sink/router-sink/src/test/java/org/springframework/cloud/stream/app/router/sink/RouterSinkIntegrationTests.java b/applications/sink/router-sink/src/test/java/org/springframework/cloud/stream/app/router/sink/RouterSinkIntegrationTests.java new file mode 100644 index 00000000..39998e74 --- /dev/null +++ b/applications/sink/router-sink/src/test/java/org/springframework/cloud/stream/app/router/sink/RouterSinkIntegrationTests.java @@ -0,0 +1,198 @@ +/* + * Copyright 2016-2020 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 + * + * https://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.cloud.stream.app.router.sink; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RouterSinkIntegrationTests { + + @Test + public void testDefaultRouter() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.resolutionRequired = true")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("hello").setHeader("routeTo", "baz").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + Message sourceMessage = processorOutput.receive(10000); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("hello"); + } + } + + @Test + public void testDefaultRouterWithByteArrayPayload() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.resolutionRequired = true")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("hello".getBytes()).setHeader("routeTo", "qux").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + Message sourceMessage = processorOutput.receive(10000); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("hello"); + } + } + + @Test + public void testRouterWithExpression() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.expression=headers['route']", + "--router.resolutionRequired=true")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("foo") + .setHeader("route", "foo").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + Message sourceMessage = processorOutput.receive(10000); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("foo"); + } + } + + @Test + public void testRouterWithChannelMappings() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.expression=headers['route']", + "--router.destinationMappings=foo=baz \n bar=qux", + "--router.resolutionRequired=true")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("foo") + .setHeader("route", "foo").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + assertThat(context.getBean("baz")).isNotNull(); + + Message sourceMessage = processorOutput.receive(10000, "baz.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("foo"); + + message = MessageBuilder.withPayload("bar") + .setHeader("route", "bar").build(); + processorInput.send(message); + + processorOutput = context.getBean(OutputDestination.class); + assertThat(context.getBean("qux")).isNotNull(); + + sourceMessage = processorOutput.receive(10000, "qux.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("bar"); + } + } + + @Test + public void testWithDiscardedChannels() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.expression=headers['route']", + "--router.defaultOutputChannel=discards", + "--spring.cloud.stream.dynamicDestinations=foo,bar,discards")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("foo") + .setHeader("route", "foo").build(); + processorInput.send(message); + + message = MessageBuilder.withPayload("bar") + .setHeader("route", "bar").build(); + processorInput.send(message); + + message = MessageBuilder.withPayload("hello") + .setHeader("route", "baz").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + Message sourceMessage = processorOutput.receive(10000, "foo.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("foo"); + + sourceMessage = processorOutput.receive(10000, "bar.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("bar"); + + sourceMessage = processorOutput.receive(10000, "discards.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("hello"); + } + } + + @Test + public void testWithGroovyScript() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(RouterSinkTestApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.function.definition=routerSinkConsumer", + "--router.script=classpath:/routertest.groovy", + "--router.variables=foo=baz", + "--router.variablesLocation=classpath:/routertest.properties")) { + + InputDestination processorInput = context.getBean(InputDestination.class); + + Message message = MessageBuilder.withPayload("foo") + .setHeader("route", "foo").build(); + processorInput.send(message); + + OutputDestination processorOutput = context.getBean(OutputDestination.class); + Message sourceMessage = processorOutput.receive(10000, "baz.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("foo"); + + + message = MessageBuilder.withPayload("bar") + .setHeader("route", "bar").build(); + processorInput.send(message); + + sourceMessage = processorOutput.receive(10000, "qux.destination"); + assertThat(new String(sourceMessage.getPayload())).isEqualTo("bar"); + } + } + + @SpringBootApplication + public static class RouterSinkTestApplication { + + } +} diff --git a/applications/sink/router-sink/src/test/resources/routertest.groovy b/applications/sink/router-sink/src/test/resources/routertest.groovy new file mode 100644 index 00000000..9db74b8a --- /dev/null +++ b/applications/sink/router-sink/src/test/resources/routertest.groovy @@ -0,0 +1,5 @@ +if (headers.route.equals('foo')) { + return "$foo" // mapped to baz in 'variables' +} else { + return "$bar" // mapped to qux in properties file +} diff --git a/applications/sink/router-sink/src/test/resources/routertest.properties b/applications/sink/router-sink/src/test/resources/routertest.properties new file mode 100644 index 00000000..1f2b6888 --- /dev/null +++ b/applications/sink/router-sink/src/test/resources/routertest.properties @@ -0,0 +1 @@ +bar=qux