From 87cd695ca3b9293937228762a75f94e8a4931469 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 23:20:47 +0000 Subject: [PATCH] Write endpoints parse the body as JSON regardless of Content-Type The insert, upsert, alter and set-column-type endpoints previously required Content-Type: application/json while /-/create parsed the body blind - and insert returned a 500 AttributeError when the header was missing entirely. The lenient rule is now uniform: the body is always parsed as JSON and invalid JSON is a 400. This makes curl -d and requests data=json.dumps(...) invocations work without remembering the header. Cross-site request forgery remains prevented by the Origin and Sec-Fetch-Site checks in CrossOriginProtectionMiddleware, which is the defense the strict content-type requirement was historically standing in for. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ --- datasette/views/table.py | 8 +--- datasette/views/table_create_alter.py | 4 -- docs/json_api.rst | 2 + existing-api.md | 6 +-- stable-api-recommendations.md | 8 +++- tests/test_api_write.py | 13 +------ tests/test_column_types.py | 12 +----- tests/test_error_shape.py | 55 +++++++++++++++++++++++++++ 8 files changed, 69 insertions(+), 39 deletions(-) diff --git a/datasette/views/table.py b/datasette/views/table.py index 0a9c88d4..80ad1aab 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -951,9 +951,7 @@ class TableInsertView(BaseView): def _errors(errors): return None, errors, {} - if not request.headers.get("content-type").startswith("application/json"): - # TODO: handle form-encoded data - return _errors(["Invalid content-type, must be application/json"]) + # The body is parsed as JSON regardless of the Content-Type header try: data = await request.json() except json.JSONDecodeError as e: @@ -1260,10 +1258,6 @@ class TableSetColumnTypeView(BaseView): ): return _error(["Permission denied"], 403) - content_type = request.headers.get("content-type") or "" - if not content_type.startswith("application/json"): - return _error(["Invalid content-type, must be application/json"], 400) - try: data = await request.json() except json.JSONDecodeError as e: diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index d0384184..c3e06c29 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -1166,10 +1166,6 @@ class TableAlterView(BaseView): if not db.is_mutable: return _error(["Database is immutable"], 403) - content_type = request.headers.get("content-type") or "" - if not content_type.startswith("application/json"): - return _error(["Invalid content-type, must be application/json"], 400) - try: data = await request.json() except json.JSONDecodeError as e: diff --git a/docs/json_api.rst b/docs/json_api.rst index c31ba756..893af634 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1668,6 +1668,8 @@ The JSON write API Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. +The request body is always parsed as JSON, regardless of the request's ``Content-Type`` header - a body that is not valid JSON returns a ``400`` error. Cross-site request forgery is prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` header checks rather than by content type requirements. + The row-based write APIs can write :ref:`binary values in JSON ` using Datasette's Base64 representation for BLOB data. .. _ExecuteWriteView: diff --git a/existing-api.md b/existing-api.md index 55e9d6a2..c58f8f41 100644 --- a/existing-api.md +++ b/existing-api.md @@ -853,8 +853,8 @@ shape) and check permissions with additionally required for `alter: true` (403 `Permission denied for alter-table`). Immutable database → 403 `Database is immutable`. -- **Request** — requires `Content-Type: application/json` (else 400 - `"Invalid content-type, must be application/json"`). Body: +- **Request** — the body is parsed as JSON regardless of the request + `Content-Type` header (invalid JSON → 400). Body: | Field | Rules | |---|---| @@ -937,7 +937,7 @@ shape) and check permissions with does not change the SQLite schema. - **Permission:** `set-column-type` (403 `Permission denied`). -- **Request** (JSON content type required): `{"column": "name", +- **Request**: `{"column": "name", "column_type": {"type": "url", "config": {...}?} | null}`. Unknown keys/invalid structure → detailed 400 errors; unknown type → 400 `"Unknown column type: x"`. Default registered types (via the diff --git a/stable-api-recommendations.md b/stable-api-recommendations.md index 9e8fa356..30dd0429 100644 --- a/stable-api-recommendations.md +++ b/stable-api-recommendations.md @@ -254,11 +254,15 @@ Concerns: executes immediately. Decide the 1.0 rule (suggestion: confirmation only for schema-destroying operations, i.e. keep as is — but document it as a deliberate contract). -- **Content-type enforcement is inconsistent:** `/-/insert`, `/-/upsert`, +- ~~**Content-type enforcement is inconsistent:** `/-/insert`, `/-/upsert`, `/-/alter`, `/-/set-column-type` demand `Content-Type: application/json` (400 otherwise); `/-/create` parses the body as JSON regardless of content type; execute-write and the query CRUD endpoints accept both JSON - and form encodings. Pick one rule for JSON-only endpoints. + and form encodings. Pick one rule for JSON-only endpoints.~~ ✅ **Done** + — the lenient rule won: JSON-only write endpoints parse the body as JSON + regardless of `Content-Type` (CSRF protection comes from the + cross-origin header checks, not content types). This also fixed a 500 on + insert when the header was absent entirely. - **JSON-vs-HTML negotiation on POST differs per endpoint:** execute-write and canned queries key off `Accept: application/json` / a `_json` body field; the write API keys off nothing (always JSON); query store keys off diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 14dedb73..840bd05b 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -394,13 +394,6 @@ async def test_insert_rows_post_body_too_large(tmp_path_factory): "Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" ], ), - ( - "/data/docs/-/insert", - {}, - "invalid_content_type", - 400, - ["Invalid content-type, must be application/json"], - ), ( "/data/docs/-/insert", [], @@ -582,11 +575,7 @@ async def test_insert_or_upsert_row_errors( json=input, headers={ "Authorization": "Bearer {}".format(token), - "Content-Type": ( - "text/plain" - if special_case == "invalid_content_type" - else "application/json" - ), + "Content-Type": "application/json", }, ) diff --git a/tests/test_column_types.py b/tests/test_column_types.py index 4e553771..cd308ec9 100644 --- a/tests/test_column_types.py +++ b/tests/test_column_types.py @@ -322,12 +322,6 @@ async def test_clear_column_type_api(ds_ct): "Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)" ], ), - ( - {"column": "title", "column_type": {"type": "email"}}, - "invalid_content_type", - 400, - ["Invalid content-type, must be application/json"], - ), ( [], None, @@ -413,11 +407,7 @@ async def test_set_column_type_api_errors( kwargs = { "headers": { "Authorization": f"Bearer {token}", - "Content-Type": ( - "text/plain" - if special_case == "invalid_content_type" - else "application/json" - ), + "Content-Type": "application/json", } } if special_case == "invalid_json": diff --git a/tests/test_error_shape.py b/tests/test_error_shape.py index b5f16b30..040e645a 100644 --- a/tests/test_error_shape.py +++ b/tests/test_error_shape.py @@ -662,3 +662,58 @@ async def test_debug_endpoints_use_size_and_page_parameters(ds_error_shape, endp bad_page = await ds_error_shape.client.get(base + "&_page=0", actor={"id": "root"}) data = assert_canonical_error(bad_page, 400) assert data["errors"] == ["_page must be a positive integer"] + + +# Write endpoints parse the body as JSON regardless of Content-Type + + +@pytest.mark.asyncio +async def test_insert_works_without_content_type_header(ds_error_shape): + # Previously a 500 AttributeError + response = await ds_error_shape.client.post( + "/data/docs/-/insert", + content='{"row": {"id": 1, "title": "One"}}', + actor={"id": "root"}, + ) + assert response.status_code == 201 + assert response.json()["rows"][0]["title"] == "One" + + +@pytest.mark.asyncio +async def test_insert_works_with_form_content_type(ds_error_shape): + # Previously 400 "Invalid content-type, must be application/json" + response = await ds_error_shape.client.post( + "/data/docs/-/insert", + content='{"row": {"id": 2, "title": "Two"}}', + headers={"Content-Type": "application/x-www-form-urlencoded"}, + actor={"id": "root"}, + ) + assert response.status_code == 201 + + +@pytest.mark.asyncio +async def test_insert_form_encoded_body_is_invalid_json(ds_error_shape): + response = await ds_error_shape.client.post( + "/data/docs/-/insert", + content="title=Three", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + actor={"id": "root"}, + ) + data = assert_canonical_error(response, 400) + assert data["errors"][0].startswith("Invalid JSON:") + + +@pytest.mark.asyncio +async def test_alter_and_set_column_type_ignore_content_type(ds_error_shape): + alter = await ds_error_shape.client.post( + "/data/docs/-/alter", + content='{"operations": [{"op": "add_column", "args": {"name": "extra"}}]}', + actor={"id": "root"}, + ) + assert alter.status_code == 200, alter.text + sct = await ds_error_shape.client.post( + "/data/docs/-/set-column-type", + content='{"column": "title", "column_type": {"type": "textarea"}}', + actor={"id": "root"}, + ) + assert sct.status_code == 200, sct.text