From 5979d82d5d42f5cb90e8183378def59b41bf0114 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Thu, 25 Sep 2025 13:58:33 -0400 Subject: [PATCH 1/4] 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 e41e6c06b7..72f7c24047 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,7 @@ ext { avroVersion = '1.12.0' awaitilityVersion = '4.3.0' camelVersion = '4.14.1' + cloudEventsVersion = '4.0.1' commonsDbcp2Version = '2.13.0' commonsIoVersion = '2.20.0' commonsNetVersion = '3.12.0' @@ -474,6 +475,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 59f4379d03..c779d128b3 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 f658e4dd0f..dd797a39b9 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -125,3 +125,9 @@ See xref:file/remote-persistent-flf.adoc[Remote Persistent File List Filters] fo === Null Safety Updated the codebase to use JSpecify and NullAway, adding a comprehensive null safety implementation that uses `@NullMarked` annotations to default all types to non-null at the package level and `@Nullable` annotations to explicitly mark types that can be null. See xref:null-safety.adoc[] for more information. + +[[x7.0-cloudevents]] +=== CloudEvents +The CloudEvent transformer converts Spring Integration messages into CloudEvent compliant messages. +This transformer provides support for the CloudEvents specification v1.0 with configurable output formats, header pattern matching, and extension management. +See xref:cloudevents/cloudevents-transform.adoc[] for more information. From f2a3fb683b467a1ec41e04e8f4ea1d6cd354e2d8 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 29 Sep 2025 11:55:42 -0600 Subject: [PATCH 2/4] 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 72f7c24047..c9a8756dcc 100644 --- a/build.gradle +++ b/build.gradle @@ -476,20 +476,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 c779d128b3..4005617fc1 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 dd797a39b9..373def9357 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -128,6 +128,7 @@ See xref:null-safety.adoc[] for more information. [[x7.0-cloudevents]] === CloudEvents + The CloudEvent transformer converts Spring Integration messages into CloudEvent compliant messages. This transformer provides support for the CloudEvents specification v1.0 with configurable output formats, header pattern matching, and extension management. -See xref:cloudevents/cloudevents-transform.adoc[] for more information. +See xref:cloudevents-transform.adoc[] for more information. From 69a988a1a781878be49944b32b37f05a22d71b8d Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Mon, 6 Oct 2025 18:14:35 -0400 Subject: [PATCH 3/4] 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 be0fa176b88ce8d60c6844dbf287d4b4f3c07416 Mon Sep 17 00:00:00 2001 From: Glenn Renfro Date: Wed, 8 Oct 2025 13:51:58 -0400 Subject: [PATCH 4/4] 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 c9a8756dcc..dd03bcbf8f 100644 --- a/build.gradle +++ b/build.gradle @@ -480,6 +480,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 373def9357..0c0b77a3db 100644 --- a/src/reference/antora/modules/ROOT/pages/whats-new.adoc +++ b/src/reference/antora/modules/ROOT/pages/whats-new.adoc @@ -130,5 +130,5 @@ See xref:null-safety.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.