Repository: floci-io/floci (2026-03-24T21:05:44Z)

Issue:
[FIX] SNS Raw Message Delivery for SQS Subscriptions
## Service

SNS

## API Action / Feature

Enforce `RawMessageDelivery` subscription attribute when delivering messages to SQS subscribers.

## AWS Documentation

- https://docs.aws.amazon.com/sns/latest/dg/sns-large-payload-raw-message-delivery.html

## Why is this needed?

When `RawMessageDelivery` is set to `true` on an SQS subscription, SNS should deliver the raw message body directly to the queue — without wrapping it in the standard SNS JSON envelope. Many applications depend on this:

- Lambda functions processing SQS messages expect the raw payload, not an SNS wrapper
- Applications using SNS→SQS fanout with raw delivery parse the message body directly
- CDK patterns like `SnsEventSource` with `rawMessageDelivery: true` expect unwrapped messages

Currently, floci stores the `RawMessageDelivery` attribute but always wraps messages in the SNS envelope regardless, which means:

```json
// What the SQS consumer expects (raw):
{"orderId": "123", "status": "created"}

// What floci delivers (always wrapped):
{
  "Type": "Notification",
  "MessageId": "...",
  "TopicArn": "...",
  "Message": "{\"orderId\": \"123\", \"status\": \"created\"}",
  "Timestamp": "..."
}
```

This breaks JSON deserialization in consumers that expect the raw format.

## Expected behavior

```bash
# Setup topic and SQS queue
TOPIC_ARN=$(aws sns create-topic --name my-topic --endpoint-url http://localhost:4566 --query TopicArn --output text)
aws sqs create-queue --queue-name raw-queue --endpoint-url http://localhost:4566

# Subscribe with raw message delivery
SUB_ARN=$(aws sns subscribe --topic-arn $TOPIC_ARN --protocol sqs \
  --notification-endpoint arn:aws:sqs:us-east-1:000000000000:raw-queue \
  --endpoint-url http://localhost:4566 --query SubscriptionArn --output text)

aws sns set-subscription-attributes --subscription-arn $SUB_ARN \
  --attribute-name RawMessageDelivery --attribute-value true \
  --endpoint-url http://localhost:4566

# Publish a message
aws sns publish --topic-arn $TOPIC_ARN --message '{"orderId":"123"}' --endpoint-url http://localhost:4566

# Receive from SQS
aws sqs receive-message --queue-url http://localhost:4566/000000000000/raw-queue --endpoint-url http://localhost:4566

# Expected message body: {"orderId":"123"}
# Actual message body: {"Type":"Notification","MessageId":"...","Message":"{\"orderId\":\"123\"}","..."}
```

## Actual behavior

Messages are always delivered in the SNS JSON envelope regardless of the `RawMessageDelivery` setting.

## Are you willing to contribute a PR?

Yes



Discussion:

Patch:
diff --git a/src/main/java/io/github/hectorvent/floci/services/sns/SnsJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/sns/SnsJsonHandler.java
index 508f18b5..eb3b22e1 100644
--- a/src/main/java/io/github/hectorvent/floci/services/sns/SnsJsonHandler.java
+++ b/src/main/java/io/github/hectorvent/floci/services/sns/SnsJsonHandler.java
@@ -3,6 +3,7 @@
 import io.github.hectorvent.floci.core.common.AwsErrorResponse;
 import io.github.hectorvent.floci.services.sns.model.Subscription;
 import io.github.hectorvent.floci.services.sns.model.Topic;
