diff --git a/README.md b/README.md index ba35038..4add3e7 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ To get the next payload from the `foo` queue, send a get request to `/dequeue/:q curl -X GET -H "Authorization: Bearer replace-with-a-secret-token" http://127.0.0.1:1991/dequeue/foo ``` -This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss are rejected with a `400` response. +This returns the oldest added payload on queue `foo` as JSON and removes it, guaranteeing both the order and that each payload will only be read once. Strings, numbers, booleans, arrays, and objects all use `application/json` so a string `"0"` is distinct from the number `0`. Numeric values that JavaScript cannot represent without loss and request bodies that are not well-formed UTF-8 are rejected with a `400` response. That's all you need to get started! 😎 diff --git a/openapi.yaml b/openapi.yaml index 45d4608..c03b957 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -84,7 +84,7 @@ paths: schema: type: string '400': - description: Bad Request - Invalid JSON, unsupported number, or Queue name too long + description: Bad Request - Invalid JSON (including invalid UTF-8), unsupported number, or Queue name too long '401': description: Unauthorized '413': diff --git a/src/handler.ts b/src/handler.ts index ee6a9e4..8eda311 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -4,7 +4,7 @@ import { withAuth, withRateLimit } from "./middleware.ts"; import { RouteHandler, Router } from "./router.ts"; const MAX_BODY_SIZE = 1024 * 1024; // 1 MB -const LOG_ENCODER = new TextEncoder(); +const LOG_ENCODER = Reflect.construct(TextEncoder, []); class UnsupportedNumberError extends Error { constructor() { @@ -114,7 +114,15 @@ async function readRequestBody(request: Request): Promise { body.set(chunk, offset); offset += chunk.byteLength; } - return await new Response(body).text(); + return decodeUtf8Body(body); +} + +function decodeUtf8Body(body: Uint8Array): string | Response { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(body); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } } function extractQueueName(match: Parameters[1]): { name: string } | { error: Response } { diff --git a/tests/handler_test.ts b/tests/handler_test.ts index ad49aa6..0997245 100644 --- a/tests/handler_test.ts +++ b/tests/handler_test.ts @@ -1208,6 +1208,48 @@ Deno.test("API: rejects rounded decimal payloads instead of changing their preci assertEquals(dequeueResponse.status, 204); }); +Deno.test("API: rejects invalid UTF-8 in JSON string payloads without enqueueing", async () => { + const mgr = new QueueManager(new Persistency.MemoryStore); + const handler = createHandler(mgr, API_TOKEN); + const invalidUtf8Body = Uint8Array.of( + ...new TextEncoder().encode('{"payload":"caf'), + 0xe9, + ...new TextEncoder().encode('"}'), + ); + + const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/invalid-utf8", { + method: "POST", + body: invalidUtf8Body, + headers: { ...authHeaders, "Content-Type": "application/json" }, + })); + + assertEquals(enqueueResponse.status, 400); + assertEquals(await enqueueResponse.text(), "Invalid JSON"); + + const dequeueResponse = await handler(new Request("http://localhost:3000/dequeue/invalid-utf8", { + headers: authHeaders, + })); + assertEquals(dequeueResponse.status, 204); +}); + +Deno.test("API: round-trips valid UTF-8 in JSON string payloads", async () => { + const mgr = new QueueManager(new Persistency.MemoryStore); + const handler = createHandler(mgr, API_TOKEN); + const enqueueResponse = await handler(new Request("http://localhost:3000/enqueue/valid-utf8", { + method: "POST", + body: JSON.stringify({ payload: "café €𝄞" }), + headers: { ...authHeaders, "Content-Type": "application/json" }, + })); + + assertEquals(enqueueResponse.status, 200); + + const dequeueResponse = await handler(new Request("http://localhost:3000/dequeue/valid-utf8", { + headers: authHeaders, + })); + assertEquals(dequeueResponse.status, 200); + assertEquals(await dequeueResponse.json(), "café €𝄞"); +}); + Deno.test("dequeue returns application/json for boolean payload", async () => { const mgr = new QueueManager(new Persistency.MemoryStore); const handler = createHandler(mgr, API_TOKEN);