Repository: floci-io/floci (2026-04-01T04:16:36Z)

Issue:
[BUG] SQS: JSON 1.0 requests to /{accountId}/{queueName} are not routed correctly
 ## Service

  SQS

  ## AWS API Action

  SendMessage, GetQueueAttributes (and other JSON 1.0 SQS operations)

  ## Expected behavior

  SQS operations succeed normally — messages are enqueued and attributes are returned.

  ## Actual behavior

  Requests are routed to S3Controller instead of the SQS handler, resulting in an
  XML error response such as:

  ```xml
  <Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message></Error>

  Reproduction

  # Using aws-sdk-sqs gem >= 1.71 (Ruby) or any SDK that sends JSON 1.0 SQS
  # requests to the queue-URL path rather than POST /

> more test.rb
require "aws-sdk-sqs"

  client = Aws::SQS::Client.new(
    region: "us-east-1",
    endpoint: "http://localhost:4566",
    credentials: Aws::Credentials.new("fake", "fake")
  )

  # Create the FIFO queue
  resp = client.create_queue(
    queue_name: "test.fifo",
    attributes: { "FifoQueue" => "true" }
  )
  queue_url = resp.queue_url
  puts "Created queue: #{queue_url}"

  # Send a message — this triggers the JSON 1.0 path-routing bug
  resp = client.send_message(
    queue_url: queue_url,
    message_body: "hello from ruby",
    message_group_id: "group1",
    message_deduplication_id: "dedup1"
  )
  puts "Sent message: #{resp.message_id}"
test.rb (END)

 gem install aws-sdk-sqs
 ruby test.rb

  Environment

  - Floci version / image tag:  hectorvent/floci:latest
  - How you're running Floci (Docker / native / mvn quarkus:dev):  Docker


Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 2fe912b9..8583daf2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ COPILOT.md
 .claude
 
 posts
+local
\ No newline at end of file
diff --git a/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java b/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java
new file mode 100644
index 00000000..d093e308
--- /dev/null
+++ b/src/main/java/io/github/hectorvent/floci/core/common/SqsQueueUrlRouterFilter.java
@@ -0,0 +1,65 @@
+package io.github.hectorvent.floci.core.common;
+
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.container.PreMatching;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.ext.Provider;
+
+import java.net.URI;
+import java.util.regex.Pattern;
+
+/**
+ * Pre-matching filter that rewrites SQS JSON 1.0 requests sent to the queue URL path
+ * (/{accountId}/{queueName}) to POST / so they are handled by AwsJsonController.
+ * <p>
+ * Newer AWS SDKs (e.g. aws-sdk-sqs Ruby gem >= 1.71) route operations to the queue URL
+ * rather than POST /. Without this filter, those requests match S3Controller's
+ * /{bucket}/{key:.+} handler and return NoSuchBucket errors.
+ */
+@Provider
+@PreMatching
+public class SqsQueueUrlRouterFilter implements ContainerRequestFilter {
+
+    private static final Pattern QUEUE_PATH = Pattern.compile("^/([^/]+)/([^/]+)$");
+
+    @Override
+    public void filter(ContainerRequestContext ctx) {
+
+        if (!"POST".equals(ctx.getMethod())) {
+            return;
+        }
+
+        String path = ctx.getUriInfo().getPath();
+        if (!QUEUE_PATH.matcher(path).matches()) {
+            return;
+        }
+
+        MediaType mt = ctx.getMediaType();
+        if (mt == null) {
+            return;
+        }
+
+        boolean isSqsJson = "application".equals(mt.getType())
+                && "x-amz-json-1.0".equals(mt.getSubtype())
+                && isSqsTarget(ctx.getHeaderString("X-Amz-Target"));
+
+        // S3 never receives form-encoded POSTs to /{bucket}/{key} paths —
+        // S3 presigned POST always goes to /{bucket}, not /{bucket}/{key}.
+        boolean isSqsQuery = "application".equals(mt.getType())
+                && "x-www-form-urlencoded".equals(mt.getSubtype());
+
+        if (!isSqsJson && !isSqsQuery) {
+            return;
+        }
+
+        URI rewritten = ctx.getUriInfo().getRequestUriBuilder()
+                .replacePath("/")
+                .build();
+        ctx.setRequestUri(rewritten);
+    }
+
+    private boolean isSqsTarget(String target) {
+        return target != null && target.startsWith("AmazonSQS.");
+    }
+}


Test patch:
diff --git a/src/test/java/io/github/hectorvent/floci/services/sqs/SqsJsonProtocolTest.java b/src/test/java/io/github/hectorvent/floci/services/sqs/SqsJsonProtocolTest.java
new file mode 100644
index 00000000..5f82011f
--- /dev/null
+++ b/src/test/java/io/github/hectorvent/floci/services/sqs/SqsJsonProtocolTest.java
@@ -0,0 +1,166 @@
+package io.github.hectorvent.floci.services.sqs;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.config.EncoderConfig;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.*;
+
+/**
+ * Integration tests for the SQS JSON 1.0 protocol (application/x-amz-json-1.0).
+ *
+ * Covers two routing modes used by AWS SDKs:
+ * - Root path: POST / with X-Amz-Target header (older SDKs)
+ * - Queue URL path: POST /{accountId}/{queueName} with X-Amz-Target header
+ *   (newer SDKs, e.g. aws-sdk-sqs Ruby gem >= 1.71)
+ */
+@QuarkusTest
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class SqsJsonProtocolTest {
+
+    private static final String CONTENT_TYPE = "application/x-amz-json-1.0";
+    private static final String ACCOUNT_ID = "000000000000";
+    private static final String QUEUE_NAME = "json-protocol-test-queue";
+
+    private static String queueUrl;
+    private static String receiptHandle;
+
+    @BeforeAll
+    static void configureRestAssured() {
+        RestAssured.config = RestAssured.config().encoderConfig(
+            EncoderConfig.encoderConfig()
+                .encodeContentTypeAs(CONTENT_TYPE, ContentType.TEXT)
+        );
+    }
+
+    // --- Root-path JSON 1.0 (POST /) ---
+
+    @Test
+    @Order(1)
+    void createQueueViaRootPath() {
+        String body = "{\"QueueName\":\"" + QUEUE_NAME + "\"}";
+
+        queueUrl = given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.CreateQueue")
+            .body(body)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body("QueueUrl", containsString(QUEUE_NAME))
+            .extract().jsonPath().getString("QueueUrl");
+    }
+
+    @Test
+    @Order(2)
+    void getQueueAttributesViaRootPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\",\"AttributeNames\":[\"All\"]}";
+
+        given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.GetQueueAttributes")
+            .body(body)
+        .when()
+            .post("/")
+        .then()
+            .statusCode(200)
+            .body("Attributes.QueueArn", notNullValue());
+    }
+
+    // --- Queue-URL-path JSON 1.0 (POST /{accountId}/{queueName}) ---
+    // Regression: these requests were previously routed to S3Controller,
+    // returning NoSuchBucket errors.
+
+    @Test
+    @Order(3)
+    void sendMessageViaQueueUrlPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\","
+                + "\"MessageBody\":\"hello from json protocol test\"}";
+
+        receiptHandle = null;
+
+        given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.SendMessage")
+            .body(body)
+        .when()
+            .post("/" + ACCOUNT_ID + "/" + QUEUE_NAME)
+        .then()
+            .statusCode(200)
+            .body("MessageId", notNullValue())
+            .body("MD5OfMessageBody", notNullValue());
+    }
+
+    @Test
+    @Order(4)
+    void receiveMessageViaQueueUrlPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\",\"MaxNumberOfMessages\":1}";
+
+        receiptHandle = given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.ReceiveMessage")
+            .body(body)
+        .when()
+            .post("/" + ACCOUNT_ID + "/" + QUEUE_NAME)
+        .then()
+            .statusCode(200)
+            .body("Messages", hasSize(1))
+            .body("Messages[0].Body", equalTo("hello from json protocol test"))
+            .extract().jsonPath().getString("Messages[0].ReceiptHandle");
+    }
+
+    @Test
+    @Order(5)
+    void deleteMessageViaQueueUrlPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\","
+                + "\"ReceiptHandle\":\"" + receiptHandle + "\"}";
+
+        given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.DeleteMessage")
+            .body(body)
+        .when()
+            .post("/" + ACCOUNT_ID + "/" + QUEUE_NAME)
+        .then()
+            .statusCode(200);
+    }
+
+    @Test
+    @Order(6)
+    void getQueueAttributesViaQueueUrlPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\",\"AttributeNames\":[\"All\"]}";
+
+        given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.GetQueueAttributes")
+            .body(body)
+        .when()
+            .post("/" + ACCOUNT_ID + "/" + QUEUE_NAME)
+        .then()
+            .statusCode(200)
+            .body("Attributes.QueueArn", notNullValue());
+    }
+
+    @Test
+    @Order(7)
+    void deleteQueueViaQueueUrlPath() {
+        String body = "{\"QueueUrl\":\"" + queueUrl + "\"}";
+
+        given()
+            .contentType(CONTENT_TYPE)
+            .header("X-Amz-Target", "AmazonSQS.DeleteQueue")
+            .body(body)
+        .when()
+            .post("/" + ACCOUNT_ID + "/" + QUEUE_NAME)
+        .then()
+            .statusCode(200);
+    }
+}