diff --git a/README.md b/README.md index 9f5228c7..eaa34b6d 100644 --- a/README.md +++ b/README.md @@ -657,7 +657,7 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a ## AWS -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. All responses use AWS-compatible XML. +S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths, SQS AwsJson1.0 and query protocol support, and query-style IAM/STS endpoints. ### S3 @@ -675,7 +675,8 @@ S3 routes use root paths matching the real AWS S3 wire format, so the official A - `DELETE /:bucket/:key` - delete object ### SQS -All operations via `POST /sqs/` with `Action` parameter: +All operations via `POST /sqs/`. SQS accepts modern AwsJson1.0 requests from current AWS SDK clients using `Content-Type: application/x-amz-json-1.0` and `X-Amz-Target: AmazonSQS.`. Legacy form-urlencoded query requests with an `Action` parameter remain supported. + - `CreateQueue`, `ListQueues`, `GetQueueUrl`, `GetQueueAttributes` - `SendMessage`, `ReceiveMessage`, `DeleteMessage` - `PurgeQueue`, `DeleteQueue` diff --git a/apps/web/app/docs/aws/page.mdx b/apps/web/app/docs/aws/page.mdx index 967d2ed0..471b1168 100644 --- a/apps/web/app/docs/aws/page.mdx +++ b/apps/web/app/docs/aws/page.mdx @@ -1,6 +1,6 @@ # AWS -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. All state is in-memory, and responses use AWS-compatible XML. +S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths, SQS AwsJson1.0 and query protocol support, and query-style IAM/STS endpoints. All state is in-memory. ## S3 @@ -20,7 +20,7 @@ S3 routes use root paths matching the real AWS S3 wire format, so the official A ## SQS -All SQS operations use `POST /sqs/` with an `Action` form parameter. +All SQS operations use `POST /sqs/`. SQS accepts modern AwsJson1.0 requests from current AWS SDK clients using `Content-Type: application/x-amz-json-1.0` and `X-Amz-Target: AmazonSQS.`. Legacy form-urlencoded query requests with an `Action` parameter remain supported. - `CreateQueue` - create queue (with optional attributes like `VisibilityTimeout`) - `ListQueues` - list queues (supports `QueueNamePrefix` filter) diff --git a/packages/@emulators/aws/README.md b/packages/@emulators/aws/README.md index 728e31b1..b5a1ff0b 100644 --- a/packages/@emulators/aws/README.md +++ b/packages/@emulators/aws/README.md @@ -1,6 +1,6 @@ # @emulators/aws -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. All responses use AWS-compatible XML. +S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths, SQS AwsJson1.0 and query protocol support, and query-style IAM/STS endpoints. Part of [emulate](https://github.com/vercel-labs/emulate) — local drop-in replacement services for CI and no-network sandboxes. @@ -28,7 +28,8 @@ S3 routes use root paths matching the real AWS S3 wire format, so the official A - `DELETE /:bucket/:key` — delete object ### SQS -All operations via `POST /sqs/` with `Action` parameter: +All operations via `POST /sqs/`. SQS accepts modern AwsJson1.0 requests from current AWS SDK clients using `Content-Type: application/x-amz-json-1.0` and `X-Amz-Target: AmazonSQS.`. Legacy form-urlencoded query requests with an `Action` parameter remain supported. + - `CreateQueue`, `ListQueues`, `GetQueueUrl`, `GetQueueAttributes` - `SendMessage`, `ReceiveMessage`, `DeleteMessage` - `PurgeQueue`, `DeleteQueue` diff --git a/packages/@emulators/aws/src/__tests__/aws.test.ts b/packages/@emulators/aws/src/__tests__/aws.test.ts index 2ae3b0c4..08d98912 100644 --- a/packages/@emulators/aws/src/__tests__/aws.test.ts +++ b/packages/@emulators/aws/src/__tests__/aws.test.ts @@ -456,6 +456,112 @@ describe("AWS plugin - SQS", () => { expect(text).toContain("emulate-default-queue"); }); + it("handles AwsJson1.0 ListQueues requests", async () => { + const res = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.ListQueues", + }, + body: "{}", + }); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/x-amz-json-1.0"); + const json = (await res.json()) as { QueueUrls: string[] }; + expect(json.QueueUrls).toContain(`${base}/sqs/123456789012/emulate-default-queue`); + }); + + it("handles AwsJson1.0 queue and message operations", async () => { + const createRes = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.CreateQueue", + }, + body: JSON.stringify({ + QueueName: "json-queue", + Attributes: { VisibilityTimeout: "45" }, + }), + }); + expect(createRes.status).toBe(200); + const createJson = (await createRes.json()) as { QueueUrl: string }; + expect(createJson.QueueUrl).toBe(`${base}/sqs/123456789012/json-queue`); + + const sendRes = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.SendMessage", + }, + body: JSON.stringify({ + QueueUrl: createJson.QueueUrl, + MessageBody: "json message", + MessageAttributes: { + type: { DataType: "String", StringValue: "greeting" }, + }, + }), + }); + expect(sendRes.status).toBe(200); + const sendJson = (await sendRes.json()) as { MD5OfMessageBody: string; MessageId: string }; + expect(sendJson.MessageId).toBeTruthy(); + expect(sendJson.MD5OfMessageBody).toBe("4e27a546974106f6e06b305041f8ab6d"); + + const receiveRes = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.ReceiveMessage", + }, + body: JSON.stringify({ QueueUrl: createJson.QueueUrl, MaxNumberOfMessages: 1 }), + }); + expect(receiveRes.status).toBe(200); + const receiveJson = (await receiveRes.json()) as { + Messages: Array<{ + Body: string; + MessageAttributes: Record; + ReceiptHandle: string; + }>; + }; + expect(receiveJson.Messages).toHaveLength(1); + expect(receiveJson.Messages[0]?.Body).toBe("json message"); + expect(receiveJson.Messages[0]?.MessageAttributes.type?.StringValue).toBe("greeting"); + + const attrRes = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.GetQueueAttributes", + }, + body: JSON.stringify({ QueueUrl: createJson.QueueUrl, AttributeNames: ["All"] }), + }); + expect(attrRes.status).toBe(200); + const attrJson = (await attrRes.json()) as { Attributes: Record }; + expect(attrJson.Attributes.VisibilityTimeout).toBe("45"); + expect(attrJson.Attributes.ApproximateNumberOfMessagesNotVisible).toBe("1"); + }); + + it("returns AwsJson1.0 errors for JSON protocol requests", async () => { + const res = await app.request(`${base}/sqs/`, { + method: "POST", + headers: { + ...authHeaders(), + "Content-Type": "application/x-amz-json-1.0", + "X-Amz-Target": "AmazonSQS.GetQueueUrl", + }, + body: JSON.stringify({ QueueName: "missing-queue" }), + }); + expect(res.status).toBe(400); + expect(res.headers.get("Content-Type")).toContain("application/x-amz-json-1.0"); + const json = (await res.json()) as { __type: string; message: string }; + expect(json.__type).toBe("AWS.SimpleQueueService.NonExistentQueue"); + expect(json.message).toContain("does not exist"); + }); + it("sends and receives a message", async () => { // Get queue URL const urlRes = await app.request(`${base}/sqs/`, { diff --git a/packages/@emulators/aws/src/routes/sqs.ts b/packages/@emulators/aws/src/routes/sqs.ts index bd02868a..7272b235 100644 --- a/packages/@emulators/aws/src/routes/sqs.ts +++ b/packages/@emulators/aws/src/routes/sqs.ts @@ -1,5 +1,7 @@ import type { RouteContext } from "@emulators/core"; import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import type { SqsMessage } from "../entities.js"; import { getAwsStore } from "../store.js"; import { awsXmlResponse, @@ -12,49 +14,59 @@ import { escapeXml, } from "../helpers.js"; +type SqsProtocol = "query" | "json"; + export function sqsRoutes(ctx: RouteContext): void { const { app, store, baseUrl } = ctx; const aws = () => getAwsStore(store); const accountId = getAccountId(); - // All SQS actions go through POST with Action parameter + // SQS supports both the legacy AWS Query protocol and modern AwsJson1.0. app.post("/sqs/", async (c) => { const body = await c.req.text(); - const params = parseQueryString(body); - const action = params["Action"] ?? c.req.query("Action") ?? ""; + const request = parseSqsRequest(c, body); + if ("response" in request) { + return request.response; + } + + const { params, action, protocol } = request; switch (action) { case "CreateQueue": - return createQueue(c, params); + return createQueue(c, params, protocol); case "DeleteQueue": - return deleteQueue(c, params); + return deleteQueue(c, params, protocol); case "ListQueues": - return listQueues(c, params); + return listQueues(c, params, protocol); case "GetQueueUrl": - return getQueueUrl(c, params); + return getQueueUrl(c, params, protocol); case "GetQueueAttributes": - return getQueueAttributes(c, params); + return getQueueAttributes(c, params, protocol); case "SendMessage": - return sendMessage(c, params); + return sendMessage(c, params, protocol); case "ReceiveMessage": - return receiveMessage(c, params); + return receiveMessage(c, params, protocol); case "DeleteMessage": - return deleteMessage(c, params); + return deleteMessage(c, params, protocol); case "PurgeQueue": - return purgeQueue(c, params); + return purgeQueue(c, params, protocol); default: - return awsErrorXml(c, "InvalidAction", `The action ${action} is not valid for this endpoint.`, 400); + return awsError(c, protocol, "InvalidAction", `The action ${action} is not valid for this endpoint.`, 400); } }); - function createQueue(c: Context, params: Record) { + function createQueue(c: Context, params: Record, protocol: SqsProtocol) { const queueName = params["QueueName"] ?? ""; if (!queueName) { - return awsErrorXml(c, "MissingParameter", "The request must contain the parameter QueueName.", 400); + return awsError(c, protocol, "MissingParameter", "The request must contain the parameter QueueName.", 400); } const existing = aws().sqsQueues.findOneBy("queue_name", queueName); if (existing) { + if (protocol === "json") { + return awsJsonResponse(c, { QueueUrl: existing.queue_url }); + } + // SQS returns success with existing queue URL if attributes match const xml = ` @@ -88,6 +100,10 @@ export function sqsRoutes(ctx: RouteContext): void { fifo, }); + if (protocol === "json") { + return awsJsonResponse(c, { QueueUrl: queueUrl }); + } + const xml = ` @@ -98,11 +114,17 @@ export function sqsRoutes(ctx: RouteContext): void { return awsXmlResponse(c, xml); } - function deleteQueue(c: Context, params: Record) { + function deleteQueue(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } // Delete all messages in the queue @@ -112,6 +134,10 @@ export function sqsRoutes(ctx: RouteContext): void { } aws().sqsQueues.delete(queue.id); + if (protocol === "json") { + return awsJsonResponse(c, {}); + } + const xml = ` ${generateMessageId()} @@ -119,13 +145,17 @@ export function sqsRoutes(ctx: RouteContext): void { return awsXmlResponse(c, xml); } - function listQueues(c: Context, params: Record) { + function listQueues(c: Context, params: Record, protocol: SqsProtocol) { const prefix = params["QueueNamePrefix"] ?? ""; let queues = aws().sqsQueues.all(); if (prefix) { queues = queues.filter((q) => q.queue_name.startsWith(prefix)); } + if (protocol === "json") { + return awsJsonResponse(c, { QueueUrls: queues.map((q) => q.queue_url) }); + } + const queueUrlsXml = queues.map((q) => ` ${escapeXml(q.queue_url)}`).join("\n"); const xml = ` @@ -138,11 +168,21 @@ ${queueUrlsXml} return awsXmlResponse(c, xml); } - function getQueueUrl(c: Context, params: Record) { + function getQueueUrl(c: Context, params: Record, protocol: SqsProtocol) { const queueName = params["QueueName"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_name", queueName); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); + } + + if (protocol === "json") { + return awsJsonResponse(c, { QueueUrl: queue.queue_url }); } const xml = ` @@ -155,53 +195,65 @@ ${queueUrlsXml} return awsXmlResponse(c, xml); } - function getQueueAttributes(c: Context, params: Record) { + function getQueueAttributes(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } const messages = aws().sqsMessages.findBy("queue_name", queue.queue_name); const now = Date.now(); const visibleCount = messages.filter((m) => m.visible_after <= now).length; const inFlightCount = messages.filter((m) => m.visible_after > now).length; + const attributes = queueAttributes(queue, visibleCount, inFlightCount); + + if (protocol === "json") { + return awsJsonResponse(c, { Attributes: attributes }); + } const xml = ` - QueueArn${queue.arn} - ApproximateNumberOfMessages${visibleCount} - ApproximateNumberOfMessagesNotVisible${inFlightCount} - VisibilityTimeout${queue.visibility_timeout} - MaximumMessageSize${queue.max_message_size} - MessageRetentionPeriod${queue.message_retention_period} - DelaySeconds${queue.delay_seconds} - ReceiveMessageWaitTimeSeconds${queue.receive_message_wait_time} - FifoQueue${queue.fifo} +${Object.entries(attributes) + .map(([name, value]) => ` ${name}${value}`) + .join("\n")} ${generateMessageId()} `; return awsXmlResponse(c, xml); } - function sendMessage(c: Context, params: Record) { + function sendMessage(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const messageBody = params["MessageBody"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } if (!messageBody) { - return awsErrorXml(c, "MissingParameter", "The request must contain the parameter MessageBody.", 400); + return awsError(c, protocol, "MissingParameter", "The request must contain the parameter MessageBody.", 400); } const bodyBytes = new TextEncoder().encode(messageBody).byteLength; if (bodyBytes > queue.max_message_size) { - return awsErrorXml( + return awsError( c, + protocol, "InvalidParameterValue", `One or more parameters are invalid. Reason: Message must be shorter than ${queue.max_message_size} bytes.`, 400, @@ -241,6 +293,13 @@ ${queueUrlsXml} receive_count: 0, }); + if (protocol === "json") { + return awsJsonResponse(c, { + MD5OfMessageBody: bodyMd5, + MessageId: messageId, + }); + } + const xml = ` @@ -252,14 +311,20 @@ ${queueUrlsXml} return awsXmlResponse(c, xml); } - function receiveMessage(c: Context, params: Record) { + function receiveMessage(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const maxMessages = Math.min(parseInt(params["MaxNumberOfMessages"] ?? "1", 10), 10); const visibilityTimeout = parseInt(params["VisibilityTimeout"] ?? "", 10); const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } const now = Date.now(); @@ -279,6 +344,10 @@ ${queueUrlsXml} msg.receive_count += 1; } + if (protocol === "json") { + return awsJsonResponse(c, { Messages: batch.map(jsonMessage) }); + } + const messagesXml = batch .map( (m) => ` @@ -303,13 +372,19 @@ ${messagesXml} return awsXmlResponse(c, xml); } - function deleteMessage(c: Context, params: Record) { + function deleteMessage(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const receiptHandle = params["ReceiptHandle"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } const messages = aws().sqsMessages.findBy("queue_name", queue.queue_name); @@ -318,6 +393,10 @@ ${messagesXml} aws().sqsMessages.delete(msg.id); } + if (protocol === "json") { + return awsJsonResponse(c, {}); + } + const xml = ` ${generateMessageId()} @@ -325,11 +404,17 @@ ${messagesXml} return awsXmlResponse(c, xml); } - function purgeQueue(c: Context, params: Record) { + function purgeQueue(c: Context, params: Record, protocol: SqsProtocol) { const queueUrl = params["QueueUrl"] ?? ""; const queue = aws().sqsQueues.findOneBy("queue_url", queueUrl); if (!queue) { - return awsErrorXml(c, "AWS.SimpleQueueService.NonExistentQueue", "The specified queue does not exist.", 400); + return awsError( + c, + protocol, + "AWS.SimpleQueueService.NonExistentQueue", + "The specified queue does not exist.", + 400, + ); } const messages = aws().sqsMessages.findBy("queue_name", queue.queue_name); @@ -337,6 +422,10 @@ ${messagesXml} aws().sqsMessages.delete(msg.id); } + if (protocol === "json") { + return awsJsonResponse(c, {}); + } + const xml = ` ${generateMessageId()} @@ -344,3 +433,129 @@ ${messagesXml} return awsXmlResponse(c, xml); } } + +function parseSqsRequest( + c: Context, + body: string, +): { action: string; params: Record; protocol: SqsProtocol } | { response: Response } { + if (isAwsJsonRequest(c)) { + try { + const payload = body ? (JSON.parse(body) as Record) : {}; + const target = c.req.header("X-Amz-Target") ?? ""; + const action = target.startsWith("AmazonSQS.") ? target.slice("AmazonSQS.".length) : ""; + return { action, params: normalizeJsonParams(payload), protocol: "json" }; + } catch { + return { response: awsError(c, "json", "InvalidRequestContent", "Could not parse request body into JSON.", 400) }; + } + } + + const params = parseQueryString(body); + return { action: params["Action"] ?? c.req.query("Action") ?? "", params, protocol: "query" }; +} + +function isAwsJsonRequest(c: Context): boolean { + const contentType = c.req.header("Content-Type") ?? ""; + const target = c.req.header("X-Amz-Target") ?? ""; + return contentType.includes("application/x-amz-json-1.0") || target.startsWith("AmazonSQS."); +} + +function normalizeJsonParams(payload: Record): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (value === undefined || value === null) { + continue; + } + if (key === "Attributes" && isRecord(value)) { + let index = 1; + for (const [name, attrValue] of Object.entries(value)) { + params[`Attribute.${index}.Name`] = name; + params[`Attribute.${index}.Value`] = String(attrValue); + index++; + } + continue; + } + if (key === "MessageAttributes" && isRecord(value)) { + let index = 1; + for (const [name, attrValue] of Object.entries(value)) { + if (!isRecord(attrValue)) { + continue; + } + params[`MessageAttribute.${index}.Name`] = name; + params[`MessageAttribute.${index}.Value.DataType`] = String(attrValue.DataType ?? "String"); + if (attrValue.StringValue !== undefined) { + params[`MessageAttribute.${index}.Value.StringValue`] = String(attrValue.StringValue); + } + if (attrValue.BinaryValue !== undefined) { + params[`MessageAttribute.${index}.Value.BinaryValue`] = String(attrValue.BinaryValue); + } + index++; + } + continue; + } + params[key] = String(value); + } + return params; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function awsJsonResponse(c: Context, body: Record, status: ContentfulStatusCode = 200) { + return c.body(JSON.stringify(body), status, { "Content-Type": "application/x-amz-json-1.0" }); +} + +function awsError( + c: Context, + protocol: SqsProtocol, + code: string, + message: string, + status: ContentfulStatusCode = 400, +) { + if (protocol === "json") { + return awsJsonResponse(c, { __type: code, message }, status); + } + + return awsErrorXml(c, code, message, status); +} + +function queueAttributes( + queue: { + arn: string; + visibility_timeout: number; + max_message_size: number; + message_retention_period: number; + delay_seconds: number; + receive_message_wait_time: number; + fifo: boolean; + }, + visibleCount: number, + inFlightCount: number, +): Record { + return { + QueueArn: queue.arn, + ApproximateNumberOfMessages: String(visibleCount), + ApproximateNumberOfMessagesNotVisible: String(inFlightCount), + VisibilityTimeout: String(queue.visibility_timeout), + MaximumMessageSize: String(queue.max_message_size), + MessageRetentionPeriod: String(queue.message_retention_period), + DelaySeconds: String(queue.delay_seconds), + ReceiveMessageWaitTimeSeconds: String(queue.receive_message_wait_time), + FifoQueue: String(queue.fifo), + }; +} + +function jsonMessage(message: SqsMessage): Record { + return { + MessageId: message.message_id, + ReceiptHandle: message.receipt_handle, + MD5OfBody: message.md5_of_body, + Body: message.body, + Attributes: { + ...message.attributes, + ApproximateReceiveCount: String(message.receive_count), + ApproximateFirstReceiveTimestamp: String(message.sent_timestamp), + }, + MessageAttributes: message.message_attributes, + }; +} diff --git a/skills/aws/SKILL.md b/skills/aws/SKILL.md index bb087b54..486621a6 100644 --- a/skills/aws/SKILL.md +++ b/skills/aws/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(npx emulate:*), Bash(emulate:*), Bash(curl:*) # AWS Emulator -S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths and query-style SQS/IAM/STS endpoints. All state is in-memory, and responses use AWS-compatible XML. +S3, SQS, IAM, and STS emulation with AWS SDK-compatible S3 paths, SQS AwsJson1.0 and query protocol support, and query-style IAM/STS endpoints. All state is in-memory. ## Start @@ -170,7 +170,7 @@ curl -X PUT http://localhost:4006/dest-bucket/copy.txt \ ### SQS -All SQS operations use `POST /sqs/` with `Action` as a form-urlencoded parameter. +All SQS operations use `POST /sqs/`. Current AWS SDK SQS clients use AwsJson1.0 with `Content-Type: application/x-amz-json-1.0` and `X-Amz-Target: AmazonSQS.`. Legacy form-urlencoded query requests with `Action` remain supported. ```bash # Create queue