Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/src/main/asciidoc/sqs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1782,9 +1782,13 @@ This allows payloads to be deserialized early in the message processing flow wit

This enables accessing the deserialized payload in components such as `MessageInterceptor`, `ErrorHandler`, and `AcknowledgementResultCallback` without type headers.

The inference supports simple types, generic types like `List<MyEvent>`, `Message<MyEvent>`, and `List<Message<MyEvent>>`.
The inference supports simple types, generic types like `List<MyEvent>`, `Message<MyEvent>`,
`List<Message<MyEvent>>`, `Wrapper<MyEvent>`, and `Message<List<MyEvent>>`.
Parameters annotated with `@Payload` are explicitly recognized as the payload parameter.

Generic type information is provided to payload converters through the `SmartMessageConverter` conversion hint.
Custom payload converters that only implement `MessageConverter` continue to receive the inferred raw payload class.

For polymorphic types (interfaces, `Object`, or `@SqsHandler` methods), a custom mapper is required.
See <<Custom payload type mapping>>.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,10 @@ public <T> void setupContainer(MessageListenerContainer<T> container) {

if (this.methodPayloadTypeInferrer != null
&& container instanceof AbstractMessageListenerContainer<?, ?, ?> amlc) {
Class<?> inferredType = this.methodPayloadTypeInferrer.inferPayloadType(this.method,
MethodPayloadMetadata payloadMetadata = this.methodPayloadTypeInferrer.inferPayloadMetadata(this.method,
this.argumentResolvers);
if (inferredType != null) {
amlc.setPayloadDeserializationType(inferredType);
if (payloadMetadata != null) {
amlc.setPayloadDeserializationType(payloadMetadata.payloadClass(), payloadMetadata.conversionHint());
}
disableDefaultPayloadTypeMapper(amlc);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ public class DefaultMethodPayloadTypeInferrer implements MethodPayloadTypeInferr

@Override
@Nullable
public Class<?> inferPayloadType(Method method, List<HandlerMethodArgumentResolver> argumentResolvers) {
public Class<?> inferPayloadType(Method method, @Nullable List<HandlerMethodArgumentResolver> argumentResolvers) {
MethodPayloadMetadata metadata = inferPayloadMetadata(method, argumentResolvers);
return metadata != null ? metadata.payloadClass() : null;
}

@Override
@Nullable
public MethodPayloadMetadata inferPayloadMetadata(Method method,
@Nullable List<HandlerMethodArgumentResolver> argumentResolvers) {
if (argumentResolvers == null || argumentResolvers.isEmpty()) {
return null;
}
Expand All @@ -61,28 +69,29 @@ public Class<?> inferPayloadType(Method method, List<HandlerMethodArgumentResolv
MethodParameter parameter = new MethodParameter(method, i);

if (parameter.hasParameterAnnotation(Payload.class)) {
return extractClass(parameter.getGenericParameterType());
return extractMetadata(parameter);
}

boolean supportedByNonPayloadResolver = nonPayloadResolvers.stream()
.anyMatch(resolver -> resolver.supportsParameter(parameter));

if (!supportedByNonPayloadResolver) {
return extractClass(parameter.getGenericParameterType());
return extractMetadata(parameter);
}
}

return null;
}

/**
* Extract the target class for payload conversion from the inferred type. Handles generic types like
* {@code List<CustomEvent>} by extracting the element type.
* @param type the inferred payload type
* @return the class to be used for payload conversion, or null if cannot be determined
* Extract the target class and conversion hint from the inferred payload parameter. Collection parameters represent
* batch listeners, so the conversion hint is nested to point at the payload of each individual message. Other
* parameters retain the method parameter so smart converters can recover their generic type.
* @param parameter the inferred payload method parameter
* @return the metadata to be used for payload conversion
*/
@Nullable
private Class<?> extractClass(Type type) {
private MethodPayloadMetadata extractMetadata(MethodParameter parameter) {
Type type = parameter.getGenericParameterType();
ResolvableType resolvableType = ResolvableType.forType(type);
Class<?> rawClass = resolvableType.toClass();

Expand All @@ -91,18 +100,17 @@ private Class<?> extractClass(Type type) {
Class<?> elementClass = resolvableType.getNested(2).toClass();
// If it's a Collection of Messages (e.g., List<Message<CustomEvent>>), go one level deeper
if (Message.class.isAssignableFrom(elementClass)) {
return resolvableType.getNested(3).toClass();
return new MethodPayloadMetadata(resolvableType.getNested(3).toClass(), parameter.nested().nested());
}
return elementClass;
return new MethodPayloadMetadata(elementClass, parameter.nested());
}

// If it's a Message<T>, unwrap to get T
if (Message.class.isAssignableFrom(rawClass)) {
return resolvableType.getNested(2).toClass();
return new MethodPayloadMetadata(resolvableType.getNested(2).toClass(), parameter);
}

// For simple types, return as-is
return rawClass;
return new MethodPayloadMetadata(rawClass, parameter);
}

private boolean isPayloadResolver(HandlerMethodArgumentResolver resolver) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright 2013-2026 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 io.awspring.cloud.sqs.config;

import org.jspecify.annotations.Nullable;

/**
* Metadata inferred from a listener method payload parameter.
* @param payloadClass the raw class used as the message conversion target
* @param conversionHint an optional hint passed to a
* {@link org.springframework.messaging.converter.SmartMessageConverter}
* @author Bruno Augusto Garcia
* @since 4.1.0
*/
public record MethodPayloadMetadata(Class<?> payloadClass, @Nullable Object conversionHint) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,29 @@ public interface MethodPayloadTypeInferrer {

/**
* Infer the payload class from the given method and its argument resolvers.
* @deprecated in favor of {@link #inferPayloadMetadata(Method, List)}
* @param method the listener method
* @param argumentResolvers the argument resolvers available for this method, may be null or empty
* @return the inferred payload class, or null if it cannot be determined
*/
@Nullable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's deprecate this method, routing implementations to the new method instead.

@Deprecated
Class<?> inferPayloadType(Method method, @Nullable List<HandlerMethodArgumentResolver> argumentResolvers);

/**
* Infer payload metadata from the given method and its argument resolvers.
* <p>
* The default implementation adapts existing {@link MethodPayloadTypeInferrer} implementations by returning the
* inferred class without a conversion hint.
* @param method the listener method
* @param argumentResolvers the argument resolvers available for this method, may be null or empty
* @return the inferred payload metadata, or null if it cannot be determined
*/
@Nullable
default MethodPayloadMetadata inferPayloadMetadata(Method method,
@Nullable List<HandlerMethodArgumentResolver> argumentResolvers) {
Class<?> payloadClass = inferPayloadType(method, argumentResolvers);
return payloadClass != null ? new MethodPayloadMetadata(payloadClass, null) : null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ public abstract class AbstractMessageListenerContainer<T, O extends ContainerOpt
@Nullable
private Class<?> payloadDeserializationType;

@Nullable
private Object payloadConversionHint;

/**
* Create an instance with the provided {@link ContainerOptions}
* @param containerOptions the options instance.
Expand Down Expand Up @@ -189,7 +192,21 @@ public void setPhase(int phase) {
* @see io.awspring.cloud.sqs.support.converter.AbstractMessagingMessageConverter
*/
public void setPayloadDeserializationType(@Nullable Class<?> payloadDeserializationType) {
setPayloadDeserializationType(payloadDeserializationType, null);
}

/**
* Set the target type and conversion hint for payload deserialization.
* <p>
* Since 4.0.0, the target type is typically inferred automatically from the {@code @SqsListener} method signature.
* A conversion hint can additionally preserve generic type information from that method.
* @param payloadDeserializationType the target type for deserialization
* @param conversionHint an optional hint for a smart message converter
*/
public void setPayloadDeserializationType(@Nullable Class<?> payloadDeserializationType,
@Nullable Object conversionHint) {
this.payloadDeserializationType = payloadDeserializationType;
this.payloadConversionHint = payloadDeserializationType != null ? conversionHint : null;
}

/**
Expand Down Expand Up @@ -256,6 +273,15 @@ public Class<?> getPayloadDeserializationType() {
return this.payloadDeserializationType;
}

/**
* Return the payload conversion hint, or null if not set.
* @return the payload conversion hint.
*/
@Nullable
public Object getPayloadConversionHint() {
return this.payloadConversionHint;
}

@Override
public String getId() {
return this.id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ protected void configureMessageSources(ContainerComponentFactory<T, O> component
teac -> teac.setTaskExecutor(taskExecutor))
.acceptManyIfNotNullAndInstance(getPayloadDeserializationType(), this.messageSources,
AbstractMessageConvertingMessageSource.class,
(type, source) -> source.setPayloadDeserializationType(type));
(type, source) -> source.setPayloadDeserializationType(type, getPayloadConversionHint()));

doConfigureMessageSources(this.messageSources);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,18 @@ protected void setupAcknowledgementForConversion(AcknowledgementCallback<T> call
* @param payloadDeserializationType the target class
*/
public void setPayloadDeserializationType(@Nullable Class<?> payloadDeserializationType) {
this.setPayloadDeserializationType(payloadDeserializationType, null);
}

/**
* Set the payload deserialization type and conversion hint.
* @param payloadDeserializationType the target class
* @param conversionHint an optional hint for a smart message converter
*/
public void setPayloadDeserializationType(@Nullable Class<?> payloadDeserializationType,
@Nullable Object conversionHint) {
ConfigUtils.INSTANCE.acceptBothIfNoneNull(payloadDeserializationType, this.messageConversionContext,
this::doConfigurePayloadTypeOnContext);
(payloadType, context) -> doConfigurePayloadTypeOnContext(payloadType, conversionHint, context));
}

/**
Expand All @@ -98,6 +108,21 @@ public void setPayloadDeserializationType(@Nullable Class<?> payloadDeserializat
protected void doConfigurePayloadTypeOnContext(Class<?> payloadType, MessageConversionContext context) {
}

/**
* Hook method for subclasses to configure the payload type and conversion hint on their specific
* {@link MessageConversionContext} implementation.
* <p>
* The default implementation delegates to {@link #doConfigurePayloadTypeOnContext(Class, MessageConversionContext)}
* for backwards compatibility with existing subclasses.
* @param payloadType the payload type to configure
* @param conversionHint an optional hint for a smart message converter
* @param context the message conversion context
*/
protected void doConfigurePayloadTypeOnContext(Class<?> payloadType, @Nullable Object conversionHint,
MessageConversionContext context) {
doConfigurePayloadTypeOnContext(payloadType, context);
}

@Nullable
private MessageConversionContext maybeCreateConversionContext() {
return this.messagingMessageConverter instanceof ContextAwareMessagingMessageConverter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
* </p>
*
* <p>
* Note that currently the payload is not converted here and is returned as String. The actual conversion to the
* {@link io.awspring.cloud.sqs.annotation.SqsListener} argument type happens on
* {@link org.springframework.messaging.handler.invocation.InvocableHandlerMethod} invocation.
* Payload conversion happens in the message source before the resulting message is emitted to the message sink and
* processing pipeline. When available, the inferred listener payload class and conversion hint are configured on the
* {@link SqsMessageConversionContext} and used by the messaging message converter during this step.
* </p>
*
* @param <T> the {@link Message} payload type.
Expand Down Expand Up @@ -141,6 +141,14 @@ protected void doConfigurePayloadTypeOnContext(Class<?> payloadType, MessageConv
ctx -> ctx.setPayloadClass(payloadType));
}

@Override
protected void doConfigurePayloadTypeOnContext(Class<?> payloadType, @Nullable Object conversionHint,
MessageConversionContext context) {
doConfigurePayloadTypeOnContext(payloadType, context);
ConfigUtils.INSTANCE.acceptIfInstance(context, SqsMessageConversionContext.class,
ctx -> ctx.setConversionHint(conversionHint));
}

// @formatter:off
private QueueAttributes resolveQueueAttributes() {
return QueueAttributesResolver.builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.converter.SmartMessageConverter;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.util.Assert;
Expand Down Expand Up @@ -171,19 +172,23 @@ private MessageHeaders getContextHeaders(S message, MessageConversionContext con
private Object convertPayload(S message, MessageHeaders messageHeaders,
@Nullable MessageConversionContext context) {
Message<?> messagingMessage = MessageBuilder.createMessage(getPayloadToDeserialize(message), messageHeaders);
Class<?> targetType = getTargetType(messagingMessage, context);
return targetType != null
? Objects.requireNonNull(this.payloadMessageConverter.fromMessage(messagingMessage, targetType),
"payloadMessageConverter returned null payload")
Class<?> mappedTargetType = this.payloadTypeMapper.apply(messagingMessage);
if (mappedTargetType != null) {
return convertPayload(messagingMessage, mappedTargetType, null);
}

Class<?> inferredTargetType = context != null ? context.getPayloadClass() : null;
return inferredTargetType != null
? convertPayload(messagingMessage, inferredTargetType, context.getConversionHint())
: messagingMessage.getPayload();
}

@Nullable
private Class<?> getTargetType(Message<?> messagingMessage, @Nullable MessageConversionContext context) {
Class<?> classFromTypeMapper = this.payloadTypeMapper.apply(messagingMessage);
return classFromTypeMapper == null && context != null && context.getPayloadClass() != null
? context.getPayloadClass()
: classFromTypeMapper;
private Object convertPayload(Message<?> message, Class<?> targetType, @Nullable Object conversionHint) {
Object convertedPayload = conversionHint != null
&& this.payloadMessageConverter instanceof SmartMessageConverter smartMessageConverter
? smartMessageConverter.fromMessage(message, targetType, conversionHint)
: this.payloadMessageConverter.fromMessage(message, targetType);
return Objects.requireNonNull(convertedPayload, "payloadMessageConverter returned null payload");
}

protected abstract Object getPayloadToDeserialize(S message);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,13 @@ public interface MessageConversionContext {
@Nullable
Class<?> getPayloadClass();

/**
* An optional hint to be used by the payload conversion process.
* @return the conversion hint.
*/
@Nullable
default Object getConversionHint() {
return null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public class SqsMessageConversionContext
@Nullable
private Class<?> payloadClass;

@Nullable
private Object conversionHint;

@Override
public void setQueueAttributes(QueueAttributes queueAttributes) {
this.queueAttributes = queueAttributes;
Expand All @@ -67,6 +70,10 @@ public void setPayloadClass(Class<?> payloadClass) {
this.payloadClass = payloadClass;
}

public void setConversionHint(@Nullable Object conversionHint) {
this.conversionHint = conversionHint;
}

@Nullable
public SqsAsyncClient getSqsAsyncClient() {
return this.sqsAsyncClient;
Expand All @@ -87,4 +94,10 @@ public AcknowledgementCallback<?> getAcknowledgementCallback() {
public Class<?> getPayloadClass() {
return this.payloadClass;
}

@Nullable
@Override
public Object getConversionHint() {
return this.conversionHint;
}
}
Loading
Loading