From f4171cae161a9866472f2b1880ad05273ece7de5 Mon Sep 17 00:00:00 2001 From: Oleg Zhurakousky Date: Mon, 8 Nov 2021 15:26:11 +0100 Subject: [PATCH] GH-764 Add support for output header enrichemnt Resolves #764 --- .../main/asciidoc/spring-cloud-function.adoc | 42 +++++++++------ .../function/context/FunctionProperties.java | 48 +++++++++++++---- ...InputEnricher.java => HeaderEnricher.java} | 6 +-- .../catalog/SimpleFunctionRegistry.java | 33 ++++++++++-- ...pingTests.java => HeaderMappingTests.java} | 52 +++++++++++++++++-- 5 files changed, 146 insertions(+), 35 deletions(-) rename spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/{InputEnricher.java => HeaderEnricher.java} (93%) rename spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/{InputHeaderMappingTests.java => HeaderMappingTests.java} (77%) diff --git a/docs/src/main/asciidoc/spring-cloud-function.adoc b/docs/src/main/asciidoc/spring-cloud-function.adoc index e175e9a06..36bd0c7e4 100644 --- a/docs/src/main/asciidoc/spring-cloud-function.adoc +++ b/docs/src/main/asciidoc/spring-cloud-function.adoc @@ -258,10 +258,9 @@ due to the nature of the reactive functions which are invoked only once to pass is handled by the reactor, hence we can not access and/or rely on the routing instructions communicated via individual values (e.g., Message). -=== Input Enrichment +=== Input/Output Enrichment -There are often times when you need to modify or refine an incoming Message and to keep your code clean of non-functional concerns, and you don’t want to -do it inside of your business logic. +There are often times when you need to modify or refine an incoming or outgoing Message and to keep your code clean of non-functional concerns. You don’t want to do it inside of your business logic. You can always accomplish it via <>. Such approach provides several benefits: @@ -289,30 +288,39 @@ manually register it as a function before you can compose it with the business f But what if modifications (enrichments) you are trying to make are trivial as they are in the preceding example? Is there a simpler and more dynamic and configurable mechanism to accomplish the same? -Since version 3.1.3, the framework allows you to provide SpEL expression to enrich individual message headers. Let’s look at one of the tests as the example. +Since version 3.1.3, the framework allows you to provide SpEL expression to enrich individual message headers for both input going into function and +and output coming out of it. Let’s look at one of the tests as the example. [source, java] ---- @Test -public void testInputHeaderMappingPropertyWithoutIndex() throws Exception { +public void testMixedInputOutputHeaderMapping() throws Exception { try (ConfigurableApplicationContext context = new SpringApplicationBuilder( SampleFunctionConfiguration.class).web(WebApplicationType.NONE).run( - "--spring.cloud.function.configuration.echo.input-header-mapping-expression.key1='hello1'", - "--spring.cloud.function.configuration.echo.input-header-mapping-expression.key2='hello2'", - "--spring.cloud.function.configuration.echo.input-header-mapping-expression.foo=headers.contentType")) { + "--logging.level.org.springframework.cloud.function=DEBUG", + "--spring.main.lazy-initialization=true", + "--spring.cloud.function.configuration.split.output-header-mapping-expression.keyOut1='hello1'", + "--spring.cloud.function.configuration.split.output-header-mapping-expression.keyOut2=headers.contentType", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key1=headers.path.split('/')[0]", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key2=headers.path.split('/')[1]", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key3=headers.path")) { - FunctionCatalog functionCatalog = context.getBean(FunctionCatalog.class); - FunctionInvocationWrapper function = functionCatalog.lookup("echo"); - function.apply(MessageBuilder.withPayload("helo") - .setHeader(MessageHeaders.CONTENT_TYPE, "application/json").build()); - } + FunctionCatalog functionCatalog = context.getBean(FunctionCatalog.class); + FunctionInvocationWrapper function = functionCatalog.lookup("split"); + Message result = (Message) function.apply(MessageBuilder.withPayload("helo") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json") + .setHeader("path", "foo/bar/baz").build()); + assertThat(result.getHeaders().containsKey("keyOut1")).isTrue(); + assertThat(result.getHeaders().get("keyOut1")).isEqualTo("hello1"); + assertThat(result.getHeaders().containsKey("keyOut2")).isTrue(); + assertThat(result.getHeaders().get("keyOut2")).isEqualTo("application/json"); + } } ---- -Here you see a property called `input-header-mapping-expression` preceded by the name of the function (i.e., `echo`) and followed by the name of the -message header key you want to set and the value as SpEL expression. The first two expressions (for 'key1' and 'key2') are literal SpEL expressions enclosed in -single quotes, effectively setting 'key1' to value `hello1` and 'key2' to value `hello2`. The third one will map Message header ‘foo’ to the value of the -current ‘contentType’ header. +Here you see a properties called `input-header-mapping-expression` and `output-header-mapping-expression` preceded by the name of the function (i.e., `split`) and followed by the name of the message header key you want to set and the value as SpEL expression. The first expression (for 'keyOut1') is literal SpEL expressions enclosed in single quotes, effectively setting 'keyOut1' to value `hello1`. The `keyOut2` is set to the value of existing 'contentType' header. + +You can also observe some interesting features in the input header mapping where we actually splitting a value of the existing header 'path', setting individual values of key1 and key2 to the values of split elements based on the index. NOTE: if for whatever reason the provided expression evaluation fails, the execution of the function will proceed as if nothing ever happen. However you will see the WARN message in your logs informing you about it diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionProperties.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionProperties.java index 5ca33bd4e..12f49a1e8 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionProperties.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/FunctionProperties.java @@ -26,6 +26,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.EnvironmentAware; import org.springframework.core.env.Environment; +import org.springframework.util.CollectionUtils; /** * @@ -88,16 +89,34 @@ public class FunctionProperties implements EnvironmentAware, ApplicationContextA String propertyX = "spring.cloud.function.configuration." + entry.getKey() + ".input-header-mapping-expression."; String propertyY = "spring.cloud.function.configuration." + entry.getKey() + ".inputHeaderMappingExpression."; Map headerMapping = entry.getValue().getInputHeaderMappingExpression(); - for (Object k : headerMapping.keySet()) { - if (this.environment.containsProperty(propertyX + k) || this.environment.containsProperty(propertyY + k)) { - Map originalMapping = entry.getValue().getInputHeaderMappingExpression(); - Map current = entry.getValue().getInputHeaderMappingExpression(); - if (current.containsKey("0")) { - ((Map) current.get("0")).put(k, headerMapping.get(k)); + if (!CollectionUtils.isEmpty(headerMapping)) { + for (Object k : headerMapping.keySet()) { + if (this.environment.containsProperty(propertyX + k) || this.environment.containsProperty(propertyY + k)) { + Map current = entry.getValue().getInputHeaderMappingExpression(); + if (current.containsKey("0")) { + ((Map) current.get("0")).put(k, headerMapping.get(k)); + } + else { + entry.getValue().setInputHeaderMappingExpression(Collections.singletonMap("0", current)); + break; + } } - else { - entry.getValue().setInputHeaderMappingExpression(Collections.singletonMap("0", originalMapping)); - break; + } + } + propertyX = "spring.cloud.function.configuration." + entry.getKey() + ".output-header-mapping-expression."; + propertyY = "spring.cloud.function.configuration." + entry.getKey() + ".outputHeaderMappingExpression."; + headerMapping = entry.getValue().getOutputHeaderMappingExpression(); + if (!CollectionUtils.isEmpty(headerMapping)) { + for (Object k : headerMapping.keySet()) { + if (this.environment.containsProperty(propertyX + k) || this.environment.containsProperty(propertyY + k)) { + Map current = entry.getValue().getOutputHeaderMappingExpression(); + if (current.containsKey("0")) { + ((Map) current.get("0")).put(k, headerMapping.get(k)); + } + else { + entry.getValue().setOutputHeaderMappingExpression(Collections.singletonMap("0", current)); + break; + } } } } @@ -149,6 +168,8 @@ public class FunctionProperties implements EnvironmentAware, ApplicationContextA private Map inputHeaderMappingExpression; + private Map outputHeaderMappingExpression; + public Map getInputHeaderMappingExpression() { return inputHeaderMappingExpression; } @@ -157,5 +178,14 @@ public class FunctionProperties implements EnvironmentAware, ApplicationContextA this.inputHeaderMappingExpression = inputHeaderMappingExpression; } + public Map getOutputHeaderMappingExpression() { + return outputHeaderMappingExpression; + } + + public void setOutputHeaderMappingExpression( + Map outputHeaderMappingExpression) { + this.outputHeaderMappingExpression = outputHeaderMappingExpression; + } + } } diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InputEnricher.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/HeaderEnricher.java similarity index 93% rename from spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InputEnricher.java rename to spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/HeaderEnricher.java index 7121a24a9..ff8e1ebe7 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/InputEnricher.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/HeaderEnricher.java @@ -42,9 +42,9 @@ import org.springframework.util.Assert; * @since 3.1.3 * */ -class InputEnricher implements Function { +class HeaderEnricher implements Function { - protected Log logger = LogFactory.getLog(InputEnricher.class); + protected Log logger = LogFactory.getLog(HeaderEnricher.class); private final Map> headerExpressions; @@ -53,7 +53,7 @@ class InputEnricher implements Function { private final StandardEvaluationContext evalContext = new StandardEvaluationContext(); @SuppressWarnings({ "rawtypes", "unchecked" }) - InputEnricher(Map headerExpressions, @Nullable BeanResolver beanResolver) { + HeaderEnricher(Map headerExpressions, @Nullable BeanResolver beanResolver) { Assert.notEmpty(headerExpressions, "'headerExpressions' must not be null or empty"); this.headerExpressions = headerExpressions; this.evalContext.addPropertyAccessor(new MapAccessor()); diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java index 019d0c53a..863aa30f9 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/context/catalog/SimpleFunctionRegistry.java @@ -304,6 +304,7 @@ public class SimpleFunctionRegistry implements FunctionRegistry, FunctionInspect composedFunction = (FunctionInvocationWrapper) composedFunction.andThen((Function) andThenFunction); } composedFunction = this.enrichInputIfNecessary(composedFunction); + composedFunction = this.enrichOutputIfNecessary(composedFunction); if (composedFunction.isSingleton) { this.wrappedFunctionDefinitions.put(composedFunction.functionDefinition, composedFunction); } @@ -329,8 +330,32 @@ public class SimpleFunctionRegistry implements FunctionRegistry, FunctionInspect BeanFactoryResolver beanResolver = this.functionProperties.getApplicationContext() != null ? new BeanFactoryResolver(this.functionProperties.getApplicationContext()) : null; - InputEnricher enricher = new InputEnricher(configuration.getInputHeaderMappingExpression(), beanResolver); - FunctionInvocationWrapper w = new FunctionInvocationWrapper("headerEnricher", enricher, Message.class, Message.class); + HeaderEnricher enricher = new HeaderEnricher(configuration.getInputHeaderMappingExpression(), beanResolver); + FunctionInvocationWrapper w = new FunctionInvocationWrapper("inputHeaderEnricher", enricher, Message.class, Message.class); + composedFunction = (FunctionInvocationWrapper) w.andThen((Function) composedFunction); + composedFunction.functionDefinition = functionDefinition; + } + } + } + return composedFunction; + } + + private FunctionInvocationWrapper enrichOutputIfNecessary(FunctionInvocationWrapper composedFunction) { + if (this.functionProperties == null) { + return composedFunction; + } + String functionDefinition = composedFunction.getFunctionDefinition(); + Map configurationProperties = this.functionProperties.getConfiguration(); + if (!CollectionUtils.isEmpty(configurationProperties)) { + FunctionConfigurationProperties configuration = configurationProperties + .get(functionDefinition.replace("|", "").replace(",", "")); + if (configuration != null) { + if (!CollectionUtils.isEmpty(configuration.getOutputHeaderMappingExpression())) { + BeanFactoryResolver beanResolver = this.functionProperties.getApplicationContext() != null + ? new BeanFactoryResolver(this.functionProperties.getApplicationContext()) + : null; + HeaderEnricher enricher = new HeaderEnricher(configuration.getOutputHeaderMappingExpression(), beanResolver); + FunctionInvocationWrapper w = new FunctionInvocationWrapper("outputHeaderEnricher", enricher, Message.class, Message.class); composedFunction = (FunctionInvocationWrapper) w.andThen((Function) composedFunction); composedFunction.functionDefinition = functionDefinition; } @@ -1050,7 +1075,9 @@ public class SimpleFunctionRegistry implements FunctionRegistry, FunctionInspect if (this.isWrapConvertedInputInMessage(convertedInput)) { convertedInput = MessageBuilder.withPayload(convertedInput).build(); } - Assert.notNull(convertedInput, () -> "Failed to convert input: " + input + " to " + type); + + Object finalInput = input; + Assert.notNull(convertedInput, () -> "Failed to convert input: " + finalInput + " to " + type); return convertedInput; } diff --git a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/InputHeaderMappingTests.java b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/HeaderMappingTests.java similarity index 77% rename from spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/InputHeaderMappingTests.java rename to spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/HeaderMappingTests.java index 0467f16e7..16eaef369 100644 --- a/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/InputHeaderMappingTests.java +++ b/spring-cloud-function-context/src/test/java/org/springframework/cloud/function/context/HeaderMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2021 the original author or authors. + * Copyright 2021-2021 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,8 +33,8 @@ import org.springframework.messaging.support.MessageBuilder; import static org.assertj.core.api.Assertions.assertThat; -//NOTE!!! assertions for all tests are in 'echo' function since we're validating what's coming into it. -public class InputHeaderMappingTests { +//NOTE!!! assertions for input in all tests are in 'echo' function since we're validating what's coming into it. +public class HeaderMappingTests { @Test public void testErrorWarnAndContinue() throws Exception { @@ -173,6 +173,52 @@ public class InputHeaderMappingTests { } } + @SuppressWarnings("unchecked") + @Test + public void testOutputHeaderMapping() throws Exception { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + SampleFunctionConfiguration.class).web(WebApplicationType.NONE).run( + "--logging.level.org.springframework.cloud.function=DEBUG", + "--spring.main.lazy-initialization=true", + "--spring.cloud.function.configuration.foo.output-header-mapping-expression.key1='hello1'", + "--spring.cloud.function.configuration.foo.output-header-mapping-expression.key2=headers.contentType")) { + + FunctionCatalog functionCatalog = context.getBean(FunctionCatalog.class); + FunctionInvocationWrapper function = functionCatalog.lookup("foo"); + Message result = (Message) function.apply(MessageBuilder.withPayload("helo") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json").build()); + assertThat(result.getHeaders().containsKey("key1")).isTrue(); + assertThat(result.getHeaders().get("key1")).isEqualTo("hello1"); + assertThat(result.getHeaders().containsKey("key2")).isTrue(); + assertThat(result.getHeaders().get("key2")).isEqualTo("application/json"); + } + } + + @SuppressWarnings("unchecked") + @Test + public void testMixedInputOutputHeaderMapping() throws Exception { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + SampleFunctionConfiguration.class).web(WebApplicationType.NONE).run( + "--logging.level.org.springframework.cloud.function=DEBUG", + "--spring.main.lazy-initialization=true", + "--spring.cloud.function.configuration.split.output-header-mapping-expression.keyOut1='hello1'", + "--spring.cloud.function.configuration.split.output-header-mapping-expression.keyOut2=headers.contentType", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key1=headers.path.split('/')[0]", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key2=headers.path.split('/')[1]", + "--spring.cloud.function.configuration.split.input-header-mapping-expression.key3=headers.path")) { + + FunctionCatalog functionCatalog = context.getBean(FunctionCatalog.class); + FunctionInvocationWrapper function = functionCatalog.lookup("split"); + Message result = (Message) function.apply(MessageBuilder.withPayload("helo") + .setHeader(MessageHeaders.CONTENT_TYPE, "application/json") + .setHeader("path", "foo/bar/baz").build()); + assertThat(result.getHeaders().containsKey("keyOut1")).isTrue(); + assertThat(result.getHeaders().get("keyOut1")).isEqualTo("hello1"); + assertThat(result.getHeaders().containsKey("keyOut2")).isTrue(); + assertThat(result.getHeaders().get("keyOut2")).isEqualTo("application/json"); + } + } + @EnableAutoConfiguration @Configuration protected static class SampleFunctionConfiguration {