mirror of
https://github.com/simonw/datasette.git
synced 2026-07-09 17:14:35 +02:00
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:
parent
022eb6d3a0
commit
87cd695ca3
8 changed files with 69 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue