diff --git a/applications/processor/aggregator-processor/README.adoc b/applications/processor/aggregator-processor/README.adoc index 0507a27a..298793c2 100644 --- a/applications/processor/aggregator-processor/README.adoc +++ b/applications/processor/aggregator-processor/README.adoc @@ -9,6 +9,10 @@ Change kafka to rabbit if you want to run it against RabbitMQ. === Payload +If an input payload is a `byte[]` and content-type header is a JSON, then `JsonBytesToMap` function tries to deserialize this payload to a `Map` for better data representation on the output of the aggregator function. +Also, such a `Map` data representation makes it easy to access to the payload content from SpEL expressions mentioned below. +Otherwise(including a deserialization error), the input payload is left as is - and it is the target application configuration to convert it into a desired form. + == Options //tag::configuration-properties[] diff --git a/applications/processor/aggregator-processor/pom.xml b/applications/processor/aggregator-processor/pom.xml index ec55aa3b..c92a7ad4 100644 --- a/applications/processor/aggregator-processor/pom.xml +++ b/applications/processor/aggregator-processor/pom.xml @@ -32,7 +32,7 @@ processor ${project.version} org.springframework.cloud.fn.aggregator.AggregatorFunctionConfiguration.class - aggregatorFunction + jsonBytesToMap|aggregatorFunction diff --git a/applications/processor/aggregator-processor/src/test/java/org/springframework/cloud/fn/aggregator/AggregatorProcessorTests.java b/applications/processor/aggregator-processor/src/test/java/org/springframework/cloud/fn/aggregator/AggregatorProcessorTests.java index 1589f1c7..5a6475c6 100644 --- a/applications/processor/aggregator-processor/src/test/java/org/springframework/cloud/fn/aggregator/AggregatorProcessorTests.java +++ b/applications/processor/aggregator-processor/src/test/java/org/springframework/cloud/fn/aggregator/AggregatorProcessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2023 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,6 +16,11 @@ package org.springframework.cloud.fn.aggregator; +import java.io.IOException; +import java.util.List; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.boot.WebApplicationType; @@ -34,41 +39,58 @@ import static org.assertj.core.api.Assertions.assertThat; public class AggregatorProcessorTests { @Test - public void testWithJdbcMessageStore() { + public void testWithJdbcMessageStore() throws IOException { try (ConfigurableApplicationContext context = new SpringApplicationBuilder( TestChannelBinderConfiguration.getCompleteConfiguration(AggregatorProcessorTestApplication.class)) .web(WebApplicationType.NONE) - .run("--spring.cloud.function.definition=aggregatorFunction", - "--aggregator.message-store-type=jdbc")) { + .run("--spring.cloud.function.definition=jsonBytesToMap|aggregatorFunction", + "--aggregator.message-store-type=jdbc", + "--aggregator.release=size()==2 or one.payload instanceof T(java.util.Map)")) { InputDestination processorInput = context.getBean(InputDestination.class); OutputDestination processorOutput = context.getBean(OutputDestination.class); - processorInput.send( - MessageBuilder.withPayload("2") - .setHeader(IntegrationMessageHeaderAccessor.CORRELATION_ID, "my_correlation") - .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_NUMBER, 2) - .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_SIZE, 2) - .build()); - processorInput.send( - MessageBuilder.withPayload("1") - .setHeader(IntegrationMessageHeaderAccessor.CORRELATION_ID, "my_correlation") - .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_NUMBER, 1) - .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_SIZE, 2) - .build()); + processorInput.send(createMessage("2", 2, 2)); - Message receive = processorOutput.receive(10_000, "aggregatorFunction-out-0"); + processorInput.send(createMessage("1", 1, 2)); - assertThat(receive).isNotNull() + Message receive = processorOutput.receive(10_000, "jsonBytesToMapaggregatorFunction-out-0"); + + assertThat(receive) .extracting(Message::getPayload) .extracting(String::new) .isEqualTo("[\"2\",\"1\"]"); + + ObjectMapper objectMapper = context.getBean(ObjectMapper.class); + + Person person = new Person("First1 Last1", "St. #1"); + processorInput.send(createMessage(objectMapper.writeValueAsBytes(person), 2, 2)); + + receive = processorOutput.receive(10_000, "jsonBytesToMapaggregatorFunction-out-0"); + + assertThat(receive).isNotNull(); + List result = + objectMapper.readValue(receive.getPayload(), + objectMapper.constructType(new TypeReference>() { })); + + assertThat(result).containsOnly(person); } } + private static Message createMessage(Object payload, int sequenceNumber, int sequenceSize) { + return MessageBuilder.withPayload(payload) + .setHeader(IntegrationMessageHeaderAccessor.CORRELATION_ID, "my_correlation") + .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_NUMBER, sequenceNumber) + .setHeader(IntegrationMessageHeaderAccessor.SEQUENCE_SIZE, sequenceSize) + .build(); + } + @SpringBootApplication public static class AggregatorProcessorTestApplication { } + private record Person(String name, String address) { + } + } diff --git a/functions/function/aggregator-function/pom.xml b/functions/function/aggregator-function/pom.xml index ce11a214..57a24503 100644 --- a/functions/function/aggregator-function/pom.xml +++ b/functions/function/aggregator-function/pom.xml @@ -15,6 +15,11 @@ Spring Native Function for Aggregator + + org.springframework.cloud.fn + payload-converter-function + ${project.version} + org.springframework.integration spring-integration-core diff --git a/functions/function/payload-converter-function/src/main/java/functions/JsonBytesToMap.java b/functions/function/payload-converter-function/src/main/java/functions/JsonBytesToMap.java new file mode 100644 index 00000000..a1bdc9df --- /dev/null +++ b/functions/function/payload-converter-function/src/main/java/functions/JsonBytesToMap.java @@ -0,0 +1,80 @@ +/* + * Copyright 2023-2023 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 functions; + +import java.io.IOException; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.core.log.LogAccessor; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeTypeUtils; + +/** + * The {@link Function} to deserialize {@code byte[]} payload into a Map + * if {@link MessageHeaders#CONTENT_TYPE} header is JSON. + * Otherwise, the message is returned as is. + * + * @author Artem Bilan + * + * @since 4.0 + */ +public class JsonBytesToMap implements Function, Message> { + + private static final LogAccessor logger = new LogAccessor(JsonBytesToMap.class); + + private final ObjectMapper objectMapper; + + public JsonBytesToMap(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Message apply(Message message) { + if (message.getPayload() instanceof byte[] payload) { + MessageHeaders headers = message.getHeaders(); + String contentType = + headers.containsKey(MessageHeaders.CONTENT_TYPE) + ? headers.get(MessageHeaders.CONTENT_TYPE).toString() + : MimeTypeUtils.APPLICATION_JSON_VALUE; + + if (contentType.contains("json")) { + message = MessageBuilder.withPayload(payloadToMapIfCan(payload)) + .copyHeaders(message.getHeaders()) + .build(); + } + } + + return message; + } + + private Object payloadToMapIfCan(byte[] payload) { + try { + return this.objectMapper.readValue(payload, Map.class); + } + catch (IOException ex) { + logger.trace(ex, "Cannot deserialize to Map"); + // Was not able to construct the map from byte[] -- returning as is + return payload; + } + } + +}