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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
Claude 2026-07-06 23:20:47 +00:00
commit 87cd695ca3
No known key found for this signature in database
8 changed files with 69 additions and 39 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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 <binary_json_format>` using Datasette's Base64 representation for BLOB data.
.. _ExecuteWriteView:

View file

@ -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

View file

@ -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

View file

@ -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",
},
)

View file

@ -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":

View file

@ -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