mirror of
https://github.com/simonw/datasette.git
synced 2026-08-02 23:44:09 +02:00
All JSON error responses now use a single format built by the new
datasette.utils.error_body() helper:
{"ok": false, "error": "...", "errors": ["..."], "status": 400}
- error is all messages joined with '; ', errors is the full list,
status always matches the HTTP status code
- The exception handler no longer emits the legacy title key in JSON
(it is still available to the HTML error template)
- The permission debug endpoints (/-/allowed, /-/rules, /-/check,
POST /-/permissions) no longer return bare {"error": ...} objects
- JSON renderer SQL errors keep their rows/truncated context keys but
now include the canonical keys as well
- _shape=object misuse (queries or tables without primary keys) now
returns HTTP 400 instead of 200 with an error body
- Method-not-allowed 405 responses use the canonical shape
Adds tests/test_error_shape.py covering all four previous shape
producers, updates affected tests, and documents the format in a new
'Error responses' section of docs/json_api.rst.
Implements section 1 of stable-api-recommendations.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
122 lines
4.2 KiB
Python
122 lines
4.2 KiB
Python
import json
|
|
from datasette.extras import extra_names_from_request
|
|
from datasette.utils import (
|
|
error_body,
|
|
value_as_boolean,
|
|
remove_infinites,
|
|
CustomJSONEncoder,
|
|
path_from_row_pks,
|
|
sqlite3,
|
|
)
|
|
from datasette.utils.asgi import Response
|
|
|
|
|
|
def convert_specific_columns_to_json(rows, columns, json_cols):
|
|
json_cols = set(json_cols)
|
|
if not json_cols.intersection(columns):
|
|
return rows
|
|
new_rows = []
|
|
for row in rows:
|
|
new_row = []
|
|
for value, column in zip(row, columns):
|
|
if column in json_cols:
|
|
try:
|
|
value = json.loads(value)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
new_row.append(value)
|
|
new_rows.append(new_row)
|
|
return new_rows
|
|
|
|
|
|
def json_renderer(request, args, data, error, truncated=None):
|
|
"""Render a response as JSON"""
|
|
status_code = 200
|
|
|
|
# Handle the _json= parameter which may modify data["rows"]
|
|
json_cols = []
|
|
if "_json" in args:
|
|
json_cols = args.getlist("_json")
|
|
if json_cols and "rows" in data and "columns" in data:
|
|
data["rows"] = convert_specific_columns_to_json(
|
|
data["rows"], data["columns"], json_cols
|
|
)
|
|
|
|
# unless _json_infinity=1 requested, replace infinity with None
|
|
if "rows" in data and not value_as_boolean(args.get("_json_infinity", "0")):
|
|
data["rows"] = [remove_infinites(row) for row in data["rows"]]
|
|
|
|
# Deal with the _shape option
|
|
shape = args.get("_shape", "objects")
|
|
# if there's an error, ignore the shape entirely
|
|
data["ok"] = True
|
|
if error:
|
|
shape = "objects"
|
|
status_code = 400
|
|
data.update(error_body(error, status_code))
|
|
|
|
if truncated is not None:
|
|
data["truncated"] = truncated
|
|
if shape == "arrayfirst":
|
|
if not data["rows"]:
|
|
data = []
|
|
elif isinstance(data["rows"][0], sqlite3.Row):
|
|
data = [row[0] for row in data["rows"]]
|
|
else:
|
|
assert isinstance(data["rows"][0], dict)
|
|
data = [next(iter(row.values())) for row in data["rows"]]
|
|
elif shape in ("objects", "object", "array"):
|
|
columns = data.get("columns")
|
|
rows = data.get("rows")
|
|
if rows and columns and not isinstance(rows[0], dict):
|
|
data["rows"] = [dict(zip(columns, row)) for row in rows]
|
|
if shape == "object":
|
|
shape_error = None
|
|
if "primary_keys" not in data:
|
|
shape_error = "_shape=object is only available on tables"
|
|
else:
|
|
pks = data["primary_keys"]
|
|
if not pks:
|
|
shape_error = (
|
|
"_shape=object not available for tables with no primary keys"
|
|
)
|
|
else:
|
|
object_rows = {}
|
|
for row in data["rows"]:
|
|
pk_string = path_from_row_pks(row, pks, not pks)
|
|
object_rows[pk_string] = row
|
|
data = object_rows
|
|
if shape_error:
|
|
status_code = 400
|
|
data = error_body(shape_error, status_code)
|
|
elif shape == "array":
|
|
data = data["rows"]
|
|
|
|
elif shape == "arrays":
|
|
if not data["rows"]:
|
|
pass
|
|
elif isinstance(data["rows"][0], sqlite3.Row):
|
|
data["rows"] = [list(row) for row in data["rows"]]
|
|
else:
|
|
data["rows"] = [list(row.values()) for row in data["rows"]]
|
|
else:
|
|
status_code = 400
|
|
data = error_body(f"Invalid _shape: {shape}", status_code)
|
|
|
|
# Don't include "columns" in output
|
|
# https://github.com/simonw/datasette/issues/2136
|
|
if isinstance(data, dict) and "columns" not in extra_names_from_request(request):
|
|
data.pop("columns", None)
|
|
|
|
# Handle _nl option for _shape=array
|
|
nl = args.get("_nl", "")
|
|
if nl and shape == "array":
|
|
body = "\n".join(json.dumps(item, cls=CustomJSONEncoder) for item in data)
|
|
content_type = "text/plain"
|
|
else:
|
|
body = json.dumps(data, cls=CustomJSONEncoder)
|
|
content_type = "application/json; charset=utf-8"
|
|
headers = {}
|
|
return Response(
|
|
body, status=status_code, headers=headers, content_type=content_type
|
|
)
|