diff --git a/SPEC.md b/SPEC.md index e115dab..7b86f6e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -24,6 +24,8 @@ The endpoint MUST accept: - Request `Content-Type`: `application/json` - Response `Content-Type`: `application/json` +A request whose `Content-Type` media type is not `application/json` MUST be rejected with the response defined in section 7 with `error.code` of `unsupported_media_type` and HTTP status `415`. Only the media type is significant: servers MUST ignore parameters such as `charset`, so `application/json; charset=utf-8` is accepted. + The endpoint MAY accept other methods (e.g. `OPTIONS` for CORS preflight) but their semantics are out of scope. ## 3. Authentication @@ -158,16 +160,19 @@ HTTP status: `4xx` or `5xx`. Registered error codes in v0.1: -| `code` | HTTP | Meaning | -|---------------------|------|-----------------------------------------------------------------| -| `bad_request` | 400 | Request body shape is invalid. | -| `sql_error` | 400 | The SQL is invalid or failed at runtime. | -| `not_allowed` | 400 | Statement shape is rejected by server policy. | -| `auth_error` | 401 | Missing or invalid credentials. | -| `permission_error` | 403 | Authenticated but not permitted to run the statement. | -| `payload_too_large` | 413 | Request body or result set exceeds a server limit. | -| `rate_limited` | 429 | Too many requests. | -| `internal_error` | 500 | Server malfunction. | +| `code` | HTTP | Meaning | +|--------------------------|------|-----------------------------------------------------------------| +| `bad_request` | 400 | Request body shape is invalid. | +| `sql_error` | 400 | The SQL is invalid or failed at runtime. | +| `not_allowed` | 400 | Statement shape is rejected by server policy. | +| `auth_error` | 401 | Missing or invalid credentials. | +| `permission_error` | 403 | Authenticated but not permitted to run the statement. | +| `payload_too_large` | 413 | Request body or result set exceeds a server limit. | +| `unsupported_media_type` | 415 | Request `Content-Type` media type is not `application/json`. | +| `rate_limited` | 429 | Too many requests. | +| `internal_error` | 500 | Server malfunction. | + +`unsupported_media_type` is introduced in v0.2. Per section 11, a new registered error code is an additive change that increments the minor version; this spec has no patch level, so the code is not available to a server that advertises `0.1`. Vendor codes carry the prefix `vendor:` (e.g. `vendor:cf_d1_quota_exceeded`). Clients SHOULD treat unknown `error.code` values as if they were the closest registered code by HTTP status family. @@ -199,7 +204,7 @@ to indicate the maximum spec version they understand. Servers MAY use this for f A v0.1 conforming server MUST: -1. Accept POST requests with `Content-Type: application/json` at one or more endpoint URLs. +1. Accept POST requests with `Content-Type: application/json` at one or more endpoint URLs, and reject other media types per section 2. 2. Accept both single-statement (section 4.1) and batch (section 4.2) request shapes. 3. Return the success envelopes defined in section 6 for successful execution. 4. Return the error envelope defined in section 7 for any failure, using the HTTP status codes in the table. diff --git a/conformance/README.md b/conformance/README.md index d4f1811..a94594c 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -2,6 +2,8 @@ A conforming http-sql v0.1 server passes the test cases below when probed at its endpoint URL with a valid bearer token. +Cases marked `(v0.2+)` exercise behavior introduced after v0.1 and are not required of a server that advertises `X-Http-Sql-Version: 0.1`. + This directory will contain a runnable TypeScript test suite. The current document defines the test cases that runner must implement, so server implementers can self-check before installing the runner. ## How conformance is claimed @@ -29,7 +31,8 @@ Conformance is self-asserted. The community can call out failures via issues. | R-1 | Body contains both `sql` and `batch` | 400, `error.code` = `bad_request` | | R-2 | Body contains neither `sql` nor `batch` | 400, `error.code` = `bad_request` | | R-3 | Body is not valid JSON | 400, `error.code` = `bad_request` | -| R-4 | `Content-Type` other than `application/json` | 400 or 415 | +| R-4 | `Content-Type` other than `application/json` (v0.2+) | 415, `error.code` = `unsupported_media_type` | +| R-5 | `Content-Type: application/json; charset=utf-8` | Executes normally -- media-type parameters are ignored | ### Single-statement execution diff --git a/examples/cloudflare-durable-object/src/index.ts b/examples/cloudflare-durable-object/src/index.ts index 482a3b4..ae09e87 100644 --- a/examples/cloudflare-durable-object/src/index.ts +++ b/examples/cloudflare-durable-object/src/index.ts @@ -31,6 +31,10 @@ app.post("/sql", async (c) => { const tenant = resolveTenant(c.req.header("authorization") ?? "", c.env); if (!tenant) return c.json({ error: { code: "auth_error", message: "missing or invalid bearer token" } }, 401); + if (!isJsonMediaType(c.req.header("content-type"))) { + return c.json({ error: { code: "unsupported_media_type", message: "Content-Type must be application/json" } }, 415); + } + const id = c.env.TENANT_DO.idFromName(tenant); const stub = c.env.TENANT_DO.get(id); return stub.fetch(c.req.raw); @@ -43,6 +47,12 @@ app.onError((err, c) => { export default app; +// SPEC.md section 2: only the media type is significant, so parameters such as +// `charset=utf-8` are ignored. +function isJsonMediaType(header: string | undefined): boolean { + return header?.split(";")[0].trim().toLowerCase() === "application/json"; +} + function resolveTenant(header: string, env: Env): string | null { const token = header.replace(/^Bearer\s+/i, "").trim(); if (!token) return null; diff --git a/examples/cloudflare-worker-to-d1/src/index.ts b/examples/cloudflare-worker-to-d1/src/index.ts index 2918988..972d7f9 100644 --- a/examples/cloudflare-worker-to-d1/src/index.ts +++ b/examples/cloudflare-worker-to-d1/src/index.ts @@ -38,6 +38,10 @@ app.post( "/", async (c, next) => bearerAuth({ token: c.env.HTTP_SQL_TOKEN })(c, next), async (c) => { + if (!isJsonMediaType(c.req.header("content-type"))) { + return c.json({ error: { code: "unsupported_media_type", message: "Content-Type must be application/json" } }, 415); + } + let body: SingleRequest | BatchRequest; try { body = await c.req.json(); } catch { return c.json({ error: { code: "bad_request", message: "invalid JSON" } }, 400); } @@ -115,6 +119,12 @@ function projectD1Result(res: D1Result): StatementResult { }; } +// SPEC.md section 2: only the media type is significant, so parameters such as +// `charset=utf-8` are ignored. +function isJsonMediaType(header: string | undefined): boolean { + return header?.split(";")[0].trim().toLowerCase() === "application/json"; +} + // Tagged values per SPEC.md section 5. function decodeParam(value: unknown): unknown { if (value && typeof value === "object" && "$type" in value && "$value" in value) { diff --git a/examples/reference-server.ts b/examples/reference-server.ts index 15f0f99..26578f3 100644 --- a/examples/reference-server.ts +++ b/examples/reference-server.ts @@ -22,6 +22,9 @@ const JSON_HEADERS = { "content-type": "application/json", ...VERSION_HEADER }; export async function handle(req: Request, auth: (req: Request) => boolean): Promise { if (!auth(req)) return errorResponse(401, "auth_error", "missing or invalid bearer token"); if (req.method !== "POST") return errorResponse(405, "bad_request", "POST required"); + if (!isJsonMediaType(req.headers.get("content-type"))) { + return errorResponse(415, "unsupported_media_type", "Content-Type must be application/json"); + } let body: RequestBody; try { body = await req.json(); } @@ -47,6 +50,12 @@ export async function handle(req: Request, auth: (req: Request) => boolean): Pro } } +// SPEC.md section 2: only the media type is significant, so parameters such as +// `charset=utf-8` are ignored. +function isJsonMediaType(header: string | null): boolean { + return header?.split(";")[0].trim().toLowerCase() === "application/json"; +} + // Replace these with calls to your actual database client. async function execute(_stmt: Statement): Promise { return { columns: [], rows: [], rowsAffected: 0, lastInsertId: null };