diff --git a/build.gradle b/build.gradle index e41e6c06b7..dd03bcbf8f 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ ext { avroVersion = '1.12.0' awaitilityVersion = '4.3.0' camelVersion = '4.14.1' + cloudEventsVersion = '4.0.1' commonsDbcp2Version = '2.13.0' commonsIoVersion = '2.20.0' commonsNetVersion = '3.12.0' @@ -474,6 +475,23 @@ project('spring-integration-cassandra') { } } +project('spring-integration-cloudevents') { + description = 'Spring Integration CloudEvents Support' + + dependencies { + api "io.cloudevents:cloudevents-core:$cloudEventsVersion" + optionalApi "io.cloudevents:cloudevents-spring:$cloudEventsVersion" + optionalApi "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" + + optionalApi("io.cloudevents:cloudevents-avro-compact:$cloudEventsVersion") { + exclude group: 'org.apache.avro', module: 'avro' + } + optionalApi "org.apache.avro:avro:$avroVersion" + optionalApi "io.cloudevents:cloudevents-xml:$cloudEventsVersion" + testImplementation 'tools.jackson.core:jackson-databind' + } +} + project('spring-integration-core') { description = 'Spring Integration Core' diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java new file mode 100644 index 0000000000..21aef6953c --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java @@ -0,0 +1,20 @@ + +/* + * Copyright 2025-present 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. + * + */ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java new file mode 100644 index 0000000000..28a9f1a227 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java @@ -0,0 +1,288 @@ +/* + * Copyright 2025-present 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.integration.cloudevents.transformer; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; + +import io.cloudevents.CloudEvent; +import io.cloudevents.CloudEventExtension; +import io.cloudevents.CloudEventExtensions; +import io.cloudevents.core.builder.CloudEventBuilder; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.provider.EventFormatProvider; +import org.jspecify.annotations.Nullable; + +import org.springframework.context.ApplicationContext; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.integration.expression.ExpressionUtils; +import org.springframework.integration.expression.FunctionExpression; +import org.springframework.integration.transformer.AbstractTransformer; +import org.springframework.integration.transformer.MessageTransformationException; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.StringUtils; + +/** + * A Spring Integration transformer that converts messages to CloudEvent format. + * Attribute and extension mapping is performed based on {@link Expression}s. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +public class ToCloudEventTransformer extends AbstractTransformer { + + private Expression idExpression = new FunctionExpression>( + msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); + + @SuppressWarnings("NullAway.Init") + private Expression sourceExpression; + + private Expression typeExpression = new LiteralExpression("spring.message"); + + @SuppressWarnings("NullAway.Init") + private Expression dataSchemaExpression; + + private Expression subjectExpression = new FunctionExpression<>((Function, @Nullable String>) + message -> null); + + private final Expression @Nullable [] cloudEventExtensionExpressions; + + @SuppressWarnings("NullAway.Init") + private EvaluationContext evaluationContext; + + private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); + + /** + * Construct a ToCloudEventTransformer. + * + * @param cloudEventExtensionExpressions an array of {@link Expression}s for establishing CloudEvent extensions + */ + public ToCloudEventTransformer(Expression @Nullable ... cloudEventExtensionExpressions) { + this.cloudEventExtensionExpressions = cloudEventExtensionExpressions; + } + + /** + * Construct a ToCloudEventTransformer with no {@link Expression}s for extensions. + * + */ + public ToCloudEventTransformer() { + this((Expression[]) null); + } + + /** + * Set the {@link Expression} for creating CloudEvent ids. + * Default expression extracts the id from the {@link MessageHeaders} of the message. + * + * @param idExpression the expression used to create the id for each CloudEvent + */ + public void setIdExpression(Expression idExpression) { + this.idExpression = idExpression; + } + + /** + * Set the {@link Expression} for creating CloudEvent source. + * Default expression is {@code "/spring/" + appName + "." + getBeanName())}. + * + * @param sourceExpression the expression used to create the source for each CloudEvent + */ + public void setSourceExpression(Expression sourceExpression) { + this.sourceExpression = sourceExpression; + } + + /** + * Set the {@link Expression} for extracting the type for the CloudEvent. + * Default expression sets the default to "spring.message". + * + * @param typeExpression the expression used to create the type for each CloudEvent + */ + public void setTypeExpression(Expression typeExpression) { + this.typeExpression = typeExpression; + } + + /** + * Set the {@link Expression} for creating the dataSchema for the CloudEvent. + * Default {@link Expression} evaluates to a null. + * + * @param dataSchemaExpression the expression used to create the dataSchema for each CloudEvent + */ + public void setDataSchemaExpression(Expression dataSchemaExpression) { + this.dataSchemaExpression = dataSchemaExpression; + } + + /** + * Set the {@link Expression} for creating the subject for the CloudEvent. + * Default {@link Expression} evaluates to a null. + * + * @param subjectExpression the expression used to create the subject for each CloudEvent + */ + public void setSubjectExpression(Expression subjectExpression) { + this.subjectExpression = subjectExpression; + } + + @Override + protected void onInit() { + super.onInit(); + this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + ApplicationContext applicationContext = getApplicationContext(); + if (this.sourceExpression == null) { // in the case the user sets the value prior to onInit. + this.sourceExpression = new FunctionExpression<>((Function, URI>) message -> { + String appName = applicationContext.getEnvironment().getProperty("spring.application.name"); + appName = appName == null ? "unknown" : appName; + return URI.create("/spring/" + appName + "." + getBeanName()); + }); + } + if (this.dataSchemaExpression == null) { // in the case the user sets the value prior to onInit. + this.dataSchemaExpression = new FunctionExpression<>((Function, @Nullable URI>) + message -> null); + } + } + + /** + * Transform the input message into a CloudEvent message. + * + * @param message the input Spring Integration message to transform + * @return CloudEvent message in the specified format + * @throws RuntimeException if serialization fails + */ + @SuppressWarnings("unchecked") + @Override + protected Object doTransform(Message message) { + + String id = this.idExpression.getValue(this.evaluationContext, message, String.class); + if (!StringUtils.hasText(id)) { + throw new MessageTransformationException(message, "No id was found with the specified expression"); + } + + URI source = this.sourceExpression.getValue(this.evaluationContext, message, URI.class); + if (source == null) { + throw new MessageTransformationException(message, "No source was found with the specified expression"); + } + + String type = this.typeExpression.getValue(this.evaluationContext, message, String.class); + if (type == null) { + throw new MessageTransformationException(message, "No type was found with the specified expression"); + } + + String contentType = message.getHeaders().get(MessageHeaders.CONTENT_TYPE, String.class); + if (contentType == null) { + throw new MessageTransformationException(message, "Missing 'Content-Type' header"); + } + + EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); + if (eventFormat == null) { + throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); + } + + ToCloudEventTransformerExtensions extensions = + new ToCloudEventTransformerExtensions(this.evaluationContext, (Message) message, + this.cloudEventExtensionExpressions); + + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId(id) + .withSource(source) + .withType(type) + .withTime(OffsetDateTime.now()) + .withDataContentType(contentType) + .withDataSchema(this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)) + .withSubject(this.subjectExpression.getValue(this.evaluationContext, message, String.class)) + .withData(getPayload(message)) + .withExtension(extensions) + .build(); + + return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) + .copyHeaders(message.getHeaders()) + .build(); + } + + @Override + public String getComponentType() { + return "ce:to-cloudevents-transformer"; + } + + private byte[] getPayload(Message message) { + if (message.getPayload() instanceof byte[] messagePayload) { + return messagePayload; + } + throw new MessageTransformationException("Message payload is not a byte array"); + } + + private static class ToCloudEventTransformerExtensions implements CloudEventExtension { + + /** + * Map storing the CloudEvent extensions extracted from message headers. + */ + private final Map cloudEventExtensions; + + /** + * Construct CloudEvent extensions by processing a message using expressions. + * + * @param message the Spring Integration message + * @param expressions an array of {@link Expression}s where each accepts a message and returns a + * {@code Map} of extensions + */ + @SuppressWarnings("unchecked") + ToCloudEventTransformerExtensions(EvaluationContext evaluationContext, Message message, + Expression @Nullable ... expressions) { + this.cloudEventExtensions = new HashMap<>(); + if (expressions == null) { + return; + } + for (Expression expression : expressions) { + Map result = (Map) expression.getValue(evaluationContext, message, + Map.class); + if (result == null) { + continue; + } + for (String key : result.keySet()) { + this.cloudEventExtensions.put(key, result.get(key)); + } + } + } + + @Override + public void readFrom(CloudEventExtensions extensions) { + extensions.getExtensionNames() + .forEach(key -> { + Object value = extensions.getExtension(key); + if (value != null) { + this.cloudEventExtensions.put(key, value); + } + }); + } + + @Override + public @Nullable Object getValue(String key) throws IllegalArgumentException { + return this.cloudEventExtensions.get(key); + } + + @Override + public Set getKeys() { + return this.cloudEventExtensions.keySet(); + } + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/package-info.java new file mode 100644 index 0000000000..02b690ceec --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/package-info.java @@ -0,0 +1,3 @@ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents.transformer; diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java new file mode 100644 index 0000000000..13d617dc54 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java @@ -0,0 +1,268 @@ +/* + * Copyright 2025-present 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.integration.cloudevents.transformer; + +import java.io.ByteArrayOutputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; + +import io.cloudevents.CloudEvent; +import io.cloudevents.CloudEventData; +import io.cloudevents.avro.compact.AvroCompactFormat; +import io.cloudevents.core.format.EventDeserializationException; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.format.EventSerializationException; +import io.cloudevents.jackson.JsonFormat; +import io.cloudevents.rw.CloudEventDataMapper; +import io.cloudevents.xml.XMLFormat; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.config.EnableIntegration; +import org.springframework.integration.transformer.MessageTransformationException; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringJUnitConfig +class ToCloudEventTransformerTests { + + private static final String TRACE_HEADER = "{'trace-id' : 'trace-123'}"; + + private static final String SPAN_HEADER = "{'span-id' : 'span-456'}"; + + private static final String USER_HEADER = "{'user-id' : 'user-789'}"; + + private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); + + @Autowired + private ToCloudEventTransformer transformerWithNoExtensions; + + @Autowired + private ToCloudEventTransformer transformerWithExtensions; + + @Autowired + private ToCloudEventTransformer transformerWithInvalidIDExpression; + + private final JsonFormat jsonFormat = new JsonFormat(); + + private final AvroCompactFormat avroFormat = new AvroCompactFormat(); + + private final XMLFormat xmlFormat = new XMLFormat(); + + @Test + @SuppressWarnings("NullAway") + void doJsonTransformWithPayloadBasedOnContentType() { + CloudEvent cloudEvent = getTransformerNoExtensions(PAYLOAD, jsonFormat); + assertThat(cloudEvent.getData().toBytes()).isEqualTo(PAYLOAD); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/unknown.transformerWithNoExtensions"); + assertThat(cloudEvent.getDataSchema()).isNull(); + assertThat(cloudEvent.getDataContentType()).isEqualTo(JsonFormat.CONTENT_TYPE); + } + + @Test + @SuppressWarnings("NullAway") + void doXMLTransformWithPayloadBasedOnContentType() { + String xmlPayload = ("" + + "testmessage"); + CloudEvent cloudEvent = getTransformerNoExtensions(xmlPayload.getBytes(), xmlFormat); + assertThat(cloudEvent.getData().toBytes()).isEqualTo(xmlPayload.getBytes()); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/unknown.transformerWithNoExtensions"); + assertThat(cloudEvent.getDataSchema()).isNull(); + assertThat(cloudEvent.getDataContentType()).isEqualTo(XMLFormat.XML_CONTENT_TYPE); + } + + @Test + @SuppressWarnings("NullAway") + void doAvroTransformWithPayloadBasedOnContentType() { + CloudEvent cloudEvent = getTransformerNoExtensions(PAYLOAD, avroFormat); + assertThat(cloudEvent.getData().toBytes()).isEqualTo(PAYLOAD); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/unknown.transformerWithNoExtensions"); + assertThat(cloudEvent.getDataSchema()).isNull(); + assertThat(cloudEvent.getDataContentType()).isEqualTo(AvroCompactFormat.AVRO_COMPACT_CONTENT_TYPE); + } + + @Test + void unregisteredFormatType() { + EventFormat testFormat = new EventFormat() { + + @Override + public byte[] serialize(CloudEvent event) throws EventSerializationException { + return new byte[0]; + } + + @Override + public CloudEvent deserialize(byte[] bytes, CloudEventDataMapper mapper) throws EventDeserializationException { + return Mockito.mock(CloudEvent.class); + } + + @Override + public String serializedContentType() { + return "application/cloudevents+invalid"; + } + }; + assertThatThrownBy(() -> getTransformerNoExtensions(PAYLOAD, testFormat)) + .hasMessage("No EventFormat found for 'application/cloudevents+invalid'"); + } + + @Test + @SuppressWarnings("unchecked") + void doTransformWithObjectPayload() throws Exception { + TestRecord testRecord = new TestRecord("sample data"); + byte[] payload = convertPayloadToBytes(testRecord); + Message message = MessageBuilder.withPayload(payload).setHeader("test_id", "test-id") + .setHeader("contentType", JsonFormat.CONTENT_TYPE) + .build(); + Object result = this.transformerWithNoExtensions.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(new String(resultMessage.getPayload())).endsWith(new String(payload) + "}"); + } + + @Test + @SuppressWarnings("NullAway") + void emptyExtensionNames() { + Message message = createBaseMessage(PAYLOAD, "application/cloudevents+json").build(); + + Object result = this.transformerWithNoExtensions.doTransform(message); + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + } + + @Test + void noContentType() { + Message message = MessageBuilder.withPayload(PAYLOAD).build(); + assertThatThrownBy(() -> this.transformerWithNoExtensions.transform(message)) + .isInstanceOf(MessageTransformationException.class) + .hasMessageContaining("Missing 'Content-Type' header"); + } + + @Test + @SuppressWarnings("unchecked") + void multipleExtensionMappings() { + String payload = "test message"; + Message message = createBaseMessage(payload.getBytes(), "application/cloudevents+json") + .setHeader("correlation-id", "corr-999") + .build(); + + Object result = this.transformerWithExtensions.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + assertThat(resultMessage.getHeaders()).containsKeys("correlation-id"); + assertThat(resultMessage.getHeaders().get("correlation-id")).isEqualTo("corr-999"); + assertThat(new String(resultMessage.getPayload())).contains("\"trace-id\":\"trace-123\""); + assertThat(new String(resultMessage.getPayload())).contains("\"span-id\":\"span-456\""); + assertThat(new String(resultMessage.getPayload())).contains("\"user-id\":\"user-789\""); + } + + @Test + void emptyStringPayloadHandling() { + Message message = createBaseMessage("".getBytes(), "application/cloudevents+json").build(); + Object result = this.transformerWithNoExtensions.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + } + + @Test + void failWhenNoIdHeaderAndNoDefault() { + Message message = MessageBuilder.withPayload(PAYLOAD) + .setHeader("contentType", JsonFormat.CONTENT_TYPE) + .build(); + + assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)).isInstanceOf(MessageTransformationException.class) + .hasMessageContaining("No id was found with the specified expression"); + } + + private CloudEvent getTransformerNoExtensions(byte[] payload, EventFormat eventFormat) { + Message message = createBaseMessage(payload, eventFormat.serializedContentType()) + .setHeader("custom-header", "test-value") + .setHeader("other-header", "other-value") + .build(); + Message result = transformMessage(message, this.transformerWithNoExtensions); + return eventFormat.deserialize(result.getPayload()); + } + + @SuppressWarnings("unchecked") + private Message transformMessage(Message message, ToCloudEventTransformer transformer) { + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + return (Message) result; + } + + private byte[] convertPayloadToBytes(TestRecord testRecord) throws Exception { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream); + out.writeObject(testRecord); + out.flush(); + return byteArrayOutputStream.toByteArray(); + } + + private MessageBuilder createBaseMessage(byte[] payload, String contentType) { + return MessageBuilder.withPayload(payload) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType); + } + + @Configuration + @EnableIntegration + public static class ContextConfiguration { + + @Bean + public ToCloudEventTransformer transformerWithNoExtensions() { + return new ToCloudEventTransformer((Expression[]) null); + } + + @Bean + public ToCloudEventTransformer transformerWithExtensions() { + ExpressionParser parser = new SpelExpressionParser(); + Expression[] expressions = {parser.parseExpression(TRACE_HEADER), + parser.parseExpression(SPAN_HEADER), + parser.parseExpression(USER_HEADER)}; + return new ToCloudEventTransformer(expressions); + } + + @Bean + public ToCloudEventTransformer transformerWithInvalidIDExpression() { + ExpressionParser parser = new SpelExpressionParser(); + ToCloudEventTransformer transformer = new ToCloudEventTransformer((Expression[]) null); + transformer.setIdExpression(parser.parseExpression("null")); + return transformer; + } + } + + private record TestRecord(String sampleValue) implements Serializable { } +} diff --git a/src/reference/antora/modules/ROOT/nav.adoc b/src/reference/antora/modules/ROOT/nav.adoc index 59f4379d03..4005617fc1 100644 --- a/src/reference/antora/modules/ROOT/nav.adoc +++ b/src/reference/antora/modules/ROOT/nav.adoc @@ -124,6 +124,7 @@ ** xref:amqp/amqp-1.0.adoc[] * xref:camel.adoc[] * xref:cassandra.adoc[] +* xref:cloudevents-transform.adoc[] * xref:debezium.adoc[] * xref:event.adoc[] * xref:feed.adoc[] diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc new file mode 100644 index 0000000000..b580761e09 --- /dev/null +++ b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc @@ -0,0 +1,133 @@ +[[cloudevents-transformer]] + += CloudEvent Transformer + +[[introduction]] +== Introduction + +The CloudEvent transformer converts Spring Integration messages into CloudEvent compliant messages. +This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes and extensions using `Expression`s. + +[[cloudevent-transformer-overview]] +=== Overview + +The CloudEvent transformer (`ToCloudEventTransformer`) extends Spring Integration's `AbstractTransformer` to convert messages to CloudEvent format. +The CloudEvent transformer identifies the `EventFormat` classes in the classpath and utilizes registers these as the available serializers for CloudEvents. +The type of serialization (JSON, XML, AVRO, etc) of the CloudEvent message is determined by `contentType` of the message. + +NOTE: Messages to be transformed must have a payload of `byte[]`. + + + +[[configure-transformer]] +=== Configuring Transformer + +The `ToCloudEventTransformer` allows the user to use SpEL `Expression`s to populate the attributes as well as the extensions. + +==== Attribute Expressions +As discussed above users are allowed to set the `id`, `source`, `type`, `dataSchema`, `subject` through SpEL `Expression`s. +The example below shows where a `ToCloudEventTransformer` is created with a null `expression`s variable. +This indicates that this transformer will not place any `extensions` in the CloudEvent. +But the user does want to set the type of the CloudEvent to `sampleType`. + +NOTE: The `time` attribute is set to the time that the CloudEvent message was created by the `ToCloudEventTransformer` transformer. + +[source,java] +---- +ExpressionParser parser = new SpelExpressionParser(); +ToCloudEventTransformer transformer = new ToCloudEventTransformer(null); +transformer.setTypeExpression(parser.parseExpression("sampleType")); +---- + +==== Extension Expressions +The expressions constructor parameter is an array of `Expression`s. +If the array is null, then no extensions will be added to the CloudEvent. +Each `Expression` in the array must return the type Map. +Where the key is a string and the value is of type object. +In the example below the extensions are hard coded to return 3 `Map` objects each containing one extension. +[source,java] +---- +ExpressionParser parser = new SpelExpressionParser(); +Expression[] extensionExpressions = { + parser.parseExpression("{'trace-id' : 'trace-123'}"), + parser.parseExpression("{'span-id' : 'span-456'}"), + parser.parseExpression("{'user-id' : 'user-789'}")}; +return new ToCloudEventTransformer(extensionExpressions); +---- + +[[cloudevent-attribute-defaults]] +==== Default Values +The following table contains the Attribute names and the value returned by the default `Expression`s. + +|=== +| Attribute Name | Default Value + +| `id` +| the id of the message. + +| `source` +| Prefix of "/spring/" followed by the appName a `.` then the name of the transformer's bean. + +| `type` +| "spring.message" + +| `dataContentType` +| The `contentType` of the message. + +| `dataSchema` +| `null` + +| `subject` +| `null` + +| `time` +| The time the CloudEvent message is created +|=== + +[[cloudevent-transformer-integration]] +=== Integration with Spring Integration Flows + +The CloudEvent transformer integrates with Spring Integration flows: + +==== Basic Flow + +[source,java] +---- +@Bean +public IntegrationFlow cloudEventTransformFlow() { + return IntegrationFlows + .from("inputChannel") + .transform(cloudEventTransformer()) + .channel("outputChannel") + .get(); +} +---- + +[[cloudevent-transformer-transformation-process]] +=== Transformation Process + +The transformer follows the process below: + +1. **CloudEvent Building**: Build CloudEvent attributes +2. **Extension Extraction**: Build the CloudEvent extensions using the array of extensionExpressions passed into the constructor. +3. **Format Conversion**: Apply the specified `EventFormat` based on the message's `contentType to create the CloudEvent. + +[[cloudevent-transformer-examples]] +=== Examples + +[[cloudevent-transformer-example-basic]] +==== Basic Message Transformation + +[source,java] +---- +// Input message with headers +Message inputMessage = MessageBuilder + .withPayload("Hello CloudEvents") + .build(); +// Transformer with extension patterns +ToCloudEventTransformer transformer = new ToCloudEventTransformer(); + +// Transform to CloudEvent +Object cloudEventMessage = transformer.transform(inputMessage); +---- + diff --git a/src/reference/antora/modules/ROOT/pages/whats-new.adoc b/src/reference/antora/modules/ROOT/pages/whats-new.adoc index f658e4dd0f..0c0b77a3db 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -125,3 +125,10 @@ See xref:file/remote-persistent-flf.adoc[Remote Persistent File List Filters] fo === Null Safety Updated the codebase to use JSpecify and NullAway, adding a comprehensive null safety implementation that uses `@NullMarked` annotations to default all types to non-null at the package level and `@Nullable` annotations to explicitly mark types that can be null. See xref:null-safety.adoc[] for more information. + +[[x7.0-cloudevents]] +=== CloudEvents + +The CloudEvent transformer converts Spring Integration messages into CloudEvent compliant messages. +This transformer provides support for the CloudEvents specification v1.0 with configurable output formats, header pattern matching, and extension management. +See xref:cloudevents-transform.adoc[] for more information.