+import io.github.hectorvent.floci.services.sqs.model.MessageAttributeValue;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -154,11 +155,13 @@ private Response handlePublish(JsonNode request, String region) {
         String message = request.path("Message").asText(null);
         String subject = request.path("Subject").asText(null);
 
-        Map<String, String> attributes = new HashMap<>();
+        Map<String, MessageAttributeValue> attributes = new HashMap<>();
         JsonNode attrsNode = request.path("MessageAttributes");
         if (attrsNode.isObject()) {
             attrsNode.fields().forEachRemaining(entry -> {
-                attributes.put(entry.getKey(), entry.getValue().path("StringValue").asText());
+                String dataType = entry.getValue().path("DataType").asText("String");
+                String stringValue = entry.getValue().path("StringValue").asText();
+                attributes.put(entry.getKey(), new MessageAttributeValue(stringValue, dataType));
             });
         }
 
diff --git a/src/main/java/io/github/hectorvent/floci/services/sns/SnsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/sns/SnsQueryHandler.java
index 39ffaef0..e8c7b3ba 100644
--- a/src/main/java/io/github/hectorvent/floci/services/sns/SnsQueryHandler.java
+++ b/src/main/java/io/github/hectorvent/floci/services/sns/SnsQueryHandler.java
@@ -3,6 +3,7 @@
 import io.github.hectorvent.floci.core.common.*;
 import io.github.hectorvent.floci.services.sns.model.Subscription;
 import io.github.hectorvent.floci.services.sns.model.Topic;
+import io.github.hectorvent.floci.services.sqs.model.MessageAttributeValue;
 import jakarta.enterprise.context.ApplicationScoped;
 import jakarta.inject.Inject;
 import jakarta.ws.rs.core.MultivaluedMap;
@@ -181,12 +182,13 @@ private Response handlePublish(MultivaluedMap<String, String> params, String reg
         String messageGroupId = getParam(params, "MessageGroupId");
         String messageDeduplicationId = getParam(params, "MessageDeduplicationId");
 
-        Map<String, String> attributes = new HashMap<>();
+        Map<String, MessageAttributeValue> attributes = new HashMap<>();
         for (int i = 1; ; i++) {
             String name = params.getFirst("MessageAttributes.entry." + i + ".Name");
             if (name == null) break;
             String value = params.getFirst("MessageAttributes.entry." + i + ".Value.StringValue");
-            if (value != null) attributes.put(name, value);
+            String dataType = params.getFirst("MessageAttributes.entry." + i + ".Value.DataType");
+            if (value != null) attributes.put(name, new MessageAttributeValue(value, dataType != null ? dataType : "String"));
         }
 
         try {
diff --git a/src/main/java/io/github/hectorvent/floci/services/sns/SnsService.java b/src/main/java/io/github/hectorvent/floci/services/sns/SnsService.java
index c2d5da6a..b71c4c8f 100644
--- a/src/main/java/io/github/hectorvent/floci/services/sns/SnsService.java
+++ b/src/main/java/io/github/hectorvent/floci/services/sns/SnsService.java
@@ -11,6 +11,7 @@
 import io.github.hectorvent.floci.services.sns.model.Subscription;
 import io.github.hectorvent.floci.services.sns.model.Topic;
 import io.github.hectorvent.floci.services.sqs.SqsService;
+import io.github.hectorvent.floci.services.sqs.model.MessageAttributeValue;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -27,6 +28,7 @@
 import java.time.Instant;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
@@ -247,12 +249,12 @@ public String publish(String topicArn, String targetArn, String message,
     }
 
     public String publish(String topicArn, String targetArn, String phoneNumber, String message,
-                          String subject, Map<String, String> messageAttributes, String region) {
+                          String subject, Map<String, MessageAttributeValue> messageAttributes, String region) {
         return publish(topicArn, targetArn, phoneNumber, message, subject, messageAttributes, null, null, region);
     }
 
     public String publish(String topicArn, String targetArn, String phoneNumber, String message,
-                          String subject, Map<String, String> messageAttributes,
+                          String subject, Map<String, MessageAttributeValue> messageAttributes,
                           String messageGroupId, String messageDeduplicationId, String region) {
         // Send SMS
         if (phoneNumber != null) {
@@ -361,7 +363,7 @@ public BatchPublishResult publishBatch(String topicArn, List<Map<String, Object>
 
             String messageId = UUID.randomUUID().toString();
             @SuppressWarnings("unchecked")
-            Map<String, String> attrs = (Map<String, String>) entry.get("MessageAttributes");
+            Map<String, MessageAttributeValue> attrs = (Map<String, MessageAttributeValue>) entry.get("MessageAttributes");
             for (Subscription sub : subscriptionsByTopic(topicArn, region)) {
                 if ("true".equals(sub.getAttributes().get("PendingConfirmation"))) continue;
                 if (!matchesFilterPolicy(sub, attrs)) continue;
@@ -411,7 +413,7 @@ public Map<String, String> listTagsForResource(String resourceArn, String region
      * All keys in the policy must match (AND logic). Within each key's rule array,
      * any matching element is sufficient (OR logic).
      */
-    private boolean matchesFilterPolicy(Subscription sub, Map<String, String> messageAttributes) {
+    private boolean matchesFilterPolicy(Subscription sub, Map<String, MessageAttributeValue> messageAttributes) {
         String filterPolicyJson = sub.getAttributes().get("FilterPolicy");
         if (filterPolicyJson == null || filterPolicyJson.isBlank()) {
             return true;
@@ -426,13 +428,14 @@ private boolean matchesFilterPolicy(Subscription sub, Map<String, String> messag
                 LOG.warnv("Invalid FilterPolicy (not a JSON object) for {0}", sub.getSubscriptionArn());
                 return false;
             }
-            Map<String, String> attrs = messageAttributes != null ? messageAttributes : Map.of();
+            Map<String, MessageAttributeValue> attrs = messageAttributes != null ? messageAttributes : Map.of();
             var fields = filterPolicy.fields();
             while (fields.hasNext()) {
                 var entry = fields.next();
                 String key = entry.getKey();
                 JsonNode rules = entry.getValue();
-                String actualValue = attrs.get(key);
+                MessageAttributeValue attr = attrs.get(key);
+                String actualValue = attr != null ? attr.getStringValue() : null;
                 if (!matchesAttributeRules(actualValue, rules)) {
                     return false;
                 }
@@ -559,7 +562,7 @@ private List<Subscription> subscriptionsByTopic(String topicArn, String region)
     }
 
     private void deliverMessage(Subscription sub, String message, String subject,
-                                Map<String, String> messageAttributes, String messageId,
+                                Map<String, MessageAttributeValue> messageAttributes, String messageId,
                                 String topicArn, String messageGroupId) {
         try {
             switch (sub.getProtocol()) {
@@ -569,10 +572,15 @@ private void deliverMessage(Subscription sub, String message, String subject,
                         region = extractRegionFromArn(topicArn);
                     }
                     String queueUrl = sqsArnToUrl(sub.getEndpoint());
-                    String envelope = buildSnsEnvelope(message, subject, messageAttributes, topicArn, messageId);
-                    sqsService.sendMessage(queueUrl, envelope, 0, messageGroupId, null, region);
-                    LOG.debugv("Delivered SNS message to SQS: {0} ({1}) in {2}",
-                            sub.getEndpoint(), queueUrl, region);
+                    boolean rawDelivery = "true".equalsIgnoreCase(sub.getAttributes().get("RawMessageDelivery"));
+                    String body = rawDelivery
+                            ? message
+                            : buildSnsEnvelope(message, subject, messageAttributes, topicArn, messageId);
+                    Map<String, MessageAttributeValue> sqsAttributes = rawDelivery
+                            ? toSqsMessageAttributes(messageAttributes)
+                            : Collections.emptyMap();
+                    sqsService.sendMessage(queueUrl, body, 0, messageGroupId, null, sqsAttributes, region);
+                    LOG.debugv("Delivered SNS message to SQS: {0} ({1}) raw={2}", sub.getEndpoint(), queueUrl, rawDelivery);
                 }
                 case "lambda" -> {
                     String fnName = extractFunctionName(sub.getEndpoint());
@@ -596,7 +604,7 @@ private void deliverMessage(Subscription sub, String message, String subject,
     }
 
     private String buildSnsLambdaEvent(String topicArn, String messageId, String message,
-                                       String subject, Map<String, String> messageAttributes,
+                                       String subject, Map<String, MessageAttributeValue> messageAttributes,
                                        String subscriptionArn) {
         try {
             String timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now());
@@ -619,8 +627,8 @@ private String buildSnsLambdaEvent(String topicArn, String messageId, String mes
             if (messageAttributes != null) {
                 for (var entry : messageAttributes.entrySet()) {
                     ObjectNode attr = attrs.putObject(entry.getKey());
-                    attr.put("Type", "String");
-                    attr.put("Value", entry.getValue());
+                    attr.put("Type", entry.getValue().getDataType());
+                    attr.put("Value", entry.getValue().getStringValue());
                 }
             }
             ObjectNode record = objectMapper.createObjectNode();
@@ -647,8 +655,19 @@ private static String extractRegionFromArn(String arn) {
         return parts.length >= 4 ? parts[3] : null;
     }
 
+    /**
+     * Forwards SNS message attributes as SQS MessageAttributeValue objects
+     * when RawMessageDelivery is enabled, preserving the original DataType.
+     */
+    private Map<String, MessageAttributeValue> toSqsMessageAttributes(Map<String, MessageAttributeValue> snsAttributes) {
+        if (snsAttributes == null || snsAttributes.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return new java.util.HashMap<>(snsAttributes);
+    }
+
     private String buildSnsEnvelope(String message, String subject,
-                                    Map<String, String> messageAttributes,
+                                    Map<String, MessageAttributeValue> messageAttributes,
                                     String topicArn, String messageId) {
         try {
             ObjectNode node = objectMapper.createObjectNode();
@@ -663,8 +682,8 @@ private String buildSnsEnvelope(String message, String subject,
             if (messageAttributes != null) {
                 for (var entry : messageAttributes.entrySet()) {
                     ObjectNode attr = attrs.putObject(entry.getKey());
-                    attr.put("Type", "String");
-                    attr.put("Value", entry.getValue());
+                    attr.put("Type", entry.getValue().getDataType());
+                    attr.put("Value", entry.getValue().getStringValue());
                 }
             }
             return objectMapper.writeValueAsString(node);


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/sns/SnsIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/sns/SnsIntegrationTest.java
index a41dd905..51697910 100644
--- a/src/test/java/io/github/hectorvent/floci/services/sns/SnsIntegrationTest.java
+++ b/src/test/java/io/github/hectorvent/floci/services/sns/SnsIntegrationTest.java
@@ -10,6 +10,8 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestMethodOrder;
 
+import java.util.UUID;
+
 import static io.restassured.RestAssured.given;
 import static org.hamcrest.Matchers.*;
 
@@ -32,6 +34,10 @@ static void configureRestAssured() {
     private static String topicArn;
     private static String subscriptionArn;
     private static String sqsQueueUrl;
+    private static String rawDeliveryQueueUrl;
+    private static String rawDeliverySubArn;
+    private static String envelopeQueueUrl;
+    private static String envelopeSubArn;
 
     @Test
     @Order(1)
@@ -587,6 +593,187 @@ void filterPolicy_cleanup() {
             .when().post("/");
     }
 
+    @Test
+    @Order(50)
+    void rawDelivery_createQueuesAndSubscribe() {
+        String suffix = UUID.randomUUID().toString().substring(0, 8);
+
+        rawDeliveryQueueUrl = given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "CreateQueue")
+            .formParam("QueueName", "sns-raw-delivery-" + suffix)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .extract().xmlPath().getString("CreateQueueResponse.CreateQueueResult.QueueUrl");
+
+        envelopeQueueUrl = given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "CreateQueue")
+            .formParam("QueueName", "sns-envelope-delivery-" + suffix)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .extract().xmlPath().getString("CreateQueueResponse.CreateQueueResult.QueueUrl");
+
+        rawDeliverySubArn = given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Subscribe")
+            .formParam("TopicArn", topicArn)
+            .formParam("Protocol", "sqs")
+            .formParam("Endpoint", rawDeliveryQueueUrl)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .extract().xmlPath().getString("SubscribeResponse.SubscribeResult.SubscriptionArn");
+
+        envelopeSubArn = given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Subscribe")
+            .formParam("TopicArn", topicArn)
+            .formParam("Protocol", "sqs")
+            .formParam("Endpoint", envelopeQueueUrl)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .extract().xmlPath().getString("SubscribeResponse.SubscribeResult.SubscriptionArn");
+    }
+
+    @Test
+    @Order(51)
+    void rawDelivery_setSubscriptionAttribute() {
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "SetSubscriptionAttributes")
+            .formParam("SubscriptionArn", rawDeliverySubArn)
+            .formParam("AttributeName", "RawMessageDelivery")
+            .formParam("AttributeValue", "true")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200);
+    }
+
+    @Test
+    @Order(52)
+    void rawDelivery_publishAndVerifyRawMessage() {
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Publish")
+            .formParam("TopicArn", topicArn)
+            .formParam("Message", "Raw delivery test message")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("<MessageId>"));
+
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "ReceiveMessage")
+            .formParam("QueueUrl", rawDeliveryQueueUrl)
+            .formParam("MaxNumberOfMessages", "1")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("Raw delivery test message"))
+            .body(not(containsString("Notification")));
+    }
+
+    @Test
+    @Order(53)
+    void rawDelivery_defaultSubscriptionWrapsInEnvelope() {
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "ReceiveMessage")
+            .formParam("QueueUrl", envelopeQueueUrl)
+            .formParam("MaxNumberOfMessages", "1")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("Raw delivery test message"))
+            .body(containsString("Notification"));
+    }
+
+    @Test
+    @Order(54)
+    void rawDelivery_messageAttributesForwardedOnRawDelivery() {
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Publish")
+            .formParam("TopicArn", topicArn)
+            .formParam("Message", "Attribute forwarding test")
+            .formParam("MessageAttributes.entry.1.Name", "color")
+            .formParam("MessageAttributes.entry.1.Value.DataType", "String")
+            .formParam("MessageAttributes.entry.1.Value.StringValue", "blue")
+            .formParam("MessageAttributes.entry.2.Name", "count")
+            .formParam("MessageAttributes.entry.2.Value.DataType", "Number")
+            .formParam("MessageAttributes.entry.2.Value.StringValue", "42")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("<MessageId>"));
+
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "ReceiveMessage")
+            .formParam("QueueUrl", rawDeliveryQueueUrl)
+            .formParam("MaxNumberOfMessages", "1")
+            .formParam("MessageAttributeNames.member.1", "All")
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body(containsString("Attribute forwarding test"))
+            .body(containsString("color"))
+            .body(containsString("blue"))
+            .body(containsString("count"))
+            .body(containsString("Number"));
+    }
+
+    @Test
+    @Order(55)
+    void rawDelivery_cleanup() {
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Unsubscribe")
+            .formParam("SubscriptionArn", rawDeliverySubArn)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200);
+
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "Unsubscribe")
+            .formParam("SubscriptionArn", envelopeSubArn)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200);
+
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "DeleteQueue")
+            .formParam("QueueUrl", rawDeliveryQueueUrl)
+        .when()
+            .post("/");
+
+        given()
+            .contentType("application/x-www-form-urlencoded")
+            .formParam("Action", "DeleteQueue")
+            .formParam("QueueUrl", envelopeQueueUrl)
+        .when()
+            .post("/");
+    }
+
     @Test
     @Order(100)
     void unsubscribe() {