From f23d9c3dfc1f647cfcf8f09ff7cc33a55bf07029 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Thu, 25 Sep 2025 13:58:33 -0400 Subject: [PATCH 01/11] Add Cloud Events support to Spring Integration Introduces Cloud Events v1.0 specification support including message converters, transformers, and utilities. Key components added: - CloudEventMessageConverter for message format conversion - ToCloudEventTransformer for transforming messages to Cloud Events - MessageBinaryMessageReader/Writer for binary format handling - CloudEventProperties for configuration management - Header pattern matching utilities for flexible event mapping - Add reference docs and what's-new paragraph --- build.gradle | 19 + .../v1/CloudEventMessageConverter.java | 112 +++ .../cloudevents/v1/CloudEventsHeaders.java | 38 + .../v1/MessageBinaryMessageReader.java | 76 ++ .../v1/MessageBuilderMessageWriter.java | 86 ++ .../v1/transformer/CloudEventProperties.java | 126 +++ .../transformer/ToCloudEventTransformer.java | 226 +++++ .../ToCloudEventTransformerExtensions.java | 96 +++ .../v1/transformer/package-info.java | 6 + .../utils/HeaderPatternMatcher.java | 74 ++ .../v1/transformer/utils/package-info.java | 6 + .../CloudEventMessageConverterTest.java | 242 ++++++ .../transformer/CloudEventPropertiesTest.java | 153 ++++ .../MessageBuilderMessageWriterTest.java | 249 ++++++ ...ToCloudEventTransformerExtensionsTest.java | 124 +++ .../ToCloudEventTransformerTest.java | 773 ++++++++++++++++++ src/reference/antora/modules/ROOT/nav.adoc | 1 + .../cloudevents/cloudevents-transform.adoc | 348 ++++++++ .../antora/modules/ROOT/pages/whats-new.adoc | 6 + 19 files changed, 2761 insertions(+) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java create mode 100644 src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc diff --git a/build.gradle b/build.gradle index eefe46b6ea..cf9d367ad8 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,7 @@ ext { avroVersion = '1.12.1' awaitilityVersion = '4.3.0' camelVersion = '4.16.0' + cloudEventsVersion = '4.0.1' commonsDbcp2Version = '2.13.0' commonsIoVersion = '2.21.0' commonsNetVersion = '3.12.0' @@ -477,6 +478,24 @@ project('spring-integration-cassandra') { } } +project('spring-integration-cloudevents') { + description = 'Spring Integration Cloud Events Support' + + dependencies { + api "io.cloudevents:cloudevents-core:$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" + optionalApi 'org.jspecify:jspecify' + + testImplementation 'org.springframework.amqp:spring-rabbit-test' + } +} + project('spring-integration-core') { description = 'Spring Integration Core' diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java new file mode 100644 index 0000000000..bf957ead4f --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java @@ -0,0 +1,112 @@ +/* + * 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.v1; + +import java.nio.charset.StandardCharsets; + +import io.cloudevents.CloudEvent; +import io.cloudevents.SpecVersion; +import io.cloudevents.core.CloudEventUtils; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.message.MessageReader; +import io.cloudevents.core.message.impl.GenericStructuredMessageReader; +import io.cloudevents.core.message.impl.MessageUtils; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.util.Assert; + +/** + * A {@link MessageConverter} that can translate to and from a {@link Message + * Message<byte[]>} or {@link Message Message<String>} and a {@link CloudEvent}. + * + * @author Dave Syer + * @author Glenn Renfro + * + * @since 7.0 + */ +public class CloudEventMessageConverter implements MessageConverter { + + private String cePrefix; + + public CloudEventMessageConverter(String cePrefix) { + this.cePrefix = cePrefix; + } + + public CloudEventMessageConverter() { + this(CloudEventsHeaders.CE_PREFIX); + } + + @Override + public Object fromMessage(Message message, Class targetClass) { + Assert.state(CloudEvent.class.isAssignableFrom(targetClass), "Target class must be a CloudEvent"); + return createMessageReader(message).toEvent(); + } + + @Override + public Message toMessage(Object payload, MessageHeaders headers) { + Assert.state(payload instanceof CloudEvent, "Payload must be a CloudEvent"); + return CloudEventUtils.toReader((CloudEvent) payload).read(new MessageBuilderMessageWriter(headers, this.cePrefix)); + } + + private MessageReader createMessageReader(Message message) { + return MessageUtils.parseStructuredOrBinaryMessage(// + () -> contentType(message.getHeaders()), // + format -> structuredMessageReader(message, format), // + () -> version(message.getHeaders()), // + version -> binaryMessageReader(message, version) // + ); + } + + private String version(MessageHeaders message) { + if (message.containsKey(CloudEventsHeaders.SPEC_VERSION)) { + return message.get(CloudEventsHeaders.SPEC_VERSION).toString(); + } + return null; + } + + private MessageReader binaryMessageReader(Message message, SpecVersion version) { + return new MessageBinaryMessageReader(version, message.getHeaders(), getBinaryData(message), this.cePrefix); + } + + private MessageReader structuredMessageReader(Message message, EventFormat format) { + return new GenericStructuredMessageReader(format, getBinaryData(message)); + } + + private String contentType(MessageHeaders message) { + if (message.containsKey(MessageHeaders.CONTENT_TYPE)) { + return message.get(MessageHeaders.CONTENT_TYPE).toString(); + } + if (message.containsKey(CloudEventsHeaders.CONTENT_TYPE)) { + return message.get(CloudEventsHeaders.CONTENT_TYPE).toString(); + } + return null; + } + + private byte[] getBinaryData(Message message) { + Object payload = message.getPayload(); + if (payload instanceof byte[] bytePayload) { + return bytePayload; + } + else if (payload instanceof String stringPayload) { + return stringPayload.getBytes(StandardCharsets.UTF_8); + } + throw new IllegalStateException("Message payload must be a byte array or a String"); + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java new file mode 100644 index 0000000000..ea5dba99e9 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java @@ -0,0 +1,38 @@ +/* + * 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.v1; + +/** + * Constants for Cloud Events header names. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +public final class CloudEventsHeaders { + + public static final String CE_PREFIX = "ce-"; + + public static final String SPEC_VERSION = CE_PREFIX + "specversion"; + + public static final String CONTENT_TYPE = CE_PREFIX + "datacontenttype"; + + private CloudEventsHeaders() { + + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java new file mode 100644 index 0000000000..ba24fd2ab2 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java @@ -0,0 +1,76 @@ +/* + * 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.v1; + +import java.util.Map; +import java.util.function.BiConsumer; + +import io.cloudevents.SpecVersion; +import io.cloudevents.core.data.BytesCloudEventData; +import io.cloudevents.core.impl.StringUtils; +import io.cloudevents.core.message.impl.BaseGenericBinaryMessageReaderImpl; + +/** + * Utility for converting maps (message headers) to `CloudEvent` contexts. + * + * @author Dave Syer + * @author Glenn Renfro + * + * @since 7.0 + * + */ +public class MessageBinaryMessageReader extends BaseGenericBinaryMessageReaderImpl { + private final String cePrefix; + + private final Map headers; + + public MessageBinaryMessageReader(SpecVersion version, Map headers, byte[] payload, String cePrefix) { + super(version, payload == null ? null : BytesCloudEventData.wrap(payload)); + this.headers = headers; + this.cePrefix = cePrefix; + } + + public MessageBinaryMessageReader(SpecVersion version, Map headers, String cePrefix) { + this(version, headers, null, cePrefix); + } + + @Override + protected boolean isContentTypeHeader(String key) { + return org.springframework.messaging.MessageHeaders.CONTENT_TYPE.equalsIgnoreCase(key); + } + + @Override + protected boolean isCloudEventsHeader(String key) { + return key != null && key.length() > this.cePrefix.length() && StringUtils.startsWithIgnoreCase(key, this.cePrefix); + } + + @Override + protected String toCloudEventsKey(String key) { + return key.substring(this.cePrefix.length()).toLowerCase(); + } + + @Override + protected void forEachHeader(BiConsumer fn) { + this.headers.forEach((k, v) -> fn.accept(k, v)); + } + + @Override + protected String toCloudEventsValue(Object value) { + return value.toString(); + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java new file mode 100644 index 0000000000..4386c422d0 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java @@ -0,0 +1,86 @@ +/* + * 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.v1; + +import java.util.HashMap; +import java.util.Map; + +import io.cloudevents.CloudEventData; +import io.cloudevents.SpecVersion; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.message.MessageWriter; +import io.cloudevents.rw.CloudEventContextWriter; +import io.cloudevents.rw.CloudEventRWException; +import io.cloudevents.rw.CloudEventWriter; + +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; + +/** + * Internal utility class for copying CloudEvent context to a map (message + * headers). + * + * @author Dave Syer + * @author Glenn Renfro + * + * @since 7.0 + */ +public class MessageBuilderMessageWriter + implements CloudEventWriter>, MessageWriter> { + + private final String cePrefix; + + private final Map headers = new HashMap<>(); + + public MessageBuilderMessageWriter(Map headers, String cePrefix) { + this.headers.putAll(headers); + this.cePrefix = cePrefix; + } + + public MessageBuilderMessageWriter() { + this.cePrefix = CloudEventsHeaders.CE_PREFIX; + } + + @Override + public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { + this.headers.put(CloudEventsHeaders.CONTENT_TYPE, format.serializedContentType()); + return MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); + } + + @Override + public Message end(CloudEventData value) throws CloudEventRWException { + return MessageBuilder.withPayload(value == null ? new byte[0] : value.toBytes()).copyHeaders(this.headers).build(); + } + + @Override + public Message end() { + return MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); + } + + @Override + public CloudEventContextWriter withContextAttribute(String name, String value) throws CloudEventRWException { + this.headers.put(this.cePrefix + name, value); + return this; + } + + @Override + public MessageBuilderMessageWriter create(SpecVersion version) { + this.headers.put(this.cePrefix + "specversion", version.toString()); + return this; + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java new file mode 100644 index 0000000000..8472be8b11 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java @@ -0,0 +1,126 @@ +/* + * 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.v1.transformer; + +import java.net.URI; +import java.time.OffsetDateTime; + +import org.jspecify.annotations.Nullable; + +import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; + +/** + * Configuration properties for CloudEvent metadata and formatting. + *

+ * This class provides configurable properties for CloudEvent creation, including + * required attributes (id, source, type) and optional attributes (subject, time, dataContentType, dataSchema). + * It also supports customization of the CloudEvent header prefix for integration with different systems. + *

+ * All properties have defaults and can be configured as needed: + *

    + *
  • Required attributes default to empty strings/URIs
  • + *
  • Optional attributes default to null
  • + *
  • CloudEvent prefix defaults to standard "ce-" format
  • + *
+ * + * @author Glenn Renfro + * + * @since 7.0 + */ +public class CloudEventProperties { + + private String id = ""; + + private URI source = URI.create(""); + + private String type = ""; + + private @Nullable String dataContentType; + + private @Nullable URI dataSchema; + + private @Nullable String subject; + + private @Nullable OffsetDateTime time; + + private String cePrefix = CloudEventsHeaders.CE_PREFIX; + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public URI getSource() { + return this.source; + } + + public void setSource(URI source) { + this.source = source; + } + + public String getType() { + return this.type; + } + + public void setType(String type) { + this.type = type; + } + + public @Nullable String getDataContentType() { + return this.dataContentType; + } + + public void setDataContentType(@Nullable String dataContentType) { + this.dataContentType = dataContentType; + } + + public @Nullable URI getDataSchema() { + return this.dataSchema; + } + + public void setDataSchema(@Nullable URI dataSchema) { + this.dataSchema = dataSchema; + } + + public @Nullable String getSubject() { + return this.subject; + } + + public void setSubject(@Nullable String subject) { + this.subject = subject; + } + + public @Nullable OffsetDateTime getTime() { + return this.time; + } + + public void setTime(@Nullable OffsetDateTime time) { + this.time = time; + } + + public String getCePrefix() { + return this.cePrefix; + } + + public void setCePrefix(String cePrefix) { + this.cePrefix = cePrefix; + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java new file mode 100644 index 0000000000..2e5e9e6718 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java @@ -0,0 +1,226 @@ +/* + * 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.v1.transformer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import io.cloudevents.CloudEvent; +import io.cloudevents.avro.compact.AvroCompactFormat; +import io.cloudevents.core.builder.CloudEventBuilder; +import io.cloudevents.jackson.JsonFormat; +import io.cloudevents.xml.XMLFormat; +import org.jspecify.annotations.Nullable; + +import org.springframework.integration.cloudevents.v1.CloudEventMessageConverter; +import org.springframework.integration.cloudevents.v1.transformer.utils.HeaderPatternMatcher; +import org.springframework.integration.transformer.AbstractTransformer; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConversionException; +import org.springframework.messaging.converter.MessageConverter; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.Assert; + +/** + * A Spring Integration transformer that converts messages to CloudEvent format. + *

+ * This transformer converts Spring Integration messages into CloudEvent compliant + * messages, supporting various output formats including structured, XML, JSON, and Avro. + * It handles CloudEvent extensions through configurable header pattern matching and provides + * configuration through {@link CloudEventProperties}. + *

+ * The transformer supports the following conversion types: + *

    + *
  • DEFAULT - Standard CloudEvent message
  • + *
  • XML - CloudEvent serialized as XML content
  • + *
  • JSON - CloudEvent serialized as JSON content
  • + *
  • AVRO - CloudEvent serialized as Avro binary content
  • + *
+ *

+ * Header filtering and extension mapping is performed based on configurable patterns, + * allowing control over which headers are preserved and which become CloudEvent extensions. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +public class ToCloudEventTransformer extends AbstractTransformer { + + /** + * Enumeration of supported CloudEvent conversion types. + *

+ * Defines the different output formats supported by the transformer: + *

    + *
  • DEFAULT - No format conversion, uses standard CloudEvent message structure
  • + *
  • XML - Serializes CloudEvent as XML in the message payload
  • + *
  • JSON - Serializes CloudEvent as JSON in the message payload
  • + *
  • AVRO - Serializes CloudEvent as compact Avro binary in the message payload
  • + *
+ */ + public enum ConversionType { DEFAULT, XML, JSON, AVRO } + + private final MessageConverter messageConverter; + + private final @Nullable String cloudEventExtensionPatterns; + + private final ConversionType conversionType; + + private final CloudEventProperties cloudEventProperties; + + /** + * ToCloudEventTransformer Constructor + * + * @param cloudEventExtensionPatterns comma-delimited patterns for matching headers that should become CloudEvent extensions, + * supports wildcards and negation with '!' prefix If a header matches one of the '!' it is excluded from + * cloud event headers and the message headers. If a header does not match for a prefix or a exclusion, the header + * is left in the message headers. . Null to disable extension mapping. + * @param conversionType the output format for the CloudEvent (DEFAULT, XML, JSON, or AVRO) + * @param cloudEventProperties configuration properties for CloudEvent metadata (id, source, type, etc.) + */ + public ToCloudEventTransformer(@Nullable String cloudEventExtensionPatterns, + ConversionType conversionType, CloudEventProperties cloudEventProperties) { + this.messageConverter = new CloudEventMessageConverter(cloudEventProperties.getCePrefix()); + this.cloudEventExtensionPatterns = cloudEventExtensionPatterns; + this.conversionType = conversionType; + this.cloudEventProperties = cloudEventProperties; + } + + public ToCloudEventTransformer() { + this(null, ConversionType.DEFAULT, new CloudEventProperties()); + } + + /** + * Transforms the input message into a CloudEvent message. + *

+ * This method performs the core transformation logic: + *

    + *
  1. Extracts CloudEvent extensions from message headers using configured patterns
  2. + *
  3. Builds a CloudEvent with the configured properties and message payload
  4. + *
  5. Applies the specified conversion type to format the output
  6. + *
  7. Filters headers to exclude those mapped to CloudEvent extensions
  8. + *
+ * + * @param message the input Spring Integration message to transform + * @return transformed message as CloudEvent in the specified format + * @throws RuntimeException if serialization fails for XML, JSON, or Avro formats + */ + @Override + protected Object doTransform(Message message) { + ToCloudEventTransformerExtensions extensions = + new ToCloudEventTransformerExtensions(message.getHeaders(), this.cloudEventExtensionPatterns); + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId(this.cloudEventProperties.getId()) + .withSource(this.cloudEventProperties.getSource()) + .withType(this.cloudEventProperties.getType()) + .withTime(this.cloudEventProperties.getTime()) + .withDataContentType(this.cloudEventProperties.getDataContentType()) + .withDataSchema(this.cloudEventProperties.getDataSchema()) + .withSubject(this.cloudEventProperties.getSubject()) + .withData(getPayloadAsBytes(message.getPayload())) + .withExtension(extensions) + .build(); + + switch (this.conversionType) { + case XML: + return convertToXmlMessage(cloudEvent, message.getHeaders()); + case JSON: + return convertToJsonMessage(cloudEvent, message.getHeaders()); + case AVRO: + return convertToAvroMessage(cloudEvent, message.getHeaders()); + default: + var result = this.messageConverter.toMessage(cloudEvent, filterHeaders(message.getHeaders())); + Assert.state(result != null, "Payload result must not be null"); + return result; + } + } + + private Message convertToXmlMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { + XMLFormat xmlFormat = new XMLFormat(); + String xmlContent = new String(xmlFormat.serialize(cloudEvent)); + return buildStringMessage(xmlContent, originalHeaders, "application/xml"); + } + + private Message convertToJsonMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { + JsonFormat jsonFormat = new JsonFormat(); + String jsonContent = new String(jsonFormat.serialize(cloudEvent)); + return buildStringMessage(jsonContent, originalHeaders, "application/json"); + } + + private Message buildStringMessage(String serializedCloudEvent, + MessageHeaders originalHeaders, String contentType) { + try { + return MessageBuilder.withPayload(serializedCloudEvent) + .copyHeaders(filterHeaders(originalHeaders)) + .setHeader("content-type", contentType) + .build(); + } + catch (Exception e) { + throw new MessageConversionException("Failed to convert CloudEvent to " + contentType, e); + } + } + + private Message convertToAvroMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { + try { + AvroCompactFormat avroFormat = new AvroCompactFormat(); + byte[] avroBytes = avroFormat.serialize(cloudEvent); + return MessageBuilder.withPayload(avroBytes) + .copyHeaders(filterHeaders(originalHeaders)) + .setHeader("content-type", "application/avro") + .build(); + } + catch (Exception e) { + throw new RuntimeException("Failed to convert CloudEvent to application/avro", e); + } + } + + /** + * This method creates a {@link MessageHeaders} that were not placed in the CloudEvent and were not excluded via the + * categorization mechanism. + * @param headers The {@link MessageHeaders} to be filtered. + * @return {@link MessageHeaders} that have been filtered. + */ + private MessageHeaders filterHeaders(MessageHeaders headers) { + + Map filteredHeaders = new HashMap<>(); + headers.keySet().forEach(key -> { + if (HeaderPatternMatcher.categorizeHeader(key, this.cloudEventExtensionPatterns) == null) { + filteredHeaders.put(key, Objects.requireNonNull(headers.get(key))); + } + }); + return new MessageHeaders(filteredHeaders); + } + + private byte[] getPayloadAsBytes(Object payload) { + if (payload instanceof byte[] bytePayload) { + return bytePayload; + } + else if (payload instanceof String stringPayload) { + return stringPayload.getBytes(); + } + else { + return payload.toString().getBytes(); + } + } + + @Override + public String getComponentType() { + return "to-cloud-transformer"; + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java new file mode 100644 index 0000000000..2dbf281374 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java @@ -0,0 +1,96 @@ +/* + * 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.v1.transformer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import io.cloudevents.CloudEventExtension; +import io.cloudevents.CloudEventExtensions; +import org.jspecify.annotations.Nullable; + +import org.springframework.integration.cloudevents.v1.transformer.utils.HeaderPatternMatcher; +import org.springframework.messaging.MessageHeaders; + +/** + * CloudEvent extension implementation that extracts extensions from Spring Integration message headers. + *

+ * This class implements the CloudEvent extension contract by filtering message headers + * based on configurable patterns and converting matching headers into CloudEvent extensions. + * It supports pattern-based inclusion and exclusion of headers using Spring's pattern matching utilities. + *

+ * Pattern matching supports: + *

    + *
  • Wildcard patterns (e.g., "trace-*" matches "trace-id", "trace-span") means the matching header will be moved + * to the CloudEvent extensions.
  • + *
  • Negation patterns with '!' prefix (e.g., "!internal-*" excludes internal headers) means the matching header + * will be not be moved to the CloudEvent extensions or left in the message header.
  • + *
  • Comma-delimited multiple patterns (e.g., "trace-*,span-*,!internal-*")
  • + *
+ * + * @author Glenn Renfro + * + * @since 7.0 + */ +public class ToCloudEventTransformerExtensions implements CloudEventExtension { + + /** + * Internal map storing the CloudEvent extensions extracted from message headers. + */ + private final Map cloudEventExtensions; + + /** + * Constructs CloudEvent extensions by filtering message headers against patterns. + *

+ * Headers are evaluated against the provided patterns using {@link HeaderPatternMatcher}. + * Only headers that match the patterns (and are not excluded by negation patterns) + * will be included as CloudEvent extensions. + * + * @param headers the Spring Integration message headers to process + * @param patterns comma-delimited patterns for header matching, may be null to include no extensions + */ + public ToCloudEventTransformerExtensions(MessageHeaders headers, @Nullable String patterns) { + this.cloudEventExtensions = new HashMap<>(); + headers.keySet().forEach(key -> { + Boolean result = HeaderPatternMatcher.categorizeHeader(key, patterns); + if (result != null && result) { + this.cloudEventExtensions.put(key, (String) Objects.requireNonNull(headers.get(key))); + } + }); + } + + @Override + public void readFrom(CloudEventExtensions extensions) { + extensions.getExtensionNames() + .forEach(key -> { + this.cloudEventExtensions.put(key, this.cloudEventExtensions.get(key)); + }); + } + + @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/v1/transformer/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java new file mode 100644 index 0000000000..b1ac6053f6 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java @@ -0,0 +1,6 @@ +/** + * Base package for CloudEvents transformer support. + */ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents.v1.transformer; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java new file mode 100644 index 0000000000..6c6f6f33e5 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java @@ -0,0 +1,74 @@ +/* + * 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.v1.transformer.utils; + +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.integration.support.utils.PatternMatchUtils; +import org.springframework.util.StringUtils; + +/** + * Utility class for matching header values against comma-delimited patterns. + *

+ * This class provides pattern matching functionality for header categorization for cloud events + * using Spring's PatternMatchUtils for smart pattern matching with support for + * wildcards and special pattern syntax. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +public final class HeaderPatternMatcher { + + private HeaderPatternMatcher() { + + } + + /** + * Categorizes a header value by matching it against a comma-delimited pattern string. + *

+ * This method takes a header value and matches it against one or more patterns + * specified in a comma-delimited string. It uses Spring's smart pattern matching + * which supports wildcards and other pattern matching features. + * + * @param value the header value to match against the patterns + * @param pattern a comma-delimited string of patterns to match against, or null. If pattern is null then null is returned. + * @return {@code Boolean.TRUE} if the value starts with a pattern token, + * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, + * or {@code null} if the header starts with a value that is not enumerated in the pattern + */ + public static @Nullable Boolean categorizeHeader(String value, @Nullable String pattern) { + if (pattern == null) { + return null; + } + Set patterns = StringUtils.commaDelimitedListToSet(pattern); + Boolean result = null; + for (String patternItem : patterns) { + result = PatternMatchUtils.smartMatch(value, patternItem); + if (result != null && result) { + break; + } + else if (result != null) { + break; + } + } + return result; + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java new file mode 100644 index 0000000000..f807c27161 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java @@ -0,0 +1,6 @@ +/** + * Base package for CloudEvents transformer util support. + */ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents.v1.transformer.utils; diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java new file mode 100644 index 0000000000..a649f8c601 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java @@ -0,0 +1,242 @@ +/* + * 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.v1.transformer; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.integration.cloudevents.v1.CloudEventMessageConverter; +import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.catchIllegalStateException; + +public class CloudEventMessageConverterTest { + + private CloudEventMessageConverter converter; + + private CloudEventMessageConverter customPrefixConverter; + + @BeforeEach + void setUp() { + this.converter = new CloudEventMessageConverter(CloudEventsHeaders.CE_PREFIX); + this.customPrefixConverter = new CloudEventMessageConverter("CUSTOM_"); + } + + @Test + void toMessageWithCloudEventAndDefaultPrefix() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("test-id") + .withSource(URI.create("https://example.com")) + .withType("com.example.test") + .withData("test data".getBytes()) + .build(); + + Map headers = new HashMap<>(); + headers.put("existing-header", "existing-value"); + MessageHeaders messageHeaders = new MessageHeaders(headers); + + Message result = this.converter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo("test data".getBytes()); + + MessageHeaders resultHeaders = result.getHeaders(); + assertThat(resultHeaders.get("existing-header")).isEqualTo("existing-value"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("test-id"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://example.com"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.test"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + } + + @Test + void toMessageWithCloudEventAndCustomPrefix() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("custom-id") + .withSource(URI.create("https://custom.example.com")) + .withType("com.example.custom") + .withData("custom data".getBytes()) + .build(); + + Map headers = new HashMap<>(); + headers.put("custom-header", "custom-value"); + MessageHeaders messageHeaders = new MessageHeaders(headers); + + Message result = this.customPrefixConverter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo("custom data".getBytes()); + + MessageHeaders resultHeaders = result.getHeaders(); + assertThat(resultHeaders.get("custom-header")).isEqualTo("custom-value"); + assertThat(resultHeaders.get("CUSTOM_id")).isEqualTo("custom-id"); + assertThat(resultHeaders.get("CUSTOM_source")).isEqualTo("https://custom.example.com"); + assertThat(resultHeaders.get("CUSTOM_type")).isEqualTo("com.example.custom"); + assertThat(resultHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); + } + + @Test + void toMessageWithCloudEventContainingExtensions() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("ext-id") + .withSource(URI.create("https://ext.example.com")) + .withType("com.example.ext") + .withExtension("spanid", "span-456") + .withData("extension data".getBytes()) + .build(); + + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + + Message result = this.customPrefixConverter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + MessageHeaders resultHeaders = result.getHeaders(); + + assertThat(resultHeaders.get("CUSTOM_id")).isEqualTo("ext-id"); + assertThat(resultHeaders.get("CUSTOM_spanid")).isEqualTo("span-456"); + } + + @Test + void toMessageWithCloudEventContainingOptionalAttributes() { + OffsetDateTime time = OffsetDateTime.now(); + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("optional-id") + .withSource(URI.create("https://optional.example.com")) + .withType("com.example.optional") + .withDataContentType("application/json") + .withDataSchema(URI.create("https://schema.example.com")) + .withSubject("test-subject") + .withTime(time) + .withData("{\"key\":\"value\"}".getBytes()) + .build(); + + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + + Message result = this.converter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + MessageHeaders resultHeaders = result.getHeaders(); + + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "subject")).isEqualTo("test-subject"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "time")).isNotNull(); + } + + @Test + void toMessageWithCloudEventWithoutData() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("no-data-id") + .withSource(URI.create("https://nodata.example.com")) + .withType("com.example.nodata") + .build(); + + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + + Message result = this.converter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + assertThat(result.getPayload()).isEqualTo(new byte[0]); + + MessageHeaders resultHeaders = result.getHeaders(); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("no-data-id"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://nodata.example.com"); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.nodata"); + } + + @Test + void toMessageWithNonCloudEventPayload() { + String payload = "regular string payload"; + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + + catchIllegalStateException(() -> this.converter.toMessage(payload, messageHeaders)); + + } + + @Test + void toMessagePreservesExistingHeaders() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("preserve-id") + .withSource(URI.create("https://preserve.example.com")) + .withType("com.example.preserve") + .withData("preserve data".getBytes()) + .build(); + + Map headers = new HashMap<>(); + headers.put("correlation-id", "corr-123"); + headers.put("message-timestamp", System.currentTimeMillis()); + headers.put("routing-key", "test.route"); + MessageHeaders messageHeaders = new MessageHeaders(headers); + + Message result = this.converter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + MessageHeaders resultHeaders = result.getHeaders(); + + assertThat(resultHeaders.get("correlation-id")).isEqualTo("corr-123"); + assertThat(resultHeaders.get("message-timestamp")).isNotNull(); + assertThat(resultHeaders.get("routing-key")).isEqualTo("test.route"); + + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("preserve-id"); + } + + @Test + void toMessageWithEmptyHeaders() { + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("empty-headers-id") + .withSource(URI.create("https://empty.example.com")) + .withType("com.example.empty") + .build(); + + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + + Message result = this.converter.toMessage(cloudEvent, messageHeaders); + + assertThat(result).isNotNull(); + MessageHeaders resultHeaders = result.getHeaders(); + assertThat(resultHeaders.size()).isEqualTo(6); + assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("empty-headers-id"); + } + + @Test + void invalidPayloadToMessage() { + Message message = MessageBuilder.withPayload(Integer.valueOf(1234)).build(); + assertThatIllegalStateException() + .isThrownBy(() -> this.converter.toMessage(message, new MessageHeaders(new HashMap<>()))) + .withMessage("Payload must be a CloudEvent"); + + } + + @Test + void invalidPayloadFromMessage() { + Message message = MessageBuilder.withPayload(Integer.valueOf(1234)).build(); + assertThatIllegalStateException() + .isThrownBy(() -> this.converter.fromMessage(message, Integer.class)) + .withMessage("Target class must be a CloudEvent"); + } +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java new file mode 100644 index 0000000000..89774de875 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java @@ -0,0 +1,153 @@ +/* + * 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.v1.transformer; + +import java.net.URI; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CloudEventPropertiesTest { + + private CloudEventProperties properties; + + @BeforeEach + void setUp() { + this.properties = new CloudEventProperties(); + } + + @Test + void defaultValues() { + assertThat(this.properties.getId()).isEqualTo(""); + assertThat(this.properties.getSource()).isEqualTo(URI.create("")); + assertThat(this.properties.getType()).isEqualTo(""); + assertThat(this.properties.getDataContentType()).isNull(); + assertThat(this.properties.getDataSchema()).isNull(); + assertThat(this.properties.getSubject()).isNull(); + assertThat(this.properties.getTime()).isNull(); + } + + @Test + void setAndGetId() { + String testId = "test-event-id-123"; + this.properties.setId(testId); + assertThat(this.properties.getId()).isEqualTo(testId); + } + + @Test + void setAndGetSource() { + URI testSource = URI.create("https://example.com/source"); + this.properties.setSource(testSource); + assertThat(this.properties.getSource()).isEqualTo(testSource); + } + + @Test + void setAndGetType() { + String testType = "com.example.event.type"; + this.properties.setType(testType); + assertThat(this.properties.getType()).isEqualTo(testType); + } + + @Test + void setAndGetDataContentType() { + String testContentType = "application/json"; + this.properties.setDataContentType(testContentType); + assertThat(this.properties.getDataContentType()).isEqualTo(testContentType); + } + + @Test + void setAndGetDataSchema() { + URI testSchema = URI.create("https://example.com/schema"); + this.properties.setDataSchema(testSchema); + assertThat(this.properties.getDataSchema()).isEqualTo(testSchema); + } + + @Test + void setAndGetSubject() { + String testSubject = "test-subject"; + this.properties.setSubject(testSubject); + assertThat(this.properties.getSubject()).isEqualTo(testSubject); + } + + @Test + void setAndGetTime() { + OffsetDateTime testTime = OffsetDateTime.now(); + this.properties.setTime(testTime); + assertThat(this.properties.getTime()).isEqualTo(testTime); + } + + @Test + void setNullValues() { + this.properties.setDataContentType(null); + assertThat(this.properties.getDataContentType()).isNull(); + + this.properties.setDataSchema(null); + assertThat(this.properties.getDataSchema()).isNull(); + + this.properties.setSubject(null); + assertThat(this.properties.getSubject()).isNull(); + + this.properties.setTime(null); + assertThat(this.properties.getTime()).isNull(); + } + + @Test + void setEmptyStringValues() { + this.properties.setId(""); + assertThat(this.properties.getId()).isEqualTo(""); + + this.properties.setType(""); + assertThat(this.properties.getType()).isEqualTo(""); + + this.properties.setDataContentType(""); + assertThat(this.properties.getDataContentType()).isEqualTo(""); + + this.properties.setSubject(""); + assertThat(this.properties.getSubject()).isEqualTo(""); + } + + @Test + void completeCloudEventProperties() { + String id = "complete-event-123"; + URI source = URI.create("https://example.com/events"); + String type = "com.example.user.created"; + String dataContentType = "application/json"; + URI dataSchema = URI.create("https://example.com/schemas/user"); + String subject = "user/123"; + OffsetDateTime time = OffsetDateTime.now(); + + this.properties.setId(id); + this.properties.setSource(source); + this.properties.setType(type); + this.properties.setDataContentType(dataContentType); + this.properties.setDataSchema(dataSchema); + this.properties.setSubject(subject); + this.properties.setTime(time); + + assertThat(this.properties.getId()).isEqualTo(id); + assertThat(this.properties.getSource()).isEqualTo(source); + assertThat(this.properties.getType()).isEqualTo(type); + assertThat(this.properties.getDataContentType()).isEqualTo(dataContentType); + assertThat(this.properties.getDataSchema()).isEqualTo(dataSchema); + assertThat(this.properties.getSubject()).isEqualTo(subject); + assertThat(this.properties.getTime()).isEqualTo(time); + } + +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java new file mode 100644 index 0000000000..20af134321 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java @@ -0,0 +1,249 @@ +/* + * 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.v1.transformer; + +import java.util.HashMap; +import java.util.Map; + +import io.cloudevents.CloudEventData; +import io.cloudevents.SpecVersion; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.jackson.JsonFormat; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; +import org.springframework.integration.cloudevents.v1.MessageBuilderMessageWriter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MessageBuilderMessageWriterTest { + + private MessageBuilderMessageWriter writer; + + private MessageBuilderMessageWriter customPrefixWriter; + + @BeforeEach + void setUp() { + Map headers = new HashMap<>(); + headers.put("existing-header", "existing-value"); + headers.put("correlation-id", "corr-123"); + + this.writer = new MessageBuilderMessageWriter(headers, CloudEventsHeaders.CE_PREFIX); + this.customPrefixWriter = new MessageBuilderMessageWriter(headers, "CUSTOM_"); + } + + @Test + void createWithSpecVersionAndDefaultPrefix() { + MessageBuilderMessageWriter result = this.writer.create(SpecVersion.V1); + + assertThat(result).isNotNull(); + assertThat(result).isSameAs(this.writer); + + Message message = result.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); + assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + } + + @Test + void createWithSpecVersionAndCustomPrefix() { + MessageBuilderMessageWriter result = this.customPrefixWriter.create(SpecVersion.V1); + + assertThat(result).isNotNull(); + assertThat(result).isSameAs(this.customPrefixWriter); + + Message message = result.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); + assertThat(messageHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); + } + + @Test + void withContextAttributeDefaultPrefix() { + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "test-id") + .withContextAttribute("source", "https://example.com") + .withContextAttribute("type", "com.example.test"); + + Message message = this.writer.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("test-id"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://example.com"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.test"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + } + + @Test + void withContextAttributeCustomPrefix() { + this.customPrefixWriter.create(SpecVersion.V1) + .withContextAttribute("id", "custom-id") + .withContextAttribute("source", "https://custom.example.com") + .withContextAttribute("type", "com.example.custom"); + + Message message = this.customPrefixWriter.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get("CUSTOM_id")).isEqualTo("custom-id"); + assertThat(messageHeaders.get("CUSTOM_source")).isEqualTo("https://custom.example.com"); + assertThat(messageHeaders.get("CUSTOM_type")).isEqualTo("com.example.custom"); + assertThat(messageHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); + } + + @Test + void withContextAttributeExtensions() { + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "ext-id") + .withContextAttribute("source", "https://ext.example.com") + .withContextAttribute("type", "com.example.ext") + .withContextAttribute("trace-id", "trace-123") + .withContextAttribute("span-id", "span-456"); + + Message message = this.writer.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "trace-id")).isEqualTo("trace-123"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "span-id")).isEqualTo("span-456"); + } + + @Test + void withContextAttributeOptionalAttributes() { + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "optional-id") + .withContextAttribute("source", "https://optional.example.com") + .withContextAttribute("type", "com.example.optional") + .withContextAttribute("datacontenttype", "application/json") + .withContextAttribute("dataschema", "https://schema.example.com") + .withContextAttribute("subject", "test-subject") + .withContextAttribute("time", "2023-01-01T10:00:00Z"); + + Message message = this.writer.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "subject")).isEqualTo("test-subject"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "time")).isEqualTo("2023-01-01T10:00:00Z"); + } + + @Test + void testEndWithEmptyPayload() { + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "empty-id") + .withContextAttribute("source", "https://empty.example.com") + .withContextAttribute("type", "com.example.empty"); + + Message message = this.writer.end(); + + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo(new byte[0]); + assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); + assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("empty-id"); + } + + @Test + void endWithCloudEventData() { + CloudEventData mockData = mock(CloudEventData.class); + byte[] testData = "test data content".getBytes(); + when(mockData.toBytes()).thenReturn(testData); + + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "data-id") + .withContextAttribute("source", "https://data.example.com") + .withContextAttribute("type", "com.example.data"); + + Message message = this.writer.end(mockData); + + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo(testData); + assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("data-id"); + } + + @Test + void endWithNullCloudEventData() { + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "null-data-id") + .withContextAttribute("source", "https://nulldata.example.com") + .withContextAttribute("type", "com.example.nulldata"); + + Message message = this.writer.end(null); + + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo(new byte[0]); + assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("null-data-id"); + } + + @Test + void setEventWithTextPayload() { + EventFormat mockFormat = mock(EventFormat.class); + when(mockFormat.serializedContentType()).thenReturn("application/cloudevents+json"); + + byte[] eventData = "serialized event data".getBytes(); + + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "format-id") + .withContextAttribute("source", "https://format.example.com") + .withContextAttribute("type", "com.example.format"); + + Message message = this.writer.setEvent(mockFormat, eventData); + + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo(eventData); + assertThat(message.getHeaders().get(CloudEventsHeaders.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); + assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); + } + + @Test + void testSetEventWithJsonPayload() { + byte[] jsonData = "{\"key\":\"value\"}".getBytes(); + + this.writer.create(SpecVersion.V1) + .withContextAttribute("id", "json-id") + .withContextAttribute("source", "https://json.example.com") + .withContextAttribute("type", "com.example.json"); + + Message message = this.writer.setEvent(new JsonFormat(), jsonData); + + assertThat(message).isNotNull(); + assertThat(message.getPayload()).isEqualTo(jsonData); + assertThat(message.getHeaders().get(CloudEventsHeaders.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); + } + + @Test + void headersCorrectlyAssignedToMessageHeader() { + this.writer.create(SpecVersion.V1); + this.writer.withContextAttribute("id", "preserve-id"); + this.writer.withContextAttribute("source", "https://preserve.example.com"); + + Message message = this.writer.end(); + MessageHeaders messageHeaders = message.getHeaders(); + + assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); + assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("preserve-id"); + assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://preserve.example.com"); + } + +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java new file mode 100644 index 0000000000..3212a0bf0e --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java @@ -0,0 +1,124 @@ +/* + * 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.v1.transformer; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.messaging.MessageHeaders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class ToCloudEventTransformerExtensionsTest { + + private String extensionPatterns; + + private Map headers; + + @BeforeEach + void setUp() { + this.extensionPatterns = "source-header,another-header"; + + this.headers = new HashMap<>(); + this.headers.put("source-header", "header-value"); + this.headers.put("another-header", "another-value"); + this.headers.put("unmapped-header", "unmapped-value"); + } + + @Test + void constructorMapsHeadersToExtensions() { + MessageHeaders messageHeaders = new MessageHeaders(this.headers); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( + messageHeaders, this.extensionPatterns + ); + + assertThat(extensions.getValue("source-header")).isEqualTo("header-value"); + assertThat(extensions.getValue("another-header")).isEqualTo("another-value"); + assertThat(extensions.getValue("unmapped-header")).isNull(); + } + + @Test + void getKeysReturnsAllExtensionKeys() { + MessageHeaders messageHeaders = new MessageHeaders(this.headers); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions(messageHeaders, + this.extensionPatterns); + + Set keys = extensions.getKeys(); + assertThat(keys).contains("source-header"); + assertThat(keys).contains("another-header"); + assertThat(keys).doesNotContain("unmapped-header"); + assertThat(keys.size()).isGreaterThanOrEqualTo(2); + } + + @Test + void excludePatternExtensionKeys() { + MessageHeaders messageHeaders = new MessageHeaders(this.headers); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions(messageHeaders, + "!source*,another*"); + + Set keys = extensions.getKeys(); + assertThat(keys).contains("another-header"); + assertThat(keys).doesNotContain("unmapped-header"); + assertThat(keys).doesNotContain("source-header"); + assertThat(keys.size()).isGreaterThanOrEqualTo(1); + } + + @Test + void forNonExistentExtensionKey() { + MessageHeaders messageHeaders = new MessageHeaders(this.headers); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( + messageHeaders, this.extensionPatterns); + + assertThat(extensions.getValue("non-existent-key")).isNull(); + } + + @Test + void emptyExtensionNamesMap() { + MessageHeaders messageHeaders = new MessageHeaders(this.headers); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( + messageHeaders, null + ); + + assertThat(extensions.getKeys()).isEmpty(); + assertThat(extensions.getValue("any-key")).isNull(); + } + + @Test + void emptyHeaders() { + MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); + ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( + messageHeaders, this.extensionPatterns); + + Set keys = extensions.getKeys(); + assertThat(keys).isEmpty(); + } + + @Test + void invalidHeaderType() { + Map mixedHeaders = new HashMap<>(); + mixedHeaders.put("source-header", "string-value"); + mixedHeaders.put("another-header", 123); // Non-string value + MessageHeaders messageHeaders = new MessageHeaders(mixedHeaders); + assertThatExceptionOfType(ClassCastException.class).isThrownBy( + () -> new ToCloudEventTransformerExtensions(messageHeaders, this.extensionPatterns)); + } +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java new file mode 100644 index 0000000000..5db3a63ef5 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java @@ -0,0 +1,773 @@ +/* + * 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.v1.transformer; + +import java.net.URI; +import java.time.OffsetDateTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +class ToCloudEventTransformerTest { + + private ToCloudEventTransformer transformer; + + @BeforeEach + void setUp() { + String extensionPatterns = "customer-header"; + this.transformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, + new CloudEventProperties()); + } + + @Test + void doTransformWithStringPayload() { + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("custom-header", "test-value") + .setHeader("other-header", "other-value") + .build(); + + Object result = this.transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isEqualTo(payload.getBytes()); + + // Verify that CloudEvent headers are present in the message + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers).isNotNull(); + + // Check that the original other-header is preserved (not mapped to extension) + assertThat(headers.containsKey("other-header")).isTrue(); + assertThat(headers.get("other-header")).isEqualTo("other-value"); + + } + + @Test + void doTransformWithByteArrayPayload() { + byte[] payload = "test message".getBytes(); + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(resultMessage.getPayload()).isEqualTo(payload); + + } + + @Test + void doTransformWithObjectPayload() { + Object payload = new Object() { + @Override + public String toString() { + return "custom object"; + } + }; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(resultMessage.getPayload()).isEqualTo(payload.toString().getBytes()); + } + + @Test + void headerFiltering() { + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("customer-header", "extension-value") + .setHeader("regular-header", "regular-value") + .setHeader("another-regular", "another-value") + .build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // Check that regular headers are preserved + assertThat(resultMessage.getHeaders().containsKey("regular-header")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("another-regular")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-customer-header")).isTrue(); + assertThat(resultMessage.getHeaders().get("regular-header")).isEqualTo("regular-value"); + assertThat(resultMessage.getHeaders().get("another-regular")).isEqualTo("another-value"); + + + + } + + @Test + void emptyExtensionNames() { + ToCloudEventTransformer emptyExtensionTransformer = new ToCloudEventTransformer(); + + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("some-header", "some-value") + .build(); + + Object result = emptyExtensionTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // All headers should be preserved when no extension mapping exists + assertThat(resultMessage.getHeaders().containsKey("some-header")).isTrue(); + assertThat(resultMessage.getHeaders().get("some-header")).isEqualTo("some-value"); + } + + @Test + void multipleExtensionMappings() { + String extensionPatterns = "trace-id,span-id,user-id"; + + ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, new CloudEventProperties()); + + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("trace-id", "trace-123") + .setHeader("span-id", "span-456") + .setHeader("user-id", "user-789") + .setHeader("correlation-id", "corr-999") + .build(); + + Object result = extendedTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // Extension-mapped headers should be converted to cloud event extensions + assertThat(resultMessage.getHeaders().containsKey("trace-id")).isFalse(); + assertThat(resultMessage.getHeaders().containsKey("span-id")).isFalse(); + assertThat(resultMessage.getHeaders().containsKey("user-id")).isFalse(); + + assertThat(resultMessage.getHeaders().containsKey("ce-trace-id")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-span-id")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-user-id")).isTrue(); + + // Non-mapped header should be preserved + assertThat(resultMessage.getHeaders().containsKey("correlation-id")).isTrue(); + assertThat(resultMessage.getHeaders().get("correlation-id")).isEqualTo("corr-999"); + } + + @Test + void emptyStringPayloadHandling() { + Message message = MessageBuilder.withPayload("").build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + } + + @Test + void avroConversion() { + ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); + + String payload = "test avro message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("source-header", "test-value") + .build(); + + Object result = avroTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); + + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers.get("content-type")).isEqualTo("application/avro"); + assertThat(headers.containsKey("source-header")).isTrue(); + } + + @Test + void avroConversionWithExtensions() { + String extensionPatterns = "trace-id"; + + ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); + + String payload = "test avro with extensions"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("trace-id", "trace-123") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = avroTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); + + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers.get("content-type")).isEqualTo("application/avro"); + assertThat(headers.containsKey("trace-id")).isFalse(); + assertThat(headers.containsKey("regular-header")).isTrue(); + } + + @Test + void xmlConversion() { + ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, new CloudEventProperties()); + + String payload = "test xml message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("source-header", "test-value") + .build(); + + Object result = xmlTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains(" message = MessageBuilder.withPayload(payload) + .setHeader("span-id", "span-456") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = xmlTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains("span-456"); + } + + @Test + void jsonConversion() { + ToCloudEventTransformer jsonTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, new CloudEventProperties()); + + String payload = "test json message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("source-header", "test-value") + .build(); + + Object result = jsonTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"specversion\""); + assertThat(jsonPayload).contains("\"type\""); + assertThat(jsonPayload).contains("\"source\""); + assertThat(jsonPayload).contains("\"id\""); + + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers.get("content-type")).isEqualTo("application/json"); + assertThat(headers.containsKey("source-header")).isTrue(); + } + + @Test + void jsonConversionWithExtensions() { + String extensionPatterns = "user-id"; + + ToCloudEventTransformer jsonTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.JSON, new CloudEventProperties()); + + String payload = "test json with extensions"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("user-id", "user-789") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = jsonTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"specversion\""); + assertThat(jsonPayload).contains("\"user-id\""); + + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers.get("content-type")).isEqualTo("application/json"); + assertThat(headers.containsKey("user-id")).isFalse(); + assertThat(jsonPayload).contains("\"user-id\":\"user-789\""); + } + + @Test + void avroConversionWithByteArrayPayload() { + ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); + + byte[] payload = "test avro bytes".getBytes(); + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = avroTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); + assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/avro"); + } + + @Test + void xmlConversionWithObjectPayload() { + ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, new CloudEventProperties()); + + Object payload = new Object() { + @Override + public String toString() { + return "custom xml object"; + } + }; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = xmlTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains(" message = MessageBuilder.withPayload("").build(); + + Object result = jsonTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"specversion\""); + assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/json"); + } + + @Test + void cloudEventPropertiesWithCustomValues() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("custom-event-id"); + properties.setSource(URI.create("https://example.com/source")); + properties.setType("com.example.custom.event"); + properties.setDataContentType("application/json"); + properties.setDataSchema(URI.create("https://example.com/schema")); + properties.setSubject("custom-subject"); + properties.setTime(OffsetDateTime.now()); + + ToCloudEventTransformer customTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); + + String payload = "test custom properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = customTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"id\":\"custom-event-id\""); + assertThat(jsonPayload).contains("\"source\":\"https://example.com/source\""); + assertThat(jsonPayload).contains("\"type\":\"com.example.custom.event\""); + assertThat(jsonPayload).contains("\"datacontenttype\":\"application/json\""); + assertThat(jsonPayload).contains("\"dataschema\":\"https://example.com/schema\""); + assertThat(jsonPayload).contains("\"subject\":\"custom-subject\""); + assertThat(jsonPayload).contains("\"time\":"); + } + + @Test + void cloudEventPropertiesWithNullValues() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("test-id"); + properties.setSource(URI.create("https://example.com")); + properties.setType("test.type"); + + ToCloudEventTransformer customTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); + + String payload = "test null properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = customTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"id\":\"test-id\""); + assertThat(jsonPayload).contains("\"source\":\"https://example.com\""); + assertThat(jsonPayload).contains("\"type\":\"test.type\""); + assertThat(jsonPayload).doesNotContain("\"datacontenttype\":"); + assertThat(jsonPayload).doesNotContain("\"dataschema\":"); + assertThat(jsonPayload).doesNotContain("\"subject\":"); + assertThat(jsonPayload).doesNotContain("\"time\":"); + } + + @Test + void cloudEventPropertiesInXmlFormat() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("xml-event-123"); + properties.setSource(URI.create("https://xml.example.com")); + properties.setType("xml.event.type"); + properties.setSubject("xml-subject"); + + ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, properties); + + String payload = "test xml properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = xmlTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains("xml-event-123"); + assertThat(xmlPayload).contains("https://xml.example.com"); + assertThat(xmlPayload).contains("xml.event.type"); + assertThat(xmlPayload).contains("xml-subject"); + } + + @Test + void cloudEventPropertiesInAvroFormat() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("avro-event-456"); + properties.setSource(URI.create("https://avro.example.com")); + properties.setType("avro.event.type"); + properties.setDataContentType("application/avro"); + + ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, properties); + + String payload = "test avro properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = avroTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); + assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/avro"); + } + + @Test + void defaultConstructorUsesDefaultCloudEventProperties() { + ToCloudEventTransformer defaultTransformer = new ToCloudEventTransformer(); + + String payload = "test default properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = defaultTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + } + + @Test + void testCloudEventPropertiesWithExtensions() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("extension-event"); + properties.setSource(URI.create("https://extension.example.com")); + properties.setType("type.event"); + + String extensionPatterns = "x-trace-id,!x-span-id"; + ToCloudEventTransformer extTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.JSON, properties); + + String payload = "test extensions with properties"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("x-trace-id", "trace-999") + .setHeader("x-span-id", "span-888") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = extTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isInstanceOf(String.class); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"id\":\"extension-event\""); + assertThat(jsonPayload).contains("\"source\":\"https://extension.example.com\""); + assertThat(jsonPayload).contains("\"type\":\"type.event\""); + assertThat(jsonPayload).contains("\"x-trace-id\":\"trace-999\""); + assertThat(jsonPayload).doesNotContain("\"x-span-id\":\"span-888\""); + + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers.containsKey("x-trace-id")).isFalse(); + assertThat(headers.containsKey("x-span-id")).isFalse(); + assertThat(headers.containsKey("regular-header")).isTrue(); + } + + @Test + void testCustomCePrefixInHeaders() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix("CUSTOM_"); + + ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.DEFAULT, properties); + + String payload = "test custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("test-header", "test-value") + .build(); + + Object result = customPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("CUSTOM_id")).isNotNull(); + assertThat(headers.get("CUSTOM_source")).isNotNull(); + assertThat(headers.get("CUSTOM_type")).isNotNull(); + assertThat(headers.get("CUSTOM_specversion")).isEqualTo("1.0"); + + assertThat(headers.containsKey("ce-id")).isFalse(); + assertThat(headers.containsKey("ce-source")).isFalse(); + assertThat(headers.containsKey("ce-type")).isFalse(); + assertThat(headers.containsKey("ce-specversion")).isFalse(); + + assertThat(headers.get("test-header")).isEqualTo("test-value"); + } + + @Test + void testCustomPrefixWithExtensions() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix("APP_CE_"); + + String extensionPatterns = "trace-id,span-id"; + ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, properties); + + String payload = "test custom prefix with extensions"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("trace-id", "trace-456") + .setHeader("span-id", "span-789") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = customExtTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("APP_CE_id")).isNotNull(); + assertThat(headers.get("APP_CE_source")).isNotNull(); + assertThat(headers.get("APP_CE_type")).isNotNull(); + assertThat(headers.get("APP_CE_specversion")).isEqualTo("1.0"); + assertThat(headers.get("APP_CE_trace-id")).isEqualTo("trace-456"); + assertThat(headers.get("APP_CE_span-id")).isEqualTo("span-789"); + + assertThat(headers.containsKey("trace-id")).isFalse(); + assertThat(headers.containsKey("span-id")).isFalse(); + assertThat(headers.get("regular-header")).isEqualTo("regular-value"); + } + + @Test + void testCustomPrefixWithJsonConversion() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("json-prefix-id"); + properties.setSource(URI.create("https://json-prefix.example.com")); + properties.setType("com.example.json.prefix"); + properties.setCePrefix("JSON_CE_"); + + ToCloudEventTransformer jsonPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); + + String payload = "test json with custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("correlation-id", "json-corr-123") + .build(); + + Object result = jsonPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + + assertThat(headers.get("content-type")).isEqualTo("application/json"); + assertThat(headers.get("correlation-id")).isEqualTo("json-corr-123"); + + String jsonPayload = (String) resultMessage.getPayload(); + assertThat(jsonPayload).contains("\"id\":\"json-prefix-id\""); + assertThat(jsonPayload).contains("\"source\":\"https://json-prefix.example.com\""); + assertThat(jsonPayload).contains("\"type\":\"com.example.json.prefix\""); + } + + @Test + void testCustomPrefixWithAvroConversion() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("avro-prefix-id"); + properties.setSource(URI.create("https://avro-prefix.example.com")); + properties.setType("com.example.avro.prefix"); + properties.setCePrefix("AVRO_CE_"); + + ToCloudEventTransformer avroPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, properties); + + String payload = "test avro with custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("message-id", "avro-msg-123") + .build(); + + Object result = avroPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("content-type")).isEqualTo("application/avro"); + assertThat(headers.get("message-id")).isEqualTo("avro-msg-123"); + assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); + } + + @Test + void testCustomPrefixWithXmlConversion() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("xml-prefix-id"); + properties.setSource(URI.create("https://xml-prefix.example.com")); + properties.setType("com.example.xml.prefix"); + properties.setCePrefix("XML_CE_"); + + ToCloudEventTransformer xmlPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, properties); + + String payload = "test xml with custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("request-id", "xml-req-123") + .build(); + + Object result = xmlPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + + assertThat(headers.get("content-type")).isEqualTo("application/xml"); + assertThat(headers.get("request-id")).isEqualTo("xml-req-123"); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains("xml-prefix-id"); + assertThat(xmlPayload).contains("https://xml-prefix.example.com"); + assertThat(xmlPayload).contains("com.example.xml.prefix"); + } + + @Test + void testCustomPrefixWithXmlConversionWithExtensions() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("xml-prefix-id"); + properties.setSource(URI.create("https://xml-prefix.example.com")); + properties.setType("com.example.xml.prefix"); + properties.setCePrefix("XML_CE_"); + + ToCloudEventTransformer xmlPrefixTransformer = new ToCloudEventTransformer("request-id", ToCloudEventTransformer.ConversionType.XML, properties); + + String payload = "test xml with custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("request-id", "xml-req-123") + .build(); + + Object result = xmlPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + + assertThat(headers.get("content-type")).isEqualTo("application/xml"); + assertThat(headers.get("request-id")).isNull(); + + String xmlPayload = (String) resultMessage.getPayload(); + assertThat(xmlPayload).contains("xml-prefix-id"); + assertThat(xmlPayload).contains("https://xml-prefix.example.com"); + assertThat(xmlPayload).contains("com.example.xml.prefix"); + assertThat(xmlPayload).contains("xml-req-123"); + } + + @Test + void testEmptyStringCePrefixBehavior() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix(""); + + ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.DEFAULT, properties); + + String payload = "test empty prefix"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = emptyPrefixTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("id")).isNotNull(); + assertThat(headers.get("source")).isNotNull(); + assertThat(headers.get("type")).isNotNull(); + assertThat(headers.get("specversion")).isEqualTo("1.0"); + + assertThat(headers.containsKey("ce-id")).isFalse(); + assertThat(headers.containsKey("ce-source")).isFalse(); + assertThat(headers.containsKey("ce-type")).isFalse(); + assertThat(headers.containsKey("ce-specversion")).isFalse(); + } +} diff --git a/src/reference/antora/modules/ROOT/nav.adoc b/src/reference/antora/modules/ROOT/nav.adoc index cd6d7d682d..dc92136a0d 100644 --- a/src/reference/antora/modules/ROOT/nav.adoc +++ b/src/reference/antora/modules/ROOT/nav.adoc @@ -62,6 +62,7 @@ ** xref:logging-adapter.adoc[] ** xref:functions-support.adoc[] ** xref:kotlin-functions.adoc[] +* xref:cloudevents/cloudevents-transform.adoc[] * xref:dsl.adoc[] ** xref:dsl/java-basics.adoc[] ** xref:dsl/java-channels.adoc[] diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc new file mode 100644 index 0000000000..f3407a94a3 --- /dev/null +++ b/src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc @@ -0,0 +1,348 @@ +[[cloudevents-transform]] + += CloudEvent Transformer + +[[cloudevent-transformer]] +== CloudEvent Transformer + +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. + +[[cloudevent-transformer-overview]] +=== Overview + +The CloudEvent transformer (`ToCloudEventTransformer`) extends Spring Integration's `AbstractTransformer` to convert messages to CloudEvent format. +It supports multiple output formats including structured CloudEvents, JSON, XML, andAvro serialization. + +[[cloudevent-transformer-configuration]] +=== Configuration + +The transformer can be configured with custom CloudEvent properties, conversion types, and extension management. + +==== Basic Configuration + +[source,java] +---- +@Bean +public ToCloudEventTransformer cloudEventTransformer() { + return new ToCloudEventTransformer(); +} +---- + +==== Advanced Configuration + +[source,java] +---- +@Bean +public ToCloudEventTransformer cloudEventTransformer() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setId("unique-event-id"); + properties.setSource(URI.create("https://io.spring.org/source")); + properties.setType("io.spring.MessageProcessed"); + properties.setDataContentType("application/json"); + properties.setCePrefix("CE_"); + + String extensionPatterns = "key-*,external-*,!internal-*"; + + return new ToCloudEventTransformer( + extensionPatterns, + ToCloudEventTransformer.ConversionType.JSON, + properties + ); +} +---- + +[[cloudevent-transformer-conversion-types]] +=== Conversion Types + +The transformer supports four conversion types for the CloudEvent through the `ToCloudEventTransformer.ConversionType` enumeration: + +* DEFAULT - No format conversion, uses standard CloudEvent message structure +* XML - Serializes CloudEvent as XML in the message payload +* JSON - Serializes CloudEvent as JSON in the message payload +* AVRO - Serializes CloudEvent as compact Avro binary in the message payload + +[[cloudevent-transformer-conversion-default]] +==== DEFAULT +The default format produces standard CloudEvent messages using Spring's CloudEvent support. +This maintains the CloudEvent structure within the `MessageHeaders`. + +[source,java] +---- +ToCloudEventTransformer.ConversionType.DEFAULT +---- + +[[cloudevent-transformer-conversion-json]] +==== JSON +Serializes the CloudEvent as JSON content in the message payload. + +[source,java] +---- +ToCloudEventTransformer.ConversionType.JSON +---- + +Output message characteristics: +- Content-Type: `application/json` +- Payload: JSON-serialized CloudEvent + +[[cloudevent-transformer-conversion-xml]] +==== XML +Serializes the CloudEvent as XML content in the message payload. + +[source,java] +---- +ToCloudEventTransformer.ConversionType.XML +---- + +Output message characteristics: +- Content-Type: `application/xml` +- Payload: XML-serialized CloudEvent + +[[cloudevent-transformer-conversion-avro]] +==== AVRO +Serializes the CloudEvent as compact Avro binary content in the message payload. + +[source,java] +---- +ToCloudEventTransformer.ConversionType.AVRO +---- + +Output message characteristics: +- Content-Type: `application/avro` +- Payload: Binary Avro-serialized CloudEvent + +[[cloudevent-properties]] +=== CloudEvent Properties + +The `CloudEventProperties` class provides configuration for CloudEvent metadata and formatting options. + +==== Properties Configuration + +[source,java] +---- +CloudEventProperties properties = new CloudEventProperties(); +properties.setId("event-123"); // The CloudEvent ID. Default is "". +properties.setSource(URI.create("https://example.com/source")); // The event source. Default is "". +properties.setType("com.example.OrderCreated"); // The event type. The Default is "". +properties.setDataContentType("application/json"); // The data content type. Default is null. +properties.setDataSchema(URI.create("https://example.com/schema")); // The eata schema. Default is null. +properties.setSubject("order-processing"); // The event subject. Default is null. +properties.setTime(OffsetDateTime.now()); // The event time. Default is null. +properties.setCePrefix(CloudeEventsHeaders.CE_PREFIX); // The CloudEvent header prefix. Default is CloudEventsHeaders.CE_PREFIX. +---- + +[[cloudevent-properties-defaults]] +==== Default Values + +|=== +| Property | Default Value | Description + +| `id` +| `""` +| Empty string - should be set to unique identifier + +| `source` +| `URI.create("")` +| Empty URI - should be set to event source + +| `type` +| `""` +| Empty string - should be set to event type + +| `dataContentType` +| `null` +| Optional data content type + +| `dataSchema` +| `null` +| Optional data schema URI + +| `subject` +| `null` +| Optional event subject + +| `time` +| `null` +| Optional event timestamp + +| `cePrefix` +| `CloudEventsHeaders.CE_PREFIX` +| Default is CloudEventsHeaders.CE_PREFIX. +|=== + +[[cloudevent-extensions]] +=== CloudEvent Extensions + +CloudEvent Extensions are managed through the `ToCloudEventTransformerExtensions` class, which implements the CloudEvent extension contract by filtering message headers based on configurable patterns. + +[[cloudevent-extensions-pattern-matching]] +==== Pattern Matching + +The extension system uses pattern matching for sophisticated header filtering: + +[source,java] +---- +// Include headers starting with "key-" or "external-" +// Exclude headers starting with "internal-" +// If the header key is neither of the above it is left in the `MessageHeader`. +String pattern = "key-*,external-*,!internal-*"; + +// Extension patterns are processed during transformation +ToCloudEventTransformer transformer = new ToCloudEventTransformer( + pattern, + ToCloudEventTransformer.ConversionType.DEFAULT, + properties +); +---- + +[[cloudevent-extensions-pattern-syntax]] +==== Pattern Syntax + +The pattern matching supports: + +* **Wildcard patterns**: Use `\*` for wildcard matching (e.g., `external-\*` matches `external-id`, `external-span`) +* **Negation patterns**: Use `!` prefix for exclusion (e.g., `!internal-*` excludes internal headers) +* If the header key is neither of the above it is left in the `MessageHeader`. +* **Multiple patterns**: Use comma-delimited patterns (e.g., `user-\*,session-\*,!debug-*`) +* **Null handling**: Null patterns disable extension processing, thus no `MessageHeaders` are moved to the CloudEvent extensions. + +[[cloudevent-extensions-behavior]] +==== Extension Behavior + +Headers that match extension patterns are: + +1. Extracted from the original message headers +2. Added as CloudEvent extensions +3. Filtered out from the output message headers (to avoid duplication) + +The `ToCloudEventTransformerExtensions` class handles this automatically during transformation. + +[[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. **Extension Extraction**: Extract CloudEvent extensions from message headers using configured patterns +2. **CloudEvent Building**: Build a CloudEvent with configured properties and message payload +3. **Format Conversion**: Apply the specified conversion type to format the output +4. **Header Filtering**: Filter headers to exclude those mapped to CloudEvent extensions + +==== Payload Handling + +The transformer supports multiple payload types: + +[source,java] +---- +// String payload +Message stringMessage = MessageBuilder.withPayload("Hello World").build(); + +// Byte array payload +Message binaryMessage = MessageBuilder.withPayload("Hello".getBytes()).build(); + +// Object payload (converted to string then bytes) +Message objectMessage = MessageBuilder.withPayload(customObject).build(); +---- + +[[cloudevent-transformer-examples]] +=== Examples + +[[cloudevent-transformer-example-basic]] +==== Basic Message Transformation + +[source,java] +---- +// Configure properties +CloudEventProperties properties = new CloudEventProperties(); +properties.setId("event-123"); +properties.setSource(URI.create("https://example.com")); +properties.setType("com.example.MessageProcessed"); + +// Input message with headers +Message inputMessage = MessageBuilder + .withPayload("Hello CloudEvents") + .setHeader("trace-id", "abc123") + .setHeader("user-session", "session456") + .build(); + +// Transformer with extension patterns +ToCloudEventTransformer transformer = new ToCloudEventTransformer( + "external-*", + ToCloudEventTransformer.ConversionType.DEFAULT, + properties +); + +// Transform to CloudEvent +Message cloudEventMessage = transformer.transform(inputMessage); +---- + +[[cloudevent-transformer-example-json]] +==== JSON Serialization Example + +[source,java] +---- +CloudEventProperties properties = new CloudEventProperties(); +properties.setId("order-123"); +properties.setSource(URI.create("https://shop.example.com")); +properties.setType("com.example.OrderCreated"); + +ToCloudEventTransformer transformer = new ToCloudEventTransformer( + "order-*,customer-*", + ToCloudEventTransformer.ConversionType.JSON, + properties +); + +Message result = (Message) transformer.transform(inputMessage); +String jsonCloudEvent = result.getPayload(); // JSON-serialized CloudEvent +String contentType = (String) result.getHeaders().get("content-type"); // "application/json" +---- + +[[cloudevent-transformer-example-xml]] +==== XML Serialization Example + +[source,java] +---- +ToCloudEventTransformer transformer = new ToCloudEventTransformer( + null, // No extension patterns + ToCloudEventTransformer.ConversionType.XML, + properties +); + +Message result = (Message) transformer.transform(inputMessage); +String xmlCloudEvent = result.getPayload(); // XML-serialized CloudEvent +String contentType = (String) result.getHeaders().get("content-type"); // "application/xml" +---- + +[[cloudevent-transformer-example-avro]] +==== Avro Serialization Example + +[source,java] +---- +ToCloudEventTransformer transformer = new ToCloudEventTransformer( + "app-*", + ToCloudEventTransformer.ConversionType.AVRO, + properties +); + +Message result = (Message) transformer.transform(inputMessage); +byte[] avroCloudEvent = result.getPayload(); // Avro-serialized CloudEvent +String contentType = (String) result.getHeaders().get("content-type"); // "application/avro" +---- diff --git a/src/reference/antora/modules/ROOT/pages/whats-new.adoc b/src/reference/antora/modules/ROOT/pages/whats-new.adoc index e46a1a2898..1e37ecc121 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -19,3 +19,9 @@ Java 17 is still the baseline, but Java 25 is supported. The Web Services Outbound Gateway now can rely on the provided `WebServiceTemplate.defaultUri`. See xref:ws.adoc[] for more information. + +[[x7.1-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/cloudevents-transform.adoc[] for more information. From 5cb3b22260270c79e70255cad40cd316ce03bd56 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 29 Sep 2025 11:55:42 -0600 Subject: [PATCH 02/11] Refactor CloudEvents package structure Remove v1 subpackage and flatten the CloudEvents package hierarchy. Introduce strategy pattern for format conversion to replace enum-based approach, improving extensibility and reduce dependencies. Key changes: - Move all classes from cloudevents.v1 to cloudevents base package - Remove optional format dependencies (JSON, XML, Avro) from build - Replace `ConversionType` enum with `FormatStrategy` interface - Add `CloudEventMessageFormatStrategy` as default implementation - Inline `HeaderPatternMatcher` logic into `ToCloudEventTransformerExtensions` - Add `@NullMarked` package annotations and `@Nullable` throughout - Document `targetClass` parameter behavior in `CloudEventMessageConverter` - Split transformer tests for better organization and coverage - Update component type identifier to "ce:to-cloudevents-transformer" - Remove unnecessary docs from package-info --- build.gradle | 12 +- .../{v1 => }/CloudEventMessageConverter.java | 30 +- .../{v1 => }/CloudEventsHeaders.java | 2 +- .../{v1 => }/MessageBinaryMessageReader.java | 8 +- .../{v1 => }/MessageBuilderMessageWriter.java | 5 +- .../integration/cloudevents/package-info.java | 3 + .../transformer/CloudEventProperties.java | 4 +- .../transformer/ToCloudEventTransformer.java | 114 +-- .../ToCloudEventTransformerExtensions.java | 59 +- .../cloudevents/transformer/package-info.java | 3 + .../CloudEventMessageFormatStrategy.java | 57 ++ .../strategies/FormatStrategy.java | 44 + .../transformer/strategies/package-info.java | 3 + .../v1/transformer/package-info.java | 6 - .../utils/HeaderPatternMatcher.java | 74 -- .../v1/transformer/utils/package-info.java | 6 - .../CloudEventMessageConverterTests.java} | 14 +- .../transformer/CloudEventPropertiesTest.java | 2 +- .../MessageBuilderMessageWriterTest.java | 23 +- ...ToCloudEventTransformerExtensionsTest.java | 2 +- .../ToCloudEventTransformerTest.java | 300 +++++++ .../CloudEventMessageFormatStrategyTests.java | 114 +++ .../ToCloudEventTransformerTest.java | 773 ------------------ src/reference/antora/modules/ROOT/nav.adoc | 2 +- .../cloudevents-transform.adoc | 61 +- .../antora/modules/ROOT/pages/whats-new.adoc | 3 +- 26 files changed, 639 insertions(+), 1085 deletions(-) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/CloudEventMessageConverter.java (74%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/CloudEventsHeaders.java (94%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/MessageBinaryMessageReader.java (88%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/MessageBuilderMessageWriter.java (93%) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/transformer/CloudEventProperties.java (95%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/transformer/ToCloudEventTransformer.java (54%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{v1 => }/transformer/ToCloudEventTransformerExtensions.java (58%) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/package-info.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/{v1/transformer/CloudEventMessageConverterTest.java => transformer/CloudEventMessageConverterTests.java} (94%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/{v1 => }/transformer/CloudEventPropertiesTest.java (98%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/{v1 => }/transformer/MessageBuilderMessageWriterTest.java (91%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/{v1 => }/transformer/ToCloudEventTransformerExtensionsTest.java (98%) create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java rename src/reference/antora/modules/ROOT/pages/{cloudevents => }/cloudevents-transform.adoc (84%) diff --git a/build.gradle b/build.gradle index cf9d367ad8..51456d4b26 100644 --- a/build.gradle +++ b/build.gradle @@ -479,20 +479,10 @@ project('spring-integration-cassandra') { } project('spring-integration-cloudevents') { - description = 'Spring Integration Cloud Events Support' + description = 'Spring Integration CloudEvents Support' dependencies { api "io.cloudevents:cloudevents-core:$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" - optionalApi 'org.jspecify:jspecify' - - testImplementation 'org.springframework.amqp:spring-rabbit-test' } } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java similarity index 74% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java index bf957ead4f..887487c453 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventMessageConverter.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1; +package org.springframework.integration.cloudevents; import java.nio.charset.StandardCharsets; @@ -25,6 +25,7 @@ import io.cloudevents.core.message.MessageReader; import io.cloudevents.core.message.impl.GenericStructuredMessageReader; import io.cloudevents.core.message.impl.MessageUtils; +import org.jspecify.annotations.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -42,7 +43,7 @@ */ public class CloudEventMessageConverter implements MessageConverter { - private String cePrefix; + private final String cePrefix; public CloudEventMessageConverter(String cePrefix) { this.cePrefix = cePrefix; @@ -52,28 +53,35 @@ public CloudEventMessageConverter() { this(CloudEventsHeaders.CE_PREFIX); } + /** + Convert the payload of a Message from a CloudEvent to a typed Object of the specified target class. + If the converter does not support the specified media type or cannot perform the conversion, it should return null. + * @param message the input message + * @param targetClass This method does not check the class since it is expected to be a {@link CloudEvent} + * @return the result of the conversion, or null if the converter cannot perform the conversion + */ @Override public Object fromMessage(Message message, Class targetClass) { - Assert.state(CloudEvent.class.isAssignableFrom(targetClass), "Target class must be a CloudEvent"); return createMessageReader(message).toEvent(); } @Override - public Message toMessage(Object payload, MessageHeaders headers) { + public Message toMessage(Object payload, @Nullable MessageHeaders headers) { Assert.state(payload instanceof CloudEvent, "Payload must be a CloudEvent"); + Assert.state(headers != null, "Headers must not be null"); return CloudEventUtils.toReader((CloudEvent) payload).read(new MessageBuilderMessageWriter(headers, this.cePrefix)); } private MessageReader createMessageReader(Message message) { - return MessageUtils.parseStructuredOrBinaryMessage(// - () -> contentType(message.getHeaders()), // - format -> structuredMessageReader(message, format), // - () -> version(message.getHeaders()), // - version -> binaryMessageReader(message, version) // + return MessageUtils.parseStructuredOrBinaryMessage( + () -> contentType(message.getHeaders()), + format -> structuredMessageReader(message, format), + () -> version(message.getHeaders()), + version -> binaryMessageReader(message, version) ); } - private String version(MessageHeaders message) { + private @Nullable String version(MessageHeaders message) { if (message.containsKey(CloudEventsHeaders.SPEC_VERSION)) { return message.get(CloudEventsHeaders.SPEC_VERSION).toString(); } @@ -88,7 +96,7 @@ private MessageReader structuredMessageReader(Message message, EventFormat fo return new GenericStructuredMessageReader(format, getBinaryData(message)); } - private String contentType(MessageHeaders message) { + private @Nullable String contentType(MessageHeaders message) { if (message.containsKey(MessageHeaders.CONTENT_TYPE)) { return message.get(MessageHeaders.CONTENT_TYPE).toString(); } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java similarity index 94% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java index ea5dba99e9..68a14056dd 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/CloudEventsHeaders.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1; +package org.springframework.integration.cloudevents; /** * Constants for Cloud Events header names. diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java similarity index 88% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java index ba24fd2ab2..5ba849c90c 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBinaryMessageReader.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1; +package org.springframework.integration.cloudevents; import java.util.Map; import java.util.function.BiConsumer; @@ -23,6 +23,7 @@ import io.cloudevents.core.data.BytesCloudEventData; import io.cloudevents.core.impl.StringUtils; import io.cloudevents.core.message.impl.BaseGenericBinaryMessageReaderImpl; +import org.jspecify.annotations.Nullable; /** * Utility for converting maps (message headers) to `CloudEvent` contexts. @@ -34,11 +35,12 @@ * */ public class MessageBinaryMessageReader extends BaseGenericBinaryMessageReaderImpl { + private final String cePrefix; private final Map headers; - public MessageBinaryMessageReader(SpecVersion version, Map headers, byte[] payload, String cePrefix) { + public MessageBinaryMessageReader(SpecVersion version, Map headers, byte @Nullable [] payload, String cePrefix) { super(version, payload == null ? null : BytesCloudEventData.wrap(payload)); this.headers = headers; this.cePrefix = cePrefix; @@ -55,7 +57,7 @@ protected boolean isContentTypeHeader(String key) { @Override protected boolean isCloudEventsHeader(String key) { - return key != null && key.length() > this.cePrefix.length() && StringUtils.startsWithIgnoreCase(key, this.cePrefix); + return key.length() > this.cePrefix.length() && StringUtils.startsWithIgnoreCase(key, this.cePrefix); } @Override diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java similarity index 93% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java index 4386c422d0..ab2b8823e6 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/MessageBuilderMessageWriter.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1; +package org.springframework.integration.cloudevents; import java.util.HashMap; import java.util.Map; @@ -26,6 +26,7 @@ import io.cloudevents.rw.CloudEventContextWriter; import io.cloudevents.rw.CloudEventRWException; import io.cloudevents.rw.CloudEventWriter; +import org.jspecify.annotations.Nullable; import org.springframework.messaging.Message; import org.springframework.messaging.support.MessageBuilder; @@ -62,7 +63,7 @@ public Message setEvent(EventFormat format, byte[] value) throws CloudEv } @Override - public Message end(CloudEventData value) throws CloudEventRWException { + public Message end(@Nullable CloudEventData value) throws CloudEventRWException { return MessageBuilder.withPayload(value == null ? new byte[0] : value.toBytes()).copyHeaders(this.headers).build(); } 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..116ccfd7f8 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java @@ -0,0 +1,3 @@ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java similarity index 95% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java index 8472be8b11..55b9394547 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventProperties.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java @@ -14,14 +14,14 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.net.URI; import java.time.OffsetDateTime; import org.jspecify.annotations.Nullable; -import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; +import org.springframework.integration.cloudevents.CloudEventsHeaders; /** * Configuration properties for CloudEvent metadata and formatting. diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java similarity index 54% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java index 2e5e9e6718..b2f5b61bca 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformer.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java @@ -14,28 +14,20 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; - -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; +package org.springframework.integration.cloudevents.transformer; import io.cloudevents.CloudEvent; -import io.cloudevents.avro.compact.AvroCompactFormat; import io.cloudevents.core.builder.CloudEventBuilder; -import io.cloudevents.jackson.JsonFormat; -import io.cloudevents.xml.XMLFormat; import org.jspecify.annotations.Nullable; -import org.springframework.integration.cloudevents.v1.CloudEventMessageConverter; -import org.springframework.integration.cloudevents.v1.transformer.utils.HeaderPatternMatcher; +import org.springframework.integration.cloudevents.CloudEventMessageConverter; +import org.springframework.integration.cloudevents.CloudEventsHeaders; +import org.springframework.integration.cloudevents.transformer.strategies.CloudEventMessageFormatStrategy; +import org.springframework.integration.cloudevents.transformer.strategies.FormatStrategy; import org.springframework.integration.transformer.AbstractTransformer; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MessageConversionException; import org.springframework.messaging.converter.MessageConverter; -import org.springframework.messaging.support.MessageBuilder; -import org.springframework.util.Assert; /** * A Spring Integration transformer that converts messages to CloudEvent format. @@ -62,24 +54,11 @@ */ public class ToCloudEventTransformer extends AbstractTransformer { - /** - * Enumeration of supported CloudEvent conversion types. - *

- * Defines the different output formats supported by the transformer: - *

    - *
  • DEFAULT - No format conversion, uses standard CloudEvent message structure
  • - *
  • XML - Serializes CloudEvent as XML in the message payload
  • - *
  • JSON - Serializes CloudEvent as JSON in the message payload
  • - *
  • AVRO - Serializes CloudEvent as compact Avro binary in the message payload
  • - *
- */ - public enum ConversionType { DEFAULT, XML, JSON, AVRO } - private final MessageConverter messageConverter; private final @Nullable String cloudEventExtensionPatterns; - private final ConversionType conversionType; + private final FormatStrategy formatStrategy; private final CloudEventProperties cloudEventProperties; @@ -90,19 +69,20 @@ public enum ConversionType { DEFAULT, XML, JSON, AVRO } * supports wildcards and negation with '!' prefix If a header matches one of the '!' it is excluded from * cloud event headers and the message headers. If a header does not match for a prefix or a exclusion, the header * is left in the message headers. . Null to disable extension mapping. - * @param conversionType the output format for the CloudEvent (DEFAULT, XML, JSON, or AVRO) + * @param formatStrategy The strategy that determines how the CloudEvent will be rendered * @param cloudEventProperties configuration properties for CloudEvent metadata (id, source, type, etc.) */ public ToCloudEventTransformer(@Nullable String cloudEventExtensionPatterns, - ConversionType conversionType, CloudEventProperties cloudEventProperties) { + FormatStrategy formatStrategy, CloudEventProperties cloudEventProperties) { this.messageConverter = new CloudEventMessageConverter(cloudEventProperties.getCePrefix()); this.cloudEventExtensionPatterns = cloudEventExtensionPatterns; - this.conversionType = conversionType; + this.formatStrategy = formatStrategy; this.cloudEventProperties = cloudEventProperties; } public ToCloudEventTransformer() { - this(null, ConversionType.DEFAULT, new CloudEventProperties()); + this(null, new CloudEventMessageFormatStrategy(CloudEventsHeaders.CE_PREFIX), + new CloudEventProperties()); } /** @@ -135,75 +115,7 @@ protected Object doTransform(Message message) { .withData(getPayloadAsBytes(message.getPayload())) .withExtension(extensions) .build(); - - switch (this.conversionType) { - case XML: - return convertToXmlMessage(cloudEvent, message.getHeaders()); - case JSON: - return convertToJsonMessage(cloudEvent, message.getHeaders()); - case AVRO: - return convertToAvroMessage(cloudEvent, message.getHeaders()); - default: - var result = this.messageConverter.toMessage(cloudEvent, filterHeaders(message.getHeaders())); - Assert.state(result != null, "Payload result must not be null"); - return result; - } - } - - private Message convertToXmlMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { - XMLFormat xmlFormat = new XMLFormat(); - String xmlContent = new String(xmlFormat.serialize(cloudEvent)); - return buildStringMessage(xmlContent, originalHeaders, "application/xml"); - } - - private Message convertToJsonMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { - JsonFormat jsonFormat = new JsonFormat(); - String jsonContent = new String(jsonFormat.serialize(cloudEvent)); - return buildStringMessage(jsonContent, originalHeaders, "application/json"); - } - - private Message buildStringMessage(String serializedCloudEvent, - MessageHeaders originalHeaders, String contentType) { - try { - return MessageBuilder.withPayload(serializedCloudEvent) - .copyHeaders(filterHeaders(originalHeaders)) - .setHeader("content-type", contentType) - .build(); - } - catch (Exception e) { - throw new MessageConversionException("Failed to convert CloudEvent to " + contentType, e); - } - } - - private Message convertToAvroMessage(CloudEvent cloudEvent, MessageHeaders originalHeaders) { - try { - AvroCompactFormat avroFormat = new AvroCompactFormat(); - byte[] avroBytes = avroFormat.serialize(cloudEvent); - return MessageBuilder.withPayload(avroBytes) - .copyHeaders(filterHeaders(originalHeaders)) - .setHeader("content-type", "application/avro") - .build(); - } - catch (Exception e) { - throw new RuntimeException("Failed to convert CloudEvent to application/avro", e); - } - } - - /** - * This method creates a {@link MessageHeaders} that were not placed in the CloudEvent and were not excluded via the - * categorization mechanism. - * @param headers The {@link MessageHeaders} to be filtered. - * @return {@link MessageHeaders} that have been filtered. - */ - private MessageHeaders filterHeaders(MessageHeaders headers) { - - Map filteredHeaders = new HashMap<>(); - headers.keySet().forEach(key -> { - if (HeaderPatternMatcher.categorizeHeader(key, this.cloudEventExtensionPatterns) == null) { - filteredHeaders.put(key, Objects.requireNonNull(headers.get(key))); - } - }); - return new MessageHeaders(filteredHeaders); + return this.formatStrategy.convert(cloudEvent, new MessageHeaders(extensions.getFilteredHeaders())); } private byte[] getPayloadAsBytes(Object payload) { @@ -220,7 +132,7 @@ else if (payload instanceof String stringPayload) { @Override public String getComponentType() { - return "to-cloud-transformer"; + return "ce:to-cloudevents-transformer"; } } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java similarity index 58% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java index 2dbf281374..33cb0ed656 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensions.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.util.HashMap; import java.util.Map; @@ -25,8 +25,9 @@ import io.cloudevents.CloudEventExtensions; import org.jspecify.annotations.Nullable; -import org.springframework.integration.cloudevents.v1.transformer.utils.HeaderPatternMatcher; +import org.springframework.integration.support.utils.PatternMatchUtils; import org.springframework.messaging.MessageHeaders; +import org.springframework.util.StringUtils; /** * CloudEvent extension implementation that extracts extensions from Spring Integration message headers. @@ -48,30 +49,39 @@ * * @since 7.0 */ -public class ToCloudEventTransformerExtensions implements CloudEventExtension { +class ToCloudEventTransformerExtensions implements CloudEventExtension { /** - * Internal map storing the CloudEvent extensions extracted from message headers. + * Map storing the CloudEvent extensions extracted from message headers. */ private final Map cloudEventExtensions; + /** + * Map storing the headers that need to remain in the {@link MessageHeaders} unchanged. + */ + private final Map filteredHeaders; + /** * Constructs CloudEvent extensions by filtering message headers against patterns. *

- * Headers are evaluated against the provided patterns using {@link HeaderPatternMatcher}. + * Headers are evaluated against the provided patterns. * Only headers that match the patterns (and are not excluded by negation patterns) * will be included as CloudEvent extensions. * * @param headers the Spring Integration message headers to process * @param patterns comma-delimited patterns for header matching, may be null to include no extensions */ - public ToCloudEventTransformerExtensions(MessageHeaders headers, @Nullable String patterns) { + ToCloudEventTransformerExtensions(MessageHeaders headers, @Nullable String patterns) { this.cloudEventExtensions = new HashMap<>(); + this.filteredHeaders = new HashMap<>(); headers.keySet().forEach(key -> { - Boolean result = HeaderPatternMatcher.categorizeHeader(key, patterns); + Boolean result = categorizeHeader(key, patterns); if (result != null && result) { this.cloudEventExtensions.put(key, (String) Objects.requireNonNull(headers.get(key))); } + else { + this.filteredHeaders.put(key, Objects.requireNonNull(headers.get(key))); + } }); } @@ -93,4 +103,39 @@ public Set getKeys() { return this.cloudEventExtensions.keySet(); } + /** + * Categorizes a header value by matching it against a comma-delimited pattern string. + *

+ * This method takes a header value and matches it against one or more patterns + * specified in a comma-delimited string. It uses Spring's smart pattern matching + * which supports wildcards and other pattern matching features. + * + * @param value the header value to match against the patterns + * @param pattern a comma-delimited string of patterns to match against, or null. If pattern is null then null is returned. + * @return {@code Boolean.TRUE} if the value starts with a pattern token, + * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, + * or {@code null} if the header starts with a value that is not enumerated in the pattern + */ + public static @Nullable Boolean categorizeHeader(String value, @Nullable String pattern) { + if (pattern == null) { + return null; + } + Set patterns = StringUtils.commaDelimitedListToSet(pattern); + Boolean result = null; + for (String patternItem : patterns) { + result = PatternMatchUtils.smartMatch(value, patternItem); + if (result != null && result) { + break; + } + else if (result != null) { + break; + } + } + return result; + } + + public Map getFilteredHeaders() { + return this.filteredHeaders; + } + } 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/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java new file mode 100644 index 0000000000..4c34c13394 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java @@ -0,0 +1,57 @@ +/* + * 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.strategies; + +import io.cloudevents.CloudEvent; + +import org.springframework.integration.cloudevents.CloudEventMessageConverter; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * Implementation of {@link FormatStrategy} that converts CloudEvents to Spring + * Integration messages. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +public class CloudEventMessageFormatStrategy implements FormatStrategy { + + private final CloudEventMessageConverter messageConverter; + + public CloudEventMessageFormatStrategy() { + this.messageConverter = new CloudEventMessageConverter("ce-"); + } + + public CloudEventMessageFormatStrategy(String cePrefix) { + this.messageConverter = new CloudEventMessageConverter(cePrefix); + } + + /** + * Converts the CloudEvent to a Spring Integration Message. + * + * @param cloudEvent the CloudEvent to convert + * @param messageHeaders additional headers to include in the message + * @return a Spring Integration Message containing the CloudEvent data and headers + */ + @Override + public Message convert(CloudEvent cloudEvent, MessageHeaders messageHeaders) { + return this.messageConverter.toMessage(cloudEvent, messageHeaders); + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java new file mode 100644 index 0000000000..408b7dc348 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java @@ -0,0 +1,44 @@ +/* + * 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.strategies; + +import io.cloudevents.CloudEvent; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +/** + * Strategy interface for converting CloudEvents to different message formats. + * + *

Implementations of this interface define how CloudEvents should be transformed + * into message objects. This allows for pluggable conversion strategies to support different messaging formats and + * protocols. + * + * @author Glenn Renfro + * @since 7.0 + */ +public interface FormatStrategy { + + /** + * Converts the {@link CloudEvent} to a message object. + * + * @param cloudEvent the CloudEvent to be converted to a {@link Message} + * @param messageHeaders the headers associated with the {@link Message} + * @return the converted {@link Message} + */ + Message convert(CloudEvent cloudEvent, MessageHeaders messageHeaders); +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java new file mode 100644 index 0000000000..9c7e28f12c --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java @@ -0,0 +1,3 @@ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents.transformer.strategies; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java deleted file mode 100644 index b1ac6053f6..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/package-info.java +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Base package for CloudEvents transformer support. - */ - -@org.jspecify.annotations.NullMarked -package org.springframework.integration.cloudevents.v1.transformer; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java deleted file mode 100644 index 6c6f6f33e5..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/HeaderPatternMatcher.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.v1.transformer.utils; - -import java.util.Set; - -import org.jspecify.annotations.Nullable; - -import org.springframework.integration.support.utils.PatternMatchUtils; -import org.springframework.util.StringUtils; - -/** - * Utility class for matching header values against comma-delimited patterns. - *

- * This class provides pattern matching functionality for header categorization for cloud events - * using Spring's PatternMatchUtils for smart pattern matching with support for - * wildcards and special pattern syntax. - * - * @author Glenn Renfro - * - * @since 7.0 - */ -public final class HeaderPatternMatcher { - - private HeaderPatternMatcher() { - - } - - /** - * Categorizes a header value by matching it against a comma-delimited pattern string. - *

- * This method takes a header value and matches it against one or more patterns - * specified in a comma-delimited string. It uses Spring's smart pattern matching - * which supports wildcards and other pattern matching features. - * - * @param value the header value to match against the patterns - * @param pattern a comma-delimited string of patterns to match against, or null. If pattern is null then null is returned. - * @return {@code Boolean.TRUE} if the value starts with a pattern token, - * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, - * or {@code null} if the header starts with a value that is not enumerated in the pattern - */ - public static @Nullable Boolean categorizeHeader(String value, @Nullable String pattern) { - if (pattern == null) { - return null; - } - Set patterns = StringUtils.commaDelimitedListToSet(pattern); - Boolean result = null; - for (String patternItem : patterns) { - result = PatternMatchUtils.smartMatch(value, patternItem); - if (result != null && result) { - break; - } - else if (result != null) { - break; - } - } - return result; - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java deleted file mode 100644 index f807c27161..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/v1/transformer/utils/package-info.java +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Base package for CloudEvents transformer util support. - */ - -@org.jspecify.annotations.NullMarked -package org.springframework.integration.cloudevents.v1.transformer.utils; diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java similarity index 94% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java index a649f8c601..ac8501acd7 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventMessageConverterTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.net.URI; import java.time.OffsetDateTime; @@ -26,17 +26,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.integration.cloudevents.v1.CloudEventMessageConverter; -import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; +import org.springframework.integration.cloudevents.CloudEventMessageConverter; +import org.springframework.integration.cloudevents.CloudEventsHeaders; import org.springframework.integration.support.MessageBuilder; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.catchIllegalStateException; -public class CloudEventMessageConverterTest { +public class CloudEventMessageConverterTests { private CloudEventMessageConverter converter; @@ -235,8 +236,7 @@ void invalidPayloadToMessage() { @Test void invalidPayloadFromMessage() { Message message = MessageBuilder.withPayload(Integer.valueOf(1234)).build(); - assertThatIllegalStateException() - .isThrownBy(() -> this.converter.fromMessage(message, Integer.class)) - .withMessage("Target class must be a CloudEvent"); + assertThatThrownBy(() -> this.converter.fromMessage(message, Integer.class)) + .hasMessage("Could not parse. Unknown encoding. Invalid content type or spec version"); } } diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java similarity index 98% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java index 89774de875..339cb6590c 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/CloudEventPropertiesTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.net.URI; import java.time.OffsetDateTime; diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java similarity index 91% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java index 20af134321..02d34b8edd 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/MessageBuilderMessageWriterTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.util.HashMap; import java.util.Map; @@ -22,12 +22,11 @@ import io.cloudevents.CloudEventData; import io.cloudevents.SpecVersion; import io.cloudevents.core.format.EventFormat; -import io.cloudevents.jackson.JsonFormat; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.integration.cloudevents.v1.CloudEventsHeaders; -import org.springframework.integration.cloudevents.v1.MessageBuilderMessageWriter; +import org.springframework.integration.cloudevents.CloudEventsHeaders; +import org.springframework.integration.cloudevents.MessageBuilderMessageWriter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -215,22 +214,6 @@ void setEventWithTextPayload() { assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); } - @Test - void testSetEventWithJsonPayload() { - byte[] jsonData = "{\"key\":\"value\"}".getBytes(); - - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "json-id") - .withContextAttribute("source", "https://json.example.com") - .withContextAttribute("type", "com.example.json"); - - Message message = this.writer.setEvent(new JsonFormat(), jsonData); - - assertThat(message).isNotNull(); - assertThat(message.getPayload()).isEqualTo(jsonData); - assertThat(message.getHeaders().get(CloudEventsHeaders.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); - } - @Test void headersCorrectlyAssignedToMessageHeader() { this.writer.create(SpecVersion.V1); diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java similarity index 98% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java index 3212a0bf0e..4182fa5d0a 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerExtensionsTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.v1.transformer; +package org.springframework.integration.cloudevents.transformer; import java.util.HashMap; import java.util.Map; diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java new file mode 100644 index 0000000000..3cffe356b3 --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java @@ -0,0 +1,300 @@ +/* + * 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.integration.cloudevents.transformer.strategies.CloudEventMessageFormatStrategy; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +class ToCloudEventTransformerTest { + + private ToCloudEventTransformer transformer; + + @BeforeEach + void setUp() { + String extensionPatterns = "customer-header"; + this.transformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy("ce-"), + new CloudEventProperties()); + } + + @Test + void doTransformWithStringPayload() { + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("custom-header", "test-value") + .setHeader("other-header", "other-value") + .build(); + + Object result = this.transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isEqualTo(payload.getBytes()); + + // Verify that CloudEvent headers are present in the message + MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers).isNotNull(); + + // Check that the original other-header is preserved (not mapped to extension) + assertThat(headers.containsKey("other-header")).isTrue(); + assertThat(headers.get("other-header")).isEqualTo("other-value"); + + } + + @Test + void doTransformWithByteArrayPayload() { + byte[] payload = "test message".getBytes(); + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(resultMessage.getPayload()).isEqualTo(payload); + + } + + @Test + void doTransformWithObjectPayload() { + Object payload = new Object() { + @Override + public String toString() { + return "custom object"; + } + }; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + + Message resultMessage = (Message) result; + assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(resultMessage.getPayload()).isEqualTo(payload.toString().getBytes()); + } + + @Test + void headerFiltering() { + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("customer-header", "extension-value") + .setHeader("regular-header", "regular-value") + .setHeader("another-regular", "another-value") + .build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // Check that regular headers are preserved + assertThat(resultMessage.getHeaders().containsKey("regular-header")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("another-regular")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-customer-header")).isTrue(); + assertThat(resultMessage.getHeaders().get("regular-header")).isEqualTo("regular-value"); + assertThat(resultMessage.getHeaders().get("another-regular")).isEqualTo("another-value"); + + + + } + + @Test + void emptyExtensionNames() { + ToCloudEventTransformer emptyExtensionTransformer = new ToCloudEventTransformer(); + + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("some-header", "some-value") + .build(); + + Object result = emptyExtensionTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // All headers should be preserved when no extension mapping exists + assertThat(resultMessage.getHeaders().containsKey("some-header")).isTrue(); + assertThat(resultMessage.getHeaders().get("some-header")).isEqualTo("some-value"); + } + + @Test + void multipleExtensionMappings() { + String extensionPatterns = "trace-id,span-id,user-id"; + + ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy("ce-"), new CloudEventProperties()); + + String payload = "test message"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("trace-id", "trace-123") + .setHeader("span-id", "span-456") + .setHeader("user-id", "user-789") + .setHeader("correlation-id", "corr-999") + .build(); + + Object result = extendedTransformer.doTransform(message); + + assertThat(result).isNotNull(); + Message resultMessage = (Message) result; + + // Extension-mapped headers should be converted to cloud event extensions + assertThat(resultMessage.getHeaders().containsKey("trace-id")).isFalse(); + assertThat(resultMessage.getHeaders().containsKey("span-id")).isFalse(); + assertThat(resultMessage.getHeaders().containsKey("user-id")).isFalse(); + + assertThat(resultMessage.getHeaders().containsKey("ce-trace-id")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-span-id")).isTrue(); + assertThat(resultMessage.getHeaders().containsKey("ce-user-id")).isTrue(); + + // Non-mapped header should be preserved + assertThat(resultMessage.getHeaders().containsKey("correlation-id")).isTrue(); + assertThat(resultMessage.getHeaders().get("correlation-id")).isEqualTo("corr-999"); + } + + @Test + void emptyStringPayloadHandling() { + Message message = MessageBuilder.withPayload("").build(); + + Object result = transformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + } + + @Test + void defaultConstructorUsesDefaultCloudEventProperties() { + ToCloudEventTransformer defaultTransformer = new ToCloudEventTransformer(); + + String payload = "test default properties"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = defaultTransformer.doTransform(message); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + } + + @Test + void testCustomCePrefixInHeaders() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix("CUSTOM_"); + + ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer(null, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); + + String payload = "test custom prefix"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("test-header", "test-value") + .build(); + + Object result = customPrefixTransformer.doTransform(message); + + Message resultMessage = getTransformedMessage(result); + + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("CUSTOM_id")).isNotNull(); + assertThat(headers.get("CUSTOM_source")).isNotNull(); + assertThat(headers.get("CUSTOM_type")).isNotNull(); + assertThat(headers.get("CUSTOM_specversion")).isEqualTo("1.0"); + + assertThat(headers.containsKey("ce-id")).isFalse(); + assertThat(headers.containsKey("ce-source")).isFalse(); + assertThat(headers.containsKey("ce-type")).isFalse(); + assertThat(headers.containsKey("ce-specversion")).isFalse(); + + assertThat(headers.get("test-header")).isEqualTo("test-value"); + } + + @Test + void testCustomPrefixWithExtensions() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix("APP_CE_"); + + String extensionPatterns = "trace-id,span-id"; + ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); + + String payload = "test custom prefix with extensions"; + Message message = MessageBuilder.withPayload(payload) + .setHeader("trace-id", "trace-456") + .setHeader("span-id", "span-789") + .setHeader("regular-header", "regular-value") + .build(); + + Object result = customExtTransformer.doTransform(message); + + Message resultMessage = getTransformedMessage(result); + + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("APP_CE_id")).isNotNull(); + assertThat(headers.get("APP_CE_source")).isNotNull(); + assertThat(headers.get("APP_CE_type")).isNotNull(); + assertThat(headers.get("APP_CE_specversion")).isEqualTo("1.0"); + assertThat(headers.get("APP_CE_trace-id")).isEqualTo("trace-456"); + assertThat(headers.get("APP_CE_span-id")).isEqualTo("span-789"); + + assertThat(headers.containsKey("trace-id")).isFalse(); + assertThat(headers.containsKey("span-id")).isFalse(); + assertThat(headers.get("regular-header")).isEqualTo("regular-value"); + } + + @Test + void testEmptyStringCePrefixBehavior() { + CloudEventProperties properties = new CloudEventProperties(); + properties.setCePrefix(""); + + ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer(null, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); + + String payload = "test empty prefix"; + Message message = MessageBuilder.withPayload(payload).build(); + + Object result = emptyPrefixTransformer.doTransform(message); + + Message resultMessage = getTransformedMessage(result); + + MessageHeaders headers = resultMessage.getHeaders(); + + assertThat(headers.get("id")).isNotNull(); + assertThat(headers.get("source")).isNotNull(); + assertThat(headers.get("type")).isNotNull(); + assertThat(headers.get("specversion")).isEqualTo("1.0"); + + assertThat(headers.containsKey("ce-id")).isFalse(); + assertThat(headers.containsKey("ce-source")).isFalse(); + assertThat(headers.containsKey("ce-type")).isFalse(); + assertThat(headers.containsKey("ce-specversion")).isFalse(); + } + + private Message getTransformedMessage(Object object) { + assertThat(object).isNotNull(); + assertThat(object).isInstanceOf(Message.class); + + return (Message) object; + } + +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java new file mode 100644 index 0000000000..285157da9c --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java @@ -0,0 +1,114 @@ +/* + * 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.strategies; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import org.junit.jupiter.api.Test; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CloudEventMessageFormatStrategyTests { + + @Test + void convertCloudEventToMessage() { + CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); + + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("test-id") + .withSource(URI.create("test-source")) + .withType("test-type") + .withData("Some data".getBytes()) + .build(); + + MessageHeaders headers = new MessageHeaders(null); + + Object result = strategy.convert(cloudEvent, headers); + + assertThat(result).isInstanceOf(Message.class); + Message message = (Message) result; + assertThat(message.getPayload()).isNotNull(); + assertThat(message.getHeaders().containsKey("ce_id")).isTrue(); + assertThat(message.getHeaders().get("ce_id")).isEqualTo("test-id"); + } + + @Test + void convertWithAdditionalHeaders() { + CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); + + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("test-id") + .withSource(URI.create("test-source")) + .withType("test-type") + .withData("application/json", "{}".getBytes()) + .build(); + + Map additionalHeaders = new HashMap<>(); + additionalHeaders.put("custom-header", "custom-value"); + MessageHeaders headers = new MessageHeaders(additionalHeaders); + + Object result = strategy.convert(cloudEvent, headers); + + assertThat(result).isInstanceOf(Message.class); + Message message = (Message) result; + assertThat(message.getHeaders().containsKey("custom-header")).isTrue(); + assertThat(message.getHeaders().get("custom-header")).isEqualTo("custom-value"); + } + + @Test + void convertWithDifferentPrefix() { + CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("cloudevent-"); + + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("test-id") + .withSource(URI.create("test-source")) + .withType("test-type") + .build(); + + MessageHeaders headers = new MessageHeaders(null); + + Object result = strategy.convert(cloudEvent, headers); + + assertThat(result).isInstanceOf(Message.class); + Message message = (Message) result; + assertThat(message.getHeaders().containsKey("cloudevent-id")).isTrue(); + } + + @Test + void convertWithEmptyHeaders() { + CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); + + CloudEvent cloudEvent = CloudEventBuilder.v1() + .withId("test-id") + .withSource(URI.create("test-source")) + .withType("test-type") + .build(); + + MessageHeaders headers = new MessageHeaders(new HashMap<>()); + + Object result = strategy.convert(cloudEvent, headers); + + assertThat(result).isInstanceOf(Message.class); + } +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java deleted file mode 100644 index 5db3a63ef5..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/v1/transformer/ToCloudEventTransformerTest.java +++ /dev/null @@ -1,773 +0,0 @@ -/* - * 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.v1.transformer; - -import java.net.URI; -import java.time.OffsetDateTime; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.support.MessageBuilder; - -import static org.assertj.core.api.Assertions.assertThat; - -class ToCloudEventTransformerTest { - - private ToCloudEventTransformer transformer; - - @BeforeEach - void setUp() { - String extensionPatterns = "customer-header"; - this.transformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, - new CloudEventProperties()); - } - - @Test - void doTransformWithStringPayload() { - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("custom-header", "test-value") - .setHeader("other-header", "other-value") - .build(); - - Object result = this.transformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isEqualTo(payload.getBytes()); - - // Verify that CloudEvent headers are present in the message - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers).isNotNull(); - - // Check that the original other-header is preserved (not mapped to extension) - assertThat(headers.containsKey("other-header")).isTrue(); - assertThat(headers.get("other-header")).isEqualTo("other-value"); - - } - - @Test - void doTransformWithByteArrayPayload() { - byte[] payload = "test message".getBytes(); - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = transformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isNotNull(); - assertThat(resultMessage.getPayload()).isEqualTo(payload); - - } - - @Test - void doTransformWithObjectPayload() { - Object payload = new Object() { - @Override - public String toString() { - return "custom object"; - } - }; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = transformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isNotNull(); - assertThat(resultMessage.getPayload()).isEqualTo(payload.toString().getBytes()); - } - - @Test - void headerFiltering() { - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("customer-header", "extension-value") - .setHeader("regular-header", "regular-value") - .setHeader("another-regular", "another-value") - .build(); - - Object result = transformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - - // Check that regular headers are preserved - assertThat(resultMessage.getHeaders().containsKey("regular-header")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("another-regular")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-customer-header")).isTrue(); - assertThat(resultMessage.getHeaders().get("regular-header")).isEqualTo("regular-value"); - assertThat(resultMessage.getHeaders().get("another-regular")).isEqualTo("another-value"); - - - - } - - @Test - void emptyExtensionNames() { - ToCloudEventTransformer emptyExtensionTransformer = new ToCloudEventTransformer(); - - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("some-header", "some-value") - .build(); - - Object result = emptyExtensionTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - - // All headers should be preserved when no extension mapping exists - assertThat(resultMessage.getHeaders().containsKey("some-header")).isTrue(); - assertThat(resultMessage.getHeaders().get("some-header")).isEqualTo("some-value"); - } - - @Test - void multipleExtensionMappings() { - String extensionPatterns = "trace-id,span-id,user-id"; - - ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, new CloudEventProperties()); - - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("trace-id", "trace-123") - .setHeader("span-id", "span-456") - .setHeader("user-id", "user-789") - .setHeader("correlation-id", "corr-999") - .build(); - - Object result = extendedTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - - // Extension-mapped headers should be converted to cloud event extensions - assertThat(resultMessage.getHeaders().containsKey("trace-id")).isFalse(); - assertThat(resultMessage.getHeaders().containsKey("span-id")).isFalse(); - assertThat(resultMessage.getHeaders().containsKey("user-id")).isFalse(); - - assertThat(resultMessage.getHeaders().containsKey("ce-trace-id")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-span-id")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-user-id")).isTrue(); - - // Non-mapped header should be preserved - assertThat(resultMessage.getHeaders().containsKey("correlation-id")).isTrue(); - assertThat(resultMessage.getHeaders().get("correlation-id")).isEqualTo("corr-999"); - } - - @Test - void emptyStringPayloadHandling() { - Message message = MessageBuilder.withPayload("").build(); - - Object result = transformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - } - - @Test - void avroConversion() { - ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); - - String payload = "test avro message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("source-header", "test-value") - .build(); - - Object result = avroTransformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.get("content-type")).isEqualTo("application/avro"); - assertThat(headers.containsKey("source-header")).isTrue(); - } - - @Test - void avroConversionWithExtensions() { - String extensionPatterns = "trace-id"; - - ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); - - String payload = "test avro with extensions"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("trace-id", "trace-123") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = avroTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.get("content-type")).isEqualTo("application/avro"); - assertThat(headers.containsKey("trace-id")).isFalse(); - assertThat(headers.containsKey("regular-header")).isTrue(); - } - - @Test - void xmlConversion() { - ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, new CloudEventProperties()); - - String payload = "test xml message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("source-header", "test-value") - .build(); - - Object result = xmlTransformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains(" message = MessageBuilder.withPayload(payload) - .setHeader("span-id", "span-456") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = xmlTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains("span-456"); - } - - @Test - void jsonConversion() { - ToCloudEventTransformer jsonTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, new CloudEventProperties()); - - String payload = "test json message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("source-header", "test-value") - .build(); - - Object result = jsonTransformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"specversion\""); - assertThat(jsonPayload).contains("\"type\""); - assertThat(jsonPayload).contains("\"source\""); - assertThat(jsonPayload).contains("\"id\""); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.get("content-type")).isEqualTo("application/json"); - assertThat(headers.containsKey("source-header")).isTrue(); - } - - @Test - void jsonConversionWithExtensions() { - String extensionPatterns = "user-id"; - - ToCloudEventTransformer jsonTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.JSON, new CloudEventProperties()); - - String payload = "test json with extensions"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("user-id", "user-789") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = jsonTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"specversion\""); - assertThat(jsonPayload).contains("\"user-id\""); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.get("content-type")).isEqualTo("application/json"); - assertThat(headers.containsKey("user-id")).isFalse(); - assertThat(jsonPayload).contains("\"user-id\":\"user-789\""); - } - - @Test - void avroConversionWithByteArrayPayload() { - ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, new CloudEventProperties()); - - byte[] payload = "test avro bytes".getBytes(); - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = avroTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); - assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/avro"); - } - - @Test - void xmlConversionWithObjectPayload() { - ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, new CloudEventProperties()); - - Object payload = new Object() { - @Override - public String toString() { - return "custom xml object"; - } - }; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = xmlTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains(" message = MessageBuilder.withPayload("").build(); - - Object result = jsonTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"specversion\""); - assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/json"); - } - - @Test - void cloudEventPropertiesWithCustomValues() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("custom-event-id"); - properties.setSource(URI.create("https://example.com/source")); - properties.setType("com.example.custom.event"); - properties.setDataContentType("application/json"); - properties.setDataSchema(URI.create("https://example.com/schema")); - properties.setSubject("custom-subject"); - properties.setTime(OffsetDateTime.now()); - - ToCloudEventTransformer customTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); - - String payload = "test custom properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = customTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"id\":\"custom-event-id\""); - assertThat(jsonPayload).contains("\"source\":\"https://example.com/source\""); - assertThat(jsonPayload).contains("\"type\":\"com.example.custom.event\""); - assertThat(jsonPayload).contains("\"datacontenttype\":\"application/json\""); - assertThat(jsonPayload).contains("\"dataschema\":\"https://example.com/schema\""); - assertThat(jsonPayload).contains("\"subject\":\"custom-subject\""); - assertThat(jsonPayload).contains("\"time\":"); - } - - @Test - void cloudEventPropertiesWithNullValues() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("test-id"); - properties.setSource(URI.create("https://example.com")); - properties.setType("test.type"); - - ToCloudEventTransformer customTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); - - String payload = "test null properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = customTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"id\":\"test-id\""); - assertThat(jsonPayload).contains("\"source\":\"https://example.com\""); - assertThat(jsonPayload).contains("\"type\":\"test.type\""); - assertThat(jsonPayload).doesNotContain("\"datacontenttype\":"); - assertThat(jsonPayload).doesNotContain("\"dataschema\":"); - assertThat(jsonPayload).doesNotContain("\"subject\":"); - assertThat(jsonPayload).doesNotContain("\"time\":"); - } - - @Test - void cloudEventPropertiesInXmlFormat() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("xml-event-123"); - properties.setSource(URI.create("https://xml.example.com")); - properties.setType("xml.event.type"); - properties.setSubject("xml-subject"); - - ToCloudEventTransformer xmlTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, properties); - - String payload = "test xml properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = xmlTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains("xml-event-123"); - assertThat(xmlPayload).contains("https://xml.example.com"); - assertThat(xmlPayload).contains("xml.event.type"); - assertThat(xmlPayload).contains("xml-subject"); - } - - @Test - void cloudEventPropertiesInAvroFormat() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("avro-event-456"); - properties.setSource(URI.create("https://avro.example.com")); - properties.setType("avro.event.type"); - properties.setDataContentType("application/avro"); - - ToCloudEventTransformer avroTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, properties); - - String payload = "test avro properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = avroTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); - assertThat(resultMessage.getHeaders().get("content-type")).isEqualTo("application/avro"); - } - - @Test - void defaultConstructorUsesDefaultCloudEventProperties() { - ToCloudEventTransformer defaultTransformer = new ToCloudEventTransformer(); - - String payload = "test default properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = defaultTransformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - } - - @Test - void testCloudEventPropertiesWithExtensions() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("extension-event"); - properties.setSource(URI.create("https://extension.example.com")); - properties.setType("type.event"); - - String extensionPatterns = "x-trace-id,!x-span-id"; - ToCloudEventTransformer extTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.JSON, properties); - - String payload = "test extensions with properties"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("x-trace-id", "trace-999") - .setHeader("x-span-id", "span-888") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = extTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isInstanceOf(String.class); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"id\":\"extension-event\""); - assertThat(jsonPayload).contains("\"source\":\"https://extension.example.com\""); - assertThat(jsonPayload).contains("\"type\":\"type.event\""); - assertThat(jsonPayload).contains("\"x-trace-id\":\"trace-999\""); - assertThat(jsonPayload).doesNotContain("\"x-span-id\":\"span-888\""); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.containsKey("x-trace-id")).isFalse(); - assertThat(headers.containsKey("x-span-id")).isFalse(); - assertThat(headers.containsKey("regular-header")).isTrue(); - } - - @Test - void testCustomCePrefixInHeaders() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix("CUSTOM_"); - - ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.DEFAULT, properties); - - String payload = "test custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("test-header", "test-value") - .build(); - - Object result = customPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); - - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers.get("CUSTOM_id")).isNotNull(); - assertThat(headers.get("CUSTOM_source")).isNotNull(); - assertThat(headers.get("CUSTOM_type")).isNotNull(); - assertThat(headers.get("CUSTOM_specversion")).isEqualTo("1.0"); - - assertThat(headers.containsKey("ce-id")).isFalse(); - assertThat(headers.containsKey("ce-source")).isFalse(); - assertThat(headers.containsKey("ce-type")).isFalse(); - assertThat(headers.containsKey("ce-specversion")).isFalse(); - - assertThat(headers.get("test-header")).isEqualTo("test-value"); - } - - @Test - void testCustomPrefixWithExtensions() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix("APP_CE_"); - - String extensionPatterns = "trace-id,span-id"; - ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer(extensionPatterns, ToCloudEventTransformer.ConversionType.DEFAULT, properties); - - String payload = "test custom prefix with extensions"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("trace-id", "trace-456") - .setHeader("span-id", "span-789") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = customExtTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers.get("APP_CE_id")).isNotNull(); - assertThat(headers.get("APP_CE_source")).isNotNull(); - assertThat(headers.get("APP_CE_type")).isNotNull(); - assertThat(headers.get("APP_CE_specversion")).isEqualTo("1.0"); - assertThat(headers.get("APP_CE_trace-id")).isEqualTo("trace-456"); - assertThat(headers.get("APP_CE_span-id")).isEqualTo("span-789"); - - assertThat(headers.containsKey("trace-id")).isFalse(); - assertThat(headers.containsKey("span-id")).isFalse(); - assertThat(headers.get("regular-header")).isEqualTo("regular-value"); - } - - @Test - void testCustomPrefixWithJsonConversion() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("json-prefix-id"); - properties.setSource(URI.create("https://json-prefix.example.com")); - properties.setType("com.example.json.prefix"); - properties.setCePrefix("JSON_CE_"); - - ToCloudEventTransformer jsonPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.JSON, properties); - - String payload = "test json with custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("correlation-id", "json-corr-123") - .build(); - - Object result = jsonPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - - assertThat(headers.get("content-type")).isEqualTo("application/json"); - assertThat(headers.get("correlation-id")).isEqualTo("json-corr-123"); - - String jsonPayload = (String) resultMessage.getPayload(); - assertThat(jsonPayload).contains("\"id\":\"json-prefix-id\""); - assertThat(jsonPayload).contains("\"source\":\"https://json-prefix.example.com\""); - assertThat(jsonPayload).contains("\"type\":\"com.example.json.prefix\""); - } - - @Test - void testCustomPrefixWithAvroConversion() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("avro-prefix-id"); - properties.setSource(URI.create("https://avro-prefix.example.com")); - properties.setType("com.example.avro.prefix"); - properties.setCePrefix("AVRO_CE_"); - - ToCloudEventTransformer avroPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.AVRO, properties); - - String payload = "test avro with custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("message-id", "avro-msg-123") - .build(); - - Object result = avroPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers.get("content-type")).isEqualTo("application/avro"); - assertThat(headers.get("message-id")).isEqualTo("avro-msg-123"); - assertThat(resultMessage.getPayload()).isInstanceOf(byte[].class); - } - - @Test - void testCustomPrefixWithXmlConversion() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("xml-prefix-id"); - properties.setSource(URI.create("https://xml-prefix.example.com")); - properties.setType("com.example.xml.prefix"); - properties.setCePrefix("XML_CE_"); - - ToCloudEventTransformer xmlPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.XML, properties); - - String payload = "test xml with custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("request-id", "xml-req-123") - .build(); - - Object result = xmlPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - - assertThat(headers.get("content-type")).isEqualTo("application/xml"); - assertThat(headers.get("request-id")).isEqualTo("xml-req-123"); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains("xml-prefix-id"); - assertThat(xmlPayload).contains("https://xml-prefix.example.com"); - assertThat(xmlPayload).contains("com.example.xml.prefix"); - } - - @Test - void testCustomPrefixWithXmlConversionWithExtensions() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("xml-prefix-id"); - properties.setSource(URI.create("https://xml-prefix.example.com")); - properties.setType("com.example.xml.prefix"); - properties.setCePrefix("XML_CE_"); - - ToCloudEventTransformer xmlPrefixTransformer = new ToCloudEventTransformer("request-id", ToCloudEventTransformer.ConversionType.XML, properties); - - String payload = "test xml with custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("request-id", "xml-req-123") - .build(); - - Object result = xmlPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - - assertThat(headers.get("content-type")).isEqualTo("application/xml"); - assertThat(headers.get("request-id")).isNull(); - - String xmlPayload = (String) resultMessage.getPayload(); - assertThat(xmlPayload).contains("xml-prefix-id"); - assertThat(xmlPayload).contains("https://xml-prefix.example.com"); - assertThat(xmlPayload).contains("com.example.xml.prefix"); - assertThat(xmlPayload).contains("xml-req-123"); - } - - @Test - void testEmptyStringCePrefixBehavior() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix(""); - - ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer(null, ToCloudEventTransformer.ConversionType.DEFAULT, properties); - - String payload = "test empty prefix"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = emptyPrefixTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers.get("id")).isNotNull(); - assertThat(headers.get("source")).isNotNull(); - assertThat(headers.get("type")).isNotNull(); - assertThat(headers.get("specversion")).isEqualTo("1.0"); - - assertThat(headers.containsKey("ce-id")).isFalse(); - assertThat(headers.containsKey("ce-source")).isFalse(); - assertThat(headers.containsKey("ce-type")).isFalse(); - assertThat(headers.containsKey("ce-specversion")).isFalse(); - } -} diff --git a/src/reference/antora/modules/ROOT/nav.adoc b/src/reference/antora/modules/ROOT/nav.adoc index dc92136a0d..2040060454 100644 --- a/src/reference/antora/modules/ROOT/nav.adoc +++ b/src/reference/antora/modules/ROOT/nav.adoc @@ -62,7 +62,6 @@ ** xref:logging-adapter.adoc[] ** xref:functions-support.adoc[] ** xref:kotlin-functions.adoc[] -* xref:cloudevents/cloudevents-transform.adoc[] * xref:dsl.adoc[] ** xref:dsl/java-basics.adoc[] ** xref:dsl/java-channels.adoc[] @@ -125,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/cloudevents-transform.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc similarity index 84% rename from src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc rename to src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc index f3407a94a3..ba12bc0a91 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents/cloudevents-transform.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc @@ -46,70 +46,17 @@ public ToCloudEventTransformer cloudEventTransformer() { return new ToCloudEventTransformer( extensionPatterns, - ToCloudEventTransformer.ConversionType.JSON, + new CloudEventMessageFormatStrategy(), properties ); } ---- [[cloudevent-transformer-conversion-types]] -=== Conversion Types +=== Format Strategy -The transformer supports four conversion types for the CloudEvent through the `ToCloudEventTransformer.ConversionType` enumeration: - -* DEFAULT - No format conversion, uses standard CloudEvent message structure -* XML - Serializes CloudEvent as XML in the message payload -* JSON - Serializes CloudEvent as JSON in the message payload -* AVRO - Serializes CloudEvent as compact Avro binary in the message payload - -[[cloudevent-transformer-conversion-default]] -==== DEFAULT -The default format produces standard CloudEvent messages using Spring's CloudEvent support. -This maintains the CloudEvent structure within the `MessageHeaders`. - -[source,java] ----- -ToCloudEventTransformer.ConversionType.DEFAULT ----- - -[[cloudevent-transformer-conversion-json]] -==== JSON -Serializes the CloudEvent as JSON content in the message payload. - -[source,java] ----- -ToCloudEventTransformer.ConversionType.JSON ----- - -Output message characteristics: -- Content-Type: `application/json` -- Payload: JSON-serialized CloudEvent - -[[cloudevent-transformer-conversion-xml]] -==== XML -Serializes the CloudEvent as XML content in the message payload. - -[source,java] ----- -ToCloudEventTransformer.ConversionType.XML ----- - -Output message characteristics: -- Content-Type: `application/xml` -- Payload: XML-serialized CloudEvent - -[[cloudevent-transformer-conversion-avro]] -==== AVRO -Serializes the CloudEvent as compact Avro binary content in the message payload. - -[source,java] ----- -ToCloudEventTransformer.ConversionType.AVRO ----- - -Output message characteristics: -- Content-Type: `application/avro` -- Payload: Binary Avro-serialized CloudEvent +The ToCloudEventTransformer accepts classes that implement the `FormatStrategy` that will serialize the +CloudEvents's data to other formats other than the default `CloudEventMessageFormatStrategy`. [[cloudevent-properties]] === CloudEvent Properties diff --git a/src/reference/antora/modules/ROOT/pages/whats-new.adoc b/src/reference/antora/modules/ROOT/pages/whats-new.adoc index 1e37ecc121..b6abf59870 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -22,6 +22,7 @@ See xref:ws.adoc[] for more information. [[x7.1-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/cloudevents-transform.adoc[] for more information. +See xref:cloudevents-transform.adoc[] for more information. From db90782daff33b3668c032546aca2dfd9ea9db1a Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 6 Oct 2025 18:14:35 -0400 Subject: [PATCH 03/11] Consolidate CloudEvent transformer configuration - Simplify the CloudEvent transformer by consolidating configuration directly into ToCloudEventTransformer class rather than using separate configuration objects - Remove CloudEventProperties and ToCloudEventTransformerExtensions classes to reduce abstraction layers and improve maintainability - Make MessageBinaryMessageReader package-private and convert CloudEventMessageConverter methods to static where possible - Move extension filtering logic into a private inner class within the transformer - Remove CloudEventsHeaders class and CE_PREFIX constant as the prefix is no longer used as a configurable value --- .../cloudevents/CloudEventsHeaders.java | 38 --- .../integration/cloudevents/package-info.java | 3 - .../transformer/CloudEventProperties.java | 126 ---------- .../transformer/ToCloudEventTransformer.java | 218 ++++++++++++++---- .../ToCloudEventTransformerExtensions.java | 141 ----------- .../CloudEventMessageFormatStrategy.java | 12 +- .../strategies/FormatStrategy.java | 2 +- .../CloudEventMessageConverter.java | 36 +-- .../MessageBinaryMessageReader.java | 14 +- .../MessageBuilderMessageWriter.java | 14 +- .../cloudeventconverter/package-info.java | 3 + .../transformer/CloudEventPropertiesTest.java | 153 ------------ ...ToCloudEventTransformerExtensionsTest.java | 124 ---------- ...java => ToCloudEventTransformerTests.java} | 62 ++--- .../CloudEventMessageFormatStrategyTests.java | 16 +- .../CloudEventMessageConverterTests.java | 32 ++- .../MessageBuilderMessageWriterTests.java} | 42 ++-- .../ROOT/pages/cloudevents-transform.adoc | 173 +++----------- 18 files changed, 313 insertions(+), 896 deletions(-) delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{ => transformer/strategies/cloudeventconverter}/CloudEventMessageConverter.java (75%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{ => transformer/strategies/cloudeventconverter}/MessageBinaryMessageReader.java (76%) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/{ => transformer/strategies/cloudeventconverter}/MessageBuilderMessageWriter.java (84%) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/{ToCloudEventTransformerTest.java => ToCloudEventTransformerTests.java} (78%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/{ => strategies/cloudeventconverter}/CloudEventMessageConverterTests.java (82%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/{MessageBuilderMessageWriterTest.java => strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java} (77%) diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java deleted file mode 100644 index 68a14056dd..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventsHeaders.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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; - -/** - * Constants for Cloud Events header names. - * - * @author Glenn Renfro - * - * @since 7.0 - */ -public final class CloudEventsHeaders { - - public static final String CE_PREFIX = "ce-"; - - public static final String SPEC_VERSION = CE_PREFIX + "specversion"; - - public static final String CONTENT_TYPE = CE_PREFIX + "datacontenttype"; - - private CloudEventsHeaders() { - - } - -} 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 deleted file mode 100644 index 116ccfd7f8..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java +++ /dev/null @@ -1,3 +0,0 @@ - -@org.jspecify.annotations.NullMarked -package org.springframework.integration.cloudevents; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java deleted file mode 100644 index 55b9394547..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventProperties.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 org.jspecify.annotations.Nullable; - -import org.springframework.integration.cloudevents.CloudEventsHeaders; - -/** - * Configuration properties for CloudEvent metadata and formatting. - *

- * This class provides configurable properties for CloudEvent creation, including - * required attributes (id, source, type) and optional attributes (subject, time, dataContentType, dataSchema). - * It also supports customization of the CloudEvent header prefix for integration with different systems. - *

- * All properties have defaults and can be configured as needed: - *

    - *
  • Required attributes default to empty strings/URIs
  • - *
  • Optional attributes default to null
  • - *
  • CloudEvent prefix defaults to standard "ce-" format
  • - *
- * - * @author Glenn Renfro - * - * @since 7.0 - */ -public class CloudEventProperties { - - private String id = ""; - - private URI source = URI.create(""); - - private String type = ""; - - private @Nullable String dataContentType; - - private @Nullable URI dataSchema; - - private @Nullable String subject; - - private @Nullable OffsetDateTime time; - - private String cePrefix = CloudEventsHeaders.CE_PREFIX; - - public String getId() { - return this.id; - } - - public void setId(String id) { - this.id = id; - } - - public URI getSource() { - return this.source; - } - - public void setSource(URI source) { - this.source = source; - } - - public String getType() { - return this.type; - } - - public void setType(String type) { - this.type = type; - } - - public @Nullable String getDataContentType() { - return this.dataContentType; - } - - public void setDataContentType(@Nullable String dataContentType) { - this.dataContentType = dataContentType; - } - - public @Nullable URI getDataSchema() { - return this.dataSchema; - } - - public void setDataSchema(@Nullable URI dataSchema) { - this.dataSchema = dataSchema; - } - - public @Nullable String getSubject() { - return this.subject; - } - - public void setSubject(@Nullable String subject) { - this.subject = subject; - } - - public @Nullable OffsetDateTime getTime() { - return this.time; - } - - public void setTime(@Nullable OffsetDateTime time) { - this.time = time; - } - - public String getCePrefix() { - return this.cePrefix; - } - - public void setCePrefix(String cePrefix) { - this.cePrefix = cePrefix; - } - -} 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 index b2f5b61bca..a03e6dcc2a 100644 --- 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 @@ -16,35 +16,28 @@ 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 io.cloudevents.CloudEvent; +import io.cloudevents.CloudEventExtension; +import io.cloudevents.CloudEventExtensions; import io.cloudevents.core.builder.CloudEventBuilder; import org.jspecify.annotations.Nullable; -import org.springframework.integration.cloudevents.CloudEventMessageConverter; -import org.springframework.integration.cloudevents.CloudEventsHeaders; import org.springframework.integration.cloudevents.transformer.strategies.CloudEventMessageFormatStrategy; import org.springframework.integration.cloudevents.transformer.strategies.FormatStrategy; +import org.springframework.integration.support.utils.PatternMatchUtils; import org.springframework.integration.transformer.AbstractTransformer; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MessageConverter; /** * A Spring Integration transformer that converts messages to CloudEvent format. - *

- * This transformer converts Spring Integration messages into CloudEvent compliant - * messages, supporting various output formats including structured, XML, JSON, and Avro. - * It handles CloudEvent extensions through configurable header pattern matching and provides - * configuration through {@link CloudEventProperties}. - *

- * The transformer supports the following conversion types: - *

    - *
  • DEFAULT - Standard CloudEvent message
  • - *
  • XML - CloudEvent serialized as XML content
  • - *
  • JSON - CloudEvent serialized as JSON content
  • - *
  • AVRO - CloudEvent serialized as Avro binary content
  • - *
- *

* Header filtering and extension mapping is performed based on configurable patterns, * allowing control over which headers are preserved and which become CloudEvent extensions. * @@ -54,35 +47,47 @@ */ public class ToCloudEventTransformer extends AbstractTransformer { - private final MessageConverter messageConverter; + public static String CE_PREFIX = "ce-"; - private final @Nullable String cloudEventExtensionPatterns; + private String id = ""; - private final FormatStrategy formatStrategy; + private URI source = URI.create(""); + + private String type = ""; + + private @Nullable String dataContentType; + + private @Nullable URI dataSchema; + + private @Nullable String subject; + + private @Nullable OffsetDateTime time; - private final CloudEventProperties cloudEventProperties; + private final String @Nullable [] cloudEventExtensionPatterns; + + private final FormatStrategy formatStrategy; /** * ToCloudEventTransformer Constructor * - * @param cloudEventExtensionPatterns comma-delimited patterns for matching headers that should become CloudEvent extensions, - * supports wildcards and negation with '!' prefix If a header matches one of the '!' it is excluded from - * cloud event headers and the message headers. If a header does not match for a prefix or a exclusion, the header - * is left in the message headers. . Null to disable extension mapping. * @param formatStrategy The strategy that determines how the CloudEvent will be rendered - * @param cloudEventProperties configuration properties for CloudEvent metadata (id, source, type, etc.) + * @param cloudEventExtensionPatterns an array of patterns for matching headers that should become CloudEvent extensions, + * supports wildcards and negation with '!' prefix If a header matches one of the '!' it is excluded from + * cloud event headers and the message headers. Null to disable extension mapping. */ - public ToCloudEventTransformer(@Nullable String cloudEventExtensionPatterns, - FormatStrategy formatStrategy, CloudEventProperties cloudEventProperties) { - this.messageConverter = new CloudEventMessageConverter(cloudEventProperties.getCePrefix()); + public ToCloudEventTransformer(FormatStrategy formatStrategy, + String @Nullable ... cloudEventExtensionPatterns) { this.cloudEventExtensionPatterns = cloudEventExtensionPatterns; this.formatStrategy = formatStrategy; - this.cloudEventProperties = cloudEventProperties; } + /** + * Constructs a {@link ToCloudEventTransformer} that defaults to the {@link CloudEventMessageFormatStrategy}. This + * strategy will use the default CE_PREFIX and will not contain and cloudEventExtensionPatterns. + * + */ public ToCloudEventTransformer() { - this(null, new CloudEventMessageFormatStrategy(CloudEventsHeaders.CE_PREFIX), - new CloudEventProperties()); + this(new CloudEventMessageFormatStrategy(CE_PREFIX), (String[]) null); } /** @@ -105,20 +110,20 @@ protected Object doTransform(Message message) { ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions(message.getHeaders(), this.cloudEventExtensionPatterns); CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId(this.cloudEventProperties.getId()) - .withSource(this.cloudEventProperties.getSource()) - .withType(this.cloudEventProperties.getType()) - .withTime(this.cloudEventProperties.getTime()) - .withDataContentType(this.cloudEventProperties.getDataContentType()) - .withDataSchema(this.cloudEventProperties.getDataSchema()) - .withSubject(this.cloudEventProperties.getSubject()) + .withId(this.id) + .withSource(this.source) + .withType(this.type) + .withTime(this.time) + .withDataContentType(this.dataContentType) + .withDataSchema(this.dataSchema) + .withSubject(this.subject) .withData(getPayloadAsBytes(message.getPayload())) .withExtension(extensions) .build(); - return this.formatStrategy.convert(cloudEvent, new MessageHeaders(extensions.getFilteredHeaders())); + return this.formatStrategy.toIntegrationMessage(cloudEvent, message.getHeaders()); } - private byte[] getPayloadAsBytes(Object payload) { + private static byte[] getPayloadAsBytes(Object payload) { if (payload instanceof byte[] bytePayload) { return bytePayload; } @@ -135,4 +140,135 @@ public String getComponentType() { return "ce:to-cloudevents-transformer"; } + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public URI getSource() { + return this.source; + } + + public void setSource(URI source) { + this.source = source; + } + + public String getType() { + return this.type; + } + + public void setType(String type) { + this.type = type; + } + + public @Nullable String getDataContentType() { + return this.dataContentType; + } + + public void setDataContentType(@Nullable String dataContentType) { + this.dataContentType = dataContentType; + } + + public @Nullable URI getDataSchema() { + return this.dataSchema; + } + + public void setDataSchema(@Nullable URI dataSchema) { + this.dataSchema = dataSchema; + } + + public @Nullable String getSubject() { + return this.subject; + } + + public void setSubject(@Nullable String subject) { + this.subject = subject; + } + + public @Nullable OffsetDateTime getTime() { + return this.time; + } + + public void setTime(@Nullable OffsetDateTime time) { + this.time = time; + } + + private static class ToCloudEventTransformerExtensions implements CloudEventExtension { + + /** + * Map storing the CloudEvent extensions extracted from message headers. + */ + private final Map cloudEventExtensions; + + /** + * Construct CloudEvent extensions by filtering message headers against patterns. + *

+ * Headers are evaluated against the provided patterns. + * Only headers that match the patterns (and are not excluded by negation patterns) + * will be included as CloudEvent extensions. + * + * @param headers the Spring Integration message headers to process + * @param patterns comma-delimited patterns for header matching, may be null to include no extensions + */ + ToCloudEventTransformerExtensions(MessageHeaders headers, String @Nullable ... patterns) { + this.cloudEventExtensions = new HashMap<>(); + headers.keySet().forEach(key -> { + Boolean result = categorizeHeader(key, patterns); + if (result != null && result) { + this.cloudEventExtensions.put(key, (String) Objects.requireNonNull(headers.get(key))); + } + }); + } + + @Override + public void readFrom(CloudEventExtensions extensions) { + extensions.getExtensionNames() + .forEach(key -> { + this.cloudEventExtensions.put(key, this.cloudEventExtensions.get(key)); + }); + } + + @Override + public @Nullable Object getValue(String key) throws IllegalArgumentException { + return this.cloudEventExtensions.get(key); + } + + @Override + public Set getKeys() { + return this.cloudEventExtensions.keySet(); + } + + /** + * Categorizes a header value by matching it against a comma-delimited pattern string. + *

+ * This method takes a header value and matches it against one or more patterns + * specified in a comma-delimited string. It uses Spring's smart pattern matching + * which supports wildcards and other pattern matching features. + * + * @param value the header value to match against the patterns + * @param patterns an array of string patterns to match against, or null. If pattern is null then null is returned. + * @return {@code Boolean.TRUE} if the value starts with a pattern token, + * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, + * or {@code null} if the header starts with a value that is not enumerated in the pattern + */ + public static @Nullable Boolean categorizeHeader(String value, String @Nullable ... patterns) { + Boolean result = null; + if (patterns != null) { + for (String patternItem : patterns) { + result = PatternMatchUtils.smartMatch(value, patternItem); + if (result != null && result) { + break; + } + else if (result != null) { + break; + } + } + } + return result; + } + + } } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java deleted file mode 100644 index 33cb0ed656..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensions.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import io.cloudevents.CloudEventExtension; -import io.cloudevents.CloudEventExtensions; -import org.jspecify.annotations.Nullable; - -import org.springframework.integration.support.utils.PatternMatchUtils; -import org.springframework.messaging.MessageHeaders; -import org.springframework.util.StringUtils; - -/** - * CloudEvent extension implementation that extracts extensions from Spring Integration message headers. - *

- * This class implements the CloudEvent extension contract by filtering message headers - * based on configurable patterns and converting matching headers into CloudEvent extensions. - * It supports pattern-based inclusion and exclusion of headers using Spring's pattern matching utilities. - *

- * Pattern matching supports: - *

    - *
  • Wildcard patterns (e.g., "trace-*" matches "trace-id", "trace-span") means the matching header will be moved - * to the CloudEvent extensions.
  • - *
  • Negation patterns with '!' prefix (e.g., "!internal-*" excludes internal headers) means the matching header - * will be not be moved to the CloudEvent extensions or left in the message header.
  • - *
  • Comma-delimited multiple patterns (e.g., "trace-*,span-*,!internal-*")
  • - *
- * - * @author Glenn Renfro - * - * @since 7.0 - */ -class ToCloudEventTransformerExtensions implements CloudEventExtension { - - /** - * Map storing the CloudEvent extensions extracted from message headers. - */ - private final Map cloudEventExtensions; - - /** - * Map storing the headers that need to remain in the {@link MessageHeaders} unchanged. - */ - private final Map filteredHeaders; - - /** - * Constructs CloudEvent extensions by filtering message headers against patterns. - *

- * Headers are evaluated against the provided patterns. - * Only headers that match the patterns (and are not excluded by negation patterns) - * will be included as CloudEvent extensions. - * - * @param headers the Spring Integration message headers to process - * @param patterns comma-delimited patterns for header matching, may be null to include no extensions - */ - ToCloudEventTransformerExtensions(MessageHeaders headers, @Nullable String patterns) { - this.cloudEventExtensions = new HashMap<>(); - this.filteredHeaders = new HashMap<>(); - headers.keySet().forEach(key -> { - Boolean result = categorizeHeader(key, patterns); - if (result != null && result) { - this.cloudEventExtensions.put(key, (String) Objects.requireNonNull(headers.get(key))); - } - else { - this.filteredHeaders.put(key, Objects.requireNonNull(headers.get(key))); - } - }); - } - - @Override - public void readFrom(CloudEventExtensions extensions) { - extensions.getExtensionNames() - .forEach(key -> { - this.cloudEventExtensions.put(key, this.cloudEventExtensions.get(key)); - }); - } - - @Override - public @Nullable Object getValue(String key) throws IllegalArgumentException { - return this.cloudEventExtensions.get(key); - } - - @Override - public Set getKeys() { - return this.cloudEventExtensions.keySet(); - } - - /** - * Categorizes a header value by matching it against a comma-delimited pattern string. - *

- * This method takes a header value and matches it against one or more patterns - * specified in a comma-delimited string. It uses Spring's smart pattern matching - * which supports wildcards and other pattern matching features. - * - * @param value the header value to match against the patterns - * @param pattern a comma-delimited string of patterns to match against, or null. If pattern is null then null is returned. - * @return {@code Boolean.TRUE} if the value starts with a pattern token, - * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, - * or {@code null} if the header starts with a value that is not enumerated in the pattern - */ - public static @Nullable Boolean categorizeHeader(String value, @Nullable String pattern) { - if (pattern == null) { - return null; - } - Set patterns = StringUtils.commaDelimitedListToSet(pattern); - Boolean result = null; - for (String patternItem : patterns) { - result = PatternMatchUtils.smartMatch(value, patternItem); - if (result != null && result) { - break; - } - else if (result != null) { - break; - } - } - return result; - } - - public Map getFilteredHeaders() { - return this.filteredHeaders; - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java index 4c34c13394..1c89fd17b2 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java @@ -18,7 +18,7 @@ import io.cloudevents.CloudEvent; -import org.springframework.integration.cloudevents.CloudEventMessageConverter; +import org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter.CloudEventMessageConverter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -34,14 +34,14 @@ public class CloudEventMessageFormatStrategy implements FormatStrategy { private final CloudEventMessageConverter messageConverter; - public CloudEventMessageFormatStrategy() { - this.messageConverter = new CloudEventMessageConverter("ce-"); - } - public CloudEventMessageFormatStrategy(String cePrefix) { this.messageConverter = new CloudEventMessageConverter(cePrefix); } + public CloudEventMessageFormatStrategy() { + this.messageConverter = new CloudEventMessageConverter(CloudEventMessageConverter.CE_PREFIX); + } + /** * Converts the CloudEvent to a Spring Integration Message. * @@ -50,7 +50,7 @@ public CloudEventMessageFormatStrategy(String cePrefix) { * @return a Spring Integration Message containing the CloudEvent data and headers */ @Override - public Message convert(CloudEvent cloudEvent, MessageHeaders messageHeaders) { + public Message toIntegrationMessage(CloudEvent cloudEvent, MessageHeaders messageHeaders) { return this.messageConverter.toMessage(cloudEvent, messageHeaders); } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java index 408b7dc348..79a3409308 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java @@ -40,5 +40,5 @@ public interface FormatStrategy { * @param messageHeaders the headers associated with the {@link Message} * @return the converted {@link Message} */ - Message convert(CloudEvent cloudEvent, MessageHeaders messageHeaders); + Message toIntegrationMessage(CloudEvent cloudEvent, MessageHeaders messageHeaders); } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java similarity index 75% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java index 887487c453..54ad50754b 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/CloudEventMessageConverter.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents; +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; import java.nio.charset.StandardCharsets; @@ -43,6 +43,12 @@ */ public class CloudEventMessageConverter implements MessageConverter { + public static final String CE_PREFIX = "ce-"; + + public static final String SPEC_VERSION = CE_PREFIX + "specversion"; + + public static final String CONTENT_TYPE = CE_PREFIX + "datacontenttype"; + private final String cePrefix; public CloudEventMessageConverter(String cePrefix) { @@ -50,7 +56,7 @@ public CloudEventMessageConverter(String cePrefix) { } public CloudEventMessageConverter() { - this(CloudEventsHeaders.CE_PREFIX); + this(CE_PREFIX); } /** @@ -62,7 +68,7 @@ public CloudEventMessageConverter() { */ @Override public Object fromMessage(Message message, Class targetClass) { - return createMessageReader(message).toEvent(); + return createMessageReader(message, this.cePrefix).toEvent(); } @Override @@ -72,41 +78,41 @@ public Message toMessage(Object payload, @Nullable MessageHeaders headers) { return CloudEventUtils.toReader((CloudEvent) payload).read(new MessageBuilderMessageWriter(headers, this.cePrefix)); } - private MessageReader createMessageReader(Message message) { + private static MessageReader createMessageReader(Message message, String cePrefix) { return MessageUtils.parseStructuredOrBinaryMessage( () -> contentType(message.getHeaders()), format -> structuredMessageReader(message, format), () -> version(message.getHeaders()), - version -> binaryMessageReader(message, version) + version -> binaryMessageReader(message, version, cePrefix) ); } - private @Nullable String version(MessageHeaders message) { - if (message.containsKey(CloudEventsHeaders.SPEC_VERSION)) { - return message.get(CloudEventsHeaders.SPEC_VERSION).toString(); + private static @Nullable String version(MessageHeaders message) { + if (message.containsKey(SPEC_VERSION)) { + return message.get(SPEC_VERSION).toString(); } return null; } - private MessageReader binaryMessageReader(Message message, SpecVersion version) { - return new MessageBinaryMessageReader(version, message.getHeaders(), getBinaryData(message), this.cePrefix); + private static MessageReader binaryMessageReader(Message message, SpecVersion version, String cePrefix) { + return new MessageBinaryMessageReader(version, message.getHeaders(), getBinaryData(message), cePrefix); } - private MessageReader structuredMessageReader(Message message, EventFormat format) { + private static MessageReader structuredMessageReader(Message message, EventFormat format) { return new GenericStructuredMessageReader(format, getBinaryData(message)); } - private @Nullable String contentType(MessageHeaders message) { + private static @Nullable String contentType(MessageHeaders message) { if (message.containsKey(MessageHeaders.CONTENT_TYPE)) { return message.get(MessageHeaders.CONTENT_TYPE).toString(); } - if (message.containsKey(CloudEventsHeaders.CONTENT_TYPE)) { - return message.get(CloudEventsHeaders.CONTENT_TYPE).toString(); + if (message.containsKey(CONTENT_TYPE)) { + return message.get(CONTENT_TYPE).toString(); } return null; } - private byte[] getBinaryData(Message message) { + private static byte[] getBinaryData(Message message) { Object payload = message.getPayload(); if (payload instanceof byte[] bytePayload) { return bytePayload; diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java similarity index 76% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java index 5ba849c90c..3b00bb4ccb 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBinaryMessageReader.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java @@ -14,17 +14,19 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents; +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; import java.util.Map; import java.util.function.BiConsumer; import io.cloudevents.SpecVersion; import io.cloudevents.core.data.BytesCloudEventData; -import io.cloudevents.core.impl.StringUtils; import io.cloudevents.core.message.impl.BaseGenericBinaryMessageReaderImpl; import org.jspecify.annotations.Nullable; +import org.springframework.messaging.MessageHeaders; +import org.springframework.util.StringUtils; + /** * Utility for converting maps (message headers) to `CloudEvent` contexts. * @@ -34,25 +36,25 @@ * @since 7.0 * */ -public class MessageBinaryMessageReader extends BaseGenericBinaryMessageReaderImpl { +class MessageBinaryMessageReader extends BaseGenericBinaryMessageReaderImpl { private final String cePrefix; private final Map headers; - public MessageBinaryMessageReader(SpecVersion version, Map headers, byte @Nullable [] payload, String cePrefix) { + MessageBinaryMessageReader(SpecVersion version, Map headers, byte @Nullable [] payload, String cePrefix) { super(version, payload == null ? null : BytesCloudEventData.wrap(payload)); this.headers = headers; this.cePrefix = cePrefix; } - public MessageBinaryMessageReader(SpecVersion version, Map headers, String cePrefix) { + MessageBinaryMessageReader(SpecVersion version, Map headers, String cePrefix) { this(version, headers, null, cePrefix); } @Override protected boolean isContentTypeHeader(String key) { - return org.springframework.messaging.MessageHeaders.CONTENT_TYPE.equalsIgnoreCase(key); + return MessageHeaders.CONTENT_TYPE.equalsIgnoreCase(key); } @Override diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java similarity index 84% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java index ab2b8823e6..d8475798a6 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/MessageBuilderMessageWriter.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents; +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; import java.util.HashMap; import java.util.Map; @@ -40,25 +40,21 @@ * * @since 7.0 */ -public class MessageBuilderMessageWriter +class MessageBuilderMessageWriter implements CloudEventWriter>, MessageWriter> { private final String cePrefix; private final Map headers = new HashMap<>(); - public MessageBuilderMessageWriter(Map headers, String cePrefix) { + MessageBuilderMessageWriter(Map headers, String cePrefix) { this.headers.putAll(headers); this.cePrefix = cePrefix; } - public MessageBuilderMessageWriter() { - this.cePrefix = CloudEventsHeaders.CE_PREFIX; - } - @Override public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { - this.headers.put(CloudEventsHeaders.CONTENT_TYPE, format.serializedContentType()); + this.headers.put(CloudEventMessageConverter.CONTENT_TYPE, format.serializedContentType()); return MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); } @@ -69,7 +65,7 @@ public Message end(@Nullable CloudEventData value) throws CloudEventRWEx @Override public Message end() { - return MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); + return end(null); } @Override diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java new file mode 100644 index 0000000000..f074a658a5 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java @@ -0,0 +1,3 @@ + +@org.jspecify.annotations.NullMarked +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; \ No newline at end of file diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java deleted file mode 100644 index 339cb6590c..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventPropertiesTest.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * 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 org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class CloudEventPropertiesTest { - - private CloudEventProperties properties; - - @BeforeEach - void setUp() { - this.properties = new CloudEventProperties(); - } - - @Test - void defaultValues() { - assertThat(this.properties.getId()).isEqualTo(""); - assertThat(this.properties.getSource()).isEqualTo(URI.create("")); - assertThat(this.properties.getType()).isEqualTo(""); - assertThat(this.properties.getDataContentType()).isNull(); - assertThat(this.properties.getDataSchema()).isNull(); - assertThat(this.properties.getSubject()).isNull(); - assertThat(this.properties.getTime()).isNull(); - } - - @Test - void setAndGetId() { - String testId = "test-event-id-123"; - this.properties.setId(testId); - assertThat(this.properties.getId()).isEqualTo(testId); - } - - @Test - void setAndGetSource() { - URI testSource = URI.create("https://example.com/source"); - this.properties.setSource(testSource); - assertThat(this.properties.getSource()).isEqualTo(testSource); - } - - @Test - void setAndGetType() { - String testType = "com.example.event.type"; - this.properties.setType(testType); - assertThat(this.properties.getType()).isEqualTo(testType); - } - - @Test - void setAndGetDataContentType() { - String testContentType = "application/json"; - this.properties.setDataContentType(testContentType); - assertThat(this.properties.getDataContentType()).isEqualTo(testContentType); - } - - @Test - void setAndGetDataSchema() { - URI testSchema = URI.create("https://example.com/schema"); - this.properties.setDataSchema(testSchema); - assertThat(this.properties.getDataSchema()).isEqualTo(testSchema); - } - - @Test - void setAndGetSubject() { - String testSubject = "test-subject"; - this.properties.setSubject(testSubject); - assertThat(this.properties.getSubject()).isEqualTo(testSubject); - } - - @Test - void setAndGetTime() { - OffsetDateTime testTime = OffsetDateTime.now(); - this.properties.setTime(testTime); - assertThat(this.properties.getTime()).isEqualTo(testTime); - } - - @Test - void setNullValues() { - this.properties.setDataContentType(null); - assertThat(this.properties.getDataContentType()).isNull(); - - this.properties.setDataSchema(null); - assertThat(this.properties.getDataSchema()).isNull(); - - this.properties.setSubject(null); - assertThat(this.properties.getSubject()).isNull(); - - this.properties.setTime(null); - assertThat(this.properties.getTime()).isNull(); - } - - @Test - void setEmptyStringValues() { - this.properties.setId(""); - assertThat(this.properties.getId()).isEqualTo(""); - - this.properties.setType(""); - assertThat(this.properties.getType()).isEqualTo(""); - - this.properties.setDataContentType(""); - assertThat(this.properties.getDataContentType()).isEqualTo(""); - - this.properties.setSubject(""); - assertThat(this.properties.getSubject()).isEqualTo(""); - } - - @Test - void completeCloudEventProperties() { - String id = "complete-event-123"; - URI source = URI.create("https://example.com/events"); - String type = "com.example.user.created"; - String dataContentType = "application/json"; - URI dataSchema = URI.create("https://example.com/schemas/user"); - String subject = "user/123"; - OffsetDateTime time = OffsetDateTime.now(); - - this.properties.setId(id); - this.properties.setSource(source); - this.properties.setType(type); - this.properties.setDataContentType(dataContentType); - this.properties.setDataSchema(dataSchema); - this.properties.setSubject(subject); - this.properties.setTime(time); - - assertThat(this.properties.getId()).isEqualTo(id); - assertThat(this.properties.getSource()).isEqualTo(source); - assertThat(this.properties.getType()).isEqualTo(type); - assertThat(this.properties.getDataContentType()).isEqualTo(dataContentType); - assertThat(this.properties.getDataSchema()).isEqualTo(dataSchema); - assertThat(this.properties.getSubject()).isEqualTo(subject); - assertThat(this.properties.getTime()).isEqualTo(time); - } - -} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java deleted file mode 100644 index 4182fa5d0a..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerExtensionsTest.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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.util.HashMap; -import java.util.Map; -import java.util.Set; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.messaging.MessageHeaders; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -class ToCloudEventTransformerExtensionsTest { - - private String extensionPatterns; - - private Map headers; - - @BeforeEach - void setUp() { - this.extensionPatterns = "source-header,another-header"; - - this.headers = new HashMap<>(); - this.headers.put("source-header", "header-value"); - this.headers.put("another-header", "another-value"); - this.headers.put("unmapped-header", "unmapped-value"); - } - - @Test - void constructorMapsHeadersToExtensions() { - MessageHeaders messageHeaders = new MessageHeaders(this.headers); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( - messageHeaders, this.extensionPatterns - ); - - assertThat(extensions.getValue("source-header")).isEqualTo("header-value"); - assertThat(extensions.getValue("another-header")).isEqualTo("another-value"); - assertThat(extensions.getValue("unmapped-header")).isNull(); - } - - @Test - void getKeysReturnsAllExtensionKeys() { - MessageHeaders messageHeaders = new MessageHeaders(this.headers); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions(messageHeaders, - this.extensionPatterns); - - Set keys = extensions.getKeys(); - assertThat(keys).contains("source-header"); - assertThat(keys).contains("another-header"); - assertThat(keys).doesNotContain("unmapped-header"); - assertThat(keys.size()).isGreaterThanOrEqualTo(2); - } - - @Test - void excludePatternExtensionKeys() { - MessageHeaders messageHeaders = new MessageHeaders(this.headers); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions(messageHeaders, - "!source*,another*"); - - Set keys = extensions.getKeys(); - assertThat(keys).contains("another-header"); - assertThat(keys).doesNotContain("unmapped-header"); - assertThat(keys).doesNotContain("source-header"); - assertThat(keys.size()).isGreaterThanOrEqualTo(1); - } - - @Test - void forNonExistentExtensionKey() { - MessageHeaders messageHeaders = new MessageHeaders(this.headers); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( - messageHeaders, this.extensionPatterns); - - assertThat(extensions.getValue("non-existent-key")).isNull(); - } - - @Test - void emptyExtensionNamesMap() { - MessageHeaders messageHeaders = new MessageHeaders(this.headers); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( - messageHeaders, null - ); - - assertThat(extensions.getKeys()).isEmpty(); - assertThat(extensions.getValue("any-key")).isNull(); - } - - @Test - void emptyHeaders() { - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - ToCloudEventTransformerExtensions extensions = new ToCloudEventTransformerExtensions( - messageHeaders, this.extensionPatterns); - - Set keys = extensions.getKeys(); - assertThat(keys).isEmpty(); - } - - @Test - void invalidHeaderType() { - Map mixedHeaders = new HashMap<>(); - mixedHeaders.put("source-header", "string-value"); - mixedHeaders.put("another-header", 123); // Non-string value - MessageHeaders messageHeaders = new MessageHeaders(mixedHeaders); - assertThatExceptionOfType(ClassCastException.class).isThrownBy( - () -> new ToCloudEventTransformerExtensions(messageHeaders, this.extensionPatterns)); - } -} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java similarity index 78% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java index 3cffe356b3..a4d19a01ac 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java @@ -26,15 +26,15 @@ import static org.assertj.core.api.Assertions.assertThat; -class ToCloudEventTransformerTest { +class ToCloudEventTransformerTests { private ToCloudEventTransformer transformer; @BeforeEach void setUp() { String extensionPatterns = "customer-header"; - this.transformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy("ce-"), - new CloudEventProperties()); + this.transformer = new ToCloudEventTransformer(new CloudEventMessageFormatStrategy("ce-"), + extensionPatterns); } @Test @@ -145,9 +145,9 @@ void emptyExtensionNames() { @Test void multipleExtensionMappings() { - String extensionPatterns = "trace-id,span-id,user-id"; + String[] extensionPatterns = {"trace-id", "span-id", "user-id"}; - ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy("ce-"), new CloudEventProperties()); + ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(new CloudEventMessageFormatStrategy("ce-"), extensionPatterns); String payload = "test message"; Message message = MessageBuilder.withPayload(payload) @@ -163,16 +163,10 @@ void multipleExtensionMappings() { Message resultMessage = (Message) result; // Extension-mapped headers should be converted to cloud event extensions - assertThat(resultMessage.getHeaders().containsKey("trace-id")).isFalse(); - assertThat(resultMessage.getHeaders().containsKey("span-id")).isFalse(); - assertThat(resultMessage.getHeaders().containsKey("user-id")).isFalse(); - - assertThat(resultMessage.getHeaders().containsKey("ce-trace-id")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-span-id")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-user-id")).isTrue(); + assertThat(resultMessage.getHeaders()).containsKeys("trace-id", "span-id", "user-id", "correlation-id", + "ce-trace-id", "ce-span-id", "ce-user-id"); // Non-mapped header should be preserved - assertThat(resultMessage.getHeaders().containsKey("correlation-id")).isTrue(); assertThat(resultMessage.getHeaders().get("correlation-id")).isEqualTo("corr-999"); } @@ -201,11 +195,9 @@ void defaultConstructorUsesDefaultCloudEventProperties() { @Test void testCustomCePrefixInHeaders() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix("CUSTOM_"); - - ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer(null, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); + ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer( + new CloudEventMessageFormatStrategy("CUSTOM_"), (String[]) null); String payload = "test custom prefix"; Message message = MessageBuilder.withPayload(payload) .setHeader("test-header", "test-value") @@ -217,26 +209,18 @@ void testCustomCePrefixInHeaders() { MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers.get("CUSTOM_id")).isNotNull(); - assertThat(headers.get("CUSTOM_source")).isNotNull(); - assertThat(headers.get("CUSTOM_type")).isNotNull(); + assertThat(headers).containsKeys("CUSTOM_id", "CUSTOM_source", "CUSTOM_type", "CUSTOM_specversion"); assertThat(headers.get("CUSTOM_specversion")).isEqualTo("1.0"); - - assertThat(headers.containsKey("ce-id")).isFalse(); - assertThat(headers.containsKey("ce-source")).isFalse(); - assertThat(headers.containsKey("ce-type")).isFalse(); - assertThat(headers.containsKey("ce-specversion")).isFalse(); - + assertThat(headers).doesNotContainKeys("ce-id", "ce-source", "ce-type", "ce-specversion"); assertThat(headers.get("test-header")).isEqualTo("test-value"); } @Test void testCustomPrefixWithExtensions() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix("APP_CE_"); - String extensionPatterns = "trace-id,span-id"; - ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer(extensionPatterns, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); + String[] extensionPatterns = {"trace-id", "span-id"}; + ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer( + new CloudEventMessageFormatStrategy("APP_CE_"), extensionPatterns); String payload = "test custom prefix with extensions"; Message message = MessageBuilder.withPayload(payload) @@ -250,6 +234,8 @@ void testCustomPrefixWithExtensions() { Message resultMessage = getTransformedMessage(result); MessageHeaders headers = resultMessage.getHeaders(); + assertThat(headers).containsKeys("APP_CE_id", "APP_CE_source", "APP_CE_type", "APP_CE_specversion", + "APP_CE_trace-id", "APP_CE_span-id"); assertThat(headers.get("APP_CE_id")).isNotNull(); assertThat(headers.get("APP_CE_source")).isNotNull(); @@ -257,19 +243,14 @@ void testCustomPrefixWithExtensions() { assertThat(headers.get("APP_CE_specversion")).isEqualTo("1.0"); assertThat(headers.get("APP_CE_trace-id")).isEqualTo("trace-456"); assertThat(headers.get("APP_CE_span-id")).isEqualTo("span-789"); - - assertThat(headers.containsKey("trace-id")).isFalse(); - assertThat(headers.containsKey("span-id")).isFalse(); + assertThat(headers).containsKeys("trace-id", "span-id"); assertThat(headers.get("regular-header")).isEqualTo("regular-value"); } @Test void testEmptyStringCePrefixBehavior() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setCePrefix(""); - - ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer(null, new CloudEventMessageFormatStrategy(properties.getCePrefix()), properties); - + ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer( + new CloudEventMessageFormatStrategy(""), (String[]) null); String payload = "test empty prefix"; Message message = MessageBuilder.withPayload(payload).build(); @@ -284,10 +265,7 @@ void testEmptyStringCePrefixBehavior() { assertThat(headers.get("type")).isNotNull(); assertThat(headers.get("specversion")).isEqualTo("1.0"); - assertThat(headers.containsKey("ce-id")).isFalse(); - assertThat(headers.containsKey("ce-source")).isFalse(); - assertThat(headers.containsKey("ce-type")).isFalse(); - assertThat(headers.containsKey("ce-specversion")).isFalse(); + assertThat(headers).doesNotContainKeys("ce-id", "ce-source", "ce-type", "ce-specversion"); } private Message getTransformedMessage(Object object) { diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java index 285157da9c..d0682d9cab 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java @@ -32,7 +32,7 @@ public class CloudEventMessageFormatStrategyTests { @Test - void convertCloudEventToMessage() { + void toIntegrationMessageCloudEventToMessage() { CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); CloudEvent cloudEvent = CloudEventBuilder.v1() @@ -44,7 +44,7 @@ void convertCloudEventToMessage() { MessageHeaders headers = new MessageHeaders(null); - Object result = strategy.convert(cloudEvent, headers); + Object result = strategy.toIntegrationMessage(cloudEvent, headers); assertThat(result).isInstanceOf(Message.class); Message message = (Message) result; @@ -54,7 +54,7 @@ void convertCloudEventToMessage() { } @Test - void convertWithAdditionalHeaders() { + void toIntegrationMessageWithAdditionalHeaders() { CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); CloudEvent cloudEvent = CloudEventBuilder.v1() @@ -68,7 +68,7 @@ void convertWithAdditionalHeaders() { additionalHeaders.put("custom-header", "custom-value"); MessageHeaders headers = new MessageHeaders(additionalHeaders); - Object result = strategy.convert(cloudEvent, headers); + Object result = strategy.toIntegrationMessage(cloudEvent, headers); assertThat(result).isInstanceOf(Message.class); Message message = (Message) result; @@ -77,7 +77,7 @@ void convertWithAdditionalHeaders() { } @Test - void convertWithDifferentPrefix() { + void toIntegrationMessageWithDifferentPrefix() { CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("cloudevent-"); CloudEvent cloudEvent = CloudEventBuilder.v1() @@ -88,7 +88,7 @@ void convertWithDifferentPrefix() { MessageHeaders headers = new MessageHeaders(null); - Object result = strategy.convert(cloudEvent, headers); + Object result = strategy.toIntegrationMessage(cloudEvent, headers); assertThat(result).isInstanceOf(Message.class); Message message = (Message) result; @@ -96,7 +96,7 @@ void convertWithDifferentPrefix() { } @Test - void convertWithEmptyHeaders() { + void toIntegrationMessageWithEmptyHeaders() { CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); CloudEvent cloudEvent = CloudEventBuilder.v1() @@ -107,7 +107,7 @@ void convertWithEmptyHeaders() { MessageHeaders headers = new MessageHeaders(new HashMap<>()); - Object result = strategy.convert(cloudEvent, headers); + Object result = strategy.toIntegrationMessage(cloudEvent, headers); assertThat(result).isInstanceOf(Message.class); } diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java similarity index 82% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java index ac8501acd7..88a1cb3b88 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.transformer; +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; import java.net.URI; import java.time.OffsetDateTime; @@ -26,8 +26,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.integration.cloudevents.CloudEventMessageConverter; -import org.springframework.integration.cloudevents.CloudEventsHeaders; import org.springframework.integration.support.MessageBuilder; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -45,7 +43,7 @@ public class CloudEventMessageConverterTests { @BeforeEach void setUp() { - this.converter = new CloudEventMessageConverter(CloudEventsHeaders.CE_PREFIX); + this.converter = new CloudEventMessageConverter(CloudEventMessageConverter.CE_PREFIX); this.customPrefixConverter = new CloudEventMessageConverter("CUSTOM_"); } @@ -69,10 +67,10 @@ void toMessageWithCloudEventAndDefaultPrefix() { MessageHeaders resultHeaders = result.getHeaders(); assertThat(resultHeaders.get("existing-header")).isEqualTo("existing-value"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("test-id"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://example.com"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.test"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("test-id"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://example.com"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.test"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); } @Test @@ -143,10 +141,10 @@ void toMessageWithCloudEventContainingOptionalAttributes() { assertThat(result).isNotNull(); MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "subject")).isEqualTo("test-subject"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "time")).isNotNull(); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "subject")).isEqualTo("test-subject"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "time")).isNotNull(); } @Test @@ -165,9 +163,9 @@ void toMessageWithCloudEventWithoutData() { assertThat(result.getPayload()).isEqualTo(new byte[0]); MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("no-data-id"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://nodata.example.com"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.nodata"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("no-data-id"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://nodata.example.com"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.nodata"); } @Test @@ -203,7 +201,7 @@ void toMessagePreservesExistingHeaders() { assertThat(resultHeaders.get("message-timestamp")).isNotNull(); assertThat(resultHeaders.get("routing-key")).isEqualTo("test.route"); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("preserve-id"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("preserve-id"); } @Test @@ -221,7 +219,7 @@ void toMessageWithEmptyHeaders() { assertThat(result).isNotNull(); MessageHeaders resultHeaders = result.getHeaders(); assertThat(resultHeaders.size()).isEqualTo(6); - assertThat(resultHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("empty-headers-id"); + assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("empty-headers-id"); } @Test diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java similarity index 77% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java index 02d34b8edd..73591b3434 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriterTest.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.integration.cloudevents.transformer; +package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; import java.util.HashMap; import java.util.Map; @@ -25,8 +25,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.integration.cloudevents.CloudEventsHeaders; -import org.springframework.integration.cloudevents.MessageBuilderMessageWriter; import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; @@ -34,7 +32,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -class MessageBuilderMessageWriterTest { +class MessageBuilderMessageWriterTests { private MessageBuilderMessageWriter writer; @@ -46,7 +44,7 @@ void setUp() { headers.put("existing-header", "existing-value"); headers.put("correlation-id", "corr-123"); - this.writer = new MessageBuilderMessageWriter(headers, CloudEventsHeaders.CE_PREFIX); + this.writer = new MessageBuilderMessageWriter(headers, CloudEventMessageConverter.CE_PREFIX); this.customPrefixWriter = new MessageBuilderMessageWriter(headers, "CUSTOM_"); } @@ -62,7 +60,7 @@ void createWithSpecVersionAndDefaultPrefix() { assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); } @Test @@ -89,10 +87,10 @@ void withContextAttributeDefaultPrefix() { Message message = this.writer.end(); MessageHeaders messageHeaders = message.getHeaders(); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("test-id"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://example.com"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "type")).isEqualTo("com.example.test"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "specversion")).isEqualTo("1.0"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("test-id"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://example.com"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.test"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); } @Test @@ -123,8 +121,8 @@ void withContextAttributeExtensions() { Message message = this.writer.end(); MessageHeaders messageHeaders = message.getHeaders(); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "trace-id")).isEqualTo("trace-123"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "span-id")).isEqualTo("span-456"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "trace-id")).isEqualTo("trace-123"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "span-id")).isEqualTo("span-456"); } @Test @@ -141,10 +139,10 @@ void withContextAttributeOptionalAttributes() { Message message = this.writer.end(); MessageHeaders messageHeaders = message.getHeaders(); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "subject")).isEqualTo("test-subject"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "time")).isEqualTo("2023-01-01T10:00:00Z"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "subject")).isEqualTo("test-subject"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "time")).isEqualTo("2023-01-01T10:00:00Z"); } @Test @@ -159,7 +157,7 @@ void testEndWithEmptyPayload() { assertThat(message).isNotNull(); assertThat(message.getPayload()).isEqualTo(new byte[0]); assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); - assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("empty-id"); + assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("empty-id"); } @Test @@ -177,7 +175,7 @@ void endWithCloudEventData() { assertThat(message).isNotNull(); assertThat(message.getPayload()).isEqualTo(testData); - assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("data-id"); + assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("data-id"); } @Test @@ -191,7 +189,7 @@ void endWithNullCloudEventData() { assertThat(message).isNotNull(); assertThat(message.getPayload()).isEqualTo(new byte[0]); - assertThat(message.getHeaders().get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("null-data-id"); + assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("null-data-id"); } @Test @@ -210,7 +208,7 @@ void setEventWithTextPayload() { assertThat(message).isNotNull(); assertThat(message.getPayload()).isEqualTo(eventData); - assertThat(message.getHeaders().get(CloudEventsHeaders.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); + assertThat(message.getHeaders().get(CloudEventMessageConverter.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); } @@ -225,8 +223,8 @@ void headersCorrectlyAssignedToMessageHeader() { assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "id")).isEqualTo("preserve-id"); - assertThat(messageHeaders.get(CloudEventsHeaders.CE_PREFIX + "source")).isEqualTo("https://preserve.example.com"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("preserve-id"); + assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://preserve.example.com"); } } diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc index ba12bc0a91..d033528c23 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc @@ -1,9 +1,9 @@ -[[cloudevents-transform]] +[[cloudevents-transformer]] = CloudEvent Transformer -[[cloudevent-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 formats, header pattern matching, and extension management. @@ -12,70 +12,30 @@ This transformer provides support for the CloudEvents specification v1.0 with c === Overview The CloudEvent transformer (`ToCloudEventTransformer`) extends Spring Integration's `AbstractTransformer` to convert messages to CloudEvent format. -It supports multiple output formats including structured CloudEvents, JSON, XML, andAvro serialization. - -[[cloudevent-transformer-configuration]] -=== Configuration - -The transformer can be configured with custom CloudEvent properties, conversion types, and extension management. - -==== Basic Configuration - -[source,java] ----- -@Bean -public ToCloudEventTransformer cloudEventTransformer() { - return new ToCloudEventTransformer(); -} ----- - -==== Advanced Configuration - -[source,java] ----- -@Bean -public ToCloudEventTransformer cloudEventTransformer() { - CloudEventProperties properties = new CloudEventProperties(); - properties.setId("unique-event-id"); - properties.setSource(URI.create("https://io.spring.org/source")); - properties.setType("io.spring.MessageProcessed"); - properties.setDataContentType("application/json"); - properties.setCePrefix("CE_"); - - String extensionPatterns = "key-*,external-*,!internal-*"; - - return new ToCloudEventTransformer( - extensionPatterns, - new CloudEventMessageFormatStrategy(), - properties - ); -} ----- +To do this, it uses a `FormatStrategy` that allows users to transform the message to the desired "CloudEvent" format (CloudEvent, JSON, XML, AVRO, etc). It defaults to `CloudEventFormatStrategy`. [[cloudevent-transformer-conversion-types]] === Format Strategy -The ToCloudEventTransformer accepts classes that implement the `FormatStrategy` that will serialize the -CloudEvents's data to other formats other than the default `CloudEventMessageFormatStrategy`. +The `ToCloudEventTransformer` accepts classes that implement the `FormatStrategy` to serialize CloudEvent data to formats other than the default `CloudEventMessageFormatStrategy`. -[[cloudevent-properties]] -=== CloudEvent Properties +[[configure-transformer]] +=== Configuring Transformer -The `CloudEventProperties` class provides configuration for CloudEvent metadata and formatting options. +The `ToCloudEventTransformer` class provides configurations for CloudEvent metadata and formatting options. ==== Properties Configuration [source,java] ---- -CloudEventProperties properties = new CloudEventProperties(); -properties.setId("event-123"); // The CloudEvent ID. Default is "". -properties.setSource(URI.create("https://example.com/source")); // The event source. Default is "". -properties.setType("com.example.OrderCreated"); // The event type. The Default is "". -properties.setDataContentType("application/json"); // The data content type. Default is null. -properties.setDataSchema(URI.create("https://example.com/schema")); // The eata schema. Default is null. -properties.setSubject("order-processing"); // The event subject. Default is null. -properties.setTime(OffsetDateTime.now()); // The event time. Default is null. -properties.setCePrefix(CloudeEventsHeaders.CE_PREFIX); // The CloudEvent header prefix. Default is CloudEventsHeaders.CE_PREFIX. +ToCloudEventTransformer cloudEventTransformer = new ToCloudEventTransformer(); +cloudEventTransformer.setId("event-123"); // The CloudEvent ID. Default is "". +cloudEventTransformer.setSource(URI.create("https://example.com/source")); // The event source. Default is "". +cloudEventTransformer.setType("com.example.OrderCreated"); // The event type. The Default is "". +cloudEventTransformer.setDataContentType("application/json"); // The data content type. Default is null. +cloudEventTransformer.setDataSchema(URI.create("https://example.com/schema")); // The data schema. Default is null. +cloudEventTransformer.setSubject("order-processing"); // The event subject. Default is null. +cloudEventTransformer.setTime(OffsetDateTime.now()); // The event time. Default is null. ---- [[cloudevent-properties-defaults]] @@ -117,28 +77,22 @@ properties.setCePrefix(CloudeEventsHeaders.CE_PREFIX); | Default is CloudEventsHeaders.CE_PREFIX. |=== -[[cloudevent-extensions]] -=== CloudEvent Extensions - -CloudEvent Extensions are managed through the `ToCloudEventTransformerExtensions` class, which implements the CloudEvent extension contract by filtering message headers based on configurable patterns. - [[cloudevent-extensions-pattern-matching]] -==== Pattern Matching +==== Cloud Event Extension Pattern Matching -The extension system uses pattern matching for sophisticated header filtering: +The transformer allows the user to specify what `MessageHeaders` will be added as extensions to the CloudEvent. The extension system uses pattern matching for extension identification: [source,java] ---- // Include headers starting with "key-" or "external-" // Exclude headers starting with "internal-" -// If the header key is neither of the above it is left in the `MessageHeader`. -String pattern = "key-*,external-*,!internal-*"; +// If the header key is neither of the above it is not included in the extensions. +String[] patterns = {"key-*", "external-*", "!internal-*"}; // Extension patterns are processed during transformation ToCloudEventTransformer transformer = new ToCloudEventTransformer( - pattern, - ToCloudEventTransformer.ConversionType.DEFAULT, - properties + new CloudEventMessageFormatStrategy(), + patterns ); ---- @@ -150,20 +104,9 @@ The pattern matching supports: * **Wildcard patterns**: Use `\*` for wildcard matching (e.g., `external-\*` matches `external-id`, `external-span`) * **Negation patterns**: Use `!` prefix for exclusion (e.g., `!internal-*` excludes internal headers) * If the header key is neither of the above it is left in the `MessageHeader`. -* **Multiple patterns**: Use comma-delimited patterns (e.g., `user-\*,session-\*,!debug-*`) +* **Multiple patterns**: Use comma-delimited patterns (e.g., `{"user-\*", "session-\*" , "!debug-*"}`) * **Null handling**: Null patterns disable extension processing, thus no `MessageHeaders` are moved to the CloudEvent extensions. -[[cloudevent-extensions-behavior]] -==== Extension Behavior - -Headers that match extension patterns are: - -1. Extracted from the original message headers -2. Added as CloudEvent extensions -3. Filtered out from the output message headers (to avoid duplication) - -The `ToCloudEventTransformerExtensions` class handles this automatically during transformation. - [[cloudevent-transformer-integration]] === Integration with Spring Integration Flows @@ -190,8 +133,7 @@ The transformer follows the process below: 1. **Extension Extraction**: Extract CloudEvent extensions from message headers using configured patterns 2. **CloudEvent Building**: Build a CloudEvent with configured properties and message payload -3. **Format Conversion**: Apply the specified conversion type to format the output -4. **Header Filtering**: Filter headers to exclude those mapped to CloudEvent extensions +3. **Format Conversion**: Apply the specified `FormatStrategy` to format the output ==== Payload Handling @@ -217,12 +159,6 @@ Message objectMessage = MessageBuilder.withPayload(customObject).build() [source,java] ---- -// Configure properties -CloudEventProperties properties = new CloudEventProperties(); -properties.setId("event-123"); -properties.setSource(URI.create("https://example.com")); -properties.setType("com.example.MessageProcessed"); - // Input message with headers Message inputMessage = MessageBuilder .withPayload("Hello CloudEvents") @@ -232,64 +168,13 @@ Message inputMessage = MessageBuilder // Transformer with extension patterns ToCloudEventTransformer transformer = new ToCloudEventTransformer( - "external-*", - ToCloudEventTransformer.ConversionType.DEFAULT, - properties -); + new CloudEventMessageFormatStrategy(), "trace-*"); +// Configure properties +transformer.setId("event-123"); +transformer.setSource(URI.create("https://example.com")); +transformer.setType("com.example.MessageProcessed"); // Transform to CloudEvent Message cloudEventMessage = transformer.transform(inputMessage); ---- -[[cloudevent-transformer-example-json]] -==== JSON Serialization Example - -[source,java] ----- -CloudEventProperties properties = new CloudEventProperties(); -properties.setId("order-123"); -properties.setSource(URI.create("https://shop.example.com")); -properties.setType("com.example.OrderCreated"); - -ToCloudEventTransformer transformer = new ToCloudEventTransformer( - "order-*,customer-*", - ToCloudEventTransformer.ConversionType.JSON, - properties -); - -Message result = (Message) transformer.transform(inputMessage); -String jsonCloudEvent = result.getPayload(); // JSON-serialized CloudEvent -String contentType = (String) result.getHeaders().get("content-type"); // "application/json" ----- - -[[cloudevent-transformer-example-xml]] -==== XML Serialization Example - -[source,java] ----- -ToCloudEventTransformer transformer = new ToCloudEventTransformer( - null, // No extension patterns - ToCloudEventTransformer.ConversionType.XML, - properties -); - -Message result = (Message) transformer.transform(inputMessage); -String xmlCloudEvent = result.getPayload(); // XML-serialized CloudEvent -String contentType = (String) result.getHeaders().get("content-type"); // "application/xml" ----- - -[[cloudevent-transformer-example-avro]] -==== Avro Serialization Example - -[source,java] ----- -ToCloudEventTransformer transformer = new ToCloudEventTransformer( - "app-*", - ToCloudEventTransformer.ConversionType.AVRO, - properties -); - -Message result = (Message) transformer.transform(inputMessage); -byte[] avroCloudEvent = result.getPayload(); // Avro-serialized CloudEvent -String contentType = (String) result.getHeaders().get("content-type"); // "application/avro" ----- From b57b8d52573281069100f97a624241148c8f4625 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Wed, 8 Oct 2025 13:51:58 -0400 Subject: [PATCH 04/11] Simplify CloudEvent formatting with SDK strategies Replace custom CloudEvent converter infrastructure with direct CloudEvents SDK format implementations. Key changes: - Replace `FormatStrategy` pattern-based approach with direct `EventFormatProvider` integration from CloudEvents SDK - Remove custom converter classes (`CloudEventMessageConverter`, `MessageBinaryMessageReader`, `MessageBuilderMessageWriter`) - Simplify transformer to use Expression-based configuration for all CloudEvent attributes (id, source, type, dataSchema, subject) - Add validation for required CloudEvent attributes with clear error messages when expressions evaluate to null or empty values - Update documentation to reflect Expression-based API and byte[] payload requirement - Consolidate tests by removing coverage for deleted converter infrastructure --- build.gradle | 9 + .../integration/cloudevents/package-info.java | 20 + .../transformer/ToCloudEventTransformer.java | 316 ++++++++-------- .../CloudEventMessageFormatStrategy.java | 57 --- .../strategies/FormatStrategy.java | 44 --- .../CloudEventMessageConverter.java | 126 ------- .../MessageBinaryMessageReader.java | 80 ---- .../MessageBuilderMessageWriter.java | 83 ----- .../cloudeventconverter/package-info.java | 3 - .../transformer/strategies/package-info.java | 3 - .../ToCloudEventTransformerTests.java | 352 +++++++++--------- .../CloudEventMessageFormatStrategyTests.java | 114 ------ .../CloudEventMessageConverterTests.java | 240 ------------ .../MessageBuilderMessageWriterTests.java | 230 ------------ .../ROOT/pages/cloudevents-transform.adoc | 137 +++---- .../antora/modules/ROOT/pages/whats-new.adoc | 2 +- 16 files changed, 411 insertions(+), 1405 deletions(-) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/package-info.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java diff --git a/build.gradle b/build.gradle index 51456d4b26..ba3bba7ff1 100644 --- a/build.gradle +++ b/build.gradle @@ -483,6 +483,15 @@ project('spring-integration-cloudevents') { 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' } } 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 index a03e6dcc2a..28a9f1a227 100644 --- 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 @@ -22,24 +22,32 @@ 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.integration.cloudevents.transformer.strategies.CloudEventMessageFormatStrategy; -import org.springframework.integration.cloudevents.transformer.strategies.FormatStrategy; -import org.springframework.integration.support.utils.PatternMatchUtils; +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. - * Header filtering and extension mapping is performed based on configurable patterns, - * allowing control over which headers are preserved and which become CloudEvent extensions. + * Attribute and extension mapping is performed based on {@link Expression}s. * * @author Glenn Renfro * @@ -47,153 +55,179 @@ */ public class ToCloudEventTransformer extends AbstractTransformer { - public static String CE_PREFIX = "ce-"; + private Expression idExpression = new FunctionExpression>( + msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); - private String id = ""; + @SuppressWarnings("NullAway.Init") + private Expression sourceExpression; - private URI source = URI.create(""); + private Expression typeExpression = new LiteralExpression("spring.message"); - private String type = ""; + @SuppressWarnings("NullAway.Init") + private Expression dataSchemaExpression; - private @Nullable String dataContentType; + private Expression subjectExpression = new FunctionExpression<>((Function, @Nullable String>) + message -> null); - private @Nullable URI dataSchema; + private final Expression @Nullable [] cloudEventExtensionExpressions; - private @Nullable String subject; + @SuppressWarnings("NullAway.Init") + private EvaluationContext evaluationContext; - private @Nullable OffsetDateTime time; - - private final String @Nullable [] cloudEventExtensionPatterns; - - private final FormatStrategy formatStrategy; + private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); /** - * ToCloudEventTransformer Constructor + * Construct a ToCloudEventTransformer. * - * @param formatStrategy The strategy that determines how the CloudEvent will be rendered - * @param cloudEventExtensionPatterns an array of patterns for matching headers that should become CloudEvent extensions, - * supports wildcards and negation with '!' prefix If a header matches one of the '!' it is excluded from - * cloud event headers and the message headers. Null to disable extension mapping. + * @param cloudEventExtensionExpressions an array of {@link Expression}s for establishing CloudEvent extensions */ - public ToCloudEventTransformer(FormatStrategy formatStrategy, - String @Nullable ... cloudEventExtensionPatterns) { - this.cloudEventExtensionPatterns = cloudEventExtensionPatterns; - this.formatStrategy = formatStrategy; + public ToCloudEventTransformer(Expression @Nullable ... cloudEventExtensionExpressions) { + this.cloudEventExtensionExpressions = cloudEventExtensionExpressions; } /** - * Constructs a {@link ToCloudEventTransformer} that defaults to the {@link CloudEventMessageFormatStrategy}. This - * strategy will use the default CE_PREFIX and will not contain and cloudEventExtensionPatterns. + * Construct a ToCloudEventTransformer with no {@link Expression}s for extensions. * */ public ToCloudEventTransformer() { - this(new CloudEventMessageFormatStrategy(CE_PREFIX), (String[]) null); + this((Expression[]) null); } /** - * Transforms the input message into a CloudEvent message. - *

- * This method performs the core transformation logic: - *

    - *
  1. Extracts CloudEvent extensions from message headers using configured patterns
  2. - *
  3. Builds a CloudEvent with the configured properties and message payload
  4. - *
  5. Applies the specified conversion type to format the output
  6. - *
  7. Filters headers to exclude those mapped to CloudEvent extensions
  8. - *
+ * Set the {@link Expression} for creating CloudEvent ids. + * Default expression extracts the id from the {@link MessageHeaders} of the message. * - * @param message the input Spring Integration message to transform - * @return transformed message as CloudEvent in the specified format - * @throws RuntimeException if serialization fails for XML, JSON, or Avro formats + * @param idExpression the expression used to create the id for each CloudEvent */ - @Override - protected Object doTransform(Message message) { - ToCloudEventTransformerExtensions extensions = - new ToCloudEventTransformerExtensions(message.getHeaders(), this.cloudEventExtensionPatterns); - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId(this.id) - .withSource(this.source) - .withType(this.type) - .withTime(this.time) - .withDataContentType(this.dataContentType) - .withDataSchema(this.dataSchema) - .withSubject(this.subject) - .withData(getPayloadAsBytes(message.getPayload())) - .withExtension(extensions) - .build(); - return this.formatStrategy.toIntegrationMessage(cloudEvent, message.getHeaders()); + public void setIdExpression(Expression idExpression) { + this.idExpression = idExpression; } - private static byte[] getPayloadAsBytes(Object payload) { - if (payload instanceof byte[] bytePayload) { - return bytePayload; - } - else if (payload instanceof String stringPayload) { - return stringPayload.getBytes(); - } - else { - return payload.toString().getBytes(); - } + /** + * 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; } - @Override - public String getComponentType() { - return "ce:to-cloudevents-transformer"; + /** + * 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; } - public String getId() { - return this.id; + /** + * 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; } - public void setId(String id) { - this.id = id; + /** + * 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; } - public URI getSource() { - return this.source; + @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); + } } - public void setSource(URI source) { - this.source = source; - } + /** + * 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) { - public String getType() { - return this.type; - } + 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"); + } - public void setType(String type) { - this.type = type; - } + 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"); + } - public @Nullable String getDataContentType() { - return this.dataContentType; - } + 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"); + } - public void setDataContentType(@Nullable String dataContentType) { - this.dataContentType = dataContentType; - } + String contentType = message.getHeaders().get(MessageHeaders.CONTENT_TYPE, String.class); + if (contentType == null) { + throw new MessageTransformationException(message, "Missing 'Content-Type' header"); + } - public @Nullable URI getDataSchema() { - return this.dataSchema; - } + EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); + if (eventFormat == null) { + throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); + } - public void setDataSchema(@Nullable URI dataSchema) { - this.dataSchema = dataSchema; - } + ToCloudEventTransformerExtensions extensions = + new ToCloudEventTransformerExtensions(this.evaluationContext, (Message) message, + this.cloudEventExtensionExpressions); - public @Nullable String getSubject() { - return this.subject; - } + 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(); - public void setSubject(@Nullable String subject) { - this.subject = subject; + return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) + .copyHeaders(message.getHeaders()) + .build(); } - public @Nullable OffsetDateTime getTime() { - return this.time; + @Override + public String getComponentType() { + return "ce:to-cloudevents-transformer"; } - public void setTime(@Nullable OffsetDateTime time) { - this.time = time; + 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 { @@ -201,33 +235,42 @@ private static class ToCloudEventTransformerExtensions implements CloudEventExte /** * Map storing the CloudEvent extensions extracted from message headers. */ - private final Map cloudEventExtensions; + private final Map cloudEventExtensions; /** - * Construct CloudEvent extensions by filtering message headers against patterns. - *

- * Headers are evaluated against the provided patterns. - * Only headers that match the patterns (and are not excluded by negation patterns) - * will be included as CloudEvent extensions. + * Construct CloudEvent extensions by processing a message using expressions. * - * @param headers the Spring Integration message headers to process - * @param patterns comma-delimited patterns for header matching, may be null to include no extensions + * @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 */ - ToCloudEventTransformerExtensions(MessageHeaders headers, String @Nullable ... patterns) { + @SuppressWarnings("unchecked") + ToCloudEventTransformerExtensions(EvaluationContext evaluationContext, Message message, + Expression @Nullable ... expressions) { this.cloudEventExtensions = new HashMap<>(); - headers.keySet().forEach(key -> { - Boolean result = categorizeHeader(key, patterns); - if (result != null && result) { - this.cloudEventExtensions.put(key, (String) Objects.requireNonNull(headers.get(key))); + 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 -> { - this.cloudEventExtensions.put(key, this.cloudEventExtensions.get(key)); + Object value = extensions.getExtension(key); + if (value != null) { + this.cloudEventExtensions.put(key, value); + } }); } @@ -240,35 +283,6 @@ public void readFrom(CloudEventExtensions extensions) { public Set getKeys() { return this.cloudEventExtensions.keySet(); } - - /** - * Categorizes a header value by matching it against a comma-delimited pattern string. - *

- * This method takes a header value and matches it against one or more patterns - * specified in a comma-delimited string. It uses Spring's smart pattern matching - * which supports wildcards and other pattern matching features. - * - * @param value the header value to match against the patterns - * @param patterns an array of string patterns to match against, or null. If pattern is null then null is returned. - * @return {@code Boolean.TRUE} if the value starts with a pattern token, - * {@code Boolean.FALSE} if the value starts with the pattern token that is prefixed with a `!`, - * or {@code null} if the header starts with a value that is not enumerated in the pattern - */ - public static @Nullable Boolean categorizeHeader(String value, String @Nullable ... patterns) { - Boolean result = null; - if (patterns != null) { - for (String patternItem : patterns) { - result = PatternMatchUtils.smartMatch(value, patternItem); - if (result != null && result) { - break; - } - else if (result != null) { - break; - } - } - } - return result; - } - } + } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java deleted file mode 100644 index 1c89fd17b2..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategy.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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.strategies; - -import io.cloudevents.CloudEvent; - -import org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter.CloudEventMessageConverter; -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; - -/** - * Implementation of {@link FormatStrategy} that converts CloudEvents to Spring - * Integration messages. - * - * @author Glenn Renfro - * - * @since 7.0 - */ -public class CloudEventMessageFormatStrategy implements FormatStrategy { - - private final CloudEventMessageConverter messageConverter; - - public CloudEventMessageFormatStrategy(String cePrefix) { - this.messageConverter = new CloudEventMessageConverter(cePrefix); - } - - public CloudEventMessageFormatStrategy() { - this.messageConverter = new CloudEventMessageConverter(CloudEventMessageConverter.CE_PREFIX); - } - - /** - * Converts the CloudEvent to a Spring Integration Message. - * - * @param cloudEvent the CloudEvent to convert - * @param messageHeaders additional headers to include in the message - * @return a Spring Integration Message containing the CloudEvent data and headers - */ - @Override - public Message toIntegrationMessage(CloudEvent cloudEvent, MessageHeaders messageHeaders) { - return this.messageConverter.toMessage(cloudEvent, messageHeaders); - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java deleted file mode 100644 index 79a3409308..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/FormatStrategy.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.strategies; - -import io.cloudevents.CloudEvent; - -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; - -/** - * Strategy interface for converting CloudEvents to different message formats. - * - *

Implementations of this interface define how CloudEvents should be transformed - * into message objects. This allows for pluggable conversion strategies to support different messaging formats and - * protocols. - * - * @author Glenn Renfro - * @since 7.0 - */ -public interface FormatStrategy { - - /** - * Converts the {@link CloudEvent} to a message object. - * - * @param cloudEvent the CloudEvent to be converted to a {@link Message} - * @param messageHeaders the headers associated with the {@link Message} - * @return the converted {@link Message} - */ - Message toIntegrationMessage(CloudEvent cloudEvent, MessageHeaders messageHeaders); -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java deleted file mode 100644 index 54ad50754b..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverter.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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.strategies.cloudeventconverter; - -import java.nio.charset.StandardCharsets; - -import io.cloudevents.CloudEvent; -import io.cloudevents.SpecVersion; -import io.cloudevents.core.CloudEventUtils; -import io.cloudevents.core.format.EventFormat; -import io.cloudevents.core.message.MessageReader; -import io.cloudevents.core.message.impl.GenericStructuredMessageReader; -import io.cloudevents.core.message.impl.MessageUtils; -import org.jspecify.annotations.Nullable; - -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MessageConverter; -import org.springframework.util.Assert; - -/** - * A {@link MessageConverter} that can translate to and from a {@link Message - * Message<byte[]>} or {@link Message Message<String>} and a {@link CloudEvent}. - * - * @author Dave Syer - * @author Glenn Renfro - * - * @since 7.0 - */ -public class CloudEventMessageConverter implements MessageConverter { - - public static final String CE_PREFIX = "ce-"; - - public static final String SPEC_VERSION = CE_PREFIX + "specversion"; - - public static final String CONTENT_TYPE = CE_PREFIX + "datacontenttype"; - - private final String cePrefix; - - public CloudEventMessageConverter(String cePrefix) { - this.cePrefix = cePrefix; - } - - public CloudEventMessageConverter() { - this(CE_PREFIX); - } - - /** - Convert the payload of a Message from a CloudEvent to a typed Object of the specified target class. - If the converter does not support the specified media type or cannot perform the conversion, it should return null. - * @param message the input message - * @param targetClass This method does not check the class since it is expected to be a {@link CloudEvent} - * @return the result of the conversion, or null if the converter cannot perform the conversion - */ - @Override - public Object fromMessage(Message message, Class targetClass) { - return createMessageReader(message, this.cePrefix).toEvent(); - } - - @Override - public Message toMessage(Object payload, @Nullable MessageHeaders headers) { - Assert.state(payload instanceof CloudEvent, "Payload must be a CloudEvent"); - Assert.state(headers != null, "Headers must not be null"); - return CloudEventUtils.toReader((CloudEvent) payload).read(new MessageBuilderMessageWriter(headers, this.cePrefix)); - } - - private static MessageReader createMessageReader(Message message, String cePrefix) { - return MessageUtils.parseStructuredOrBinaryMessage( - () -> contentType(message.getHeaders()), - format -> structuredMessageReader(message, format), - () -> version(message.getHeaders()), - version -> binaryMessageReader(message, version, cePrefix) - ); - } - - private static @Nullable String version(MessageHeaders message) { - if (message.containsKey(SPEC_VERSION)) { - return message.get(SPEC_VERSION).toString(); - } - return null; - } - - private static MessageReader binaryMessageReader(Message message, SpecVersion version, String cePrefix) { - return new MessageBinaryMessageReader(version, message.getHeaders(), getBinaryData(message), cePrefix); - } - - private static MessageReader structuredMessageReader(Message message, EventFormat format) { - return new GenericStructuredMessageReader(format, getBinaryData(message)); - } - - private static @Nullable String contentType(MessageHeaders message) { - if (message.containsKey(MessageHeaders.CONTENT_TYPE)) { - return message.get(MessageHeaders.CONTENT_TYPE).toString(); - } - if (message.containsKey(CONTENT_TYPE)) { - return message.get(CONTENT_TYPE).toString(); - } - return null; - } - - private static byte[] getBinaryData(Message message) { - Object payload = message.getPayload(); - if (payload instanceof byte[] bytePayload) { - return bytePayload; - } - else if (payload instanceof String stringPayload) { - return stringPayload.getBytes(StandardCharsets.UTF_8); - } - throw new IllegalStateException("Message payload must be a byte array or a String"); - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java deleted file mode 100644 index 3b00bb4ccb..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBinaryMessageReader.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.strategies.cloudeventconverter; - -import java.util.Map; -import java.util.function.BiConsumer; - -import io.cloudevents.SpecVersion; -import io.cloudevents.core.data.BytesCloudEventData; -import io.cloudevents.core.message.impl.BaseGenericBinaryMessageReaderImpl; -import org.jspecify.annotations.Nullable; - -import org.springframework.messaging.MessageHeaders; -import org.springframework.util.StringUtils; - -/** - * Utility for converting maps (message headers) to `CloudEvent` contexts. - * - * @author Dave Syer - * @author Glenn Renfro - * - * @since 7.0 - * - */ -class MessageBinaryMessageReader extends BaseGenericBinaryMessageReaderImpl { - - private final String cePrefix; - - private final Map headers; - - MessageBinaryMessageReader(SpecVersion version, Map headers, byte @Nullable [] payload, String cePrefix) { - super(version, payload == null ? null : BytesCloudEventData.wrap(payload)); - this.headers = headers; - this.cePrefix = cePrefix; - } - - MessageBinaryMessageReader(SpecVersion version, Map headers, String cePrefix) { - this(version, headers, null, cePrefix); - } - - @Override - protected boolean isContentTypeHeader(String key) { - return MessageHeaders.CONTENT_TYPE.equalsIgnoreCase(key); - } - - @Override - protected boolean isCloudEventsHeader(String key) { - return key.length() > this.cePrefix.length() && StringUtils.startsWithIgnoreCase(key, this.cePrefix); - } - - @Override - protected String toCloudEventsKey(String key) { - return key.substring(this.cePrefix.length()).toLowerCase(); - } - - @Override - protected void forEachHeader(BiConsumer fn) { - this.headers.forEach((k, v) -> fn.accept(k, v)); - } - - @Override - protected String toCloudEventsValue(Object value) { - return value.toString(); - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java deleted file mode 100644 index d8475798a6..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriter.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * 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.strategies.cloudeventconverter; - -import java.util.HashMap; -import java.util.Map; - -import io.cloudevents.CloudEventData; -import io.cloudevents.SpecVersion; -import io.cloudevents.core.format.EventFormat; -import io.cloudevents.core.message.MessageWriter; -import io.cloudevents.rw.CloudEventContextWriter; -import io.cloudevents.rw.CloudEventRWException; -import io.cloudevents.rw.CloudEventWriter; -import org.jspecify.annotations.Nullable; - -import org.springframework.messaging.Message; -import org.springframework.messaging.support.MessageBuilder; - -/** - * Internal utility class for copying CloudEvent context to a map (message - * headers). - * - * @author Dave Syer - * @author Glenn Renfro - * - * @since 7.0 - */ -class MessageBuilderMessageWriter - implements CloudEventWriter>, MessageWriter> { - - private final String cePrefix; - - private final Map headers = new HashMap<>(); - - MessageBuilderMessageWriter(Map headers, String cePrefix) { - this.headers.putAll(headers); - this.cePrefix = cePrefix; - } - - @Override - public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { - this.headers.put(CloudEventMessageConverter.CONTENT_TYPE, format.serializedContentType()); - return MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); - } - - @Override - public Message end(@Nullable CloudEventData value) throws CloudEventRWException { - return MessageBuilder.withPayload(value == null ? new byte[0] : value.toBytes()).copyHeaders(this.headers).build(); - } - - @Override - public Message end() { - return end(null); - } - - @Override - public CloudEventContextWriter withContextAttribute(String name, String value) throws CloudEventRWException { - this.headers.put(this.cePrefix + name, value); - return this; - } - - @Override - public MessageBuilderMessageWriter create(SpecVersion version) { - this.headers.put(this.cePrefix + "specversion", version.toString()); - return this; - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java deleted file mode 100644 index f074a658a5..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/package-info.java +++ /dev/null @@ -1,3 +0,0 @@ - -@org.jspecify.annotations.NullMarked -package org.springframework.integration.cloudevents.transformer.strategies.cloudeventconverter; \ No newline at end of file diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java deleted file mode 100644 index 9c7e28f12c..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/strategies/package-info.java +++ /dev/null @@ -1,3 +0,0 @@ - -@org.jspecify.annotations.NullMarked -package org.springframework.integration.cloudevents.transformer.strategies; 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 index a4d19a01ac..13d617dc54 100644 --- 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 @@ -16,263 +16,253 @@ package org.springframework.integration.cloudevents.transformer; -import org.junit.jupiter.api.BeforeEach; +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.springframework.integration.cloudevents.transformer.strategies.CloudEventMessageFormatStrategy; +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 ToCloudEventTransformer transformer; + private static final String TRACE_HEADER = "{'trace-id' : 'trace-123'}"; - @BeforeEach - void setUp() { - String extensionPatterns = "customer-header"; - this.transformer = new ToCloudEventTransformer(new CloudEventMessageFormatStrategy("ce-"), - extensionPatterns); - } + private static final String SPAN_HEADER = "{'span-id' : 'span-456'}"; - @Test - void doTransformWithStringPayload() { - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("custom-header", "test-value") - .setHeader("other-header", "other-value") - .build(); + private static final String USER_HEADER = "{'user-id' : 'user-789'}"; - Object result = this.transformer.doTransform(message); + private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); + @Autowired + private ToCloudEventTransformer transformerWithNoExtensions; - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isEqualTo(payload.getBytes()); + @Autowired + private ToCloudEventTransformer transformerWithExtensions; + + @Autowired + private ToCloudEventTransformer transformerWithInvalidIDExpression; + + private final JsonFormat jsonFormat = new JsonFormat(); - // Verify that CloudEvent headers are present in the message - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers).isNotNull(); + private final AvroCompactFormat avroFormat = new AvroCompactFormat(); - // Check that the original other-header is preserved (not mapped to extension) - assertThat(headers.containsKey("other-header")).isTrue(); - assertThat(headers.get("other-header")).isEqualTo("other-value"); + 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 - void doTransformWithByteArrayPayload() { - byte[] payload = "test message".getBytes(); - Message message = MessageBuilder.withPayload(payload).build(); + @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); + } - Object result = transformer.doTransform(message); + @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); + } - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); + @Test + void unregisteredFormatType() { + EventFormat testFormat = new EventFormat() { - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isNotNull(); - assertThat(resultMessage.getPayload()).isEqualTo(payload); + @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); + } - @Test - void doTransformWithObjectPayload() { - Object payload = new Object() { @Override - public String toString() { - return "custom object"; + public String serializedContentType() { + return "application/cloudevents+invalid"; } }; - Message message = MessageBuilder.withPayload(payload).build(); + assertThatThrownBy(() -> getTransformerNoExtensions(PAYLOAD, testFormat)) + .hasMessage("No EventFormat found for 'application/cloudevents+invalid'"); + } - Object result = transformer.doTransform(message); + @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; + Message resultMessage = (Message) result; assertThat(resultMessage.getPayload()).isNotNull(); - assertThat(resultMessage.getPayload()).isEqualTo(payload.toString().getBytes()); + assertThat(new String(resultMessage.getPayload())).endsWith(new String(payload) + "}"); } @Test - void headerFiltering() { - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("customer-header", "extension-value") - .setHeader("regular-header", "regular-value") - .setHeader("another-regular", "another-value") - .build(); - - Object result = transformer.doTransform(message); + @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; - - // Check that regular headers are preserved - assertThat(resultMessage.getHeaders().containsKey("regular-header")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("another-regular")).isTrue(); - assertThat(resultMessage.getHeaders().containsKey("ce-customer-header")).isTrue(); - assertThat(resultMessage.getHeaders().get("regular-header")).isEqualTo("regular-value"); - assertThat(resultMessage.getHeaders().get("another-regular")).isEqualTo("another-value"); - - - + assertThat(resultMessage.getPayload()).isNotNull(); } @Test - void emptyExtensionNames() { - ToCloudEventTransformer emptyExtensionTransformer = new ToCloudEventTransformer(); - - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("some-header", "some-value") - .build(); - - Object result = emptyExtensionTransformer.doTransform(message); - - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - - // All headers should be preserved when no extension mapping exists - assertThat(resultMessage.getHeaders().containsKey("some-header")).isTrue(); - assertThat(resultMessage.getHeaders().get("some-header")).isEqualTo("some-value"); + 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[] extensionPatterns = {"trace-id", "span-id", "user-id"}; - - ToCloudEventTransformer extendedTransformer = new ToCloudEventTransformer(new CloudEventMessageFormatStrategy("ce-"), extensionPatterns); - String payload = "test message"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("trace-id", "trace-123") - .setHeader("span-id", "span-456") - .setHeader("user-id", "user-789") + Message message = createBaseMessage(payload.getBytes(), "application/cloudevents+json") .setHeader("correlation-id", "corr-999") .build(); - Object result = extendedTransformer.doTransform(message); + Object result = this.transformerWithExtensions.doTransform(message); assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - - // Extension-mapped headers should be converted to cloud event extensions - assertThat(resultMessage.getHeaders()).containsKeys("trace-id", "span-id", "user-id", "correlation-id", - "ce-trace-id", "ce-span-id", "ce-user-id"); + Message resultMessage = (Message) result; - // Non-mapped header should be preserved + 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 = MessageBuilder.withPayload("").build(); - - Object result = transformer.doTransform(message); + Message message = createBaseMessage("".getBytes(), "application/cloudevents+json").build(); + Object result = this.transformerWithNoExtensions.doTransform(message); assertThat(result).isNotNull(); assertThat(result).isInstanceOf(Message.class); } @Test - void defaultConstructorUsesDefaultCloudEventProperties() { - ToCloudEventTransformer defaultTransformer = new ToCloudEventTransformer(); - - String payload = "test default properties"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = defaultTransformer.doTransform(message); + void failWhenNoIdHeaderAndNoDefault() { + Message message = MessageBuilder.withPayload(PAYLOAD) + .setHeader("contentType", JsonFormat.CONTENT_TYPE) + .build(); - assertThat(result).isNotNull(); - assertThat(result).isInstanceOf(Message.class); + assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)).isInstanceOf(MessageTransformationException.class) + .hasMessageContaining("No id was found with the specified expression"); } - @Test - void testCustomCePrefixInHeaders() { - - ToCloudEventTransformer customPrefixTransformer = new ToCloudEventTransformer( - new CloudEventMessageFormatStrategy("CUSTOM_"), (String[]) null); - String payload = "test custom prefix"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("test-header", "test-value") - .build(); - - Object result = customPrefixTransformer.doTransform(message); - - Message resultMessage = getTransformedMessage(result); - - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers).containsKeys("CUSTOM_id", "CUSTOM_source", "CUSTOM_type", "CUSTOM_specversion"); - assertThat(headers.get("CUSTOM_specversion")).isEqualTo("1.0"); - assertThat(headers).doesNotContainKeys("ce-id", "ce-source", "ce-type", "ce-specversion"); - assertThat(headers.get("test-header")).isEqualTo("test-value"); + 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()); } - @Test - void testCustomPrefixWithExtensions() { - - String[] extensionPatterns = {"trace-id", "span-id"}; - ToCloudEventTransformer customExtTransformer = new ToCloudEventTransformer( - new CloudEventMessageFormatStrategy("APP_CE_"), extensionPatterns); - - String payload = "test custom prefix with extensions"; - Message message = MessageBuilder.withPayload(payload) - .setHeader("trace-id", "trace-456") - .setHeader("span-id", "span-789") - .setHeader("regular-header", "regular-value") - .build(); - - Object result = customExtTransformer.doTransform(message); - - Message resultMessage = getTransformedMessage(result); - - MessageHeaders headers = resultMessage.getHeaders(); - assertThat(headers).containsKeys("APP_CE_id", "APP_CE_source", "APP_CE_type", "APP_CE_specversion", - "APP_CE_trace-id", "APP_CE_span-id"); + @SuppressWarnings("unchecked") + private Message transformMessage(Message message, ToCloudEventTransformer transformer) { + Object result = transformer.doTransform(message); - assertThat(headers.get("APP_CE_id")).isNotNull(); - assertThat(headers.get("APP_CE_source")).isNotNull(); - assertThat(headers.get("APP_CE_type")).isNotNull(); - assertThat(headers.get("APP_CE_specversion")).isEqualTo("1.0"); - assertThat(headers.get("APP_CE_trace-id")).isEqualTo("trace-456"); - assertThat(headers.get("APP_CE_span-id")).isEqualTo("span-789"); - assertThat(headers).containsKeys("trace-id", "span-id"); - assertThat(headers.get("regular-header")).isEqualTo("regular-value"); + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(Message.class); + return (Message) result; } - @Test - void testEmptyStringCePrefixBehavior() { - ToCloudEventTransformer emptyPrefixTransformer = new ToCloudEventTransformer( - new CloudEventMessageFormatStrategy(""), (String[]) null); - String payload = "test empty prefix"; - Message message = MessageBuilder.withPayload(payload).build(); - - Object result = emptyPrefixTransformer.doTransform(message); - - Message resultMessage = getTransformedMessage(result); - - MessageHeaders headers = resultMessage.getHeaders(); - - assertThat(headers.get("id")).isNotNull(); - assertThat(headers.get("source")).isNotNull(); - assertThat(headers.get("type")).isNotNull(); - assertThat(headers.get("specversion")).isEqualTo("1.0"); - - assertThat(headers).doesNotContainKeys("ce-id", "ce-source", "ce-type", "ce-specversion"); + 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 Message getTransformedMessage(Object object) { - assertThat(object).isNotNull(); - assertThat(object).isInstanceOf(Message.class); + private MessageBuilder createBaseMessage(byte[] payload, String contentType) { + return MessageBuilder.withPayload(payload) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType); + } - return (Message) object; + @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/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java deleted file mode 100644 index d0682d9cab..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/CloudEventMessageFormatStrategyTests.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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.strategies; - -import java.net.URI; -import java.util.HashMap; -import java.util.Map; - -import io.cloudevents.CloudEvent; -import io.cloudevents.core.builder.CloudEventBuilder; -import org.junit.jupiter.api.Test; - -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; - -import static org.assertj.core.api.Assertions.assertThat; - -public class CloudEventMessageFormatStrategyTests { - - @Test - void toIntegrationMessageCloudEventToMessage() { - CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); - - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("test-id") - .withSource(URI.create("test-source")) - .withType("test-type") - .withData("Some data".getBytes()) - .build(); - - MessageHeaders headers = new MessageHeaders(null); - - Object result = strategy.toIntegrationMessage(cloudEvent, headers); - - assertThat(result).isInstanceOf(Message.class); - Message message = (Message) result; - assertThat(message.getPayload()).isNotNull(); - assertThat(message.getHeaders().containsKey("ce_id")).isTrue(); - assertThat(message.getHeaders().get("ce_id")).isEqualTo("test-id"); - } - - @Test - void toIntegrationMessageWithAdditionalHeaders() { - CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); - - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("test-id") - .withSource(URI.create("test-source")) - .withType("test-type") - .withData("application/json", "{}".getBytes()) - .build(); - - Map additionalHeaders = new HashMap<>(); - additionalHeaders.put("custom-header", "custom-value"); - MessageHeaders headers = new MessageHeaders(additionalHeaders); - - Object result = strategy.toIntegrationMessage(cloudEvent, headers); - - assertThat(result).isInstanceOf(Message.class); - Message message = (Message) result; - assertThat(message.getHeaders().containsKey("custom-header")).isTrue(); - assertThat(message.getHeaders().get("custom-header")).isEqualTo("custom-value"); - } - - @Test - void toIntegrationMessageWithDifferentPrefix() { - CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("cloudevent-"); - - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("test-id") - .withSource(URI.create("test-source")) - .withType("test-type") - .build(); - - MessageHeaders headers = new MessageHeaders(null); - - Object result = strategy.toIntegrationMessage(cloudEvent, headers); - - assertThat(result).isInstanceOf(Message.class); - Message message = (Message) result; - assertThat(message.getHeaders().containsKey("cloudevent-id")).isTrue(); - } - - @Test - void toIntegrationMessageWithEmptyHeaders() { - CloudEventMessageFormatStrategy strategy = new CloudEventMessageFormatStrategy("ce_"); - - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("test-id") - .withSource(URI.create("test-source")) - .withType("test-type") - .build(); - - MessageHeaders headers = new MessageHeaders(new HashMap<>()); - - Object result = strategy.toIntegrationMessage(cloudEvent, headers); - - assertThat(result).isInstanceOf(Message.class); - } -} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java deleted file mode 100644 index 88a1cb3b88..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/CloudEventMessageConverterTests.java +++ /dev/null @@ -1,240 +0,0 @@ -/* - * 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.strategies.cloudeventconverter; - -import java.net.URI; -import java.time.OffsetDateTime; -import java.util.HashMap; -import java.util.Map; - -import io.cloudevents.CloudEvent; -import io.cloudevents.core.builder.CloudEventBuilder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.integration.support.MessageBuilder; -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalStateException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.Assertions.catchIllegalStateException; - -public class CloudEventMessageConverterTests { - - private CloudEventMessageConverter converter; - - private CloudEventMessageConverter customPrefixConverter; - - @BeforeEach - void setUp() { - this.converter = new CloudEventMessageConverter(CloudEventMessageConverter.CE_PREFIX); - this.customPrefixConverter = new CloudEventMessageConverter("CUSTOM_"); - } - - @Test - void toMessageWithCloudEventAndDefaultPrefix() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("test-id") - .withSource(URI.create("https://example.com")) - .withType("com.example.test") - .withData("test data".getBytes()) - .build(); - - Map headers = new HashMap<>(); - headers.put("existing-header", "existing-value"); - MessageHeaders messageHeaders = new MessageHeaders(headers); - - Message result = this.converter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - assertThat(result.getPayload()).isEqualTo("test data".getBytes()); - - MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.get("existing-header")).isEqualTo("existing-value"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("test-id"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://example.com"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.test"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); - } - - @Test - void toMessageWithCloudEventAndCustomPrefix() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("custom-id") - .withSource(URI.create("https://custom.example.com")) - .withType("com.example.custom") - .withData("custom data".getBytes()) - .build(); - - Map headers = new HashMap<>(); - headers.put("custom-header", "custom-value"); - MessageHeaders messageHeaders = new MessageHeaders(headers); - - Message result = this.customPrefixConverter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - assertThat(result.getPayload()).isEqualTo("custom data".getBytes()); - - MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.get("custom-header")).isEqualTo("custom-value"); - assertThat(resultHeaders.get("CUSTOM_id")).isEqualTo("custom-id"); - assertThat(resultHeaders.get("CUSTOM_source")).isEqualTo("https://custom.example.com"); - assertThat(resultHeaders.get("CUSTOM_type")).isEqualTo("com.example.custom"); - assertThat(resultHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); - } - - @Test - void toMessageWithCloudEventContainingExtensions() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("ext-id") - .withSource(URI.create("https://ext.example.com")) - .withType("com.example.ext") - .withExtension("spanid", "span-456") - .withData("extension data".getBytes()) - .build(); - - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - - Message result = this.customPrefixConverter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - MessageHeaders resultHeaders = result.getHeaders(); - - assertThat(resultHeaders.get("CUSTOM_id")).isEqualTo("ext-id"); - assertThat(resultHeaders.get("CUSTOM_spanid")).isEqualTo("span-456"); - } - - @Test - void toMessageWithCloudEventContainingOptionalAttributes() { - OffsetDateTime time = OffsetDateTime.now(); - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("optional-id") - .withSource(URI.create("https://optional.example.com")) - .withType("com.example.optional") - .withDataContentType("application/json") - .withDataSchema(URI.create("https://schema.example.com")) - .withSubject("test-subject") - .withTime(time) - .withData("{\"key\":\"value\"}".getBytes()) - .build(); - - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - - Message result = this.converter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - MessageHeaders resultHeaders = result.getHeaders(); - - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "subject")).isEqualTo("test-subject"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "time")).isNotNull(); - } - - @Test - void toMessageWithCloudEventWithoutData() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("no-data-id") - .withSource(URI.create("https://nodata.example.com")) - .withType("com.example.nodata") - .build(); - - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - - Message result = this.converter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - assertThat(result.getPayload()).isEqualTo(new byte[0]); - - MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("no-data-id"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://nodata.example.com"); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.nodata"); - } - - @Test - void toMessageWithNonCloudEventPayload() { - String payload = "regular string payload"; - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - - catchIllegalStateException(() -> this.converter.toMessage(payload, messageHeaders)); - - } - - @Test - void toMessagePreservesExistingHeaders() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("preserve-id") - .withSource(URI.create("https://preserve.example.com")) - .withType("com.example.preserve") - .withData("preserve data".getBytes()) - .build(); - - Map headers = new HashMap<>(); - headers.put("correlation-id", "corr-123"); - headers.put("message-timestamp", System.currentTimeMillis()); - headers.put("routing-key", "test.route"); - MessageHeaders messageHeaders = new MessageHeaders(headers); - - Message result = this.converter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - MessageHeaders resultHeaders = result.getHeaders(); - - assertThat(resultHeaders.get("correlation-id")).isEqualTo("corr-123"); - assertThat(resultHeaders.get("message-timestamp")).isNotNull(); - assertThat(resultHeaders.get("routing-key")).isEqualTo("test.route"); - - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("preserve-id"); - } - - @Test - void toMessageWithEmptyHeaders() { - CloudEvent cloudEvent = CloudEventBuilder.v1() - .withId("empty-headers-id") - .withSource(URI.create("https://empty.example.com")) - .withType("com.example.empty") - .build(); - - MessageHeaders messageHeaders = new MessageHeaders(new HashMap<>()); - - Message result = this.converter.toMessage(cloudEvent, messageHeaders); - - assertThat(result).isNotNull(); - MessageHeaders resultHeaders = result.getHeaders(); - assertThat(resultHeaders.size()).isEqualTo(6); - assertThat(resultHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("empty-headers-id"); - } - - @Test - void invalidPayloadToMessage() { - Message message = MessageBuilder.withPayload(Integer.valueOf(1234)).build(); - assertThatIllegalStateException() - .isThrownBy(() -> this.converter.toMessage(message, new MessageHeaders(new HashMap<>()))) - .withMessage("Payload must be a CloudEvent"); - - } - - @Test - void invalidPayloadFromMessage() { - Message message = MessageBuilder.withPayload(Integer.valueOf(1234)).build(); - assertThatThrownBy(() -> this.converter.fromMessage(message, Integer.class)) - .hasMessage("Could not parse. Unknown encoding. Invalid content type or spec version"); - } -} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java deleted file mode 100644 index 73591b3434..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/strategies/cloudeventconverter/MessageBuilderMessageWriterTests.java +++ /dev/null @@ -1,230 +0,0 @@ -/* - * 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.strategies.cloudeventconverter; - -import java.util.HashMap; -import java.util.Map; - -import io.cloudevents.CloudEventData; -import io.cloudevents.SpecVersion; -import io.cloudevents.core.format.EventFormat; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class MessageBuilderMessageWriterTests { - - private MessageBuilderMessageWriter writer; - - private MessageBuilderMessageWriter customPrefixWriter; - - @BeforeEach - void setUp() { - Map headers = new HashMap<>(); - headers.put("existing-header", "existing-value"); - headers.put("correlation-id", "corr-123"); - - this.writer = new MessageBuilderMessageWriter(headers, CloudEventMessageConverter.CE_PREFIX); - this.customPrefixWriter = new MessageBuilderMessageWriter(headers, "CUSTOM_"); - } - - @Test - void createWithSpecVersionAndDefaultPrefix() { - MessageBuilderMessageWriter result = this.writer.create(SpecVersion.V1); - - assertThat(result).isNotNull(); - assertThat(result).isSameAs(this.writer); - - Message message = result.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); - assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); - } - - @Test - void createWithSpecVersionAndCustomPrefix() { - MessageBuilderMessageWriter result = this.customPrefixWriter.create(SpecVersion.V1); - - assertThat(result).isNotNull(); - assertThat(result).isSameAs(this.customPrefixWriter); - - Message message = result.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); - assertThat(messageHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); - } - - @Test - void withContextAttributeDefaultPrefix() { - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "test-id") - .withContextAttribute("source", "https://example.com") - .withContextAttribute("type", "com.example.test"); - - Message message = this.writer.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("test-id"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://example.com"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "type")).isEqualTo("com.example.test"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "specversion")).isEqualTo("1.0"); - } - - @Test - void withContextAttributeCustomPrefix() { - this.customPrefixWriter.create(SpecVersion.V1) - .withContextAttribute("id", "custom-id") - .withContextAttribute("source", "https://custom.example.com") - .withContextAttribute("type", "com.example.custom"); - - Message message = this.customPrefixWriter.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get("CUSTOM_id")).isEqualTo("custom-id"); - assertThat(messageHeaders.get("CUSTOM_source")).isEqualTo("https://custom.example.com"); - assertThat(messageHeaders.get("CUSTOM_type")).isEqualTo("com.example.custom"); - assertThat(messageHeaders.get("CUSTOM_specversion")).isEqualTo("1.0"); - } - - @Test - void withContextAttributeExtensions() { - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "ext-id") - .withContextAttribute("source", "https://ext.example.com") - .withContextAttribute("type", "com.example.ext") - .withContextAttribute("trace-id", "trace-123") - .withContextAttribute("span-id", "span-456"); - - Message message = this.writer.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "trace-id")).isEqualTo("trace-123"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "span-id")).isEqualTo("span-456"); - } - - @Test - void withContextAttributeOptionalAttributes() { - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "optional-id") - .withContextAttribute("source", "https://optional.example.com") - .withContextAttribute("type", "com.example.optional") - .withContextAttribute("datacontenttype", "application/json") - .withContextAttribute("dataschema", "https://schema.example.com") - .withContextAttribute("subject", "test-subject") - .withContextAttribute("time", "2023-01-01T10:00:00Z"); - - Message message = this.writer.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "datacontenttype")).isEqualTo("application/json"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "dataschema")).isEqualTo("https://schema.example.com"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "subject")).isEqualTo("test-subject"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "time")).isEqualTo("2023-01-01T10:00:00Z"); - } - - @Test - void testEndWithEmptyPayload() { - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "empty-id") - .withContextAttribute("source", "https://empty.example.com") - .withContextAttribute("type", "com.example.empty"); - - Message message = this.writer.end(); - - assertThat(message).isNotNull(); - assertThat(message.getPayload()).isEqualTo(new byte[0]); - assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); - assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("empty-id"); - } - - @Test - void endWithCloudEventData() { - CloudEventData mockData = mock(CloudEventData.class); - byte[] testData = "test data content".getBytes(); - when(mockData.toBytes()).thenReturn(testData); - - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "data-id") - .withContextAttribute("source", "https://data.example.com") - .withContextAttribute("type", "com.example.data"); - - Message message = this.writer.end(mockData); - - assertThat(message).isNotNull(); - assertThat(message.getPayload()).isEqualTo(testData); - assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("data-id"); - } - - @Test - void endWithNullCloudEventData() { - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "null-data-id") - .withContextAttribute("source", "https://nulldata.example.com") - .withContextAttribute("type", "com.example.nulldata"); - - Message message = this.writer.end(null); - - assertThat(message).isNotNull(); - assertThat(message.getPayload()).isEqualTo(new byte[0]); - assertThat(message.getHeaders().get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("null-data-id"); - } - - @Test - void setEventWithTextPayload() { - EventFormat mockFormat = mock(EventFormat.class); - when(mockFormat.serializedContentType()).thenReturn("application/cloudevents+json"); - - byte[] eventData = "serialized event data".getBytes(); - - this.writer.create(SpecVersion.V1) - .withContextAttribute("id", "format-id") - .withContextAttribute("source", "https://format.example.com") - .withContextAttribute("type", "com.example.format"); - - Message message = this.writer.setEvent(mockFormat, eventData); - - assertThat(message).isNotNull(); - assertThat(message.getPayload()).isEqualTo(eventData); - assertThat(message.getHeaders().get(CloudEventMessageConverter.CONTENT_TYPE)).isEqualTo("application/cloudevents+json"); - assertThat(message.getHeaders().get("existing-header")).isEqualTo("existing-value"); - } - - @Test - void headersCorrectlyAssignedToMessageHeader() { - this.writer.create(SpecVersion.V1); - this.writer.withContextAttribute("id", "preserve-id"); - this.writer.withContextAttribute("source", "https://preserve.example.com"); - - Message message = this.writer.end(); - MessageHeaders messageHeaders = message.getHeaders(); - - assertThat(messageHeaders.get("existing-header")).isEqualTo("existing-value"); - assertThat(messageHeaders.get("correlation-id")).isEqualTo("corr-123"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "id")).isEqualTo("preserve-id"); - assertThat(messageHeaders.get(CloudEventMessageConverter.CE_PREFIX + "source")).isEqualTo("https://preserve.example.com"); - } - -} diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc index d033528c23..b580761e09 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc @@ -6,107 +6,84 @@ == 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 formats, header pattern matching, and extension management. +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. -To do this, it uses a `FormatStrategy` that allows users to transform the message to the desired "CloudEvent" format (CloudEvent, JSON, XML, AVRO, etc). It defaults to `CloudEventFormatStrategy`. +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[]`. -[[cloudevent-transformer-conversion-types]] -=== Format Strategy -The `ToCloudEventTransformer` accepts classes that implement the `FormatStrategy` to serialize CloudEvent data to formats other than the default `CloudEventMessageFormatStrategy`. [[configure-transformer]] === Configuring Transformer -The `ToCloudEventTransformer` class provides configurations for CloudEvent metadata and formatting options. +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`. -==== Properties Configuration +NOTE: The `time` attribute is set to the time that the CloudEvent message was created by the `ToCloudEventTransformer` transformer. [source,java] ---- -ToCloudEventTransformer cloudEventTransformer = new ToCloudEventTransformer(); -cloudEventTransformer.setId("event-123"); // The CloudEvent ID. Default is "". -cloudEventTransformer.setSource(URI.create("https://example.com/source")); // The event source. Default is "". -cloudEventTransformer.setType("com.example.OrderCreated"); // The event type. The Default is "". -cloudEventTransformer.setDataContentType("application/json"); // The data content type. Default is null. -cloudEventTransformer.setDataSchema(URI.create("https://example.com/schema")); // The data schema. Default is null. -cloudEventTransformer.setSubject("order-processing"); // The event subject. Default is null. -cloudEventTransformer.setTime(OffsetDateTime.now()); // The event time. Default is null. +ExpressionParser parser = new SpelExpressionParser(); +ToCloudEventTransformer transformer = new ToCloudEventTransformer(null); +transformer.setTypeExpression(parser.parseExpression("sampleType")); ---- -[[cloudevent-properties-defaults]] +==== 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. |=== -| Property | Default Value | Description +| Attribute Name | Default Value | `id` -| `""` -| Empty string - should be set to unique identifier +| the id of the message. | `source` -| `URI.create("")` -| Empty URI - should be set to event source +| Prefix of "/spring/" followed by the appName a `.` then the name of the transformer's bean. | `type` -| `""` -| Empty string - should be set to event type +| "spring.message" | `dataContentType` -| `null` -| Optional data content type +| The `contentType` of the message. | `dataSchema` | `null` -| Optional data schema URI | `subject` | `null` -| Optional event subject | `time` -| `null` -| Optional event timestamp - -| `cePrefix` -| `CloudEventsHeaders.CE_PREFIX` -| Default is CloudEventsHeaders.CE_PREFIX. +| The time the CloudEvent message is created |=== -[[cloudevent-extensions-pattern-matching]] -==== Cloud Event Extension Pattern Matching - -The transformer allows the user to specify what `MessageHeaders` will be added as extensions to the CloudEvent. The extension system uses pattern matching for extension identification: - -[source,java] ----- -// Include headers starting with "key-" or "external-" -// Exclude headers starting with "internal-" -// If the header key is neither of the above it is not included in the extensions. -String[] patterns = {"key-*", "external-*", "!internal-*"}; - -// Extension patterns are processed during transformation -ToCloudEventTransformer transformer = new ToCloudEventTransformer( - new CloudEventMessageFormatStrategy(), - patterns -); ----- - -[[cloudevent-extensions-pattern-syntax]] -==== Pattern Syntax - -The pattern matching supports: - -* **Wildcard patterns**: Use `\*` for wildcard matching (e.g., `external-\*` matches `external-id`, `external-span`) -* **Negation patterns**: Use `!` prefix for exclusion (e.g., `!internal-*` excludes internal headers) -* If the header key is neither of the above it is left in the `MessageHeader`. -* **Multiple patterns**: Use comma-delimited patterns (e.g., `{"user-\*", "session-\*" , "!debug-*"}`) -* **Null handling**: Null patterns disable extension processing, thus no `MessageHeaders` are moved to the CloudEvent extensions. - [[cloudevent-transformer-integration]] === Integration with Spring Integration Flows @@ -131,25 +108,9 @@ public IntegrationFlow cloudEventTransformFlow() { The transformer follows the process below: -1. **Extension Extraction**: Extract CloudEvent extensions from message headers using configured patterns -2. **CloudEvent Building**: Build a CloudEvent with configured properties and message payload -3. **Format Conversion**: Apply the specified `FormatStrategy` to format the output - -==== Payload Handling - -The transformer supports multiple payload types: - -[source,java] ----- -// String payload -Message stringMessage = MessageBuilder.withPayload("Hello World").build(); - -// Byte array payload -Message binaryMessage = MessageBuilder.withPayload("Hello".getBytes()).build(); - -// Object payload (converted to string then bytes) -Message objectMessage = MessageBuilder.withPayload(customObject).build(); ----- +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 @@ -162,19 +123,11 @@ Message objectMessage = MessageBuilder.withPayload(customObject).build() // Input message with headers Message inputMessage = MessageBuilder .withPayload("Hello CloudEvents") - .setHeader("trace-id", "abc123") - .setHeader("user-session", "session456") .build(); - // Transformer with extension patterns -ToCloudEventTransformer transformer = new ToCloudEventTransformer( - new CloudEventMessageFormatStrategy(), "trace-*"); -// Configure properties -transformer.setId("event-123"); -transformer.setSource(URI.create("https://example.com")); -transformer.setType("com.example.MessageProcessed"); +ToCloudEventTransformer transformer = new ToCloudEventTransformer(); // Transform to CloudEvent -Message cloudEventMessage = transformer.transform(inputMessage); +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 b6abf59870..9f37d852e3 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -24,5 +24,5 @@ See xref:ws.adoc[] for more information. === 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. +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. From 61f799dddd2a46c3ccff4c603a95df1b137d541f Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Fri, 17 Oct 2025 08:01:40 -0400 Subject: [PATCH 05/11] Refactor CloudEvent extensions and update javadocs The previous extension extraction mechanism using `Expression` arrays and a separate `ExtensionsExtractor` interface was overly complex for the simple use case of pattern matching header names. This change simplifies the API by: - Removing the `ExtensionsExtractor` interface and implementations - Replacing `Expression`-based extension configuration with simple String pattern matching in the transformer constructor - Updating all javadocs to use imperative voice per Spring conventions (e.g., "Converts messages" instead of "A transformer that converts messages") - Making default value descriptions more concise (e.g., "Defaults to null" instead of "Default Expression evaluates to a null") - Add extensionPattern match logic --- build.gradle | 10 +- ...mer.java => ToCloudEventsTransformer.java} | 153 ++++------ ...ava => ToCloudEventsTransformerTests.java} | 59 ++-- src/reference/antora/modules/ROOT/nav.adoc | 2 +- .../ROOT/pages/cloudevents-transform.adoc | 133 --------- .../modules/ROOT/pages/cloudevents.adoc | 271 ++++++++++++++++++ .../antora/modules/ROOT/pages/whats-new.adoc | 5 +- 7 files changed, 370 insertions(+), 263 deletions(-) rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/{ToCloudEventTransformer.java => ToCloudEventsTransformer.java} (55%) rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/{ToCloudEventTransformerTests.java => ToCloudEventsTransformerTests.java} (83%) delete mode 100644 src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc create mode 100644 src/reference/antora/modules/ROOT/pages/cloudevents.adoc diff --git a/build.gradle b/build.gradle index ba3bba7ff1..f60f4ddd69 100644 --- a/build.gradle +++ b/build.gradle @@ -483,15 +483,13 @@ project('spring-integration-cloudevents') { dependencies { api "io.cloudevents:cloudevents-core:$cloudEventsVersion" - optionalApi "io.cloudevents:cloudevents-spring:$cloudEventsVersion" - optionalApi "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" + testImplementation "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" - optionalApi("io.cloudevents:cloudevents-avro-compact:$cloudEventsVersion") { + testImplementation("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' + testImplementation "org.apache.avro:avro:$avroVersion" + testImplementation "io.cloudevents:cloudevents-xml:$cloudEventsVersion" } } 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/ToCloudEventsTransformer.java similarity index 55% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java index 28a9f1a227..96f92c3638 100644 --- 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/ToCloudEventsTransformer.java @@ -14,15 +14,16 @@ * limitations under the License. */ + package org.springframework.integration.cloudevents.transformer; import java.net.URI; import java.time.OffsetDateTime; +import java.util.Collections; 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; @@ -38,24 +39,26 @@ import org.springframework.expression.common.LiteralExpression; import org.springframework.integration.expression.ExpressionUtils; import org.springframework.integration.expression.FunctionExpression; +import org.springframework.integration.expression.ValueExpression; +import org.springframework.integration.support.utils.PatternMatchUtils; 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; +import org.springframework.util.Assert; /** - * A Spring Integration transformer that converts messages to CloudEvent format. - * Attribute and extension mapping is performed based on {@link Expression}s. + * Converts messages to CloudEvent format. + * Performs attribute and extension mapping based on {@link Expression}s. * * @author Glenn Renfro * * @since 7.0 */ -public class ToCloudEventTransformer extends AbstractTransformer { +public class ToCloudEventsTransformer extends AbstractTransformer { - private Expression idExpression = new FunctionExpression>( + private Expression eventIdExpression = new FunctionExpression>( msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); @SuppressWarnings("NullAway.Init") @@ -64,12 +67,11 @@ public class ToCloudEventTransformer extends AbstractTransformer { private Expression typeExpression = new LiteralExpression("spring.message"); @SuppressWarnings("NullAway.Init") - private Expression dataSchemaExpression; + private @Nullable Expression dataSchemaExpression; - private Expression subjectExpression = new FunctionExpression<>((Function, @Nullable String>) - message -> null); + private @Nullable Expression subjectExpression; - private final Expression @Nullable [] cloudEventExtensionExpressions; + private final String [] extensionPatterns; @SuppressWarnings("NullAway.Init") private EvaluationContext evaluationContext; @@ -77,37 +79,34 @@ public class ToCloudEventTransformer extends AbstractTransformer { private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); /** - * Construct a ToCloudEventTransformer. - * - * @param cloudEventExtensionExpressions an array of {@link Expression}s for establishing CloudEvent extensions + * Construct a ToCloudEventsTransformer. + * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions + * to the CloudEvent */ - public ToCloudEventTransformer(Expression @Nullable ... cloudEventExtensionExpressions) { - this.cloudEventExtensionExpressions = cloudEventExtensionExpressions; + public ToCloudEventsTransformer(String ... extensionPatterns) { + this.extensionPatterns = extensionPatterns; } /** - * Construct a ToCloudEventTransformer with no {@link Expression}s for extensions. - * + * Construct a ToCloudEventsTransformer with no extensionPatterns. */ - public ToCloudEventTransformer() { - this((Expression[]) null); + public ToCloudEventsTransformer() { + this.extensionPatterns = new String[0]; } /** * 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 + * Defaults to extracting the id from the {@link MessageHeaders} of the message. + * @param eventIdExpression the expression to create the id for each CloudEvent */ - public void setIdExpression(Expression idExpression) { - this.idExpression = idExpression; + public void setEventIdExpression(Expression eventIdExpression) { + this.eventIdExpression = eventIdExpression; } /** * 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 + * Defaults to {@code "/spring/" + appName + "." + getBeanName())}. + * @param sourceExpression the expression to create the source for each CloudEvent */ public void setSourceExpression(Expression sourceExpression) { this.sourceExpression = sourceExpression; @@ -115,9 +114,8 @@ public void setSourceExpression(Expression 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 + * Defaults to "spring.message". + * @param typeExpression the expression to create the type for each CloudEvent */ public void setTypeExpression(Expression typeExpression) { this.typeExpression = typeExpression; @@ -125,21 +123,19 @@ public void setTypeExpression(Expression 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 + * Defaults to null. + * @param dataSchemaExpression the expression to create the dataSchema for each CloudEvent */ - public void setDataSchemaExpression(Expression dataSchemaExpression) { + public void setDataSchemaExpression(@Nullable 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 + * Defaults to null. + * @param subjectExpression the expression to create the subject for each CloudEvent */ - public void setSubjectExpression(Expression subjectExpression) { + public void setSubjectExpression(@Nullable Expression subjectExpression) { this.subjectExpression = subjectExpression; } @@ -149,21 +145,14 @@ protected void 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); + String appName = applicationContext.getEnvironment().getProperty("spring.application.name"); + appName = appName == null ? "unknown" : appName; + this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); } } /** * 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 @@ -171,21 +160,11 @@ protected void onInit() { @SuppressWarnings("unchecked") @Override protected Object doTransform(Message message) { + Assert.isInstanceOf(byte[].class, message.getPayload(), "Message payload must be of type byte[]"); - 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"); - } - + String id = this.eventIdExpression.getValue(this.evaluationContext, message, String.class); 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) { @@ -198,23 +177,30 @@ protected Object doTransform(Message message) { } ToCloudEventTransformerExtensions extensions = - new ToCloudEventTransformerExtensions(this.evaluationContext, (Message) message, - this.cloudEventExtensionExpressions); + new ToCloudEventTransformerExtensions(message.getHeaders(), + this.extensionPatterns); - CloudEvent cloudEvent = CloudEventBuilder.v1() + CloudEventBuilder cloudEventBuilder = 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)) + .withDataContentType(contentType); + + if (this.subjectExpression != null) { + cloudEventBuilder.withSubject(this.subjectExpression.getValue(this.evaluationContext, message, String.class)); + } + if (this.dataSchemaExpression != null) { + cloudEventBuilder.withDataSchema(this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)); + } + + CloudEvent cloudEvent = cloudEventBuilder.withData((byte[])message.getPayload()) .withExtension(extensions) .build(); return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) .copyHeaders(message.getHeaders()) + .setHeader(MessageHeaders.CONTENT_TYPE, "application/cloudevents") .build(); } @@ -223,42 +209,27 @@ 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. + * Stores 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 + * @param headers the headers from the Spring Integration message + * @param extensionPatterns patterns to determine whether message headers are extensions */ @SuppressWarnings("unchecked") - ToCloudEventTransformerExtensions(EvaluationContext evaluationContext, Message message, - Expression @Nullable ... expressions) { + ToCloudEventTransformerExtensions(Map headers, String ... extensionPatterns) { 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)); + Boolean result = null; + for (Map.Entry header : headers.entrySet()) { + result = PatternMatchUtils.smartMatch(header.getKey(), extensionPatterns); + if (result != null && result) { + this.cloudEventExtensions.put(header.getKey(), header.getValue()); } } } 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/ToCloudEventsTransformerTests.java similarity index 83% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java index 13d617dc54..2f4713d9dd 100644 --- 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/ToCloudEventsTransformerTests.java @@ -36,7 +36,6 @@ 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; @@ -44,30 +43,34 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.support.MessageBuilder; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +@DirtiesContext @SpringJUnitConfig -class ToCloudEventTransformerTests { +class ToCloudEventsTransformerTests { - private static final String TRACE_HEADER = "{'trace-id' : 'trace-123'}"; + private static final String TRACE_HEADER = "traceId"; - private static final String SPAN_HEADER = "{'span-id' : 'span-456'}"; + private static final String SPAN_HEADER = "spanId"; - private static final String USER_HEADER = "{'user-id' : 'user-789'}"; + private static final String USER_HEADER = "userId"; + + private static final String [] TEST_PATTERNS = {"trace*", SPAN_HEADER, USER_HEADER}; private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); @Autowired - private ToCloudEventTransformer transformerWithNoExtensions; + private ToCloudEventsTransformer transformerWithNoExtensions; @Autowired - private ToCloudEventTransformer transformerWithExtensions; + private ToCloudEventsTransformer transformerWithExtensions; @Autowired - private ToCloudEventTransformer transformerWithInvalidIDExpression; + private ToCloudEventsTransformer transformerWithInvalidIDExpression; private final JsonFormat jsonFormat = new JsonFormat(); @@ -173,7 +176,8 @@ void multipleExtensionMappings() { String payload = "test message"; Message message = createBaseMessage(payload.getBytes(), "application/cloudevents+json") .setHeader("correlation-id", "corr-999") - .build(); + .setHeader(TRACE_HEADER, "trace-123") + .build(); Object result = this.transformerWithExtensions.doTransform(message); @@ -182,9 +186,9 @@ void multipleExtensionMappings() { 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\""); + assertThat(new String(resultMessage.getPayload())).contains("\"traceId\":\"trace-123\""); + assertThat(new String(resultMessage.getPayload())).doesNotContain("\"spanId\":\"span-456\"", + "\"userId\":\"user-789\""); } @Test @@ -203,20 +207,20 @@ void failWhenNoIdHeaderAndNoDefault() { .build(); assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)).isInstanceOf(MessageTransformationException.class) - .hasMessageContaining("No id was found with the specified expression"); + .hasMessageContaining("failed to transform message"); } private CloudEvent getTransformerNoExtensions(byte[] payload, EventFormat eventFormat) { Message message = createBaseMessage(payload, eventFormat.serializedContentType()) - .setHeader("custom-header", "test-value") - .setHeader("other-header", "other-value") + .setHeader("customheader", "test-value") + .setHeader("otherheader", "other-value") .build(); Message result = transformMessage(message, this.transformerWithNoExtensions); return eventFormat.deserialize(result.getPayload()); } @SuppressWarnings("unchecked") - private Message transformMessage(Message message, ToCloudEventTransformer transformer) { + private Message transformMessage(Message message, ToCloudEventsTransformer transformer) { Object result = transformer.doTransform(message); assertThat(result).isNotNull(); @@ -232,7 +236,7 @@ private byte[] convertPayloadToBytes(TestRecord testRecord) throws Exception { return byteArrayOutputStream.toByteArray(); } - private MessageBuilder createBaseMessage(byte[] payload, String contentType) { + private static MessageBuilder createBaseMessage(byte[] payload, String contentType) { return MessageBuilder.withPayload(payload) .setHeader(MessageHeaders.CONTENT_TYPE, contentType); } @@ -241,25 +245,22 @@ private MessageBuilder createBaseMessage(byte[] payload, String contentT @EnableIntegration public static class ContextConfiguration { + private static final ExpressionParser parser = new SpelExpressionParser(); + @Bean - public ToCloudEventTransformer transformerWithNoExtensions() { - return new ToCloudEventTransformer((Expression[]) null); + public ToCloudEventsTransformer transformerWithNoExtensions() { + return new ToCloudEventsTransformer(); } @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); + public ToCloudEventsTransformer transformerWithExtensions() { + return new ToCloudEventsTransformer(TEST_PATTERNS); } @Bean - public ToCloudEventTransformer transformerWithInvalidIDExpression() { - ExpressionParser parser = new SpelExpressionParser(); - ToCloudEventTransformer transformer = new ToCloudEventTransformer((Expression[]) null); - transformer.setIdExpression(parser.parseExpression("null")); + public ToCloudEventsTransformer transformerWithInvalidIDExpression() { + ToCloudEventsTransformer transformer = new ToCloudEventsTransformer(); + transformer.setEventIdExpression(parser.parseExpression("null")); return transformer; } } diff --git a/src/reference/antora/modules/ROOT/nav.adoc b/src/reference/antora/modules/ROOT/nav.adoc index 2040060454..4c7cd4a747 100644 --- a/src/reference/antora/modules/ROOT/nav.adoc +++ b/src/reference/antora/modules/ROOT/nav.adoc @@ -124,7 +124,7 @@ ** xref:amqp/amqp-1.0.adoc[] * xref:camel.adoc[] * xref:cassandra.adoc[] -* xref:cloudevents-transform.adoc[] +* xref:cloudevents.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 deleted file mode 100644 index b580761e09..0000000000 --- a/src/reference/antora/modules/ROOT/pages/cloudevents-transform.adoc +++ /dev/null @@ -1,133 +0,0 @@ -[[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/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc new file mode 100644 index 0000000000..8ef29d4f59 --- /dev/null +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -0,0 +1,271 @@ +[[cloudevents]] += CloudEvents Support + +Spring Integration provides transformers (starting with version 7.0) for transforming messages into CloudEvent messages. +It is fully based on the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project. + +This dependency is required for the project: + +[tabs] +====== +Maven:: ++ +[source, xml, subs="normal", role="primary"] +---- + + org.springframework.integration + spring-integration-cloudevents + {project-version} + +---- + +Gradle:: ++ +[source, groovy, subs="normal", role="secondary"] +---- +compile "org.springframework.integration:spring-integration-cloudevents:{project-version}" +---- +====== + +[[cloudevent-transformers]] + +== CloudEvent Transformers + +[[tocloudeventstransformer]] +=== ToCloudEventsTransformer + +The `ToCloudEventsTransformer` converts Spring Integration messages into CloudEvents 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 CloudEvents transformer (`ToCloudEventsTransformer`) converts messages to CloudEvents format. +The CloudEvents transformer utilizes `io.cloudevents.core.provider.EventFormatProvider` to find `EventFormat` classes in the classpath and registers these as the available serializers for CloudEvents. +The type of serialization (JSON, XML, AVRO, etc) of the CloudEvents' message is determined by `contentType` of the message. + +NOTE: Messages to be transformed must have a payload of `byte[]`. + + + +[[configure-transformer]] +===== Configuring Transformer + +The `ToCloudEventsTransformer` allows the user to use SpEL `Expression`s to populate the attributes as well as the extensions. + +====== Attribute Expressions + +Users are allowed to set the CloudEvents' attributes of `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 `ToCloudEventsTransformer` transformer. + +[source,java] +---- +ToCloudEventTransformer transformer = new ToCloudEventTransformer(null); +transformer.setTypeExpression(new LiteralExpression("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 period then the name of the transformer's bean. i.e. `/spring/myapp.toCloudEventsTransformerBean` + +| `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") + .withHeader("contentType", "application/octet-stream") + .build(); +// Transformer with extension patterns +ToCloudEventTransformer transformer = new ToCloudEventTransformer(); + +// Transform to CloudEvent +Object cloudEventMessage = transformer.transform(inputMessage); +---- + +[[eventformats]] +===== EventFormats + +The `ToCloudEventsTransformer` uses `EventFormat` s to serialize the CloudEvent into the message's payload. +The `EventFormat` s used by the `ToCloudEventsTransformer` are obtained from the classpath of the project. +The `EventFormat` s that are available from the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project are as follows: + +[[jsonformat]] +====== JsonFormat +The following dependency can be used to include this `JsonFormat` in your project. + +[tabs] +====== +Maven:: ++ +[source, xml, subs="normal", role="primary"] +---- + + io.cloudevents + cloudevents-json-jackson + $cloudEventsVersion + +---- + +Gradle:: ++ +[source, groovy, subs="normal", role="secondary"] +---- +compile "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" +---- +====== + +[[xmlformat]] +====== XMLFormat +The following dependency can be used to include this `XMLFormat` in your project. + +[tabs] +====== +Maven:: ++ +[source, xml, subs="normal", role="primary"] +---- + + io.cloudevents + cloudevents-xml + $cloudEventsVersion + +---- + +Gradle:: ++ +[source, groovy, subs="normal", role="secondary"] +---- +compile "io.cloudevents:cloudevents-xml:$cloudEventsVersion" +---- +====== + +[[avrocompactformat]] +====== AvroCompactFormat +The following dependency can be used to include this `AvroCompactFormat` in your project. + +[tabs] +====== +Maven:: ++ +[source, xml, subs="normal", role="primary"] +---- + + io.cloudevents + cloudevents-avro-compact + $cloudEventsVersion + +---- + +Gradle:: ++ +[source, groovy, subs="normal", role="secondary"] +---- +compile "io.cloudevents:cloudevents-avro-compact:$cloudEventsVersion" +---- +====== + +[[protobufformat]] +====== ProtobufFormat +The following dependency can be used to include this `ProtobufFormat` in your project. + +[tabs] +====== +Maven:: ++ +[source, xml, subs="normal", role="primary"] +---- + + io.cloudevents + cloudevents-protobuf + $cloudEventsVersion + +---- + +Gradle:: ++ +[source, groovy, subs="normal", role="secondary"] +---- +compile "io.cloudevents:cloudevents-protobuf:$cloudEventsVersion" +---- +====== \ No newline at end of file diff --git a/src/reference/antora/modules/ROOT/pages/whats-new.adoc b/src/reference/antora/modules/ROOT/pages/whats-new.adoc index 9f37d852e3..dbc8b85907 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -23,6 +23,5 @@ See xref:ws.adoc[] for more information. [[x7.1-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. +CloudEvents are now supported in the `spring-integration-cloudevents` module. +See xref:cloudevents.adoc[] for more information. From 120a08bf7079f12c0f47d4a119dcfe433c0a16af Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 3 Nov 2025 17:04:54 -0500 Subject: [PATCH 06/11] Enable CloudEvents to be sent with headers Enable CloudEvents to be sent with headers instead of requiring structured format serialization. This provides flexibility when integrating with systems that don't support CloudEvents structured formats. Introduce `CloudEventMessageConverter` to handle CloudEvent to Message conversion, utilizing the CloudEvents SDK's `MessageWriter` abstraction. Add `noFormat` configuration option to `ToCloudEventsTransformer`. When enabled and no `EventFormat` is available for the content type, CloudEvent attributes are written to message headers with configurable prefix (defaults to "ce-"). Add `cloudEventPrefix` property to customize the header prefix when `noFormat` is set to true, supporting different integration scenarios. Add test coverage for binary content mode including extension handling, custom prefixes, and validation that original headers are preserved alongside CloudEvent headers. --- .../integration/cloudevents/package-info.java | 17 --- .../CloudEventMessageConverter.java | 76 ++++++++++ .../MessageBuilderMessageWriter.java | 137 ++++++++++++++++++ .../transformer/ToCloudEventsTransformer.java | 85 +++++++++-- .../CloudEventMessageConverterTests.java | 63 ++++++++ .../ToCloudEventsTransformerTests.java | 74 +++++++++- 6 files changed, 421 insertions(+), 31 deletions(-) create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java create mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java create mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java 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 index 21aef6953c..116ccfd7f8 100644 --- 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 @@ -1,20 +1,3 @@ -/* - * 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/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java new file mode 100644 index 0000000000..75ee879953 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java @@ -0,0 +1,76 @@ +/* + * 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.util.Objects; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.CloudEventUtils; +import org.jspecify.annotations.Nullable; + +import org.springframework.integration.transformer.MessageTransformationException; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.converter.MessageConverter; + +/** + * Convert Spring Integration {@link Message}s to CloudEvents. + * + * @author Glenn Renfro + * + * @since 7.0 + */ +class CloudEventMessageConverter implements MessageConverter { + + private final String cloudEventPrefix; + + private final String specVersionKey; + + private final String dataContentTypeKey; + + /** + * Construct a CloudEventMessageConverter with the specified configuration. + * @param cloudEventPrefix the prefix for CloudEvent headers in binary content mode + * @param specVersionKey the header name for the specification version + * @param dataContentTypeKey the header name for the data content type + */ + CloudEventMessageConverter(String cloudEventPrefix, String specVersionKey, String dataContentTypeKey) { + this.cloudEventPrefix = cloudEventPrefix; + this.specVersionKey = specVersionKey; + this.dataContentTypeKey = dataContentTypeKey; + } + + /** + * This converter only supports CloudEvent to Message conversion. + * @throws UnsupportedOperationException always, as this operation is not supported + */ + @Override + public @Nullable Object fromMessage(Message message, Class targetClass) { + throw new UnsupportedOperationException("CloudEventMessageConverter does not support fromMessage method"); + } + + @Override + public Message toMessage(Object payload, @Nullable MessageHeaders headers) { + if (payload instanceof CloudEvent event) { + return CloudEventUtils.toReader(event).read(new MessageBuilderMessageWriter(this.cloudEventPrefix, + this.specVersionKey, this.dataContentTypeKey, Objects.requireNonNull(headers))); + } + throw new MessageTransformationException("Unsupported payload type. Should be CloudEvent but was: " + + payload.getClass()); + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java new file mode 100644 index 0000000000..63f8e68f81 --- /dev/null +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java @@ -0,0 +1,137 @@ +/* + * 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.util.HashMap; +import java.util.Map; + +import io.cloudevents.CloudEventData; +import io.cloudevents.SpecVersion; +import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.message.MessageWriter; +import io.cloudevents.rw.CloudEventContextWriter; +import io.cloudevents.rw.CloudEventRWException; +import io.cloudevents.rw.CloudEventWriter; + +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; + +/** + * Adapt CloudEvents to Spring Integration {@link Message}s using the CloudEvents SDK + * {@link MessageWriter} abstraction. + * Write CloudEvent attributes as message headers with a configurable prefix for + * binary content mode serialization. Used internally by {@link CloudEventMessageConverter} + * to convert CloudEvent objects into Spring Integration messages. + * + * @author Glenn Renfro + * + * @since 7.0 + * + * @see CloudEventMessageConverter + */ +class MessageBuilderMessageWriter + implements CloudEventWriter>, MessageWriter> { + + private final String cloudEventPrefix; + + private final String specVersionKey; + + private final String dataContentTypeKey; + + private final Map headers = new HashMap<>(); + + /** + * Construct a MessageBuilderMessageWriter with the specified configuration. + * @param cloudEventPrefix the prefix to prepend to CloudEvent attribute names in message headers + * @param specVersionKey the header name for the CloudEvent specification version + * @param dataContentTypeKey the header name for the data content type + * @param headers the base message headers to include in the output message + */ + MessageBuilderMessageWriter(String cloudEventPrefix, String specVersionKey, String dataContentTypeKey, Map headers) { + this.headers.putAll(headers); + this.cloudEventPrefix = cloudEventPrefix; + this.specVersionKey = specVersionKey; + this.dataContentTypeKey = dataContentTypeKey; + } + + /** + * Set the event in structured content mode. + * Create a message with the serialized CloudEvent as the payload and set the + * data content type header to the format's serialized content type. + * @param format the event format used to serialize the CloudEvent + * @param value the serialized CloudEvent bytes + * @return the Spring Integration message containing the serialized CloudEvent + * @throws CloudEventRWException if an error occurs during message creation + */ + @Override + public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { + this.headers.put(this.dataContentTypeKey, format.serializedContentType()); + return MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); + } + + /** + * Complete the message creation with CloudEvent data. + * Create a message with the CloudEvent data as the payload. CloudEvent attributes + * are already set as headers via {@link #withContextAttribute(String, String)}. + * @param value the CloudEvent data to use as the message payload + * @return the Spring Integration message with CloudEvent data and attributes + * @throws CloudEventRWException if an error occurs during message creation + */ + @Override + public Message end(CloudEventData value) throws CloudEventRWException { + return MessageBuilder.withPayload(value.toBytes()).copyHeaders(this.headers).build(); + } + + /** + * Complete the message creation without CloudEvent data. + * Create a message with an empty payload when the CloudEvent contains no data. + * CloudEvent attributes are set as headers via {@link #withContextAttribute(String, String)}. + * @return the Spring Integration message with an empty payload and CloudEvent attributes as headers + */ + @Override + public Message end() { + return MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); + } + + /** + * Add a CloudEvent context attribute to the message headers. + * Map the CloudEvent attribute to a message header by prepending the configured prefix + * to the attribute name (e.g., "id" becomes "ce-id" with default prefix). + * @param name the CloudEvent attribute name + * @param value the CloudEvent attribute value + * @return this writer for method chaining + * @throws CloudEventRWException if an error occurs while setting the attribute + */ + @Override + public CloudEventContextWriter withContextAttribute(String name, String value) throws CloudEventRWException { + this.headers.put(this.cloudEventPrefix + name, value); + return this; + } + + /** + * Initialize the writer with the CloudEvent specification version. + * Set the specification version as a message header using the configured version key. + * @param version the CloudEvent specification version + * @return this writer for method chaining + */ + @Override + public MessageBuilderMessageWriter create(SpecVersion version) { + this.headers.put(this.specVersionKey, version.toString()); + return this; + } + +} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java index 96f92c3638..508138783a 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java @@ -19,7 +19,6 @@ import java.net.URI; import java.time.OffsetDateTime; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -58,6 +57,16 @@ */ public class ToCloudEventsTransformer extends AbstractTransformer { + private static final String DEFAULT_PREFIX = "ce-"; + + private static final String DEFAULT_SPECVERSION_KEY = "specversion"; + + private static final String DEFAULT_DATACONTENTTYPE_KEY = "datacontenttype"; + + private String cloudEventPrefix = DEFAULT_PREFIX; + + private boolean noFormat = false; + private Expression eventIdExpression = new FunctionExpression>( msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); @@ -71,7 +80,10 @@ public class ToCloudEventsTransformer extends AbstractTransformer { private @Nullable Expression subjectExpression; - private final String [] extensionPatterns; + private final String[] extensionPatterns; + + @SuppressWarnings("NullAway.Init") + private CloudEventMessageConverter cloudEventMessageConverter; @SuppressWarnings("NullAway.Init") private EvaluationContext evaluationContext; @@ -149,6 +161,7 @@ protected void onInit() { appName = appName == null ? "unknown" : appName; this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); } + this.cloudEventMessageConverter = new CloudEventMessageConverter(this.cloudEventPrefix, this.getSpecVersionKey(), this.getDataContentTypeKey()); } /** @@ -171,11 +184,6 @@ protected Object doTransform(Message message) { 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(message.getHeaders(), this.extensionPatterns); @@ -194,14 +202,25 @@ protected Object doTransform(Message message) { cloudEventBuilder.withDataSchema(this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)); } - CloudEvent cloudEvent = cloudEventBuilder.withData((byte[])message.getPayload()) + CloudEvent cloudEvent = cloudEventBuilder.withData((byte[]) message.getPayload()) .withExtension(extensions) .build(); - return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) - .copyHeaders(message.getHeaders()) - .setHeader(MessageHeaders.CONTENT_TYPE, "application/cloudevents") - .build(); + EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); + if (eventFormat == null && !this.noFormat) { + throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); + } + + if (eventFormat != null) { + return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) + .copyHeaders(message.getHeaders()) + .setHeader(MessageHeaders.CONTENT_TYPE, "application/cloudevents") + .build(); + } + Map headers = new HashMap<>(message.getHeaders()); + headers.putAll(extensions.cloudEventExtensions); + + return this.cloudEventMessageConverter.toMessage(cloudEvent, new MessageHeaders(headers)); } @Override @@ -209,6 +228,48 @@ public String getComponentType() { return "ce:to-cloudevents-transformer"; } + /** + * Returns CloudEvent information to the header if no {@link EventFormat} is found for content type. + * @return true if CloudEvent information should be added to header if no {@link EventFormat} is found. + */ + public boolean isNoFormat() { + return this.noFormat; + } + + /** + * Set CloudEvent information to the header if no {@link EventFormat} is found for content type. + * When true and no {@link EventFormat} is found for the content type, CloudEvents are sent with headers instead of + * structured format. + * @param noFormat true to disable format serialization + */ + public void setNoFormat(boolean noFormat) { + this.noFormat = noFormat; + } + + /** + * Return the prefix used for CloudEvent headers in binary content mode. + * @return the CloudEvent header prefix + */ + public String getCloudEventPrefix() { + return this.cloudEventPrefix; + } + + /** + * Set the prefix for CloudEvent headers in binary content mode. + * @param cloudEventPrefix the prefix to use for CloudEvent headers + */ + public void setCloudEventPrefix(String cloudEventPrefix) { + this.cloudEventPrefix = cloudEventPrefix; + } + + private String getSpecVersionKey() { + return this.cloudEventPrefix + DEFAULT_SPECVERSION_KEY; + } + + private String getDataContentTypeKey() { + return this.cloudEventPrefix + DEFAULT_DATACONTENTTYPE_KEY; + } + private static class ToCloudEventTransformerExtensions implements CloudEventExtension { /** diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java new file mode 100644 index 0000000000..c0c3863c7d --- /dev/null +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java @@ -0,0 +1,63 @@ +/* + * 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.Collections; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.data.BytesCloudEventData; +import io.cloudevents.core.v1.CloudEventV1; +import org.junit.jupiter.api.Test; + +import org.springframework.integration.support.MessageBuilder; +import org.springframework.messaging.Message; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +public class CloudEventMessageConverterTests { + + private static final String DEFAULT_PREFIX = "ce-"; + + private static final String DEFAULT_SPECVERSION_KEY = DEFAULT_PREFIX + "specversion"; + + private static final String DEFAULT_DATACONTENTTYPE_KEY = DEFAULT_PREFIX + "datacontenttype"; + + @Test + void binaryModeContainsAllCloudEventAttributes() { + CloudEventMessageConverter cloudEventMessageConverter = new CloudEventMessageConverter(DEFAULT_PREFIX, + DEFAULT_SPECVERSION_KEY, DEFAULT_DATACONTENTTYPE_KEY); + Message message = MessageBuilder.withPayload(new CloudEventV1("1", URI.create("http://localhost:8080/cloudevents"), + "sampleType", "text/plain", + URI.create("http://sample:8080/sample"), "sample subject", OffsetDateTime.now(), + BytesCloudEventData.wrap(new byte[0]), Collections.emptyMap())).build(); + Message convertedMessage = cloudEventMessageConverter.toMessage(message.getPayload(), message.getHeaders()); + assertThat(convertedMessage.getHeaders()).containsKeys(DEFAULT_DATACONTENTTYPE_KEY, DEFAULT_SPECVERSION_KEY, "ce-time", "ce-id"); + } + + @Test + void fromMessageThrowsUnsupportedOperation() { + CloudEventMessageConverter cloudEventMessageConverter = new CloudEventMessageConverter(DEFAULT_PREFIX, + DEFAULT_SPECVERSION_KEY, DEFAULT_DATACONTENTTYPE_KEY); + assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> + cloudEventMessageConverter.fromMessage(MessageBuilder.withPayload(new byte[0]).build(), + CloudEvent.class)).withMessage( + "CloudEventMessageConverter does not support fromMessage method"); + } +} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java index 2f4713d9dd..0b1ecd816d 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java @@ -59,7 +59,7 @@ class ToCloudEventsTransformerTests { private static final String USER_HEADER = "userId"; - private static final String [] TEST_PATTERNS = {"trace*", SPAN_HEADER, USER_HEADER}; + private static final String[] TEST_PATTERNS = {"trace*", SPAN_HEADER, USER_HEADER}; private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); @@ -69,6 +69,15 @@ class ToCloudEventsTransformerTests { @Autowired private ToCloudEventsTransformer transformerWithExtensions; + @Autowired + private ToCloudEventsTransformer transformerWithNoExtensionsNoFormat; + + @Autowired + private ToCloudEventsTransformer transformerWithExtensionsNoFormat; + + @Autowired + private ToCloudEventsTransformer transformerWithExtensionsNoFormatWithPrefix; + @Autowired private ToCloudEventsTransformer transformerWithInvalidIDExpression; @@ -133,6 +142,45 @@ public String serializedContentType() { .hasMessage("No EventFormat found for 'application/cloudevents+invalid'"); } + @Test + void convertMessageNoExtensions() { + Message message = MessageBuilder.withPayload(PAYLOAD) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") + .setHeader(TRACE_HEADER, "test-value") + .setHeader(SPAN_HEADER, "other-value") + .build(); + Message result = transformMessage(message, this.transformerWithNoExtensionsNoFormat); + assertThat(result.getPayload()).isEqualTo(PAYLOAD); + assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); + assertThat(result.getHeaders()).doesNotContainKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); + } + + @Test + void convertMessageWithExtensions() { + Message message = MessageBuilder.withPayload(PAYLOAD) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") + .setHeader(TRACE_HEADER, "test-value") + .setHeader(SPAN_HEADER, "other-value") + .build(); + Message result = transformMessage(message, this.transformerWithExtensionsNoFormat); + assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); + assertThat(result.getHeaders()).containsKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); + } + + @Test + void convertMessageWithExtensionsNewPrefix() { + Message message = MessageBuilder.withPayload(PAYLOAD) + .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") + .setHeader(TRACE_HEADER, "test-value") + .setHeader(SPAN_HEADER, "other-value") + .build(); + Message result = transformMessage(message, this.transformerWithExtensionsNoFormatWithPrefix); + assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); + assertThat(result.getHeaders()).containsKeys("CLOUDEVENTS-" + TRACE_HEADER, "CLOUDEVENTS-" + SPAN_HEADER, + "CLOUDEVENTS-id", "CLOUDEVENTS-specversion", "CLOUDEVENTS-datacontenttype"); + + } + @Test @SuppressWarnings("unchecked") void doTransformWithObjectPayload() throws Exception { @@ -171,7 +219,7 @@ void noContentType() { } @Test - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "NullAway"}) void multipleExtensionMappings() { String payload = "test message"; Message message = createBaseMessage(payload.getBytes(), "application/cloudevents+json") @@ -257,6 +305,28 @@ public ToCloudEventsTransformer transformerWithExtensions() { return new ToCloudEventsTransformer(TEST_PATTERNS); } + @Bean + public ToCloudEventsTransformer transformerWithNoExtensionsNoFormat() { + ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(); + toCloudEventsTransformer.setNoFormat(true); + return toCloudEventsTransformer; + } + + @Bean + public ToCloudEventsTransformer transformerWithExtensionsNoFormat() { + ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(TEST_PATTERNS); + toCloudEventsTransformer.setNoFormat(true); + return toCloudEventsTransformer; + } + + @Bean + public ToCloudEventsTransformer transformerWithExtensionsNoFormatWithPrefix() { + ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(TEST_PATTERNS); + toCloudEventsTransformer.setNoFormat(true); + toCloudEventsTransformer.setCloudEventPrefix("CLOUDEVENTS-"); + return toCloudEventsTransformer; + } + @Bean public ToCloudEventsTransformer transformerWithInvalidIDExpression() { ToCloudEventsTransformer transformer = new ToCloudEventsTransformer(); From 982da5f25f72140832daeb81eed6605d431b8347 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 24 Nov 2025 09:42:15 -0500 Subject: [PATCH 07/11] Improve CloudEvents documentation and javadocs Update javadoc comments in `CloudEventMessageConverter` and `ToCloudEventsTransformer` to improve clarity and readability. Enhance the CloudEvents reference documentation to better explain how extensions are populated using pattern matching instead of SpEL expressions. Changes include: - Clarify `CloudEventMessageConverter` class-level javadoc - Improve `isNoFormat()` and `setNoFormat()` method documentation - Update reference docs to reflect extension patterns approach - Fix minor formatting issues (double spaces) --- .../CloudEventMessageConverter.java | 4 ++-- .../transformer/ToCloudEventsTransformer.java | 13 +++++++---- .../modules/ROOT/pages/cloudevents.adoc | 23 ++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java index 75ee879953..b34e05a128 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java @@ -28,7 +28,7 @@ import org.springframework.messaging.converter.MessageConverter; /** - * Convert Spring Integration {@link Message}s to CloudEvents. + * Convert Spring Integration {@link Message}s into CloudEvent messages. * * @author Glenn Renfro * @@ -69,7 +69,7 @@ public Message toMessage(Object payload, @Nullable MessageHeaders headers) { return CloudEventUtils.toReader(event).read(new MessageBuilderMessageWriter(this.cloudEventPrefix, this.specVersionKey, this.dataContentTypeKey, Objects.requireNonNull(headers))); } - throw new MessageTransformationException("Unsupported payload type. Should be CloudEvent but was: " + + throw new MessageTransformationException("Unsupported payload type. Should be CloudEvent but was: " + payload.getClass()); } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java index 508138783a..1131400eb7 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java @@ -229,17 +229,20 @@ public String getComponentType() { } /** - * Returns CloudEvent information to the header if no {@link EventFormat} is found for content type. - * @return true if CloudEvent information should be added to header if no {@link EventFormat} is found. + * Indicates whether CloudEvent metadata should be added to the message headers + * when no {@link EventFormat} is available for the content type. + * @return {@code true} if CloudEvent metadata should be added to headers + * when no suitable {@link EventFormat} is found; + * {@code false} otherwise */ public boolean isNoFormat() { return this.noFormat; } /** - * Set CloudEvent information to the header if no {@link EventFormat} is found for content type. - * When true and no {@link EventFormat} is found for the content type, CloudEvents are sent with headers instead of - * structured format. + * Establishes if CloudEvent information is written to the header if no {@link EventFormat} is found for content + * type. When true and no {@link EventFormat} is found for the content type, CloudEvents are sent with headers + * instead of structured format. * @param noFormat true to disable format serialization */ public void setNoFormat(boolean noFormat) { diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc index 8ef29d4f59..a4b8954647 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -35,7 +35,7 @@ compile "org.springframework.integration:spring-integration-cloudevents:{project === ToCloudEventsTransformer The `ToCloudEventsTransformer` converts Spring Integration messages into CloudEvents compliant messages. -This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes and extensions using `Expression` s. +This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes using `Expression` s and identifying extensions in the message headers via patterns. [[cloudevent-transformer-overview]] ==== Overview @@ -51,7 +51,8 @@ NOTE: Messages to be transformed must have a payload of `byte[]`. [[configure-transformer]] ===== Configuring Transformer -The `ToCloudEventsTransformer` allows the user to use SpEL `Expression`s to populate the attributes as well as the extensions. +The `ToCloudEventsTransformer` allows the user to use SpEL `Expression`s to populate the attributes. +Extensions are populated from the headers using pattern matching. ====== Attribute Expressions @@ -68,22 +69,18 @@ ToCloudEventTransformer transformer = new ToCloudEventTransformer(null); transformer.setTypeExpression(new LiteralExpression("sampleType")); ---- -====== Extension Expressions +====== Extension Patterns -The expressions constructor parameter is an array of `Expression` s. +The extensionPatterns constructor parameter is an array of `Strings` 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. +Each `pattern` in the array is the search criteria for finding the headers that need to be added as extensions to the new `CloudEvent`. +In the example below the system will search for any header key that starts with the word `trace` and add those headers to the extensions for the `CloudEvent` message. +Then it will search for any header that contains a key of 'span-id' as well as 'user-id' and add those headers as extensions if found. [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); +String[] extensionPatterns = {"trace*", "span-id", "user-id"}; +return new ToCloudEventTransformer(extensionPatterns); ---- [[cloudevent-attribute-defaults]] From 74012c9f6a026c782e5f0bb231b6250d6d32936c Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Fri, 26 Dec 2025 06:52:41 -0500 Subject: [PATCH 08/11] Refactor CloudEvents transformer for version 7.1 This commit updates the CloudEvents module from version 7.0 to 7.1 by consolidating classes and improving the architecture. The previous design separated the CloudEvent conversion logic across multiple classes (`CloudEventMessageConverter` and `MessageBuilderMessageWriter`), which added unnecessary complexity for what is fundamentally a single transformation operation. Key changes: - Removed `CloudEventMessageConverter` and `MessageBuilderMessageWriter` as standalone classes - Consolidated conversion logic into `ToCloudEventTransformer` by making `MessageBuilderMessageWriter` a private static inner class - Renamed `ToCloudEventsTransformer` to `ToCloudEventTransformer` to match naming conventions (singular form) - Removed Avro dependencies and associated tests that were no longer needed - Changed `setNoFormat()` to `setFailOnNoFormat()` for clearer semantics - Enhanced null safety by removing `@Nullable` from setters that should always have values - Re-add isFailOnNoFormat logic and tests that was removed - Improved documentation and added defensive copying for the `extensionPatterns` array parameter - Updated tests to reflect the new class names and structure - Rebased The refactoring reduces the public API surface while maintaining all functionality, making the code easier to understand and maintain. --- build.gradle | 3 - .../CloudEventMessageConverter.java | 76 ------ .../MessageBuilderMessageWriter.java | 137 ----------- ...rmer.java => ToCloudEventTransformer.java} | 200 +++++++++++----- .../CloudEventMessageConverterTests.java | 63 ----- ...java => ToCloudEventTransformerTests.java} | 116 ++++------ .../modules/ROOT/pages/cloudevents.adoc | 217 ++++-------------- 7 files changed, 230 insertions(+), 582 deletions(-) delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java delete mode 100644 spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java rename spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/{ToCloudEventsTransformer.java => ToCloudEventTransformer.java} (57%) delete mode 100644 spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java rename spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/{ToCloudEventsTransformerTests.java => ToCloudEventTransformerTests.java} (75%) diff --git a/build.gradle b/build.gradle index f60f4ddd69..9f04a84b33 100644 --- a/build.gradle +++ b/build.gradle @@ -485,9 +485,6 @@ project('spring-integration-cloudevents') { api "io.cloudevents:cloudevents-core:$cloudEventsVersion" testImplementation "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" - testImplementation("io.cloudevents:cloudevents-avro-compact:$cloudEventsVersion") { - exclude group: 'org.apache.avro', module: 'avro' - } testImplementation "org.apache.avro:avro:$avroVersion" testImplementation "io.cloudevents:cloudevents-xml:$cloudEventsVersion" } diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java deleted file mode 100644 index b34e05a128..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverter.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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.util.Objects; - -import io.cloudevents.CloudEvent; -import io.cloudevents.core.CloudEventUtils; -import org.jspecify.annotations.Nullable; - -import org.springframework.integration.transformer.MessageTransformationException; -import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; -import org.springframework.messaging.converter.MessageConverter; - -/** - * Convert Spring Integration {@link Message}s into CloudEvent messages. - * - * @author Glenn Renfro - * - * @since 7.0 - */ -class CloudEventMessageConverter implements MessageConverter { - - private final String cloudEventPrefix; - - private final String specVersionKey; - - private final String dataContentTypeKey; - - /** - * Construct a CloudEventMessageConverter with the specified configuration. - * @param cloudEventPrefix the prefix for CloudEvent headers in binary content mode - * @param specVersionKey the header name for the specification version - * @param dataContentTypeKey the header name for the data content type - */ - CloudEventMessageConverter(String cloudEventPrefix, String specVersionKey, String dataContentTypeKey) { - this.cloudEventPrefix = cloudEventPrefix; - this.specVersionKey = specVersionKey; - this.dataContentTypeKey = dataContentTypeKey; - } - - /** - * This converter only supports CloudEvent to Message conversion. - * @throws UnsupportedOperationException always, as this operation is not supported - */ - @Override - public @Nullable Object fromMessage(Message message, Class targetClass) { - throw new UnsupportedOperationException("CloudEventMessageConverter does not support fromMessage method"); - } - - @Override - public Message toMessage(Object payload, @Nullable MessageHeaders headers) { - if (payload instanceof CloudEvent event) { - return CloudEventUtils.toReader(event).read(new MessageBuilderMessageWriter(this.cloudEventPrefix, - this.specVersionKey, this.dataContentTypeKey, Objects.requireNonNull(headers))); - } - throw new MessageTransformationException("Unsupported payload type. Should be CloudEvent but was: " + - payload.getClass()); - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java deleted file mode 100644 index 63f8e68f81..0000000000 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/MessageBuilderMessageWriter.java +++ /dev/null @@ -1,137 +0,0 @@ -/* - * 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.util.HashMap; -import java.util.Map; - -import io.cloudevents.CloudEventData; -import io.cloudevents.SpecVersion; -import io.cloudevents.core.format.EventFormat; -import io.cloudevents.core.message.MessageWriter; -import io.cloudevents.rw.CloudEventContextWriter; -import io.cloudevents.rw.CloudEventRWException; -import io.cloudevents.rw.CloudEventWriter; - -import org.springframework.integration.support.MessageBuilder; -import org.springframework.messaging.Message; - -/** - * Adapt CloudEvents to Spring Integration {@link Message}s using the CloudEvents SDK - * {@link MessageWriter} abstraction. - * Write CloudEvent attributes as message headers with a configurable prefix for - * binary content mode serialization. Used internally by {@link CloudEventMessageConverter} - * to convert CloudEvent objects into Spring Integration messages. - * - * @author Glenn Renfro - * - * @since 7.0 - * - * @see CloudEventMessageConverter - */ -class MessageBuilderMessageWriter - implements CloudEventWriter>, MessageWriter> { - - private final String cloudEventPrefix; - - private final String specVersionKey; - - private final String dataContentTypeKey; - - private final Map headers = new HashMap<>(); - - /** - * Construct a MessageBuilderMessageWriter with the specified configuration. - * @param cloudEventPrefix the prefix to prepend to CloudEvent attribute names in message headers - * @param specVersionKey the header name for the CloudEvent specification version - * @param dataContentTypeKey the header name for the data content type - * @param headers the base message headers to include in the output message - */ - MessageBuilderMessageWriter(String cloudEventPrefix, String specVersionKey, String dataContentTypeKey, Map headers) { - this.headers.putAll(headers); - this.cloudEventPrefix = cloudEventPrefix; - this.specVersionKey = specVersionKey; - this.dataContentTypeKey = dataContentTypeKey; - } - - /** - * Set the event in structured content mode. - * Create a message with the serialized CloudEvent as the payload and set the - * data content type header to the format's serialized content type. - * @param format the event format used to serialize the CloudEvent - * @param value the serialized CloudEvent bytes - * @return the Spring Integration message containing the serialized CloudEvent - * @throws CloudEventRWException if an error occurs during message creation - */ - @Override - public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { - this.headers.put(this.dataContentTypeKey, format.serializedContentType()); - return MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); - } - - /** - * Complete the message creation with CloudEvent data. - * Create a message with the CloudEvent data as the payload. CloudEvent attributes - * are already set as headers via {@link #withContextAttribute(String, String)}. - * @param value the CloudEvent data to use as the message payload - * @return the Spring Integration message with CloudEvent data and attributes - * @throws CloudEventRWException if an error occurs during message creation - */ - @Override - public Message end(CloudEventData value) throws CloudEventRWException { - return MessageBuilder.withPayload(value.toBytes()).copyHeaders(this.headers).build(); - } - - /** - * Complete the message creation without CloudEvent data. - * Create a message with an empty payload when the CloudEvent contains no data. - * CloudEvent attributes are set as headers via {@link #withContextAttribute(String, String)}. - * @return the Spring Integration message with an empty payload and CloudEvent attributes as headers - */ - @Override - public Message end() { - return MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); - } - - /** - * Add a CloudEvent context attribute to the message headers. - * Map the CloudEvent attribute to a message header by prepending the configured prefix - * to the attribute name (e.g., "id" becomes "ce-id" with default prefix). - * @param name the CloudEvent attribute name - * @param value the CloudEvent attribute value - * @return this writer for method chaining - * @throws CloudEventRWException if an error occurs while setting the attribute - */ - @Override - public CloudEventContextWriter withContextAttribute(String name, String value) throws CloudEventRWException { - this.headers.put(this.cloudEventPrefix + name, value); - return this; - } - - /** - * Initialize the writer with the CloudEvent specification version. - * Set the specification version as a message header using the configured version key. - * @param version the CloudEvent specification version - * @return this writer for method chaining - */ - @Override - public MessageBuilderMessageWriter create(SpecVersion version) { - this.headers.put(this.specVersionKey, version.toString()); - return this; - } - -} diff --git a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java similarity index 57% rename from spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java rename to spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java index 1131400eb7..37e8497560 100644 --- a/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformer.java +++ b/spring-integration-cloudevents/src/main/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformer.java @@ -14,22 +14,29 @@ * limitations under the License. */ - package org.springframework.integration.cloudevents.transformer; import java.net.URI; import java.time.OffsetDateTime; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Set; import io.cloudevents.CloudEvent; +import io.cloudevents.CloudEventData; import io.cloudevents.CloudEventExtension; import io.cloudevents.CloudEventExtensions; +import io.cloudevents.SpecVersion; +import io.cloudevents.core.CloudEventUtils; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; +import io.cloudevents.core.message.MessageWriter; import io.cloudevents.core.provider.EventFormatProvider; +import io.cloudevents.rw.CloudEventContextWriter; +import io.cloudevents.rw.CloudEventRWException; +import io.cloudevents.rw.CloudEventWriter; import org.jspecify.annotations.Nullable; import org.springframework.context.ApplicationContext; @@ -53,9 +60,9 @@ * * @author Glenn Renfro * - * @since 7.0 + * @since 7.1 */ -public class ToCloudEventsTransformer extends AbstractTransformer { +public class ToCloudEventTransformer extends AbstractTransformer { private static final String DEFAULT_PREFIX = "ce-"; @@ -65,7 +72,7 @@ public class ToCloudEventsTransformer extends AbstractTransformer { private String cloudEventPrefix = DEFAULT_PREFIX; - private boolean noFormat = false; + private boolean failOnNoFormat = false; private Expression eventIdExpression = new FunctionExpression>( msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); @@ -82,27 +89,25 @@ public class ToCloudEventsTransformer extends AbstractTransformer { private final String[] extensionPatterns; - @SuppressWarnings("NullAway.Init") - private CloudEventMessageConverter cloudEventMessageConverter; - @SuppressWarnings("NullAway.Init") private EvaluationContext evaluationContext; - private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); + private static final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); /** - * Construct a ToCloudEventsTransformer. + * Construct a ToCloudEventTransformer. * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions * to the CloudEvent */ - public ToCloudEventsTransformer(String ... extensionPatterns) { - this.extensionPatterns = extensionPatterns; + public ToCloudEventTransformer(String ... extensionPatterns) { + this.extensionPatterns = extensionPatterns == null ? new String[0] : + Arrays.copyOf(extensionPatterns, extensionPatterns.length); } /** - * Construct a ToCloudEventsTransformer with no extensionPatterns. + * Construct a ToCloudEventTransformer with no extensionPatterns. */ - public ToCloudEventsTransformer() { + public ToCloudEventTransformer() { this.extensionPatterns = new String[0]; } @@ -135,22 +140,30 @@ public void setTypeExpression(Expression typeExpression) { /** * Set the {@link Expression} for creating the dataSchema for the CloudEvent. - * Defaults to null. * @param dataSchemaExpression the expression to create the dataSchema for each CloudEvent */ - public void setDataSchemaExpression(@Nullable Expression dataSchemaExpression) { + public void setDataSchemaExpression(Expression dataSchemaExpression) { this.dataSchemaExpression = dataSchemaExpression; } /** * Set the {@link Expression} for creating the subject for the CloudEvent. - * Defaults to null. * @param subjectExpression the expression to create the subject for each CloudEvent */ - public void setSubjectExpression(@Nullable Expression subjectExpression) { + public void setSubjectExpression(Expression subjectExpression) { this.subjectExpression = subjectExpression; } + /** + * Set to {@code true} to fail if no {@link EventFormat} is found for message's content type. + * When {@code false} and no {@link EventFormat} is found, then a {@link CloudEvent}' body is + * set as an output message's payload, and its attributes are set into headers. + * @param failOnNoFormat true to disable format serialization + */ + public void setFailOnNoFormat(boolean failOnNoFormat) { + this.failOnNoFormat = failOnNoFormat; + } + @Override protected void onInit() { super.onInit(); @@ -161,7 +174,6 @@ protected void onInit() { appName = appName == null ? "unknown" : appName; this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); } - this.cloudEventMessageConverter = new CloudEventMessageConverter(this.cloudEventPrefix, this.getSpecVersionKey(), this.getDataContentTypeKey()); } /** @@ -178,14 +190,15 @@ protected Object doTransform(Message message) { String id = this.eventIdExpression.getValue(this.evaluationContext, message, String.class); URI source = this.sourceExpression.getValue(this.evaluationContext, message, URI.class); String type = this.typeExpression.getValue(this.evaluationContext, message, String.class); + MessageHeaders headers = message.getHeaders(); - String contentType = message.getHeaders().get(MessageHeaders.CONTENT_TYPE, String.class); + String contentType = headers.get(MessageHeaders.CONTENT_TYPE, String.class); if (contentType == null) { - throw new MessageTransformationException(message, "Missing 'Content-Type' header"); + contentType = "application/octet-stream"; } ToCloudEventTransformerExtensions extensions = - new ToCloudEventTransformerExtensions(message.getHeaders(), + new ToCloudEventTransformerExtensions(headers, this.extensionPatterns); CloudEventBuilder cloudEventBuilder = CloudEventBuilder.v1() @@ -206,47 +219,41 @@ protected Object doTransform(Message message) { .withExtension(extensions) .build(); - EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); - if (eventFormat == null && !this.noFormat) { + EventFormat eventFormat = eventFormatProvider.resolveFormat(contentType); + + if (eventFormat == null && this.failOnNoFormat) { throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); } if (eventFormat != null) { return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) - .copyHeaders(message.getHeaders()) - .setHeader(MessageHeaders.CONTENT_TYPE, "application/cloudevents") + .copyHeaders(headers) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType) .build(); } - Map headers = new HashMap<>(message.getHeaders()); - headers.putAll(extensions.cloudEventExtensions); - return this.cloudEventMessageConverter.toMessage(cloudEvent, new MessageHeaders(headers)); + Message result = CloudEventUtils.toReader(cloudEvent).read( + new MessageBuilderMessageWriter(this.cloudEventPrefix, Objects.requireNonNull(headers))); + return MessageBuilder.withPayload(result.getPayload()) + .copyHeaders(result.getHeaders()) + .setHeader(MessageHeaders.CONTENT_TYPE, contentType) + .build(); } @Override public String getComponentType() { - return "ce:to-cloudevents-transformer"; + return "ce:to-cloudevent-transformer"; } /** - * Indicates whether CloudEvent metadata should be added to the message headers + * Indicates whether the transformer will transform the message * when no {@link EventFormat} is available for the content type. - * @return {@code true} if CloudEvent metadata should be added to headers + * @return {@code true} if transformation should fail * when no suitable {@link EventFormat} is found; * {@code false} otherwise */ - public boolean isNoFormat() { - return this.noFormat; - } - - /** - * Establishes if CloudEvent information is written to the header if no {@link EventFormat} is found for content - * type. When true and no {@link EventFormat} is found for the content type, CloudEvents are sent with headers - * instead of structured format. - * @param noFormat true to disable format serialization - */ - public void setNoFormat(boolean noFormat) { - this.noFormat = noFormat; + public boolean isFailOnNoFormat() { + return this.failOnNoFormat; } /** @@ -265,14 +272,6 @@ public void setCloudEventPrefix(String cloudEventPrefix) { this.cloudEventPrefix = cloudEventPrefix; } - private String getSpecVersionKey() { - return this.cloudEventPrefix + DEFAULT_SPECVERSION_KEY; - } - - private String getDataContentTypeKey() { - return this.cloudEventPrefix + DEFAULT_DATACONTENTTYPE_KEY; - } - private static class ToCloudEventTransformerExtensions implements CloudEventExtension { /** @@ -282,7 +281,6 @@ private static class ToCloudEventTransformerExtensions implements CloudEventExte /** * Construct CloudEvent extensions by processing a message using expressions. - * * @param headers the headers from the Spring Integration message * @param extensionPatterns patterns to determine whether message headers are extensions */ @@ -300,13 +298,7 @@ private static class ToCloudEventTransformerExtensions implements CloudEventExte @Override public void readFrom(CloudEventExtensions extensions) { - extensions.getExtensionNames() - .forEach(key -> { - Object value = extensions.getExtension(key); - if (value != null) { - this.cloudEventExtensions.put(key, value); - } - }); + throw new UnsupportedOperationException(); } @Override @@ -320,4 +312,94 @@ public Set getKeys() { } } + private static class MessageBuilderMessageWriter + implements CloudEventWriter>, MessageWriter> { + + private final String cloudEventPrefix; + + private final String specVersionKey; + + private final String dataContentTypeKey; + + private final Map headers = new HashMap<>(); + + /** + * Construct a MessageBuilderMessageWriter with the specified configuration. + * @param cloudEventPrefix the prefix to prepend to CloudEvent attribute names in message headers + * @param headers the base message headers to include in the output message + */ + MessageBuilderMessageWriter(String cloudEventPrefix, Map headers) { + this.headers.putAll(headers); + this.cloudEventPrefix = cloudEventPrefix; + this.specVersionKey = this.cloudEventPrefix + DEFAULT_SPECVERSION_KEY; + this.dataContentTypeKey = this.cloudEventPrefix + DEFAULT_DATACONTENTTYPE_KEY; + } + + /** + * Set the event in structured content mode. + * Create a message with the serialized CloudEvent as the payload and set the + * data content type header to the format's serialized content type. + * @param format the event format used to serialize the CloudEvent + * @param value the serialized CloudEvent bytes + * @return the Spring Integration message containing the serialized CloudEvent + * @throws CloudEventRWException if an error occurs during message creation + */ + @Override + public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { + this.headers.put(this.dataContentTypeKey, format.serializedContentType()); + return org.springframework.integration.support.MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); + } + + /** + * Complete the message creation with CloudEvent data. + * Create a message with the CloudEvent data as the payload. CloudEvent attributes + * are already set as headers via {@link #withContextAttribute(String, String)}. + * @param value the CloudEvent data to use as the message payload + * @return the Spring Integration message with CloudEvent data and attributes + * @throws CloudEventRWException if an error occurs during message creation + */ + @Override + public Message end(CloudEventData value) throws CloudEventRWException { + return org.springframework.integration.support.MessageBuilder.withPayload(value.toBytes()).copyHeaders(this.headers).build(); + } + + /** + * Complete the message creation without CloudEvent data. + * Create a message with an empty payload when the CloudEvent contains no data. + * CloudEvent attributes are set as headers via {@link #withContextAttribute(String, String)}. + * @return the Spring Integration message with an empty payload and CloudEvent attributes as headers + */ + @Override + public Message end() { + return org.springframework.integration.support.MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); + } + + /** + * Add a CloudEvent context attribute to the message headers. + * Map the CloudEvent attribute to a message header by prepending the configured prefix + * to the attribute name (e.g., "id" becomes "ce-id" with default prefix). + * @param name the CloudEvent attribute name + * @param value the CloudEvent attribute value + * @return this writer for method chaining + * @throws CloudEventRWException if an error occurs while setting the attribute + */ + @Override + public CloudEventContextWriter withContextAttribute(String name, String value) throws CloudEventRWException { + this.headers.put(this.cloudEventPrefix + name, value); + return this; + } + + /** + * Initialize the writer with the CloudEvent specification version. + * Set the specification version as a message header using the configured version key. + * @param version the CloudEvent specification version + * @return this writer for method chaining + */ + @Override + public MessageBuilderMessageWriter create(SpecVersion version) { + this.headers.put(this.specVersionKey, version.toString()); + return this; + } + + } } diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java deleted file mode 100644 index c0c3863c7d..0000000000 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/CloudEventMessageConverterTests.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.Collections; - -import io.cloudevents.CloudEvent; -import io.cloudevents.core.data.BytesCloudEventData; -import io.cloudevents.core.v1.CloudEventV1; -import org.junit.jupiter.api.Test; - -import org.springframework.integration.support.MessageBuilder; -import org.springframework.messaging.Message; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -public class CloudEventMessageConverterTests { - - private static final String DEFAULT_PREFIX = "ce-"; - - private static final String DEFAULT_SPECVERSION_KEY = DEFAULT_PREFIX + "specversion"; - - private static final String DEFAULT_DATACONTENTTYPE_KEY = DEFAULT_PREFIX + "datacontenttype"; - - @Test - void binaryModeContainsAllCloudEventAttributes() { - CloudEventMessageConverter cloudEventMessageConverter = new CloudEventMessageConverter(DEFAULT_PREFIX, - DEFAULT_SPECVERSION_KEY, DEFAULT_DATACONTENTTYPE_KEY); - Message message = MessageBuilder.withPayload(new CloudEventV1("1", URI.create("http://localhost:8080/cloudevents"), - "sampleType", "text/plain", - URI.create("http://sample:8080/sample"), "sample subject", OffsetDateTime.now(), - BytesCloudEventData.wrap(new byte[0]), Collections.emptyMap())).build(); - Message convertedMessage = cloudEventMessageConverter.toMessage(message.getPayload(), message.getHeaders()); - assertThat(convertedMessage.getHeaders()).containsKeys(DEFAULT_DATACONTENTTYPE_KEY, DEFAULT_SPECVERSION_KEY, "ce-time", "ce-id"); - } - - @Test - void fromMessageThrowsUnsupportedOperation() { - CloudEventMessageConverter cloudEventMessageConverter = new CloudEventMessageConverter(DEFAULT_PREFIX, - DEFAULT_SPECVERSION_KEY, DEFAULT_DATACONTENTTYPE_KEY); - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> - cloudEventMessageConverter.fromMessage(MessageBuilder.withPayload(new byte[0]).build(), - CloudEvent.class)).withMessage( - "CloudEventMessageConverter does not support fromMessage method"); - } -} diff --git a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java similarity index 75% rename from spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java rename to spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java index 0b1ecd816d..b4fe832f3b 100644 --- a/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventsTransformerTests.java +++ b/spring-integration-cloudevents/src/test/java/org/springframework/integration/cloudevents/transformer/ToCloudEventTransformerTests.java @@ -22,16 +22,10 @@ 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; @@ -49,9 +43,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test {@link ToCloudEventTransformer} transformer. + * + * @author Glenn Renfro + * + * @since 7.1 + */ @DirtiesContext @SpringJUnitConfig -class ToCloudEventsTransformerTests { +class ToCloudEventTransformerTests { private static final String TRACE_HEADER = "traceId"; @@ -64,26 +66,27 @@ class ToCloudEventsTransformerTests { private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); @Autowired - private ToCloudEventsTransformer transformerWithNoExtensions; + private ToCloudEventTransformer transformerWithNoExtensions; @Autowired - private ToCloudEventsTransformer transformerWithExtensions; + private ToCloudEventTransformer transformerWithExtensions; @Autowired - private ToCloudEventsTransformer transformerWithNoExtensionsNoFormat; + private ToCloudEventTransformer transformerWithNoExtensionsNoFormat; @Autowired - private ToCloudEventsTransformer transformerWithExtensionsNoFormat; + private ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabled; @Autowired - private ToCloudEventsTransformer transformerWithExtensionsNoFormatWithPrefix; + private ToCloudEventTransformer transformerWithExtensionsNoFormat; @Autowired - private ToCloudEventsTransformer transformerWithInvalidIDExpression; + private ToCloudEventTransformer transformerWithExtensionsNoFormatWithPrefix; - private final JsonFormat jsonFormat = new JsonFormat(); + @Autowired + private ToCloudEventTransformer transformerWithInvalidIDExpression; - private final AvroCompactFormat avroFormat = new AvroCompactFormat(); + private final JsonFormat jsonFormat = new JsonFormat(); private final XMLFormat xmlFormat = new XMLFormat(); @@ -109,39 +112,6 @@ void doXMLTransformWithPayloadBasedOnContentType() { 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 void convertMessageNoExtensions() { Message message = MessageBuilder.withPayload(PAYLOAD) @@ -213,9 +183,16 @@ void emptyExtensionNames() { @Test void noContentType() { Message message = MessageBuilder.withPayload(PAYLOAD).build(); - assertThatThrownBy(() -> this.transformerWithNoExtensions.transform(message)) + Message result = this.transformerWithNoExtensions.transform(message); + assertThat(result.getHeaders().get("ce-datacontenttype")).isEqualTo("application/octet-stream"); + } + + @Test + void noContentTypeNoFormatEnabled() { + Message message = MessageBuilder.withPayload(PAYLOAD).build(); + assertThatThrownBy(() -> this.transformerWithNoExtensionsNoFormatEnabled.transform(message)) .isInstanceOf(MessageTransformationException.class) - .hasMessageContaining("Missing 'Content-Type' header"); + .hasMessageContaining("No EventFormat found for 'application/octet-stream'"); } @Test @@ -268,7 +245,7 @@ private CloudEvent getTransformerNoExtensions(byte[] payload, EventFormat eventF } @SuppressWarnings("unchecked") - private Message transformMessage(Message message, ToCloudEventsTransformer transformer) { + private Message transformMessage(Message message, ToCloudEventTransformer transformer) { Object result = transformer.doTransform(message); assertThat(result).isNotNull(); @@ -296,43 +273,50 @@ public static class ContextConfiguration { private static final ExpressionParser parser = new SpelExpressionParser(); @Bean - public ToCloudEventsTransformer transformerWithNoExtensions() { - return new ToCloudEventsTransformer(); + public ToCloudEventTransformer transformerWithNoExtensions() { + return new ToCloudEventTransformer(); } @Bean - public ToCloudEventsTransformer transformerWithExtensions() { - return new ToCloudEventsTransformer(TEST_PATTERNS); + public ToCloudEventTransformer transformerWithExtensions() { + return new ToCloudEventTransformer(TEST_PATTERNS); } @Bean - public ToCloudEventsTransformer transformerWithNoExtensionsNoFormat() { - ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(); - toCloudEventsTransformer.setNoFormat(true); + public ToCloudEventTransformer transformerWithNoExtensionsNoFormat() { + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); + toCloudEventsTransformer.setFailOnNoFormat(false); return toCloudEventsTransformer; } @Bean - public ToCloudEventsTransformer transformerWithExtensionsNoFormat() { - ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(TEST_PATTERNS); - toCloudEventsTransformer.setNoFormat(true); + public ToCloudEventTransformer transformerWithExtensionsNoFormat() { + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); + toCloudEventsTransformer.setFailOnNoFormat(false); return toCloudEventsTransformer; } @Bean - public ToCloudEventsTransformer transformerWithExtensionsNoFormatWithPrefix() { - ToCloudEventsTransformer toCloudEventsTransformer = new ToCloudEventsTransformer(TEST_PATTERNS); - toCloudEventsTransformer.setNoFormat(true); + public ToCloudEventTransformer transformerWithExtensionsNoFormatWithPrefix() { + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); + toCloudEventsTransformer.setFailOnNoFormat(false); toCloudEventsTransformer.setCloudEventPrefix("CLOUDEVENTS-"); return toCloudEventsTransformer; } @Bean - public ToCloudEventsTransformer transformerWithInvalidIDExpression() { - ToCloudEventsTransformer transformer = new ToCloudEventsTransformer(); + public ToCloudEventTransformer transformerWithInvalidIDExpression() { + ToCloudEventTransformer transformer = new ToCloudEventTransformer(); transformer.setEventIdExpression(parser.parseExpression("null")); return transformer; } + + @Bean + public ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabled() { + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); + toCloudEventsTransformer.setFailOnNoFormat(true); + return toCloudEventsTransformer; + } } private record TestRecord(String sampleValue) implements Serializable { } diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc index a4b8954647..f2e4cf96ea 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -1,7 +1,7 @@ [[cloudevents]] = CloudEvents Support -Spring Integration provides transformers (starting with version 7.0) for transforming messages into CloudEvent messages. +Spring Integration provides transformers (starting with version 7.1) for transforming messages into CloudEvent messages. It is fully based on the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project. This dependency is required for the project: @@ -27,65 +27,21 @@ compile "org.springframework.integration:spring-integration-cloudevents:{project ---- ====== -[[cloudevent-transformers]] +[[to-cloud-event-transformer]] +=== ToCloudEventTransformer -== CloudEvent Transformers +The `ToCloudEventTransformer` converts Spring Integration messages into CloudEvents compliant messages. +This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes using ``Expression`` s and identifying extensions in the message headers via patterns. -[[tocloudeventstransformer]] -=== ToCloudEventsTransformer +NOTE: Messages to be transformed must have a payload of type `byte[]`. +The transformer will throw an `IllegalArgumentException` if the payload is not a byte array. -The `ToCloudEventsTransformer` converts Spring Integration messages into CloudEvents compliant messages. -This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes using `Expression` s and identifying extensions in the message headers via patterns. +==== Attribute Expressions +Users are allowed to set the CloudEvents' attributes of `id`, `source`, `type`, `dataSchema`, `subject` through SpEL ``Expression``s. -[[cloudevent-transformer-overview]] -==== Overview +NOTE: The `time` attribute is set to the time that the CloudEvent message was created by the `ToCloudEventTransformer` transformer. -The CloudEvents transformer (`ToCloudEventsTransformer`) converts messages to CloudEvents format. -The CloudEvents transformer utilizes `io.cloudevents.core.provider.EventFormatProvider` to find `EventFormat` classes in the classpath and registers these as the available serializers for CloudEvents. -The type of serialization (JSON, XML, AVRO, etc) of the CloudEvents' message is determined by `contentType` of the message. - -NOTE: Messages to be transformed must have a payload of `byte[]`. - - - -[[configure-transformer]] -===== Configuring Transformer - -The `ToCloudEventsTransformer` allows the user to use SpEL `Expression`s to populate the attributes. -Extensions are populated from the headers using pattern matching. - -====== Attribute Expressions - -Users are allowed to set the CloudEvents' attributes of `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 `ToCloudEventsTransformer` transformer. - -[source,java] ----- -ToCloudEventTransformer transformer = new ToCloudEventTransformer(null); -transformer.setTypeExpression(new LiteralExpression("sampleType")); ----- - -====== Extension Patterns - -The extensionPatterns constructor parameter is an array of `Strings` s. -If the array is `null`, then no extensions will be added to the CloudEvent. -Each `pattern` in the array is the search criteria for finding the headers that need to be added as extensions to the new `CloudEvent`. -In the example below the system will search for any header key that starts with the word `trace` and add those headers to the extensions for the `CloudEvent` message. -Then it will search for any header that contains a key of 'span-id' as well as 'user-id' and add those headers as extensions if found. - -[source,java] ----- -String[] extensionPatterns = {"trace*", "span-id", "user-id"}; -return new ToCloudEventTransformer(extensionPatterns); ----- - -[[cloudevent-attribute-defaults]] -====== Default Values -The following table contains the Attribute names and the value returned by the default `Expression`s. +The following table contains the Attribute names and the value returned by the default ``Expression``s. |=== | Attribute Name | Default Value @@ -94,13 +50,13 @@ The following table contains the Attribute names and the value returned by the d | the id of the message. | `source` -| Prefix of "/spring/" followed by the appName a period then the name of the transformer's bean. i.e. `/spring/myapp.toCloudEventsTransformerBean` +| prefix of "/spring/" followed by the appName a period then the name of the transformer's bean. i.e. `/spring/myapp.toCloudEventTransformerBean` | `type` | "spring.message" | `dataContentType` -| The `contentType` of the message. +| The contentType of the message, defaults to "application/octet-stream" | `dataSchema` | `null` @@ -109,49 +65,55 @@ The following table contains the Attribute names and the value returned by the d | `null` | `time` -| The time the CloudEvent message is created +| the time the CloudEvent message is created |=== +==== Extension Patterns + +The extensionPatterns constructor parameter is a Vararg of ``String``s. +Each `pattern` in the array is the search criteria for finding the headers that need to be added as extensions to the new `CloudEvent`. +In this example the following patterns were passed to the constructor: `"trace*", "span-id", "user-id"`. `ToCloudEventTransformer` will search for any header key that starts with the word `trace` and add those headers to the extensions for the `CloudEvent` message. +Then it will search for any header that contains a key of `span-id` as well as `user-id` and add those headers as extensions if found. + [[cloudevent-transformer-integration]] ==== Integration with Spring Integration Flows The CloudEvent transformer integrates with Spring Integration flows: -====== Basic Flow - [source,java] ---- @Bean -public IntegrationFlow cloudEventTransformFlow() { +public ToCloudEventTransformer cloudEventTransformer() { + return new ToCloudEventTransformer(); +} + +@Bean +public IntegrationFlow cloudEventTransformFlow(ToCloudEventTransformer toCloudEventTransformer) { return IntegrationFlows .from("inputChannel") - .transform(cloudEventTransformer()) + .transform(toCloudEventTransformer) .channel("outputChannel") .get(); } ---- -[[cloudevent-transformer-transformation-process]] -===== Transformation Process +[[cloudevent-transformer-process]] +==== CloudEvent Transformer 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. +2. **Extension Extraction**: Build the CloudEvent extensions using the array of extensionPatterns 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 +A basic transformation may have the following pattern: [source,java] ---- // Input message with headers -Message inputMessage = MessageBuilder - .withPayload("Hello CloudEvents") - .withHeader("contentType", "application/octet-stream") +Message inputMessage = MessageBuilder + .withPayload("Hello CloudEvents".getBytes(StandardCharsets.UTF_8)) + .withHeader(MessageHeaders.CONTENT_TYPE, "application/octet-stream") .build(); // Transformer with extension patterns ToCloudEventTransformer transformer = new ToCloudEventTransformer(); @@ -161,108 +123,7 @@ Object cloudEventMessage = transformer.transform(inputMessage); ---- [[eventformats]] -===== EventFormats - -The `ToCloudEventsTransformer` uses `EventFormat` s to serialize the CloudEvent into the message's payload. -The `EventFormat` s used by the `ToCloudEventsTransformer` are obtained from the classpath of the project. -The `EventFormat` s that are available from the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project are as follows: +==== EventFormats -[[jsonformat]] -====== JsonFormat -The following dependency can be used to include this `JsonFormat` in your project. - -[tabs] -====== -Maven:: -+ -[source, xml, subs="normal", role="primary"] ----- - - io.cloudevents - cloudevents-json-jackson - $cloudEventsVersion - ----- - -Gradle:: -+ -[source, groovy, subs="normal", role="secondary"] ----- -compile "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" ----- -====== - -[[xmlformat]] -====== XMLFormat -The following dependency can be used to include this `XMLFormat` in your project. - -[tabs] -====== -Maven:: -+ -[source, xml, subs="normal", role="primary"] ----- - - io.cloudevents - cloudevents-xml - $cloudEventsVersion - ----- - -Gradle:: -+ -[source, groovy, subs="normal", role="secondary"] ----- -compile "io.cloudevents:cloudevents-xml:$cloudEventsVersion" ----- -====== - -[[avrocompactformat]] -====== AvroCompactFormat -The following dependency can be used to include this `AvroCompactFormat` in your project. - -[tabs] -====== -Maven:: -+ -[source, xml, subs="normal", role="primary"] ----- - - io.cloudevents - cloudevents-avro-compact - $cloudEventsVersion - ----- - -Gradle:: -+ -[source, groovy, subs="normal", role="secondary"] ----- -compile "io.cloudevents:cloudevents-avro-compact:$cloudEventsVersion" ----- -====== - -[[protobufformat]] -====== ProtobufFormat -The following dependency can be used to include this `ProtobufFormat` in your project. - -[tabs] -====== -Maven:: -+ -[source, xml, subs="normal", role="primary"] ----- - - io.cloudevents - cloudevents-protobuf - $cloudEventsVersion - ----- - -Gradle:: -+ -[source, groovy, subs="normal", role="secondary"] ----- -compile "io.cloudevents:cloudevents-protobuf:$cloudEventsVersion" ----- -====== \ No newline at end of file +The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload. To utilize a specific `EventFormat` the associated dependency needs to be added. For example adding the XML `EventFormat` would require the following dependency `io.cloudevents:cloudevents-xml`. +Information on the ``EventFormat``s that are available can be found in the https://github.com/cloudevents/sdk-java[CloudEvents SDK project's] reference docs. From a1a4ae7d9ddcd86e14d2801f2fd9c1d6477036f4 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 29 Dec 2025 14:55:40 -0500 Subject: [PATCH 09/11] Refine CloudEvents transformer implementation Address multiple improvements to the CloudEvents transformer module: - Reorganize constructor ordering in `ToCloudEventTransformer` to put the no-arg constructor first, following Java conventions - Remove unused Avro test dependency from build.gradle - Improve content type handling using `MessageHeaderAccessor` and `MimeType` instead of direct String extraction - Refactor extension processing to use `Map.copyOf()` for immutability - Rename `ToCloudEventTransformerExtensions` to singular `ToCloudEventTransformerExtension` for consistency - Inline constant strings for specversion and datacontenttype keys - Fix content type header to use `eventFormat.serializedContentType()` instead of the input message's content type - Simplify binary mode message creation by constructing `MessageHeaders` directly - Update test assertions to match null appName behavior - Enhance documentation clarity: improve wording, fix grammar, capitalize table entries, and add examples for content types - Restructure documentation sections for better readability - Update `MessageBuilderMessageWriter` to implement `CloudEventWriterFactory` instead of `MessageWriter` The message writer had default functions unused by the read method. - Remove unused `setEvent()` method and `dataContentTypeKey` field from `MessageBuilderMessageWriter` - Apply Spring code convention --- build.gradle | 2 - .../transformer/ToCloudEventTransformer.java | 242 +++++++++--------- .../ToCloudEventTransformerTests.java | 27 +- .../modules/ROOT/pages/cloudevents.adoc | 48 ++-- 4 files changed, 170 insertions(+), 149 deletions(-) diff --git a/build.gradle b/build.gradle index 9f04a84b33..0c68d18ca1 100644 --- a/build.gradle +++ b/build.gradle @@ -484,8 +484,6 @@ project('spring-integration-cloudevents') { dependencies { api "io.cloudevents:cloudevents-core:$cloudEventsVersion" testImplementation "io.cloudevents:cloudevents-json-jackson:$cloudEventsVersion" - - testImplementation "org.apache.avro:avro:$avroVersion" testImplementation "io.cloudevents:cloudevents-xml:$cloudEventsVersion" } } 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 index 37e8497560..aace0e65dc 100644 --- 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 @@ -32,11 +32,11 @@ import io.cloudevents.core.CloudEventUtils; import io.cloudevents.core.builder.CloudEventBuilder; import io.cloudevents.core.format.EventFormat; -import io.cloudevents.core.message.MessageWriter; import io.cloudevents.core.provider.EventFormatProvider; import io.cloudevents.rw.CloudEventContextWriter; import io.cloudevents.rw.CloudEventRWException; import io.cloudevents.rw.CloudEventWriter; +import io.cloudevents.rw.CloudEventWriterFactory; import org.jspecify.annotations.Nullable; import org.springframework.context.ApplicationContext; @@ -52,23 +52,24 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.Assert; +import org.springframework.util.MimeType; /** - * Converts messages to CloudEvent format. - * Performs attribute and extension mapping based on {@link Expression}s. + * Convert messages to CloudEvent format. + * Perform attribute and extension mapping based on {@link Expression}s. * * @author Glenn Renfro - * * @since 7.1 */ public class ToCloudEventTransformer extends AbstractTransformer { private static final String DEFAULT_PREFIX = "ce-"; - private static final String DEFAULT_SPECVERSION_KEY = "specversion"; + private static final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); - private static final String DEFAULT_DATACONTENTTYPE_KEY = "datacontenttype"; + private final String[] extensionPatterns; private String cloudEventPrefix = DEFAULT_PREFIX; @@ -87,93 +88,128 @@ public class ToCloudEventTransformer extends AbstractTransformer { private @Nullable Expression subjectExpression; - private final String[] extensionPatterns; - @SuppressWarnings("NullAway.Init") private EvaluationContext evaluationContext; - private static final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); + /** + * Create a ToCloudEventTransformer with no extensionPatterns. + * @since 7.1 + */ + public ToCloudEventTransformer() { + this(new String[0]); + } /** - * Construct a ToCloudEventTransformer. + * Create a ToCloudEventTransformer. * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions * to the CloudEvent + * @since 7.1 */ - public ToCloudEventTransformer(String ... extensionPatterns) { - this.extensionPatterns = extensionPatterns == null ? new String[0] : - Arrays.copyOf(extensionPatterns, extensionPatterns.length); + public ToCloudEventTransformer(String... extensionPatterns) { + this.extensionPatterns = Arrays.copyOf(extensionPatterns, extensionPatterns.length); } - /** - * Construct a ToCloudEventTransformer with no extensionPatterns. - */ - public ToCloudEventTransformer() { - this.extensionPatterns = new String[0]; + @Override + protected void onInit() { + super.onInit(); + this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); + if (this.sourceExpression == null) { // in the case the user sets the value prior to onInit. + ApplicationContext applicationContext = getApplicationContext(); + String appName = applicationContext.getEnvironment().getProperty("spring.application.name"); + logger.warn("'spring.application.name' is not set. " + + "CloudEvent source URIs will use 'null' as the application name. " + + "Consider setting 'spring.application.name'"); + this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); + } } /** - * Set the {@link Expression} for creating CloudEvent ids. + * Set the {@link Expression} to create CloudEvent ids. * Defaults to extracting the id from the {@link MessageHeaders} of the message. * @param eventIdExpression the expression to create the id for each CloudEvent + * @since 7.1 */ public void setEventIdExpression(Expression eventIdExpression) { this.eventIdExpression = eventIdExpression; } /** - * Set the {@link Expression} for creating CloudEvent source. + * Set the {@link Expression} to create CloudEvent source. * Defaults to {@code "/spring/" + appName + "." + getBeanName())}. * @param sourceExpression the expression to create the source for each CloudEvent + * @since 7.1 */ public void setSourceExpression(Expression sourceExpression) { this.sourceExpression = sourceExpression; } /** - * Set the {@link Expression} for extracting the type for the CloudEvent. + * Set the {@link Expression} to extract the type for the CloudEvent. * Defaults to "spring.message". * @param typeExpression the expression to create the type for each CloudEvent + * @since 7.1 */ public void setTypeExpression(Expression typeExpression) { this.typeExpression = typeExpression; } /** - * Set the {@link Expression} for creating the dataSchema for the CloudEvent. + * Set the {@link Expression} to create the dataSchema for the CloudEvent. * @param dataSchemaExpression the expression to create the dataSchema for each CloudEvent + * @since 7.1 */ public void setDataSchemaExpression(Expression dataSchemaExpression) { this.dataSchemaExpression = dataSchemaExpression; } /** - * Set the {@link Expression} for creating the subject for the CloudEvent. + * Set the {@link Expression} to create the subject for the CloudEvent. * @param subjectExpression the expression to create the subject for each CloudEvent + * @since 7.1 */ public void setSubjectExpression(Expression subjectExpression) { this.subjectExpression = subjectExpression; } /** - * Set to {@code true} to fail if no {@link EventFormat} is found for message's content type. + * Set to {@code true} to fail if no {@link EventFormat} is found for message content type. * When {@code false} and no {@link EventFormat} is found, then a {@link CloudEvent}' body is * set as an output message's payload, and its attributes are set into headers. * @param failOnNoFormat true to disable format serialization + * @since 7.1 */ public void setFailOnNoFormat(boolean failOnNoFormat) { this.failOnNoFormat = failOnNoFormat; } - @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. - String appName = applicationContext.getEnvironment().getProperty("spring.application.name"); - appName = appName == null ? "unknown" : appName; - this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); - } + /** + * Return whether the transformer will transform the message + * when no {@link EventFormat} is available for the content type. + * @return {@code true} if transformation should fail + * when no suitable {@link EventFormat} is found; + * {@code false} otherwise + * @since 7.1 + */ + public boolean isFailOnNoFormat() { + return this.failOnNoFormat; + } + + /** + * Set the prefix for CloudEvent headers in binary content mode. + * @param cloudEventPrefix the prefix to use for CloudEvent headers + * @since 7.1 + */ + public void setCloudEventPrefix(String cloudEventPrefix) { + this.cloudEventPrefix = cloudEventPrefix; + } + + /** + * Return the prefix used for CloudEvent headers in binary content mode. + * @return the CloudEvent header prefix + * @since 7.1 + */ + public String getCloudEventPrefix() { + return this.cloudEventPrefix; } /** @@ -182,24 +218,28 @@ protected void onInit() { * @return CloudEvent message in the specified format * @throws RuntimeException if serialization fails */ - @SuppressWarnings("unchecked") @Override protected Object doTransform(Message message) { - Assert.isInstanceOf(byte[].class, message.getPayload(), "Message payload must be of type byte[]"); + Assert.isInstanceOf(byte[].class, message.getPayload(), "Message payload must be byte[]"); String id = this.eventIdExpression.getValue(this.evaluationContext, message, String.class); URI source = this.sourceExpression.getValue(this.evaluationContext, message, URI.class); String type = this.typeExpression.getValue(this.evaluationContext, message, String.class); MessageHeaders headers = message.getHeaders(); - - String contentType = headers.get(MessageHeaders.CONTENT_TYPE, String.class); - if (contentType == null) { + MessageHeaderAccessor accessor = new MessageHeaderAccessor(message); + MimeType mimeType = accessor.getContentType(); + String contentType; + if (mimeType == null) { contentType = "application/octet-stream"; } + else { + contentType = mimeType.toString(); + } + + Map cloudEventExtensions = getCloudEventExtensions(headers); - ToCloudEventTransformerExtensions extensions = - new ToCloudEventTransformerExtensions(headers, - this.extensionPatterns); + ToCloudEventTransformerExtension extensions = + new ToCloudEventTransformerExtension(cloudEventExtensions); CloudEventBuilder cloudEventBuilder = CloudEventBuilder.v1() .withId(id) @@ -209,10 +249,12 @@ protected Object doTransform(Message message) { .withDataContentType(contentType); if (this.subjectExpression != null) { - cloudEventBuilder.withSubject(this.subjectExpression.getValue(this.evaluationContext, message, String.class)); + cloudEventBuilder.withSubject( + this.subjectExpression.getValue(this.evaluationContext, message, String.class)); } if (this.dataSchemaExpression != null) { - cloudEventBuilder.withDataSchema(this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)); + cloudEventBuilder.withDataSchema( + this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)); } CloudEvent cloudEvent = cloudEventBuilder.withData((byte[]) message.getPayload()) @@ -228,16 +270,13 @@ protected Object doTransform(Message message) { if (eventFormat != null) { return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) .copyHeaders(headers) - .setHeader(MessageHeaders.CONTENT_TYPE, contentType) + .setHeader(MessageHeaders.CONTENT_TYPE, eventFormat.serializedContentType()) .build(); } - - Message result = CloudEventUtils.toReader(cloudEvent).read( - new MessageBuilderMessageWriter(this.cloudEventPrefix, Objects.requireNonNull(headers))); - return MessageBuilder.withPayload(result.getPayload()) - .copyHeaders(result.getHeaders()) - .setHeader(MessageHeaders.CONTENT_TYPE, contentType) - .build(); + HashMap messageMap = new HashMap<>(headers); + messageMap.put(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); + return CloudEventUtils.toReader(cloudEvent) + .read(new MessageBuilderMessageWriter(this.cloudEventPrefix, new MessageHeaders(messageMap))); } @Override @@ -246,54 +285,35 @@ public String getComponentType() { } /** - * Indicates whether the transformer will transform the message - * when no {@link EventFormat} is available for the content type. - * @return {@code true} if transformation should fail - * when no suitable {@link EventFormat} is found; - * {@code false} otherwise - */ - public boolean isFailOnNoFormat() { - return this.failOnNoFormat; - } - - /** - * Return the prefix used for CloudEvent headers in binary content mode. - * @return the CloudEvent header prefix - */ - public String getCloudEventPrefix() { - return this.cloudEventPrefix; - } - - /** - * Set the prefix for CloudEvent headers in binary content mode. - * @param cloudEventPrefix the prefix to use for CloudEvent headers + * Extract CloudEvent extensions from message headers based on pattern matching. + * Iterate through all message headers and apply the configured extension patterns + * to determine which headers should be included as CloudEvent extensions. + * @param headers the message headers to extract extensions from + * @return a map of header key-value pairs that match the extension patterns; + * returns an empty map if no headers match the patterns */ - public void setCloudEventPrefix(String cloudEventPrefix) { - this.cloudEventPrefix = cloudEventPrefix; + private Map getCloudEventExtensions(MessageHeaders headers) { + Map cloudEventExtensions = new HashMap<>(); + Boolean patternResult; + for (Map.Entry header : headers.entrySet()) { + patternResult = PatternMatchUtils.smartMatch(header.getKey(), this.extensionPatterns); + if (patternResult != null && patternResult) { + cloudEventExtensions.put(header.getKey(), header.getValue()); + } + } + return cloudEventExtensions; } - private static class ToCloudEventTransformerExtensions implements CloudEventExtension { + private static class ToCloudEventTransformerExtension implements CloudEventExtension { - /** - * Stores the CloudEvent extensions extracted from message headers. - */ private final Map cloudEventExtensions; /** - * Construct CloudEvent extensions by processing a message using expressions. - * @param headers the headers from the Spring Integration message - * @param extensionPatterns patterns to determine whether message headers are extensions + * Create CloudEvent extensions by processing a message using expressions. + * @param headers to be added as cloudEventExtensions */ - @SuppressWarnings("unchecked") - ToCloudEventTransformerExtensions(Map headers, String ... extensionPatterns) { - this.cloudEventExtensions = new HashMap<>(); - Boolean result = null; - for (Map.Entry header : headers.entrySet()) { - result = PatternMatchUtils.smartMatch(header.getKey(), extensionPatterns); - if (result != null && result) { - this.cloudEventExtensions.put(header.getKey(), header.getValue()); - } - } + ToCloudEventTransformerExtension(Map headers) { + this.cloudEventExtensions = Map.copyOf(headers); } @Override @@ -310,44 +330,27 @@ public void readFrom(CloudEventExtensions extensions) { public Set getKeys() { return this.cloudEventExtensions.keySet(); } + } - private static class MessageBuilderMessageWriter - implements CloudEventWriter>, MessageWriter> { + private static class MessageBuilderMessageWriter implements CloudEventWriter>, + CloudEventWriterFactory> { private final String cloudEventPrefix; private final String specVersionKey; - private final String dataContentTypeKey; - private final Map headers = new HashMap<>(); /** - * Construct a MessageBuilderMessageWriter with the specified configuration. + * Create a MessageBuilderMessageWriter with the specified configuration. * @param cloudEventPrefix the prefix to prepend to CloudEvent attribute names in message headers * @param headers the base message headers to include in the output message */ MessageBuilderMessageWriter(String cloudEventPrefix, Map headers) { this.headers.putAll(headers); this.cloudEventPrefix = cloudEventPrefix; - this.specVersionKey = this.cloudEventPrefix + DEFAULT_SPECVERSION_KEY; - this.dataContentTypeKey = this.cloudEventPrefix + DEFAULT_DATACONTENTTYPE_KEY; - } - - /** - * Set the event in structured content mode. - * Create a message with the serialized CloudEvent as the payload and set the - * data content type header to the format's serialized content type. - * @param format the event format used to serialize the CloudEvent - * @param value the serialized CloudEvent bytes - * @return the Spring Integration message containing the serialized CloudEvent - * @throws CloudEventRWException if an error occurs during message creation - */ - @Override - public Message setEvent(EventFormat format, byte[] value) throws CloudEventRWException { - this.headers.put(this.dataContentTypeKey, format.serializedContentType()); - return org.springframework.integration.support.MessageBuilder.withPayload(value).copyHeaders(this.headers).build(); + this.specVersionKey = this.cloudEventPrefix + "specversion"; } /** @@ -360,7 +363,10 @@ public Message setEvent(EventFormat format, byte[] value) throws CloudEv */ @Override public Message end(CloudEventData value) throws CloudEventRWException { - return org.springframework.integration.support.MessageBuilder.withPayload(value.toBytes()).copyHeaders(this.headers).build(); + return org.springframework.integration.support.MessageBuilder + .withPayload(value.toBytes()) + .copyHeaders(this.headers) + .build(); } /** @@ -371,7 +377,10 @@ public Message end(CloudEventData value) throws CloudEventRWException { */ @Override public Message end() { - return org.springframework.integration.support.MessageBuilder.withPayload(new byte[0]).copyHeaders(this.headers).build(); + return org.springframework.integration.support.MessageBuilder + .withPayload(new byte[0]) + .copyHeaders(this.headers) + .build(); } /** @@ -402,4 +411,5 @@ public MessageBuilderMessageWriter create(SpecVersion version) { } } + } 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 index b4fe832f3b..4540c1873e 100644 --- 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 @@ -48,7 +48,6 @@ * Test {@link ToCloudEventTransformer} transformer. * * @author Glenn Renfro - * * @since 7.1 */ @DirtiesContext @@ -86,16 +85,16 @@ class ToCloudEventTransformerTests { @Autowired private ToCloudEventTransformer transformerWithInvalidIDExpression; - private final JsonFormat jsonFormat = new JsonFormat(); + private final JsonFormat jsonFormat = new JsonFormat(); - private final XMLFormat xmlFormat = new XMLFormat(); + private final XMLFormat xmlFormat = new XMLFormat(); @Test @SuppressWarnings("NullAway") void doJsonTransformWithPayloadBasedOnContentType() { - CloudEvent cloudEvent = getTransformerNoExtensions(PAYLOAD, jsonFormat); + CloudEvent cloudEvent = getTransformerNoExtensions(PAYLOAD, this.jsonFormat); assertThat(cloudEvent.getData().toBytes()).isEqualTo(PAYLOAD); - assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/unknown.transformerWithNoExtensions"); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.transformerWithNoExtensions"); assertThat(cloudEvent.getDataSchema()).isNull(); assertThat(cloudEvent.getDataContentType()).isEqualTo(JsonFormat.CONTENT_TYPE); } @@ -105,9 +104,9 @@ void doJsonTransformWithPayloadBasedOnContentType() { void doXMLTransformWithPayloadBasedOnContentType() { String xmlPayload = ("" + "testmessage"); - CloudEvent cloudEvent = getTransformerNoExtensions(xmlPayload.getBytes(), xmlFormat); + CloudEvent cloudEvent = getTransformerNoExtensions(xmlPayload.getBytes(), this.xmlFormat); assertThat(cloudEvent.getData().toBytes()).isEqualTo(xmlPayload.getBytes()); - assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/unknown.transformerWithNoExtensions"); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.transformerWithNoExtensions"); assertThat(cloudEvent.getDataSchema()).isNull(); assertThat(cloudEvent.getDataContentType()).isEqualTo(XMLFormat.XML_CONTENT_TYPE); } @@ -135,6 +134,7 @@ void convertMessageWithExtensions() { Message result = transformMessage(message, this.transformerWithExtensionsNoFormat); assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); assertThat(result.getHeaders()).containsKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); + assertThat(result.getPayload()).isEqualTo(PAYLOAD); } @Test @@ -148,7 +148,7 @@ void convertMessageWithExtensionsNewPrefix() { assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); assertThat(result.getHeaders()).containsKeys("CLOUDEVENTS-" + TRACE_HEADER, "CLOUDEVENTS-" + SPAN_HEADER, "CLOUDEVENTS-id", "CLOUDEVENTS-specversion", "CLOUDEVENTS-datacontenttype"); - + assertThat(result.getPayload()).isEqualTo(PAYLOAD); } @Test @@ -172,12 +172,14 @@ void doTransformWithObjectPayload() throws Exception { @Test @SuppressWarnings("NullAway") void emptyExtensionNames() { - Message message = createBaseMessage(PAYLOAD, "application/cloudevents+json").build(); + Message message = createBaseMessage(PAYLOAD, JsonFormat.CONTENT_TYPE).build(); Object result = this.transformerWithNoExtensions.doTransform(message); assertThat(result).isNotNull(); Message resultMessage = (Message) result; assertThat(resultMessage.getPayload()).isNotNull(); + assertThat(message.getPayload()).isEqualTo(PAYLOAD); + } @Test @@ -185,6 +187,8 @@ void noContentType() { Message message = MessageBuilder.withPayload(PAYLOAD).build(); Message result = this.transformerWithNoExtensions.transform(message); assertThat(result.getHeaders().get("ce-datacontenttype")).isEqualTo("application/octet-stream"); + assertThat(message.getPayload()).isEqualTo(PAYLOAD); + } @Test @@ -231,7 +235,8 @@ void failWhenNoIdHeaderAndNoDefault() { .setHeader("contentType", JsonFormat.CONTENT_TYPE) .build(); - assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)).isInstanceOf(MessageTransformationException.class) + assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)) + .isInstanceOf(MessageTransformationException.class) .hasMessageContaining("failed to transform message"); } @@ -317,7 +322,9 @@ public ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabled() { toCloudEventsTransformer.setFailOnNoFormat(true); return toCloudEventsTransformer; } + } private record TestRecord(String sampleValue) implements Serializable { } + } diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc index f2e4cf96ea..c642fb7228 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -4,7 +4,7 @@ Spring Integration provides transformers (starting with version 7.1) for transforming messages into CloudEvent messages. It is fully based on the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project. -This dependency is required for the project: +Add the following dependency to your project: [tabs] ====== @@ -28,35 +28,38 @@ compile "org.springframework.integration:spring-integration-cloudevents:{project ====== [[to-cloud-event-transformer]] -=== ToCloudEventTransformer + +=== `ToCloudEventTransformer` The `ToCloudEventTransformer` converts Spring Integration messages into CloudEvents compliant messages. This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes using ``Expression`` s and identifying extensions in the message headers via patterns. -NOTE: Messages to be transformed must have a payload of type `byte[]`. -The transformer will throw an `IllegalArgumentException` if the payload is not a byte array. +NOTE: Ensure messages to be transformed have a payload of type `byte[]`. +The transformer throws an `IllegalArgumentException` if the payload is not a byte array. ==== Attribute Expressions -Users are allowed to set the CloudEvents' attributes of `id`, `source`, `type`, `dataSchema`, `subject` through SpEL ``Expression``s. + +Set the CloudEvents' attributes of `id`, `source`, `type`, `dataSchema`, and `subject` through SpEL ``Expression``s. NOTE: The `time` attribute is set to the time that the CloudEvent message was created by the `ToCloudEventTransformer` transformer. -The following table contains the Attribute names and the value returned by the default ``Expression``s. +The following table contains the attribute names and the values returned by the default ``Expression``s. |=== | Attribute Name | Default Value | `id` -| the id of the message. +| The id of the message. | `source` -| prefix of "/spring/" followed by the appName a period then the name of the transformer's bean. i.e. `/spring/myapp.toCloudEventTransformerBean` +| Prefix of "/spring/" followed by the appName, a period, and then the name of the transformer's bean (for example, `/spring/myapp.toCloudEventTransformerBean`). | `type` | "spring.message" | `dataContentType` -| The contentType of the message, defaults to "application/octet-stream" +| The contentType of the message, defaults to `application/octet-stream`. +Some other examples are but not limited to: `application/json`, `application/x-avro`, and `application/xml`. | `dataSchema` | `null` @@ -65,20 +68,21 @@ The following table contains the Attribute names and the value returned by the d | `null` | `time` -| the time the CloudEvent message is created +| The time the CloudEvent message is created. |=== ==== Extension Patterns -The extensionPatterns constructor parameter is a Vararg of ``String``s. +The extensionPatterns constructor parameter is a vararg of ``String``s. Each `pattern` in the array is the search criteria for finding the headers that need to be added as extensions to the new `CloudEvent`. -In this example the following patterns were passed to the constructor: `"trace*", "span-id", "user-id"`. `ToCloudEventTransformer` will search for any header key that starts with the word `trace` and add those headers to the extensions for the `CloudEvent` message. -Then it will search for any header that contains a key of `span-id` as well as `user-id` and add those headers as extensions if found. +In this example, the following patterns were passed to the constructor: `"trace*", "span-id", "user-id"`. +`ToCloudEventTransformer` searches for any header key that starts with the word `trace` and adds those headers to the extensions for the `CloudEvent` message. +Then it searches for any header that contains a key of `span-id` as well as `user-id` and adds those headers as extensions if found. -[[cloudevent-transformer-integration]] -==== Integration with Spring Integration Flows +[[configuration-with-dsl]] +==== Configuration With DSL -The CloudEvent transformer integrates with Spring Integration flows: +Add the CloudEvent transformer to flows using the DSL: [source,java] ---- @@ -102,9 +106,9 @@ public IntegrationFlow cloudEventTransformFlow(ToCloudEventTransformer toCloudEv The transformer follows the process below: -1. **CloudEvent Building**: Build CloudEvent attributes -2. **Extension Extraction**: Build the CloudEvent extensions using the array of extensionPatterns passed into the constructor. -3. **Format Conversion**: Apply the specified `EventFormat` based on the message's `contentType` to create the CloudEvent. +1. **CloudEvent Building**: Builds CloudEvent attributes. +2. **Extension Extraction**: Builds the CloudEvent extensions using the array of extensionPatterns passed into the constructor. +3. **Format Conversion**: Applies the specified `EventFormat` based on the message's `contentType` to create the CloudEvent. A basic transformation may have the following pattern: @@ -125,5 +129,7 @@ Object cloudEventMessage = transformer.transform(inputMessage); [[eventformats]] ==== EventFormats -The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload. To utilize a specific `EventFormat` the associated dependency needs to be added. For example adding the XML `EventFormat` would require the following dependency `io.cloudevents:cloudevents-xml`. -Information on the ``EventFormat``s that are available can be found in the https://github.com/cloudevents/sdk-java[CloudEvents SDK project's] reference docs. +The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload. +To utilize a specific `EventFormat`, add the associated dependency. +For example, to add the XML `EventFormat`, add the following dependency: `io.cloudevents:cloudevents-xml`. +See the https://github.com/cloudevents/sdk-java[CloudEvents SDK project's] reference docs for information on the ``EventFormat``s that are available. From 256edad93302d4d629e4d77e4727c93683c5754b Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Tue, 30 Dec 2025 14:56:30 -0500 Subject: [PATCH 10/11] Refine CloudEvents transformer code quality and tests Code quality improvements: - Remove unnecessary `@since 7.1` tags from internal javadocs - Fix NullAway suppressions by adding proper null checks - Replace `MessageHeaderAccessor` with `StaticMessageHeaderAccessor` - Eliminate redundant field initialization (failOnNoFormat defaults to false) - Simplify extension comparison using `Boolean.TRUE.equals()` - Remove unnecessary Map.copyOf() in extension implementation - Fix app name null check to only warn when actually null Test refactoring: - Make test helper methods static for better encapsulation - Rename test methods to clearly indicate content type being tested - Separate JSON format vs application/json content type test cases - Add explicit verification for application/* content types - Improve test assertions using containsEntry() instead of get() - Remove unnecessary null checks in tests - Consolidate duplicate test setup code Documentation: - Add package-info.java descriptions for cloudevents packages - Update reference documentation for extension patterns clarity --- .../integration/cloudevents/package-info.java | 3 + .../transformer/ToCloudEventTransformer.java | 81 ++++---- .../cloudevents/transformer/package-info.java | 3 + .../ToCloudEventTransformerTests.java | 184 ++++++++++-------- .../modules/ROOT/pages/cloudevents.adoc | 30 ++- 5 files changed, 161 insertions(+), 140 deletions(-) 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 index 116ccfd7f8..130c148285 100644 --- 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 @@ -1,3 +1,6 @@ +/** + * Provides core CloudEvents support classes and components + */ @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 index aace0e65dc..183985d923 100644 --- 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 @@ -43,6 +43,7 @@ import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.common.LiteralExpression; +import org.springframework.integration.StaticMessageHeaderAccessor; import org.springframework.integration.expression.ExpressionUtils; import org.springframework.integration.expression.FunctionExpression; import org.springframework.integration.expression.ValueExpression; @@ -52,9 +53,9 @@ import org.springframework.messaging.Message; import org.springframework.messaging.MessageHeaders; import org.springframework.messaging.support.MessageBuilder; -import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.util.Assert; import org.springframework.util.MimeType; +import org.springframework.util.StringUtils; /** * Convert messages to CloudEvent format. @@ -67,13 +68,13 @@ public class ToCloudEventTransformer extends AbstractTransformer { private static final String DEFAULT_PREFIX = "ce-"; - private static final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); + private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); private final String[] extensionPatterns; private String cloudEventPrefix = DEFAULT_PREFIX; - private boolean failOnNoFormat = false; + private boolean failOnNoFormat; private Expression eventIdExpression = new FunctionExpression>( msg -> Objects.requireNonNull(msg.getHeaders().getId()).toString()); @@ -83,7 +84,6 @@ public class ToCloudEventTransformer extends AbstractTransformer { private Expression typeExpression = new LiteralExpression("spring.message"); - @SuppressWarnings("NullAway.Init") private @Nullable Expression dataSchemaExpression; private @Nullable Expression subjectExpression; @@ -93,7 +93,6 @@ public class ToCloudEventTransformer extends AbstractTransformer { /** * Create a ToCloudEventTransformer with no extensionPatterns. - * @since 7.1 */ public ToCloudEventTransformer() { this(new String[0]); @@ -103,7 +102,6 @@ public ToCloudEventTransformer() { * Create a ToCloudEventTransformer. * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions * to the CloudEvent - * @since 7.1 */ public ToCloudEventTransformer(String... extensionPatterns) { this.extensionPatterns = Arrays.copyOf(extensionPatterns, extensionPatterns.length); @@ -113,12 +111,14 @@ public ToCloudEventTransformer(String... extensionPatterns) { protected void onInit() { super.onInit(); this.evaluationContext = ExpressionUtils.createStandardEvaluationContext(getBeanFactory()); - if (this.sourceExpression == null) { // in the case the user sets the value prior to onInit. + if (this.sourceExpression == null) { ApplicationContext applicationContext = getApplicationContext(); String appName = applicationContext.getEnvironment().getProperty("spring.application.name"); - logger.warn("'spring.application.name' is not set. " + - "CloudEvent source URIs will use 'null' as the application name. " + - "Consider setting 'spring.application.name'"); + if (!StringUtils.hasText(appName)) { + logger.warn("'spring.application.name' is not set. " + + "CloudEvent source URIs will use 'null' as the application name. " + + "Consider setting 'spring.application.name'"); + } this.sourceExpression = new ValueExpression<>(URI.create("/spring/" + appName + "." + getBeanName())); } } @@ -127,7 +127,6 @@ protected void onInit() { * Set the {@link Expression} to create CloudEvent ids. * Defaults to extracting the id from the {@link MessageHeaders} of the message. * @param eventIdExpression the expression to create the id for each CloudEvent - * @since 7.1 */ public void setEventIdExpression(Expression eventIdExpression) { this.eventIdExpression = eventIdExpression; @@ -137,7 +136,6 @@ public void setEventIdExpression(Expression eventIdExpression) { * Set the {@link Expression} to create CloudEvent source. * Defaults to {@code "/spring/" + appName + "." + getBeanName())}. * @param sourceExpression the expression to create the source for each CloudEvent - * @since 7.1 */ public void setSourceExpression(Expression sourceExpression) { this.sourceExpression = sourceExpression; @@ -147,7 +145,6 @@ public void setSourceExpression(Expression sourceExpression) { * Set the {@link Expression} to extract the type for the CloudEvent. * Defaults to "spring.message". * @param typeExpression the expression to create the type for each CloudEvent - * @since 7.1 */ public void setTypeExpression(Expression typeExpression) { this.typeExpression = typeExpression; @@ -156,7 +153,6 @@ public void setTypeExpression(Expression typeExpression) { /** * Set the {@link Expression} to create the dataSchema for the CloudEvent. * @param dataSchemaExpression the expression to create the dataSchema for each CloudEvent - * @since 7.1 */ public void setDataSchemaExpression(Expression dataSchemaExpression) { this.dataSchemaExpression = dataSchemaExpression; @@ -165,7 +161,6 @@ public void setDataSchemaExpression(Expression dataSchemaExpression) { /** * Set the {@link Expression} to create the subject for the CloudEvent. * @param subjectExpression the expression to create the subject for each CloudEvent - * @since 7.1 */ public void setSubjectExpression(Expression subjectExpression) { this.subjectExpression = subjectExpression; @@ -176,7 +171,6 @@ public void setSubjectExpression(Expression subjectExpression) { * When {@code false} and no {@link EventFormat} is found, then a {@link CloudEvent}' body is * set as an output message's payload, and its attributes are set into headers. * @param failOnNoFormat true to disable format serialization - * @since 7.1 */ public void setFailOnNoFormat(boolean failOnNoFormat) { this.failOnNoFormat = failOnNoFormat; @@ -188,7 +182,6 @@ public void setFailOnNoFormat(boolean failOnNoFormat) { * @return {@code true} if transformation should fail * when no suitable {@link EventFormat} is found; * {@code false} otherwise - * @since 7.1 */ public boolean isFailOnNoFormat() { return this.failOnNoFormat; @@ -197,7 +190,6 @@ public boolean isFailOnNoFormat() { /** * Set the prefix for CloudEvent headers in binary content mode. * @param cloudEventPrefix the prefix to use for CloudEvent headers - * @since 7.1 */ public void setCloudEventPrefix(String cloudEventPrefix) { this.cloudEventPrefix = cloudEventPrefix; @@ -206,7 +198,6 @@ public void setCloudEventPrefix(String cloudEventPrefix) { /** * Return the prefix used for CloudEvent headers in binary content mode. * @return the CloudEvent header prefix - * @since 7.1 */ public String getCloudEventPrefix() { return this.cloudEventPrefix; @@ -220,14 +211,14 @@ public String getCloudEventPrefix() { */ @Override protected Object doTransform(Message message) { - Assert.isInstanceOf(byte[].class, message.getPayload(), "Message payload must be byte[]"); + Object payload = message.getPayload(); + Assert.isInstanceOf(byte[].class, payload, "Message payload must be byte[]"); String id = this.eventIdExpression.getValue(this.evaluationContext, message, String.class); URI source = this.sourceExpression.getValue(this.evaluationContext, message, URI.class); String type = this.typeExpression.getValue(this.evaluationContext, message, String.class); MessageHeaders headers = message.getHeaders(); - MessageHeaderAccessor accessor = new MessageHeaderAccessor(message); - MimeType mimeType = accessor.getContentType(); + MimeType mimeType = StaticMessageHeaderAccessor.getContentType(message); String contentType; if (mimeType == null) { contentType = "application/octet-stream"; @@ -257,11 +248,11 @@ protected Object doTransform(Message message) { this.dataSchemaExpression.getValue(this.evaluationContext, message, URI.class)); } - CloudEvent cloudEvent = cloudEventBuilder.withData((byte[]) message.getPayload()) + CloudEvent cloudEvent = cloudEventBuilder.withData((byte[]) payload) .withExtension(extensions) .build(); - EventFormat eventFormat = eventFormatProvider.resolveFormat(contentType); + EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); if (eventFormat == null && this.failOnNoFormat) { throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); @@ -273,10 +264,11 @@ protected Object doTransform(Message message) { .setHeader(MessageHeaders.CONTENT_TYPE, eventFormat.serializedContentType()) .build(); } + HashMap messageMap = new HashMap<>(headers); messageMap.put(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); return CloudEventUtils.toReader(cloudEvent) - .read(new MessageBuilderMessageWriter(this.cloudEventPrefix, new MessageHeaders(messageMap))); + .read(new MessageBuilderMessageWriter(this.cloudEventPrefix, messageMap)); } @Override @@ -295,25 +287,26 @@ public String getComponentType() { private Map getCloudEventExtensions(MessageHeaders headers) { Map cloudEventExtensions = new HashMap<>(); Boolean patternResult; + String headerKey; for (Map.Entry header : headers.entrySet()) { - patternResult = PatternMatchUtils.smartMatch(header.getKey(), this.extensionPatterns); - if (patternResult != null && patternResult) { - cloudEventExtensions.put(header.getKey(), header.getValue()); + headerKey = header.getKey(); + patternResult = PatternMatchUtils.smartMatch(headerKey, this.extensionPatterns); + if (Boolean.TRUE.equals(patternResult)) { + cloudEventExtensions.put(headerKey, header.getValue()); } } return cloudEventExtensions; } + /** + * A custom CloudEvent extension implementation that wraps a map of headers as CloudEvent extension attributes. + */ private static class ToCloudEventTransformerExtension implements CloudEventExtension { private final Map cloudEventExtensions; - /** - * Create CloudEvent extensions by processing a message using expressions. - * @param headers to be added as cloudEventExtensions - */ ToCloudEventTransformerExtension(Map headers) { - this.cloudEventExtensions = Map.copyOf(headers); + this.cloudEventExtensions = headers; } @Override @@ -333,24 +326,20 @@ public Set getKeys() { } + /** + * A CloudEvent writer implementation that converts CloudEvent objects into + * Spring Integration {@link Message} instances with CloudEvent attributes as headers. + */ private static class MessageBuilderMessageWriter implements CloudEventWriter>, CloudEventWriterFactory> { private final String cloudEventPrefix; - private final String specVersionKey; + private final Map headers; - private final Map headers = new HashMap<>(); - - /** - * Create a MessageBuilderMessageWriter with the specified configuration. - * @param cloudEventPrefix the prefix to prepend to CloudEvent attribute names in message headers - * @param headers the base message headers to include in the output message - */ MessageBuilderMessageWriter(String cloudEventPrefix, Map headers) { - this.headers.putAll(headers); + this.headers = headers; this.cloudEventPrefix = cloudEventPrefix; - this.specVersionKey = this.cloudEventPrefix + "specversion"; } /** @@ -363,7 +352,7 @@ private static class MessageBuilderMessageWriter implements CloudEventWriter end(CloudEventData value) throws CloudEventRWException { - return org.springframework.integration.support.MessageBuilder + return MessageBuilder .withPayload(value.toBytes()) .copyHeaders(this.headers) .build(); @@ -377,7 +366,7 @@ public Message end(CloudEventData value) throws CloudEventRWException { */ @Override public Message end() { - return org.springframework.integration.support.MessageBuilder + return MessageBuilder .withPayload(new byte[0]) .copyHeaders(this.headers) .build(); @@ -406,7 +395,7 @@ public CloudEventContextWriter withContextAttribute(String name, String value) t */ @Override public MessageBuilderMessageWriter create(SpecVersion version) { - this.headers.put(this.specVersionKey, version.toString()); + this.headers.put(this.cloudEventPrefix + "specversion", version.toString()); return this; } 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 index 02b690ceec..4809240669 100644 --- 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 @@ -1,3 +1,6 @@ +/** + * Provides CloudEvents transformer implementations + */ @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 index 4540c1873e..891904ecd7 100644 --- 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 @@ -22,7 +22,7 @@ import java.nio.charset.StandardCharsets; import io.cloudevents.CloudEvent; -import io.cloudevents.core.format.EventFormat; +import io.cloudevents.jackson.JsonCloudEventData; import io.cloudevents.jackson.JsonFormat; import io.cloudevents.xml.XMLFormat; import org.junit.jupiter.api.Test; @@ -30,6 +30,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.serializer.DefaultSerializer; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.integration.config.EnableIntegration; @@ -48,6 +49,7 @@ * Test {@link ToCloudEventTransformer} transformer. * * @author Glenn Renfro + * * @since 7.1 */ @DirtiesContext @@ -60,9 +62,12 @@ class ToCloudEventTransformerTests { private static final String USER_HEADER = "userId"; - private static final String[] TEST_PATTERNS = {"trace*", SPAN_HEADER, USER_HEADER}; + private static final byte[] TEXT_PLAIN_PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); + + private static final byte[] XML_PAYLOAD = ("" + + "testmessage").getBytes(StandardCharsets.UTF_8); - private static final byte[] PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); + private static final byte[] JSON_PAYLOAD = ("{\"message\":\"Hello, World!\"}").getBytes(StandardCharsets.UTF_8); @Autowired private ToCloudEventTransformer transformerWithNoExtensions; @@ -90,22 +95,39 @@ class ToCloudEventTransformerTests { private final XMLFormat xmlFormat = new XMLFormat(); @Test - @SuppressWarnings("NullAway") - void doJsonTransformWithPayloadBasedOnContentType() { - CloudEvent cloudEvent = getTransformerNoExtensions(PAYLOAD, this.jsonFormat); - assertThat(cloudEvent.getData().toBytes()).isEqualTo(PAYLOAD); + void transformWithPayloadBasedOnJsonFormatContentType() { + Message message = + getTransformerNoExtensions(JSON_PAYLOAD, this.transformerWithNoExtensions, JsonFormat.CONTENT_TYPE); + CloudEvent cloudEvent = this.jsonFormat.deserialize(message.getPayload()); + assertThat(((JsonCloudEventData) cloudEvent.getData()).getNode().toString().getBytes(StandardCharsets.UTF_8)). + isEqualTo(JSON_PAYLOAD); assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.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(), this.xmlFormat); - assertThat(cloudEvent.getData().toBytes()).isEqualTo(xmlPayload.getBytes()); + void transformWithPayloadBasedOnApplicationJsonType() { + Message message = + getTransformerNoExtensions(JSON_PAYLOAD, this.transformerWithNoExtensions, "application/json"); + assertThat(message.getPayload()).isEqualTo(JSON_PAYLOAD); + verifyApplicationTypes(message); + } + + @Test + void transformWithPayloadBasedOnApplicationXMLType() { + Message message = getTransformerNoExtensions(XML_PAYLOAD, + this.transformerWithNoExtensions, "application/xml"); + assertThat(message.getPayload()).isEqualTo(XML_PAYLOAD); + verifyApplicationTypes(message); + } + + @Test + void transformWithPayloadBasedOnContentXMLFormatType() { + Message message = getTransformerNoExtensions(XML_PAYLOAD, + this.transformerWithNoExtensions, XMLFormat.XML_CONTENT_TYPE); + CloudEvent cloudEvent = this.xmlFormat.deserialize(message.getPayload()); + assertThat(cloudEvent.getData().toBytes()).isEqualTo(XML_PAYLOAD); assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.transformerWithNoExtensions"); assertThat(cloudEvent.getDataSchema()).isNull(); assertThat(cloudEvent.getDataContentType()).isEqualTo(XMLFormat.XML_CONTENT_TYPE); @@ -113,42 +135,44 @@ void doXMLTransformWithPayloadBasedOnContentType() { @Test void convertMessageNoExtensions() { - Message message = MessageBuilder.withPayload(PAYLOAD) + Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD) .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") .setHeader(TRACE_HEADER, "test-value") .setHeader(SPAN_HEADER, "other-value") .build(); - Message result = transformMessage(message, this.transformerWithNoExtensionsNoFormat); - assertThat(result.getPayload()).isEqualTo(PAYLOAD); + Message result = (Message) this.transformerWithNoExtensionsNoFormat.doTransform(message); + assertThat(result.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); assertThat(result.getHeaders()).doesNotContainKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); + assertThat(result.getHeaders()).containsEntry(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); + } @Test void convertMessageWithExtensions() { - Message message = MessageBuilder.withPayload(PAYLOAD) + Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD) .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") .setHeader(TRACE_HEADER, "test-value") .setHeader(SPAN_HEADER, "other-value") .build(); - Message result = transformMessage(message, this.transformerWithExtensionsNoFormat); - assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); - assertThat(result.getHeaders()).containsKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); - assertThat(result.getPayload()).isEqualTo(PAYLOAD); + Message result = (Message) transformerWithExtensionsNoFormat.doTransform(message); + assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER). + containsKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); + assertThat(result.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); } @Test void convertMessageWithExtensionsNewPrefix() { - Message message = MessageBuilder.withPayload(PAYLOAD) + Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD) .setHeader(MessageHeaders.CONTENT_TYPE, "text/plain") .setHeader(TRACE_HEADER, "test-value") .setHeader(SPAN_HEADER, "other-value") .build(); - Message result = transformMessage(message, this.transformerWithExtensionsNoFormatWithPrefix); - assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); - assertThat(result.getHeaders()).containsKeys("CLOUDEVENTS-" + TRACE_HEADER, "CLOUDEVENTS-" + SPAN_HEADER, - "CLOUDEVENTS-id", "CLOUDEVENTS-specversion", "CLOUDEVENTS-datacontenttype"); - assertThat(result.getPayload()).isEqualTo(PAYLOAD); + Message result = (Message) this.transformerWithExtensionsNoFormatWithPrefix.doTransform(message); + assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER, "CLOUDEVENTS-" + TRACE_HEADER, + "CLOUDEVENTS-" + SPAN_HEADER, "CLOUDEVENTS-id", "CLOUDEVENTS-specversion", + "CLOUDEVENTS-datacontenttype"); + assertThat(result.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); } @Test @@ -157,112 +181,103 @@ 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) + .setHeader(MessageHeaders.CONTENT_TYPE, 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, JsonFormat.CONTENT_TYPE).build(); - - Object result = this.transformerWithNoExtensions.doTransform(message); - assertThat(result).isNotNull(); - Message resultMessage = (Message) result; - assertThat(resultMessage.getPayload()).isNotNull(); - assertThat(message.getPayload()).isEqualTo(PAYLOAD); - - } - @Test void noContentType() { - Message message = MessageBuilder.withPayload(PAYLOAD).build(); + Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD).build(); Message result = this.transformerWithNoExtensions.transform(message); - assertThat(result.getHeaders().get("ce-datacontenttype")).isEqualTo("application/octet-stream"); - assertThat(message.getPayload()).isEqualTo(PAYLOAD); + assertThat(result.getHeaders()).containsEntry("ce-datacontenttype", "application/octet-stream"); + assertThat(message.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); } @Test void noContentTypeNoFormatEnabled() { - Message message = MessageBuilder.withPayload(PAYLOAD).build(); + Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD).build(); assertThatThrownBy(() -> this.transformerWithNoExtensionsNoFormatEnabled.transform(message)) .isInstanceOf(MessageTransformationException.class) .hasMessageContaining("No EventFormat found for 'application/octet-stream'"); } @Test - @SuppressWarnings({"unchecked", "NullAway"}) - void multipleExtensionMappings() { - String payload = "test message"; - Message message = createBaseMessage(payload.getBytes(), "application/cloudevents+json") - .setHeader("correlation-id", "corr-999") + @SuppressWarnings({"unchecked"}) + void multipleExtensionMappingsWithJsonFormatType() { + Message message = createBaseMessage(JSON_PAYLOAD, JsonFormat.CONTENT_TYPE) + .setHeader("correlation-id", "corr-999") .setHeader(TRACE_HEADER, "trace-123") .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(resultMessage.getHeaders()).containsEntry("correlation-id", "corr-999"); assertThat(new String(resultMessage.getPayload())).contains("\"traceId\":\"trace-123\""); - assertThat(new String(resultMessage.getPayload())).doesNotContain("\"spanId\":\"span-456\"", - "\"userId\":\"user-789\""); + } + + @Test + @SuppressWarnings({"unchecked"}) + void multipleExtensionMappingsWithApplicationJsonType() { + Message message = createBaseMessage(JSON_PAYLOAD, "application/json") + .setHeader("correlation-id", "corr-999") + .setHeader(TRACE_HEADER, "trace-123") + .build(); + + Object result = this.transformerWithExtensions.doTransform(message); + + Message resultMessage = (Message) result; + + assertThat(resultMessage.getHeaders()).containsEntry("correlation-id", "corr-999"). + containsEntry("ce-traceId", "trace-123"); } @Test void emptyStringPayloadHandling() { - Message message = createBaseMessage("".getBytes(), "application/cloudevents+json").build(); + Message message = createBaseMessage("".getBytes(), "text/plain").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(); - + Message message = createBaseMessage(TEXT_PLAIN_PAYLOAD, JsonFormat.CONTENT_TYPE).build(); assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)) .isInstanceOf(MessageTransformationException.class) .hasMessageContaining("failed to transform message"); } - private CloudEvent getTransformerNoExtensions(byte[] payload, EventFormat eventFormat) { - Message message = createBaseMessage(payload, eventFormat.serializedContentType()) + private static void verifyApplicationTypes(Message message) { + assertThat(message.getHeaders()) + .containsEntry("ce-source", "/spring/null.transformerWithNoExtensions") + .containsEntry("ce-type", "spring.message") + .containsEntry(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); + } + + private static Message getTransformerNoExtensions(byte[] payload, + ToCloudEventTransformer transformer, String contentType) { + Message message = createBaseMessage(payload, contentType) .setHeader("customheader", "test-value") .setHeader("otherheader", "other-value") .build(); - Message result = transformMessage(message, this.transformerWithNoExtensions); - return eventFormat.deserialize(result.getPayload()); + Message result = (Message) transformer.doTransform(message); + return result; } - @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 { + private static byte[] convertPayloadToBytes(TestRecord testRecord) throws Exception { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream); - out.writeObject(testRecord); - out.flush(); + DefaultSerializer defaultSerializer = new DefaultSerializer(); + defaultSerializer.serialize(testRecord, out); return byteArrayOutputStream.toByteArray(); } @@ -271,10 +286,12 @@ private static MessageBuilder createBaseMessage(byte[] payload, String c .setHeader(MessageHeaders.CONTENT_TYPE, contentType); } - @Configuration + @Configuration(proxyBeanMethods = false) @EnableIntegration public static class ContextConfiguration { + private static final String[] TEST_PATTERNS = {"trace*", SPAN_HEADER, USER_HEADER}; + private static final ExpressionParser parser = new SpelExpressionParser(); @Bean @@ -289,22 +306,19 @@ public ToCloudEventTransformer transformerWithExtensions() { @Bean public ToCloudEventTransformer transformerWithNoExtensionsNoFormat() { - ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); - toCloudEventsTransformer.setFailOnNoFormat(false); + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); return toCloudEventsTransformer; } @Bean public ToCloudEventTransformer transformerWithExtensionsNoFormat() { ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); - toCloudEventsTransformer.setFailOnNoFormat(false); return toCloudEventsTransformer; } @Bean public ToCloudEventTransformer transformerWithExtensionsNoFormatWithPrefix() { ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); - toCloudEventsTransformer.setFailOnNoFormat(false); toCloudEventsTransformer.setCloudEventPrefix("CLOUDEVENTS-"); return toCloudEventsTransformer; } @@ -318,7 +332,7 @@ public ToCloudEventTransformer transformerWithInvalidIDExpression() { @Bean public ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabled() { - ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); toCloudEventsTransformer.setFailOnNoFormat(true); return toCloudEventsTransformer; } diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc index c642fb7228..73fb481f21 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -32,7 +32,7 @@ compile "org.springframework.integration:spring-integration-cloudevents:{project === `ToCloudEventTransformer` The `ToCloudEventTransformer` converts Spring Integration messages into CloudEvents compliant messages. -This transformer provides support for the CloudEvents specification v1.0 with configurable output format and defining attributes using ``Expression`` s and identifying extensions in the message headers via patterns. +This transformer provides support for the CloudEvents specification v1.0, automatically serializing CloudEvents based on the message's `contentType` header, defining attributes using Expressions, and identifying extensions in the message headers via patterns. NOTE: Ensure messages to be transformed have a payload of type `byte[]`. The transformer throws an `IllegalArgumentException` if the payload is not a byte array. @@ -73,13 +73,20 @@ Some other examples are but not limited to: `application/json`, `application/x-a ==== Extension Patterns -The extensionPatterns constructor parameter is a vararg of ``String``s. -Each `pattern` in the array is the search criteria for finding the headers that need to be added as extensions to the new `CloudEvent`. -In this example, the following patterns were passed to the constructor: `"trace*", "span-id", "user-id"`. -`ToCloudEventTransformer` searches for any header key that starts with the word `trace` and adds those headers to the extensions for the `CloudEvent` message. -Then it searches for any header that contains a key of `span-id` as well as `user-id` and adds those headers as extensions if found. +The extensionPatterns constructor parameter is a vararg of strings that supports pattern matching with wildcards (`*`). +Message headers with keys matching any pattern are included as CloudEvent extensions. +Patterns also support negation using a `!` prefix to explicitly exclude headers. +The first matching pattern wins (positive or negative). + +For example, with patterns `"trace*", "span-id", "user-id"`: +- Headers starting with `trace` (e.g., `trace-id`, `traceparent`) are included +- Headers with exact keys `span-id` and `user-id` are included +- All matching headers are added as extensions to the CloudEvent + +To exclude specific headers, use negated patterns: `"custom-*", "!custom-internal"` would include all headers starting with `custom-` except `custom-internal`. [[configuration-with-dsl]] + ==== Configuration With DSL Add the CloudEvent transformer to flows using the DSL: @@ -102,6 +109,7 @@ public IntegrationFlow cloudEventTransformFlow(ToCloudEventTransformer toCloudEv ---- [[cloudevent-transformer-process]] + ==== CloudEvent Transformer Process The transformer follows the process below: @@ -117,9 +125,9 @@ A basic transformation may have the following pattern: // Input message with headers Message inputMessage = MessageBuilder .withPayload("Hello CloudEvents".getBytes(StandardCharsets.UTF_8)) - .withHeader(MessageHeaders.CONTENT_TYPE, "application/octet-stream") + .withHeader(MessageHeaders.CONTENT_TYPE, "text/plain") .build(); -// Transformer with extension patterns + ToCloudEventTransformer transformer = new ToCloudEventTransformer(); // Transform to CloudEvent @@ -127,9 +135,13 @@ Object cloudEventMessage = transformer.transform(inputMessage); ---- [[eventformats]] + ==== EventFormats -The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload. +The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload if the EventFormat for the content type is available. +For example if the content type is `application/cloudevents+xml` and the `io.cloudevents:cloudevents-xml` dependency is specified, then the XMLFormat will be used to serialize the CloudEvent to the payload. +However, if the type is not supported by one of the cloudevents dependencies and the `failOnNoFormat` is set to `false`, (for example `text/plain`) then cloud event attributes and extensions are added to the message headers with the cloud event prefix (default is `ce-`) and the payload is left unchanged. +If `failOnNoFormat` is set to `true` then a `MessageTransformationException` is thrown if a `EventFormat` is not found. To utilize a specific `EventFormat`, add the associated dependency. For example, to add the XML `EventFormat`, add the following dependency: `io.cloudevents:cloudevents-xml`. See the https://github.com/cloudevents/sdk-java[CloudEvents SDK project's] reference docs for information on the ``EventFormat``s that are available. From 00b7cf6046adbec959a06041fcda9871316eced8 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 5 Jan 2026 17:48:19 -0500 Subject: [PATCH 11/11] Use injection and expressions to set EventFormat Key changes: - Add eventFormatContentTypeExpression as a method for a user to use the EventFormatProvider - Allow user to inject EventFormat into the transformer - Resolved contentType resolution allowing the eventFormat or the MessageBuilderMessageWriter to determine it. - Add constructors accepting `@Nullable` `EventFormat` parameter for dependency injection, enabling flexible format configuration - Refactor default constructor to delegate to parameterized constructor, avoiding code duplication - Add `eventFormatContentTypeExpression` property to support dynamic format resolution via `EventFormatProvider` - Reorder fields per Spring conventions (static fields first) - Update javadocs to use imperative style and improve clarity - Simplify `getCloudEventExtensions()` with inline declarations - Remove redundant Content-Type header assignment in binary mode - Update test configuration to inject `EventFormat` instances - Add test coverage for `EventFormatProvider` expression usage - Refine test assertions to verify CloudEvent serialization - Update reference documentation with latest changes - Rebased --- .../transformer/ToCloudEventTransformer.java | 100 +++++++---- .../ToCloudEventTransformerTests.java | 168 +++++++++++------- .../modules/ROOT/pages/cloudevents.adoc | 72 +++++--- 3 files changed, 208 insertions(+), 132 deletions(-) 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 index 183985d923..60e4ac07d4 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025-present the original author or authors. + * Copyright 2026-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. @@ -58,20 +58,20 @@ import org.springframework.util.StringUtils; /** - * Convert messages to CloudEvent format. - * Perform attribute and extension mapping based on {@link Expression}s. + * Transform messages to CloudEvent format with attribute and extension mapping. * * @author Glenn Renfro + * * @since 7.1 */ public class ToCloudEventTransformer extends AbstractTransformer { private static final String DEFAULT_PREFIX = "ce-"; - private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); - private final String[] extensionPatterns; + private final EventFormatProvider eventFormatProvider = EventFormatProvider.getInstance(); + private String cloudEventPrefix = DEFAULT_PREFIX; private boolean failOnNoFormat; @@ -88,23 +88,37 @@ public class ToCloudEventTransformer extends AbstractTransformer { private @Nullable Expression subjectExpression; + private @Nullable Expression eventFormatContentTypeExpression; + + private @Nullable EventFormat eventFormat; + @SuppressWarnings("NullAway.Init") private EvaluationContext evaluationContext; + /** + * Create a ToCloudEventTransformer. + * @param eventFormat the event format to use for serialization + * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions + * to the CloudEvent + */ + public ToCloudEventTransformer(@Nullable EventFormat eventFormat, String... extensionPatterns) { + this.eventFormat = eventFormat; + this.extensionPatterns = Arrays.copyOf(extensionPatterns, extensionPatterns.length); + } + /** * Create a ToCloudEventTransformer with no extensionPatterns. + * @param eventFormat the event format to use for serialization */ - public ToCloudEventTransformer() { - this(new String[0]); + public ToCloudEventTransformer(@Nullable EventFormat eventFormat) { + this(eventFormat, new String[0]); } /** - * Create a ToCloudEventTransformer. - * @param extensionPatterns patterns to evaluate whether message headers should be added as extensions - * to the CloudEvent + * Create a ToCloudEventTransformer with no extensionPatterns and no {@link EventFormat}. */ - public ToCloudEventTransformer(String... extensionPatterns) { - this.extensionPatterns = Arrays.copyOf(extensionPatterns, extensionPatterns.length); + public ToCloudEventTransformer() { + this(null); } @Override @@ -125,7 +139,7 @@ protected void onInit() { /** * Set the {@link Expression} to create CloudEvent ids. - * Defaults to extracting the id from the {@link MessageHeaders} of the message. + * Default extracts the id from the {@link MessageHeaders} of the message. * @param eventIdExpression the expression to create the id for each CloudEvent */ public void setEventIdExpression(Expression eventIdExpression) { @@ -134,7 +148,7 @@ public void setEventIdExpression(Expression eventIdExpression) { /** * Set the {@link Expression} to create CloudEvent source. - * Defaults to {@code "/spring/" + appName + "." + getBeanName())}. + * Default is {@code "/spring/" + appName + "." + getBeanName())}. * @param sourceExpression the expression to create the source for each CloudEvent */ public void setSourceExpression(Expression sourceExpression) { @@ -143,7 +157,7 @@ public void setSourceExpression(Expression sourceExpression) { /** * Set the {@link Expression} to extract the type for the CloudEvent. - * Defaults to "spring.message". + * Default is "spring.message". * @param typeExpression the expression to create the type for each CloudEvent */ public void setTypeExpression(Expression typeExpression) { @@ -166,6 +180,17 @@ public void setSubjectExpression(Expression subjectExpression) { this.subjectExpression = subjectExpression; } + /** + * Set the {@link Expression} to create for the content type to use + * when {@link EventFormatProvider} is to be used to determine + * {@link EventFormat}. + * @param eventFormatContentTypeExpression the expression to create + * content type for the {@link EventFormatProvider} + */ + public void setEventFormatContentTypeExpression(Expression eventFormatContentTypeExpression) { + this.eventFormatContentTypeExpression = eventFormatContentTypeExpression; + } + /** * Set to {@code true} to fail if no {@link EventFormat} is found for message content type. * When {@code false} and no {@link EventFormat} is found, then a {@link CloudEvent}' body is @@ -177,11 +202,10 @@ public void setFailOnNoFormat(boolean failOnNoFormat) { } /** - * Return whether the transformer will transform the message - * when no {@link EventFormat} is available for the content type. - * @return {@code true} if transformation should fail - * when no suitable {@link EventFormat} is found; - * {@code false} otherwise + * Return whether the transformer will fail when no {@link EventFormat} + * is available for the content type. + * @return {@code true} if transformation should fail when no suitable + * {@link EventFormat} is found; {@code false} otherwise */ public boolean isFailOnNoFormat() { return this.failOnNoFormat; @@ -252,21 +276,22 @@ protected Object doTransform(Message message) { .withExtension(extensions) .build(); - EventFormat eventFormat = this.eventFormatProvider.resolveFormat(contentType); + if (this.eventFormatContentTypeExpression != null) { + this.eventFormat = this.eventFormatProvider.resolveFormat( + this.eventFormatContentTypeExpression.getValue(this.evaluationContext, message, String.class)); + } - if (eventFormat == null && this.failOnNoFormat) { + if (this.eventFormat == null && this.failOnNoFormat) { throw new MessageTransformationException("No EventFormat found for '" + contentType + "'"); } - if (eventFormat != null) { - return MessageBuilder.withPayload(eventFormat.serialize(cloudEvent)) + if (this.eventFormat != null) { + return MessageBuilder.withPayload(this.eventFormat.serialize(cloudEvent)) .copyHeaders(headers) - .setHeader(MessageHeaders.CONTENT_TYPE, eventFormat.serializedContentType()) + .setHeader(MessageHeaders.CONTENT_TYPE, this.eventFormat.serializedContentType()) .build(); } - HashMap messageMap = new HashMap<>(headers); - messageMap.put(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); return CloudEventUtils.toReader(cloudEvent) .read(new MessageBuilderMessageWriter(this.cloudEventPrefix, messageMap)); } @@ -278,19 +303,15 @@ public String getComponentType() { /** * Extract CloudEvent extensions from message headers based on pattern matching. - * Iterate through all message headers and apply the configured extension patterns - * to determine which headers should be included as CloudEvent extensions. * @param headers the message headers to extract extensions from * @return a map of header key-value pairs that match the extension patterns; - * returns an empty map if no headers match the patterns + * an empty map if no headers match the patterns */ private Map getCloudEventExtensions(MessageHeaders headers) { Map cloudEventExtensions = new HashMap<>(); - Boolean patternResult; - String headerKey; for (Map.Entry header : headers.entrySet()) { - headerKey = header.getKey(); - patternResult = PatternMatchUtils.smartMatch(headerKey, this.extensionPatterns); + String headerKey = header.getKey(); + Boolean patternResult = PatternMatchUtils.smartMatch(headerKey, this.extensionPatterns); if (Boolean.TRUE.equals(patternResult)) { cloudEventExtensions.put(headerKey, header.getValue()); } @@ -299,7 +320,8 @@ private Map getCloudEventExtensions(MessageHeaders headers) { } /** - * A custom CloudEvent extension implementation that wraps a map of headers as CloudEvent extension attributes. + * Custom CloudEvent extension implementation that wraps a map of headers + * as CloudEvent extension attributes. */ private static class ToCloudEventTransformerExtension implements CloudEventExtension { @@ -327,7 +349,7 @@ public Set getKeys() { } /** - * A CloudEvent writer implementation that converts CloudEvent objects into + * CloudEvent writer implementation that converts CloudEvent objects into * Spring Integration {@link Message} instances with CloudEvent attributes as headers. */ private static class MessageBuilderMessageWriter implements CloudEventWriter>, @@ -344,8 +366,8 @@ private static class MessageBuilderMessageWriter implements CloudEventWriter end(CloudEventData value) throws CloudEventRWException { /** * Complete the message creation without CloudEvent data. * Create a message with an empty payload when the CloudEvent contains no data. - * CloudEvent attributes are set as headers via {@link #withContextAttribute(String, String)}. + * Set CloudEvent attributes as headers via {@link #withContextAttribute(String, String)}. * @return the Spring Integration message with an empty payload and CloudEvent attributes as headers */ @Override 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 index 891904ecd7..53adc04614 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2025-present the original author or authors. + * Copyright 2026-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. @@ -16,11 +16,12 @@ package org.springframework.integration.cloudevents.transformer; -import java.io.ByteArrayOutputStream; -import java.io.ObjectOutputStream; +import java.io.IOException; import java.io.Serializable; import java.nio.charset.StandardCharsets; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; import io.cloudevents.CloudEvent; import io.cloudevents.jackson.JsonCloudEventData; import io.cloudevents.jackson.JsonFormat; @@ -30,8 +31,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.serializer.DefaultSerializer; import org.springframework.expression.ExpressionParser; +import org.springframework.expression.common.LiteralExpression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.integration.config.EnableIntegration; import org.springframework.integration.transformer.MessageTransformationException; @@ -56,11 +57,11 @@ @SpringJUnitConfig class ToCloudEventTransformerTests { - private static final String TRACE_HEADER = "traceId"; + private static final String TRACE_HEADER = "traceid"; - private static final String SPAN_HEADER = "spanId"; + private static final String SPAN_HEADER = "spanid"; - private static final String USER_HEADER = "userId"; + private static final String USER_HEADER = "userid"; private static final byte[] TEXT_PLAIN_PAYLOAD = "\"test message\"".getBytes(StandardCharsets.UTF_8); @@ -70,10 +71,13 @@ class ToCloudEventTransformerTests { private static final byte[] JSON_PAYLOAD = ("{\"message\":\"Hello, World!\"}").getBytes(StandardCharsets.UTF_8); @Autowired - private ToCloudEventTransformer transformerWithNoExtensions; + private ToCloudEventTransformer xmlTransformerWithNoExtensions; @Autowired - private ToCloudEventTransformer transformerWithExtensions; + private ToCloudEventTransformer jsonTransformerWithNoExtensions; + + @Autowired + private ToCloudEventTransformer jsonTransformerWithExtensions; @Autowired private ToCloudEventTransformer transformerWithNoExtensionsNoFormat; @@ -88,49 +92,67 @@ class ToCloudEventTransformerTests { private ToCloudEventTransformer transformerWithExtensionsNoFormatWithPrefix; @Autowired - private ToCloudEventTransformer transformerWithInvalidIDExpression; + private ToCloudEventTransformer xmlTransformerWithInvalidIDExpression; + + @Autowired + private ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabledWithProviderExpression; private final JsonFormat jsonFormat = new JsonFormat(); private final XMLFormat xmlFormat = new XMLFormat(); + @Test + void transformWithPayloadBasedOnJsonFormatContentTypeWithProviderExpression() { + Message originalMessage = createBaseMessage(JSON_PAYLOAD, "text/plain") + .setHeader("customheader", "test-value") + .setHeader("otherheader", "other-value") + .build(); + ToCloudEventTransformer transformer = this.transformerWithNoExtensionsNoFormatEnabledWithProviderExpression; + + Message message = (Message) transformer.doTransform(originalMessage); + + CloudEvent cloudEvent = this.jsonFormat.deserialize(message.getPayload()); + verifyCloudEvent(cloudEvent, "transformerWithNoExtensionsNoFormatEnabledWithProviderExpression", + "text/plain"); + assertThat(cloudEvent.getData().toBytes()).isEqualTo(JSON_PAYLOAD); + } + @Test void transformWithPayloadBasedOnJsonFormatContentType() { Message message = - getTransformerNoExtensions(JSON_PAYLOAD, this.transformerWithNoExtensions, JsonFormat.CONTENT_TYPE); + getTransformerNoExtensions(JSON_PAYLOAD, this.jsonTransformerWithNoExtensions, JsonFormat.CONTENT_TYPE); CloudEvent cloudEvent = this.jsonFormat.deserialize(message.getPayload()); + verifyCloudEvent(cloudEvent, "jsonTransformerWithNoExtensions", JsonFormat.CONTENT_TYPE); assertThat(((JsonCloudEventData) cloudEvent.getData()).getNode().toString().getBytes(StandardCharsets.UTF_8)). isEqualTo(JSON_PAYLOAD); - assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.transformerWithNoExtensions"); - assertThat(cloudEvent.getDataSchema()).isNull(); - assertThat(cloudEvent.getDataContentType()).isEqualTo(JsonFormat.CONTENT_TYPE); } @Test void transformWithPayloadBasedOnApplicationJsonType() { Message message = - getTransformerNoExtensions(JSON_PAYLOAD, this.transformerWithNoExtensions, "application/json"); - assertThat(message.getPayload()).isEqualTo(JSON_PAYLOAD); - verifyApplicationTypes(message); + getTransformerNoExtensions(JSON_PAYLOAD, this.jsonTransformerWithNoExtensions, + "application/json"); + CloudEvent cloudEvent = this.jsonFormat.deserialize(message.getPayload()); + verifyCloudEvent(cloudEvent, "jsonTransformerWithNoExtensions", "application/json"); + assertThat(new String(cloudEvent.getData().toBytes())).contains("{\"message\":\"Hello, World!\"}"); } @Test - void transformWithPayloadBasedOnApplicationXMLType() { + void transformWithPayloadBasedOnApplicationXMLType() throws IOException { Message message = getTransformerNoExtensions(XML_PAYLOAD, - this.transformerWithNoExtensions, "application/xml"); - assertThat(message.getPayload()).isEqualTo(XML_PAYLOAD); - verifyApplicationTypes(message); + this.xmlTransformerWithNoExtensions, "application/xml"); + CloudEvent cloudEvent = xmlFormat.deserialize(message.getPayload()); + verifyCloudEvent(cloudEvent, "xmlTransformerWithNoExtensions", "application/xml"); + assertThat(new String(cloudEvent.getData().toBytes())).contains("testmessage"); } @Test void transformWithPayloadBasedOnContentXMLFormatType() { Message message = getTransformerNoExtensions(XML_PAYLOAD, - this.transformerWithNoExtensions, XMLFormat.XML_CONTENT_TYPE); + this.xmlTransformerWithNoExtensions, XMLFormat.XML_CONTENT_TYPE); CloudEvent cloudEvent = this.xmlFormat.deserialize(message.getPayload()); + verifyCloudEvent(cloudEvent, "xmlTransformerWithNoExtensions", XMLFormat.XML_CONTENT_TYPE); assertThat(cloudEvent.getData().toBytes()).isEqualTo(XML_PAYLOAD); - assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null.transformerWithNoExtensions"); - assertThat(cloudEvent.getDataSchema()).isNull(); - assertThat(cloudEvent.getDataContentType()).isEqualTo(XMLFormat.XML_CONTENT_TYPE); } @Test @@ -141,11 +163,10 @@ void convertMessageNoExtensions() { .setHeader(SPAN_HEADER, "other-value") .build(); Message result = (Message) this.transformerWithNoExtensionsNoFormat.doTransform(message); - assertThat(result.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); assertThat(result.getHeaders()).containsKeys(TRACE_HEADER, SPAN_HEADER); assertThat(result.getHeaders()).doesNotContainKeys("ce-" + TRACE_HEADER, "ce-" + SPAN_HEADER); - assertThat(result.getHeaders()).containsEntry(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); - + assertThat(result.getHeaders()).containsEntry(MessageHeaders.CONTENT_TYPE, "text/plain"); + assertThat(result.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); } @Test @@ -183,21 +204,19 @@ void doTransformWithObjectPayload() throws Exception { Message message = MessageBuilder.withPayload(payload).setHeader("test_id", "test-id") .setHeader(MessageHeaders.CONTENT_TYPE, JsonFormat.CONTENT_TYPE) .build(); - Object result = this.transformerWithNoExtensions.doTransform(message); - - assertThat(result).isInstanceOf(Message.class); - Message resultMessage = (Message) result; - assertThat(new String(resultMessage.getPayload())).endsWith(new String(payload) + "}"); + Message resultMessage = (Message) this.jsonTransformerWithNoExtensions.doTransform(message); + CloudEvent cloudEvent = this.jsonFormat.deserialize(resultMessage.getPayload()); + verifyCloudEvent(cloudEvent, "jsonTransformerWithNoExtensions", JsonFormat.CONTENT_TYPE); + assertThat(new String(resultMessage.getPayload())).contains(new String(payload)); } @Test void noContentType() { Message message = MessageBuilder.withPayload(TEXT_PLAIN_PAYLOAD).build(); - Message result = this.transformerWithNoExtensions.transform(message); + Message result = this.transformerWithNoExtensionsNoFormat.transform(message); assertThat(result.getHeaders()).containsEntry("ce-datacontenttype", "application/octet-stream"); assertThat(message.getPayload()).isEqualTo(TEXT_PLAIN_PAYLOAD); - } @Test @@ -216,12 +235,13 @@ void multipleExtensionMappingsWithJsonFormatType() { .setHeader(TRACE_HEADER, "trace-123") .build(); - Object result = this.transformerWithExtensions.doTransform(message); + Message resultMessage = (Message) this.jsonTransformerWithExtensions.doTransform(message); - Message resultMessage = (Message) result; + CloudEvent cloudEvent = this.jsonFormat.deserialize(resultMessage.getPayload()); assertThat(resultMessage.getHeaders()).containsEntry("correlation-id", "corr-999"); - assertThat(new String(resultMessage.getPayload())).contains("\"traceId\":\"trace-123\""); + verifyCloudEvent(cloudEvent, "jsonTransformerWithExtensions", JsonFormat.CONTENT_TYPE); + assertThat(new String(resultMessage.getPayload())).contains("\"traceid\":\"trace-123\""); } @Test @@ -232,35 +252,38 @@ void multipleExtensionMappingsWithApplicationJsonType() { .setHeader(TRACE_HEADER, "trace-123") .build(); - Object result = this.transformerWithExtensions.doTransform(message); + Object result = this.jsonTransformerWithExtensions.doTransform(message); Message resultMessage = (Message) result; - - assertThat(resultMessage.getHeaders()).containsEntry("correlation-id", "corr-999"). - containsEntry("ce-traceId", "trace-123"); + CloudEvent cloudEvent = this.jsonFormat.deserialize(resultMessage.getPayload()); + assertThat(resultMessage.getHeaders()).containsEntry("correlation-id", "corr-999"); + assertThat(cloudEvent.getExtensionNames()).containsExactly("traceid"). + doesNotContain("correlation-id"); + verifyCloudEvent(cloudEvent, "jsonTransformerWithExtensions", "application/json"); } @Test void emptyStringPayloadHandling() { Message message = createBaseMessage("".getBytes(), "text/plain").build(); - Object result = this.transformerWithNoExtensions.doTransform(message); - - assertThat(result).isInstanceOf(Message.class); + Message resultMessage = (Message) this.jsonTransformerWithNoExtensions.doTransform(message); + CloudEvent cloudEvent = this.jsonFormat.deserialize(resultMessage.getPayload()); + assertThat(cloudEvent.getData().toBytes()).isEmpty(); + verifyCloudEvent(cloudEvent, "jsonTransformerWithNoExtensions", "text/plain"); } @Test void failWhenNoIdHeaderAndNoDefault() { Message message = createBaseMessage(TEXT_PLAIN_PAYLOAD, JsonFormat.CONTENT_TYPE).build(); - assertThatThrownBy(() -> this.transformerWithInvalidIDExpression.transform(message)) + assertThatThrownBy(() -> this.xmlTransformerWithInvalidIDExpression.transform(message)) .isInstanceOf(MessageTransformationException.class) .hasMessageContaining("failed to transform message"); } - private static void verifyApplicationTypes(Message message) { - assertThat(message.getHeaders()) - .containsEntry("ce-source", "/spring/null.transformerWithNoExtensions") - .containsEntry("ce-type", "spring.message") - .containsEntry(MessageHeaders.CONTENT_TYPE, "application/cloudevents"); + private static void verifyCloudEvent(CloudEvent cloudEvent, String beanName, String type) { + assertThat(cloudEvent.getDataContentType()).isEqualTo(type); + assertThat(cloudEvent.getSource().toString()).isEqualTo("/spring/null." + beanName); + assertThat(cloudEvent.getType()).isEqualTo("spring.message"); + assertThat(cloudEvent.getDataSchema()).isNull(); } private static Message getTransformerNoExtensions(byte[] payload, @@ -269,16 +292,13 @@ private static Message getTransformerNoExtensions(byte[] payload, .setHeader("customheader", "test-value") .setHeader("otherheader", "other-value") .build(); - Message result = (Message) transformer.doTransform(message); - return result; + return (Message) transformer.doTransform(message); } private static byte[] convertPayloadToBytes(TestRecord testRecord) throws Exception { - ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); - ObjectOutputStream out = new ObjectOutputStream(byteArrayOutputStream); - DefaultSerializer defaultSerializer = new DefaultSerializer(); - defaultSerializer.serialize(testRecord, out); - return byteArrayOutputStream.toByteArray(); + ObjectMapper objectMapper = new ObjectMapper(); + ObjectWriter writer = objectMapper.writer(); + return writer.writeValueAsBytes(testRecord); } private static MessageBuilder createBaseMessage(byte[] payload, String contentType) { @@ -295,37 +315,40 @@ public static class ContextConfiguration { private static final ExpressionParser parser = new SpelExpressionParser(); @Bean - public ToCloudEventTransformer transformerWithNoExtensions() { - return new ToCloudEventTransformer(); + public ToCloudEventTransformer xmlTransformerWithNoExtensions() { + return new ToCloudEventTransformer(new XMLFormat()); + } + + @Bean + public ToCloudEventTransformer jsonTransformerWithNoExtensions() { + return new ToCloudEventTransformer(new JsonFormat()); } @Bean - public ToCloudEventTransformer transformerWithExtensions() { - return new ToCloudEventTransformer(TEST_PATTERNS); + public ToCloudEventTransformer jsonTransformerWithExtensions() { + return new ToCloudEventTransformer(new JsonFormat(), TEST_PATTERNS); } @Bean public ToCloudEventTransformer transformerWithNoExtensionsNoFormat() { - ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); - return toCloudEventsTransformer; + return new ToCloudEventTransformer(); } @Bean public ToCloudEventTransformer transformerWithExtensionsNoFormat() { - ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); - return toCloudEventsTransformer; + return new ToCloudEventTransformer(null, TEST_PATTERNS); } @Bean public ToCloudEventTransformer transformerWithExtensionsNoFormatWithPrefix() { - ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(TEST_PATTERNS); + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(null, TEST_PATTERNS); toCloudEventsTransformer.setCloudEventPrefix("CLOUDEVENTS-"); return toCloudEventsTransformer; } @Bean - public ToCloudEventTransformer transformerWithInvalidIDExpression() { - ToCloudEventTransformer transformer = new ToCloudEventTransformer(); + public ToCloudEventTransformer xmlTransformerWithInvalidIDExpression() { + ToCloudEventTransformer transformer = new ToCloudEventTransformer(new XMLFormat()); transformer.setEventIdExpression(parser.parseExpression("null")); return transformer; } @@ -337,6 +360,13 @@ public ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabled() { return toCloudEventsTransformer; } + @Bean + public ToCloudEventTransformer transformerWithNoExtensionsNoFormatEnabledWithProviderExpression() { + ToCloudEventTransformer toCloudEventsTransformer = new ToCloudEventTransformer(); + toCloudEventsTransformer.setEventFormatContentTypeExpression(new LiteralExpression(JsonFormat.CONTENT_TYPE)); + toCloudEventsTransformer.setFailOnNoFormat(true); + return toCloudEventsTransformer; + } } private record TestRecord(String sampleValue) implements Serializable { } diff --git a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc index 73fb481f21..4fe041f62b 100644 --- a/src/reference/antora/modules/ROOT/pages/cloudevents.adoc +++ b/src/reference/antora/modules/ROOT/pages/cloudevents.adoc @@ -1,8 +1,8 @@ [[cloudevents]] = CloudEvents Support -Spring Integration provides transformers (starting with version 7.1) for transforming messages into CloudEvent messages. -It is fully based on the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project. +Use Spring Integration transformers (starting with version 7.1) to transform messages into CloudEvent messages. +The implementation is fully based on the https://github.com/cloudevents/sdk-java[CloudEvents SDK] project. Add the following dependency to your project: @@ -31,8 +31,9 @@ compile "org.springframework.integration:spring-integration-cloudevents:{project === `ToCloudEventTransformer` -The `ToCloudEventTransformer` converts Spring Integration messages into CloudEvents compliant messages. -This transformer provides support for the CloudEvents specification v1.0, automatically serializing CloudEvents based on the message's `contentType` header, defining attributes using Expressions, and identifying extensions in the message headers via patterns. +Use the `ToCloudEventTransformer` to convert Spring Integration messages into CloudEvents compliant messages. +This transformer supports the CloudEvents specification v1.0, automatically serializes CloudEvents if an EventFormat or eventFormatContentTypeExpression is specified. +The transformer supports defining attributes using Expressions, and identifies extensions in the message headers via patterns. NOTE: Ensure messages to be transformed have a payload of type `byte[]`. The transformer throws an `IllegalArgumentException` if the payload is not a byte array. @@ -41,9 +42,9 @@ The transformer throws an `IllegalArgumentException` if the payload is not a byt Set the CloudEvents' attributes of `id`, `source`, `type`, `dataSchema`, and `subject` through SpEL ``Expression``s. -NOTE: The `time` attribute is set to the time that the CloudEvent message was created by the `ToCloudEventTransformer` transformer. +NOTE: The transformer sets the `time` attribute to the time when it creates the CloudEvent message. -The following table contains the attribute names and the values returned by the default ``Expression``s. +The following table lists the attribute names and the values the default ``Expression``s return. |=== | Attribute Name | Default Value @@ -73,17 +74,35 @@ Some other examples are but not limited to: `application/json`, `application/x-a ==== Extension Patterns -The extensionPatterns constructor parameter is a vararg of strings that supports pattern matching with wildcards (`*`). -Message headers with keys matching any pattern are included as CloudEvent extensions. -Patterns also support negation using a `!` prefix to explicitly exclude headers. -The first matching pattern wins (positive or negative). +Use the extensionPatterns constructor parameter (a vararg of strings) to specify pattern matching with wildcards (`*`). +The transformer includes message headers with keys matching any pattern as CloudEvent extensions. +Use a `!` prefix to explicitly exclude headers through negation. +Note that the first matching pattern wins (positive or negative). -For example, with patterns `"trace*", "span-id", "user-id"`: -- Headers starting with `trace` (e.g., `trace-id`, `traceparent`) are included -- Headers with exact keys `span-id` and `user-id` are included -- All matching headers are added as extensions to the CloudEvent +For example, configure patterns `"trace*", "span-id", "user-id"` to: +- Include headers starting with `trace` (e.g., `trace-id`, `traceparent`) +- Include headers with exact keys `span-id` and `user-id` +- Add all matching headers as extensions to the CloudEvent -To exclude specific headers, use negated patterns: `"custom-*", "!custom-internal"` would include all headers starting with `custom-` except `custom-internal`. +To exclude specific headers, use negated patterns: `"custom-*", "!custom-internal"` includes all headers starting with `custom-` except `custom-internal`. + +[[constructors]] + +==== Constructors + +Choose from three constructors for flexible configuration: + +[source,java] +---- +// Default constructor - no EventFormat, no extension patterns +new ToCloudEventTransformer() + +// Constructor with EventFormat injection +new ToCloudEventTransformer(eventFormat) + +// Constructor with EventFormat and extension patterns +new ToCloudEventTransformer(eventFormat, "trace*", "span-id", "user-id") +---- [[configuration-with-dsl]] @@ -95,7 +114,7 @@ Add the CloudEvent transformer to flows using the DSL: ---- @Bean public ToCloudEventTransformer cloudEventTransformer() { - return new ToCloudEventTransformer(); + return new ToCloudEventTransformer(new JsonFormat(), "trace*", "correlation-id"); } @Bean @@ -112,11 +131,11 @@ public IntegrationFlow cloudEventTransformFlow(ToCloudEventTransformer toCloudEv ==== CloudEvent Transformer Process -The transformer follows the process below: +Understand the transformation process: -1. **CloudEvent Building**: Builds CloudEvent attributes. -2. **Extension Extraction**: Builds the CloudEvent extensions using the array of extensionPatterns passed into the constructor. -3. **Format Conversion**: Applies the specified `EventFormat` based on the message's `contentType` to create the CloudEvent. +1. **CloudEvent Building**: Build CloudEvent attributes. +2. **Extension Extraction**: Build the CloudEvent extensions using the array of extensionPatterns passed into the constructor. +3. **Format Conversion**: Apply the specified `EventFormat` or, if not set, handle conversion via Binary Format Mode. A basic transformation may have the following pattern: @@ -137,11 +156,16 @@ Object cloudEventMessage = transformer.transform(inputMessage); [[eventformats]] ==== EventFormats +Use ``EventFormat``s to serialize the CloudEvent into the message's payload when the EventFormat is available, or use Binary Format Mode when an `EventFormat` is not available. +Set the EventFormat in one of two ways: + +1. Set the desired `EventFormat` via the constructor +2. Set the `eventFormatContentTypeExpression` with an expression that resolves to a content type that `eventFormatProvider` can use to provide the required `EventFormat`. + +However, when you do not specify the `EventFormat` via the constructor and the type is not supported by one of the cloudevents dependencies and `failOnNoFormat` is set to `false` (for example `text/plain`), the transformer adds cloud event attributes and extensions to the message headers with the cloud event prefix (default is `ce-`) and leaves the payload unchanged (Binary Format Mode). +When `failOnNoFormat` is set to `true`, the transformer throws a `MessageTransformationException` if it cannot find an `EventFormat`. -The `ToCloudEventTransformer` uses ``EventFormat``s to serialize the CloudEvent into the message's payload if the EventFormat for the content type is available. -For example if the content type is `application/cloudevents+xml` and the `io.cloudevents:cloudevents-xml` dependency is specified, then the XMLFormat will be used to serialize the CloudEvent to the payload. -However, if the type is not supported by one of the cloudevents dependencies and the `failOnNoFormat` is set to `false`, (for example `text/plain`) then cloud event attributes and extensions are added to the message headers with the cloud event prefix (default is `ce-`) and the payload is left unchanged. -If `failOnNoFormat` is set to `true` then a `MessageTransformationException` is thrown if a `EventFormat` is not found. To utilize a specific `EventFormat`, add the associated dependency. For example, to add the XML `EventFormat`, add the following dependency: `io.cloudevents:cloudevents-xml`. See the https://github.com/cloudevents/sdk-java[CloudEvents SDK project's] reference docs for information on the ``EventFormat``s that are available. +