From c489503d41c965a2250bfee62ff3103406907d8b Mon Sep 17 00:00:00 2001 From: radhakrishnan Date: Thu, 3 Sep 2026 01:14:55 +0530 Subject: [PATCH] Handle Structured CloudEvent binary payload convertion Signed-off-by: radhakrishnan --- .../cloudevent/CloudEventMessageUtils.java | 21 +++++- .../cloudevent/CloudEventFunctionTests.java | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/cloudevent/CloudEventMessageUtils.java b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/cloudevent/CloudEventMessageUtils.java index 867b135f9..926c3897d 100644 --- a/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/cloudevent/CloudEventMessageUtils.java +++ b/spring-cloud-function-context/src/main/java/org/springframework/cloud/function/cloudevent/CloudEventMessageUtils.java @@ -287,7 +287,7 @@ static Message toCanonical(Message inputMessage, MessageConverter messageC .fromMessage(cloudEventMessage, Map.class); canonicalizeHeaders(structuredCloudEvent, true); return buildBinaryMessageFromStructuredMap(structuredCloudEvent, - inputMessage.getHeaders()); + inputMessage.getHeaders(), dataContentType, messageConverter); } } else if (StringUtils.hasText(inputContentType)) { @@ -461,11 +461,22 @@ private static boolean isAttribute(String key) { } private static Message buildBinaryMessageFromStructuredMap(Map structuredCloudEvent, - MessageHeaders originalHeaders) { + MessageHeaders originalHeaders, String dataContentType, MessageConverter messageConverter) { Object payload = structuredCloudEvent.remove(DATA); if (payload == null) { payload = Collections.emptyMap(); } + // The structured envelope was fully deserialized, so 'data' is now a Java object (e.g. a Map). + // A binary-mode CloudEvent carries its data as serialized bytes, and downstream binary readers + // (e.g. the CNCF CloudEventMessageConverter) only read byte[]/String payloads. Re-serialize the + // data using the data content-type so the binary-mode message is well-formed. + if (!(payload instanceof byte[]) && !(payload instanceof String) && StringUtils.hasText(dataContentType)) { + Message serializedData = messageConverter.toMessage(payload, + new MessageHeaders(Collections.singletonMap(MessageHeaders.CONTENT_TYPE, dataContentType))); + if (serializedData != null) { + payload = serializedData.getPayload(); + } + } CloudEventMessageBuilder messageBuilder = CloudEventMessageBuilder .withData(payload) @@ -477,6 +488,12 @@ private static Message buildBinaryMessageFromStructuredMap(Map function = this.lookup("echoCloudEvent", TestConfiguration.class); + + Message inputMessage = MessageBuilder + .withPayload(payload) + .setHeader(MessageHeaders.CONTENT_TYPE, CloudEventMessageUtils.APPLICATION_CLOUDEVENTS_VALUE + "+json") + .build(); + + // structured-mode: attributes live in the body, not the headers + assertThat(CloudEventMessageUtils.isCloudEvent(inputMessage)).isFalse(); + + Message resultMessage = (Message) function.apply(inputMessage); + + /* + * When the target type is the CNCF io.cloudevents.CloudEvent, toCanonical converts the + * structured envelope to binary-mode (data as payload, attributes as ce-* headers) and must + * also reset content-type to the datacontenttype. Otherwise the CNCF CloudEventMessageConverter + * re-dispatches on the stale application/cloudevents+json content-type, picks the structured + * reader and fails to read the (now Map) payload as the serialized envelope. + */ + assertThat(resultMessage).isNotNull(); + CloudEvent cloudEvent = resultMessage.getPayload(); + assertThat(cloudEvent).isNotNull(); + assertThat(cloudEvent.getSpecVersion().toString()).isEqualTo("1.0"); + assertThat(cloudEvent.getType()).isEqualTo("org.springframework"); + assertThat(cloudEvent.getSource()).isEqualTo(URI.create("https://spring.io/")); + assertThat(cloudEvent.getId()).isEqualTo("A234-1234-1234"); + + // the CloudEvent actually delivered to the user function must carry the data + CloudEvent received = TestConfiguration.RECEIVED_CLOUD_EVENT.get(); + assertThat(received).isNotNull(); + assertThat(received.getType()).isEqualTo("org.springframework"); + assertThat(received.getData()).isNotNull(); + assertThat(new String(received.getData().toBytes())).contains("Spring Framework"); + } + private Function lookup(String functionDefinition, Class... configClass) { ApplicationContext context = new SpringApplicationBuilder(configClass).run( "--logging.level.org.springframework.cloud.function=DEBUG", "--spring.main.lazy-initialization=true"); @@ -342,11 +395,23 @@ private Function lookup(String functionDefinition, Class... c @EnableAutoConfiguration @Configuration public static class TestConfiguration { + + static final AtomicReference RECEIVED_CLOUD_EVENT = + new AtomicReference<>(); + @Bean Function, Message> echo() { return Function.identity(); } + @Bean + Function, Message> echoCloudEvent() { + return message -> { + RECEIVED_CLOUD_EVENT.set(message.getPayload()); + return message; + }; + } + @Bean Function springRelease() { return event -> {