Fable review of JSON API consistency and subsequent improvements

Merge PR #2824
This commit is contained in:
Simon Willison 2026-07-06 22:30:19 -07:00 committed by GitHub
commit 27a5be1326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2189 additions and 597 deletions

View file

@ -1,7 +1,7 @@
from datasette.permissions import Permission # noqa
from datasette.version import __version_info__, __version__ # noqa
from datasette.events import Event # noqa
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
from datasette.utils.asgi import ( # noqa
Forbidden,
NotFound,

View file

@ -111,6 +111,7 @@ from .utils import (
baseconv,
call_with_supported_arguments,
detect_json1,
add_cors_headers,
display_actor,
escape_css_string,
escape_sqlite,
@ -130,6 +131,7 @@ from .utils import (
redact_keys,
row_sql_params_pks,
)
from .tokens import TokenInvalid
from .utils.asgi import (
AsgiLifespan,
Forbidden,
@ -910,7 +912,9 @@ class Datasette:
Verify an API token by trying all registered token handlers.
Returns an actor dict from the first handler that recognizes the
token, or None if no handler accepts it.
token, or None if no handler accepts it. A handler may raise
TokenInvalid for a token it recognizes but rejects (bad signature,
expired) - Datasette turns that into a 401 response.
"""
for token_handler in self._token_handlers():
result = await token_handler.verify_token(self, token)
@ -2173,6 +2177,18 @@ class Datasette:
for name, d in self.databases.items()
]
async def _connected_databases_for_actor(self, actor):
page = await self.allowed_resources("view-database", actor)
allowed_names = {resource.parent async for resource in page.all()}
return [
database
for database in self._connected_databases()
if database["name"] in allowed_names
]
async def _databases_data(self, request):
return {"databases": await self._connected_databases_for_actor(request.actor)}
def _versions(self):
conn = sqlite3.connect(":memory:")
self._prepare_connection(conn, "_memory")
@ -2519,8 +2535,8 @@ class Datasette:
def add_route(view, regex):
routes.append((regex, view))
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$")
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$")
add_route(permanent_redirect("/-/"), r"/-$")
add_route(favicon, "/favicon.ico")
@ -2556,7 +2572,10 @@ class Datasette:
)
add_route(
JsonDataView.as_view(
self, "plugins.json", self._plugins, needs_request=True
self,
"plugins.json",
lambda request: {"plugins": self._plugins(request)},
needs_request=True,
),
r"/-/plugins(\.(?P<format>json))?$",
)
@ -2569,11 +2588,18 @@ class Datasette:
r"/-/config(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(self, "threads.json", self._threads),
JsonDataView.as_view(
self, "threads.json", self._threads, permission="permissions-debug"
),
r"/-/threads(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(self, "databases.json", self._connected_databases),
JsonDataView.as_view(
self,
"databases.json",
self._databases_data,
needs_request=True,
),
r"/-/databases(\.(?P<format>json))?$",
)
add_route(
@ -2586,7 +2612,7 @@ class Datasette:
JsonDataView.as_view(
self,
"actions.json",
self._actions,
lambda: {"actions": self._actions()},
template="debug_actions.html",
permission="permissions-debug",
),
@ -2876,13 +2902,24 @@ class DatasetteRouter:
# Handle authentication
default_actor = scope.get("actor") or None
actor = None
token_error = None
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
for result in results:
result = await await_me_maybe(result)
try:
result = await await_me_maybe(result)
except TokenInvalid as ex:
# A presented token was recognized but rejected - fail the
# request with a 401 even if another credential is valid,
# but keep awaiting the remaining coroutines first
if token_error is None:
token_error = ex
continue
if result and actor is None:
actor = result
# Don't break — we must await all coroutines to avoid
# "coroutine was never awaited" warnings
if token_error is not None:
return await self.handle_401(request, send, token_error)
scope_modifications["actor"] = actor or default_actor
scope = dict(scope, **scope_modifications)
@ -2914,6 +2951,15 @@ class DatasetteRouter:
except Exception as exception:
return await self.handle_exception(request, send, exception)
async def handle_401(self, request, send, exception):
# A presented bearer token was recognized by a handler but rejected.
# Bearer tokens are API credentials, so this is always JSON.
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
if self.ds.cors:
add_cors_headers(headers)
response = Response.error([str(exception)], 401, headers=headers)
await response.asgi_send(send)
async def handle_404(self, request, send, exception=None):
# If path contains % encoding, redirect to tilde encoding
if "%" in request.path:

View file

@ -5,6 +5,8 @@ from typing import ClassVar
from asyncinject import Registry
from datasette.utils.asgi import BadRequest
def extra_names_from_request(request):
extra_bits = request.args.getlist("_extra")
@ -113,6 +115,17 @@ class ExtraRegistry:
self._allowed_names[key] = names
return names
def validate_requested(self, requested, scope):
"""
Raise BadRequest if any requested extra name is not a public extra
for this scope. Used by data formats such as .json - HTML pages
silently ignore unknown names instead.
"""
allowed = self._allowed_names_for_scope(scope, include_internal=False)
unknown = sorted(name for name in requested if name not in allowed)
if unknown:
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
async def resolve(self, requested, context, scope, include_internal=False):
allowed_names = self._allowed_names_for_scope(scope, include_internal)
requested_names = [name for name in requested if name in allowed_names]

View file

@ -1,9 +1,19 @@
from datasette import hookimpl, Response
from .utils import add_cors_headers
@hookimpl(trylast=True)
def forbidden(datasette, request, message):
async def inner():
if (
request.path.split("?")[0].endswith(".json")
or "application/json" in (request.headers.get("accept") or "")
or request.headers.get("content-type") == "application/json"
):
headers = {}
if datasette.cors:
add_cors_headers(headers)
return Response.error(message, 403, headers=headers)
return Response.html(
await datasette.render_template(
"error.html",

View file

@ -1,5 +1,5 @@
from datasette import hookimpl, Response
from .utils import add_cors_headers
from .utils import add_cors_headers, error_body
from .utils.asgi import (
Base400,
)
@ -28,6 +28,7 @@ def handle_exception(datasette, request, exception):
rich.get_console().print_exception(show_locals=True)
title = None
plain_message = None
if isinstance(exception, Base400):
status = exception.status
info = {}
@ -36,6 +37,7 @@ def handle_exception(datasette, request, exception):
status = exception.status
info = exception.error_dict
message = exception.message
plain_message = exception.plain_message
if exception.message_is_html:
message = Markup(message)
title = exception.title
@ -45,6 +47,13 @@ def handle_exception(datasette, request, exception):
message = str(exception)
traceback.print_exc()
templates = [f"{status}.html", "error.html"]
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
body = dict(info)
body.update(error_body(plain_message or message, status))
return Response.json(body, status=status, headers=headers)
info.update(
{
"ok": False,
@ -53,24 +62,18 @@ def handle_exception(datasette, request, exception):
"title": title,
}
)
headers = {}
if datasette.cors:
add_cors_headers(headers)
if request.path.split("?")[0].endswith(".json"):
return Response.json(info, status=status, headers=headers)
else:
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
environment = datasette.get_jinja_environment(request)
template = environment.select_template(templates)
return Response.html(
await template.render_async(
dict(
info,
urls=datasette.urls,
menu_links=lambda: [],
)
),
status=status,
headers=headers,
)
return inner

View file

@ -1,6 +1,7 @@
import json
from datasette.extras import extra_names_from_request
from datasette.utils import (
error_body,
value_as_boolean,
remove_infinites,
CustomJSONEncoder,
@ -52,8 +53,7 @@ def json_renderer(request, args, data, error, truncated=None):
if error:
shape = "objects"
status_code = 400
data["error"] = error
data["ok"] = False
data.update(error_body(error, status_code))
if truncated is not None:
data["truncated"] = truncated
@ -87,7 +87,8 @@ def json_renderer(request, args, data, error, truncated=None):
object_rows[pk_string] = row
data = object_rows
if shape_error:
data = {"ok": False, "error": shape_error}
status_code = 400
data = error_body(shape_error, status_code)
elif shape == "array":
data = data["rows"]
@ -100,12 +101,7 @@ def json_renderer(request, args, data, error, truncated=None):
data["rows"] = [list(row.values()) for row in data["rows"]]
else:
status_code = 400
data = {
"ok": False,
"error": f"Invalid _shape: {shape}",
"status": 400,
"title": None,
}
data = error_body(f"Invalid _shape: {shape}", status_code)
# Don't include "columns" in output
# https://github.com/simonw/datasette/issues/2136

View file

@ -62,7 +62,6 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
"description_html": query.description_html,
"hide_sql": query.hide_sql,
"fragment": query.fragment,
"params": list(query.parameters),
"parameters": list(query.parameters),
"is_write": query.is_write,
"is_private": query.is_private,
@ -84,7 +83,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return {
"queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next,
"has_more": page.has_more,
"limit": page.limit,
}

View file

@ -9,7 +9,7 @@
{% include "_permissions_debug_tabs.html" %}
<p style="margin-bottom: 2em;">
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
Actions are used by the permission system to control access to different features.
</p>
@ -26,7 +26,7 @@
</tr>
</thead>
<tbody>
{% for action in data %}
{% for action in data.actions %}
<tr>
<td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

View file

@ -49,7 +49,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -88,7 +88,7 @@ const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('page');
const page = params.get('_page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -102,14 +102,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== 'page_size') {
if (value && key !== '_size' && key !== '_page') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('page', page.toString());
params.append('page_size', pageSize);
params.append('_page', page.toString());
params.append('_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {

View file

@ -37,7 +37,7 @@
<div class="form-section">
<label for="page_size">Page size:</label>
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
<small>Number of results per page (max 200)</small>
</div>
@ -75,7 +75,7 @@ const submitBtn = document.getElementById('submit-btn');
(function() {
const params = populateFormFromURL();
const action = params.get('action');
const page = params.get('page');
const page = params.get('_page');
if (action) {
fetchResults(page ? parseInt(page) : 1);
}
@ -89,14 +89,14 @@ async function fetchResults(page = 1) {
const params = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (value && key !== 'page_size') {
if (value && key !== '_size' && key !== '_page') {
params.append(key, value);
}
}
const pageSize = document.getElementById('page_size').value || '50';
params.append('page', page.toString());
params.append('page_size', pageSize);
params.append('_page', page.toString());
params.append('_size', pageSize);
try {
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {

View file

@ -18,6 +18,21 @@ if TYPE_CHECKING:
from datasette.app import Datasette
class TokenInvalid(Exception):
"""
Raised by a TokenHandler when a token it recognizes is invalid -
for example a bad signature, malformed payload or expired token.
Datasette responds to this with an HTTP 401 error. Handlers should
return None instead for tokens they do not recognize at all, so that
other registered handlers get a chance to verify them.
"""
def __init__(self, message="Invalid token"):
self.message = message
super().__init__(message)
@dataclasses.dataclass
class TokenRestrictions:
"""
@ -108,8 +123,12 @@ class TokenHandler:
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
"""
Verify a token and return an actor dict, or None if this handler
does not recognize the token.
Verify a token and return an actor dict.
Return None if this handler does not recognize the token at all,
so other handlers can try it. Raise TokenInvalid if the token is
recognized but invalid (bad signature, malformed, expired) - the
request will fail with a 401 error.
"""
raise NotImplementedError
@ -147,29 +166,32 @@ class SignedTokenHandler(TokenHandler):
async def verify_token(self, datasette: "Datasette", token: str) -> Optional[dict]:
prefix = "dstok_"
if not datasette.setting("allow_signed_tokens"):
if not token.startswith(prefix):
# Not one of our tokens - leave it for other handlers
return None
if not datasette.setting("allow_signed_tokens"):
raise TokenInvalid(
"Signed tokens are not enabled for this Datasette instance"
)
max_signed_tokens_ttl = datasette.setting("max_signed_tokens_ttl")
if not token.startswith(prefix):
return None
raw = token[len(prefix) :]
try:
decoded = datasette.unsign(raw, namespace="token")
except itsdangerous.BadSignature:
return None
raise TokenInvalid("Invalid token signature")
if "t" not in decoded:
return None
raise TokenInvalid("Invalid token: no timestamp")
created = decoded["t"]
if not isinstance(created, int):
return None
raise TokenInvalid("Invalid token: invalid timestamp")
duration = decoded.get("d")
if duration is not None and not isinstance(duration, int):
return None
raise TokenInvalid("Invalid token: invalid duration")
if (duration is None and max_signed_tokens_ttl) or (
duration is not None
@ -180,7 +202,7 @@ class SignedTokenHandler(TokenHandler):
if duration:
if time.time() - created > duration:
return None
raise TokenInvalid("Token has expired")
actor = {"id": decoded["a"], "token": "dstok"}

View file

@ -1318,6 +1318,54 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
return named_parameters(sql)
def parse_size_limit(value, default, maximum, name="_size"):
"""
Parse a page-size parameter using the same semantics as the table
view's ?_size=: blank means default, "max" means maximum, integers
must be 0 or greater and no larger than maximum. Raises ValueError
with a message suitable for a 400 response.
"""
if value in (None, ""):
return default
if value == "max":
return maximum
try:
size = int(value)
if size < 0:
raise ValueError
except ValueError:
raise ValueError("{} must be a positive integer".format(name))
if size > maximum:
raise ValueError("{} must be <= {}".format(name, maximum))
return size
UNSTABLE_API_MESSAGE = (
"This API is not part of Datasette's stable interface and may change at any time"
)
def error_body(messages, status):
"""
The canonical JSON error body used by every Datasette JSON error response:
{"ok": False, "error": "...", "errors": ["...", ...], "status": 400}
"error" is all of the messages joined with "; ", "errors" is the full
list, "status" matches the HTTP status code. Callers may add extra
context keys to the returned dictionary but must not remove these four.
"""
if isinstance(messages, str):
messages = [messages]
messages = [str(message) for message in messages]
return {
"ok": False,
"error": "; ".join(messages),
"errors": messages,
"status": status,
}
def add_cors_headers(headers):
headers["Access-Control-Allow-Origin"] = "*"
headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"

View file

@ -1,6 +1,6 @@
import json
from typing import Optional
from datasette.utils import MultiParams, calculate_etag, sha256_file
from datasette.utils import MultiParams, calculate_etag, error_body, sha256_file
from datasette.utils.multipart import (
parse_form_data,
MultipartParseError,
@ -575,6 +575,18 @@ class Response:
content_type="application/json; charset=utf-8",
)
@classmethod
def error(cls, messages, status=400, headers=None):
"""
A JSON error response using Datasette's standard error format.
messages can be a single string or a list of strings. For errors
that should content-negotiate between JSON and HTML, raise
Forbidden, NotFound, BadRequest or DatasetteError instead and let
Datasette's error handling hooks build the response.
"""
return cls.json(error_body(messages, status), status=status, headers=headers)
@classmethod
def redirect(cls, path, status=302, headers=None):
headers = headers or {}

View file

@ -28,12 +28,15 @@ class DatasetteError(Exception):
status=500,
template=None,
message_is_html=False,
plain_message=None,
):
self.message = message
self.title = title
self.error_dict = error_dict or {}
self.status = status
self.message_is_html = message_is_html
# Plain text used for JSON error responses when message is HTML
self.plain_message = plain_message
class View:
@ -49,9 +52,7 @@ class View:
request.path.endswith(".json")
or request.headers.get("content-type") == "application/json"
):
response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
response = Response.error("Method not allowed", 405)
else:
response = Response.text("Method not allowed", status=405)
return response
@ -90,9 +91,7 @@ class BaseView:
request.path.endswith(".json")
or request.headers.get("content-type") == "application/json"
):
response = Response.json(
{"ok": False, "error": "Method not allowed"}, status=405
)
response = Response.error("Method not allowed", 405)
else:
response = Response.text("Method not allowed", status=405)
return response
@ -180,10 +179,6 @@ class BaseView:
return view
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)
async def stream_csv(datasette, fetch_data, request, database):
kwargs = {}
stream = request.args.get("_stream")

View file

@ -8,7 +8,7 @@ import markupsafe
import os
import textwrap
from datasette.extras import extra_names_from_request
from datasette.extras import extra_names_from_request, ExtraScope
from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, stored_query_to_dict
@ -16,6 +16,7 @@ from datasette.write_sql import QueryWriteRejected
from datasette.utils import (
add_cors_headers,
await_me_maybe,
error_body,
call_with_supported_arguments,
named_parameters as derive_named_parameters,
format_bytes,
@ -607,11 +608,7 @@ class QueryView(View):
"_json"
):
return Response.json(
{
"ok": False,
"message": ex.message,
"redirect": None,
},
dict(error_body([ex.message], 403), redirect=None),
status=403,
)
datasette.add_message(request, ex.message, datasette.ERROR)
@ -681,12 +678,17 @@ class QueryView(View):
redirect_url = stored_query.on_error_redirect
ok = False
if should_return_json:
if ok:
return Response.json(
{
"ok": True,
"message": message,
"redirect": redirect_url,
}
)
return Response.json(
{
"ok": ok,
"message": message,
"redirect": redirect_url,
}
dict(error_body([message], 400), redirect=redirect_url),
status=400,
)
else:
datasette.add_message(request, message, message_type)
@ -817,6 +819,10 @@ class QueryView(View):
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except sqlite3.DatabaseError as ex:
query_error = str(ex)
@ -849,8 +855,11 @@ class QueryView(View):
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
elif format_ in datasette.renderers.keys():
if not sql:
raise DatasetteError("?sql= is required", status=400)
data = {"ok": True, "rows": rows, "columns": columns}
extras = extra_names_from_request(request)
table_extra_registry.validate_requested(extras, ExtraScope.QUERY)
if extras:
query_extra_context = QueryExtraContext(
datasette=datasette,

View file

@ -2,10 +2,10 @@ import re
from urllib.parse import urlencode
from datasette.resources import DatabaseResource
from datasette.utils import sqlite3
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3
from datasette.utils.asgi import Response
from .base import BaseView, _error
from .base import BaseView
from .database import display_rows as display_query_rows
from .query_helpers import (
QueryValidationError,
@ -348,7 +348,7 @@ class ExecuteWriteView(BaseView):
)
if not db.is_mutable:
return _block_framing(
_error(
Response.error(
["Cannot execute write SQL because this database is immutable."],
403,
)
@ -367,10 +367,10 @@ class ExecuteWriteView(BaseView):
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
Response.error(["Permission denied: need execute-write-sql"], 403)
)
if not db.is_mutable:
return _block_framing(_error(["Database is immutable"], 403))
return _block_framing(Response.error(["Database is immutable"], 403))
data = {}
is_json = request.headers.get("content-type", "").startswith("application/json")
@ -384,7 +384,7 @@ class ExecuteWriteView(BaseView):
)
except QueryValidationError as ex:
if _wants_json(request, is_json, data):
return _block_framing(_error([ex.message], ex.status))
return _block_framing(Response.error([ex.message], ex.status))
if ex.flash:
self.ds.add_message(request, ex.message, self.ds.ERROR)
return await self._render_form(
@ -405,7 +405,7 @@ class ExecuteWriteView(BaseView):
except sqlite3.DatabaseError as ex:
message = str(ex)
if wants_json:
return _block_framing(_error([message], 400))
return _block_framing(Response.error([message], 400))
return await self._render_form(
request,
db,
@ -488,20 +488,18 @@ class ExecuteWriteAnalyzeView(BaseView):
actor=request.actor,
):
return _block_framing(
_error(["Permission denied: need execute-write-sql"], 403)
Response.error(["Permission denied: need execute-write-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))

View file

@ -6,6 +6,7 @@ from datasette.utils import (
await_me_maybe,
make_slot_function,
CustomJSONEncoder,
UNSTABLE_API_MESSAGE,
)
from datasette.utils.asgi import Response
from datasette.version import __version__
@ -151,7 +152,9 @@ class IndexView(BaseView):
return Response(
json.dumps(
{
"databases": {db["name"]: db for db in databases},
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": databases,
"metadata": await self.ds.get_instance_metadata(),
},
cls=CustomJSONEncoder,

View file

@ -13,6 +13,7 @@ from datasette.write_sql import (
operation_is_write,
)
from datasette.utils import (
parse_size_limit,
named_parameters as derive_named_parameters,
escape_sqlite,
path_from_row_pks,
@ -32,7 +33,6 @@ _query_fields = {
"hide_sql",
"fragment",
"parameters",
"params",
"is_private",
"on_success_message",
"on_success_redirect",
@ -94,13 +94,11 @@ def _as_optional_bool(value, name):
raise QueryValidationError("{} must be 0 or 1".format(name))
def _query_list_limit(value, default=50):
if value in (None, ""):
return default
def _query_list_limit(value, default, maximum):
try:
return min(max(1, int(value)), 1000)
return parse_size_limit(value, default, maximum)
except ValueError as ex:
raise QueryValidationError("_size must be an integer") from ex
raise QueryValidationError(str(ex)) from ex
def _derived_query_parameters(sql):
@ -541,7 +539,7 @@ async def _prepare_query_create(datasette, request, db, data):
raise QueryValidationError("Writable query fields require writable SQL")
parameters = _coerce_query_parameters(
data.get("parameters", data.get("params")),
data.get("parameters"),
derived,
)
return {
@ -586,9 +584,9 @@ async def _prepare_query_update(datasette, request, db, existing: StoredQuery, u
actor=request.actor,
)
if "parameters" in update or "params" in update:
if "parameters" in update:
parameters = _coerce_query_parameters(
update.get("parameters", update.get("params")),
update.get("parameters"),
derived,
)
elif "sql" in update:

View file

@ -12,7 +12,7 @@ from datasette.utils.asgi import NotFound, Forbidden, PayloadTooLarge, Response
from datasette.database import QueryInterrupted
from datasette.events import UpdateRowEvent, DeleteRowEvent
from datasette.resources import TableResource
from .base import BaseView, DatasetteError, _error, stream_csv
from .base import BaseView, DatasetteError, stream_csv
from datasette.utils import (
add_cors_headers,
await_me_maybe,
@ -23,7 +23,6 @@ from datasette.utils import (
InvalidSql,
make_slot_function,
path_from_row_pks,
path_with_added_args,
path_with_format,
path_with_removed_args,
to_css_class,
@ -201,6 +200,10 @@ class RowView(BaseView):
title="SQL Interrupted",
status=400,
message_is_html=True,
plain_message=(
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
),
)
except (sqlite3.OperationalError, InvalidSql) as e:
raise DatasetteError(str(e), title="Invalid SQL", status=400)
@ -212,18 +215,6 @@ class RowView(BaseView):
end = time.perf_counter()
data["query_ms"] = (end - start) * 1000
# Special case for .jsono extension - redirect to _shape=objects
if format_ == "jsono":
return self.redirect(
request,
path_with_added_args(
request,
{"_shape": "objects"},
path=request.path.rsplit(".jsono", 1)[0] + ".json",
),
forward_querystring=False,
)
if format_ in self.ds.renderers.keys():
# Dispatch request to the correct output format renderer
# (CSV is not handled here due to streaming)
@ -612,6 +603,9 @@ class RowView(BaseView):
}
extras = extra_names_from_request(request)
if request.url_vars.get("format"):
# Data formats reject unknown extras; HTML ignores them
table_extra_registry.validate_requested(extras, ExtraScope.ROW)
# Process extras
row_extra_context = RowExtraContext(
@ -721,11 +715,13 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
try:
resolved = await datasette.resolve_row(request)
except DatabaseNotFound as e:
return False, _error(["Database not found: {}".format(e.database_name)], 404)
return False, Response.error(
["Database not found: {}".format(e.database_name)], 404
)
except TableNotFound as e:
return False, _error(["Table not found: {}".format(e.table)], 404)
return False, Response.error(["Table not found: {}".format(e.table)], 404)
except RowNotFound as e:
return False, _error(["Record not found: {}".format(e.pk_values)], 404)
return False, Response.error(["Record not found: {}".format(e.pk_values)], 404)
# Ensure user has permission to delete this row
if not await datasette.allowed(
@ -733,7 +729,7 @@ async def _resolve_row_and_check_permission(datasette, request, permission):
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return False, _error(["Permission denied"], 403)
return False, Response.error(["Permission denied"], 403)
return True, resolved
@ -758,7 +754,7 @@ class RowDeleteView(BaseView):
try:
await resolved.db.execute_write_fn(delete_row, request=request)
except Exception as e:
return _error([str(e)], 500)
return Response.error([str(e)], 400)
await self.ds.track_event(
DeleteRowEvent(
@ -797,24 +793,24 @@ class RowUpdateView(BaseView):
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"])
return Response.error(["JSON must be a dictionary"])
if "update" not in data or not isinstance(data["update"], dict):
return _error(["JSON must contain an update dictionary"])
return Response.error(["JSON must contain an update dictionary"])
invalid_keys = set(data.keys()) - {"update", "return", "alter"}
if invalid_keys:
return _error(["Invalid keys: {}".format(", ".join(invalid_keys))])
return Response.error(["Invalid keys: {}".format(", ".join(invalid_keys))])
update = data["update"]
try:
update = decode_write_json_row(update)
except WriteJsonValueError as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
# Validate column types
from datasette.views.table import _validate_column_types
@ -823,7 +819,7 @@ class RowUpdateView(BaseView):
self.ds, resolved.db.name, resolved.table, [update]
)
if ct_errors:
return _error(ct_errors, 400)
return Response.error(ct_errors, 400)
alter = data.get("alter")
if alter and not await self.ds.allowed(
@ -831,7 +827,7 @@ class RowUpdateView(BaseView):
resource=TableResource(database=resolved.db.name, table=resolved.table),
actor=request.actor,
):
return _error(["Permission denied for alter-table"], 403)
return Response.error(["Permission denied for alter-table"], 403)
def update_row(conn):
sqlite_utils.Database(conn)[resolved.table].update(
@ -841,7 +837,7 @@ class RowUpdateView(BaseView):
try:
await resolved.db.execute_write_fn(update_row, request=request)
except Exception as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
result = {"ok": True}
returned_row = None
@ -850,7 +846,7 @@ class RowUpdateView(BaseView):
resolved.sql, resolved.params, truncate=True
)
returned_row = results.dicts()[0]
result["row"] = returned_row
result["rows"] = [returned_row]
await self.ds.track_event(
UpdateRowEvent(

View file

@ -6,9 +6,12 @@ from datasette.events import LogoutEvent, LoginEvent, CreateTokenEvent
from datasette.resources import DatabaseResource, TableResource
from datasette.utils.asgi import Response, Forbidden
from datasette.utils import (
UNSTABLE_API_MESSAGE,
actor_matches_allow,
parse_size_limit,
add_cors_headers,
await_me_maybe,
error_body,
tilde_encode,
tilde_decode,
)
@ -52,9 +55,9 @@ class JsonDataView(BaseView):
if self.permission:
await self.ds.ensure_permission(action=self.permission, actor=request.actor)
if self.needs_request:
data = self.data_callback(request)
data = await await_me_maybe(self.data_callback(request))
else:
data = self.data_callback()
data = await await_me_maybe(self.data_callback())
# Return JSON or HTML depending on format parameter
as_format = request.url_vars.get("format")
@ -62,6 +65,8 @@ class JsonDataView(BaseView):
headers = {}
if self.ds.cors:
add_cors_headers(headers)
if isinstance(data, dict):
data = {"ok": True, **data}
return Response.json(data, headers=headers)
else:
context = {
@ -292,6 +297,12 @@ class PermissionsDebugView(BaseView):
response, status = await _check_permission_for_actor(
self.ds, permission, parent, child, actor
)
if response.get("ok"):
response = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
**response,
}
return Response.json(response, status=status)
@ -348,29 +359,32 @@ class AllowedResourcesView(BaseView):
async def _allowed_payload(self, request, has_debug_permission):
action = request.args.get("action")
if not action:
return {"error": "action parameter is required"}, 400
return error_body("action parameter is required", 400), 400
if action not in self.ds.actions:
return {"error": f"Unknown action: {action}"}, 404
return error_body(f"Unknown action: {action}", 404), 404
actor = request.actor if isinstance(request.actor, dict) else None
actor_id = actor.get("id") if actor else None
parent_filter = request.args.get("parent")
child_filter = request.args.get("child")
if child_filter and not parent_filter:
return {"error": "parent must be provided when child is specified"}, 400
return (
error_body("parent must be provided when child is specified", 400),
400,
)
try:
page = int(request.args.get("page", "1"))
page_size = int(request.args.get("page_size", "50"))
page = int(request.args.get("_page", "1"))
if page < 1:
raise ValueError
except ValueError:
return {"error": "page and page_size must be integers"}, 400
if page < 1:
return {"error": "page must be >= 1"}, 400
if page_size < 1:
return {"error": "page_size must be >= 1"}, 400
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
return error_body("_page must be a positive integer", 400), 400
try:
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
)
except ValueError as ex:
return error_body(str(ex), 400), 400
offset = (page - 1) * page_size
# Use the simplified allowed_resources method
@ -410,6 +424,7 @@ class AllowedResourcesView(BaseView):
# If catalog tables don't exist yet, return empty results
return (
{
"ok": True,
"action": action,
"actor_id": actor_id,
"page": page,
@ -434,16 +449,17 @@ class AllowedResourcesView(BaseView):
def build_page_url(page_number):
pairs = []
for key in request.args:
if key in {"page", "page_size"}:
if key in {"_page", "_size"}:
continue
for value in request.args.getlist(key):
pairs.append((key, value))
pairs.append(("page", str(page_number)))
pairs.append(("page_size", str(page_size)))
pairs.append(("_page", str(page_number)))
pairs.append(("_size", str(page_size)))
query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}"
response = {
"ok": True,
"action": action,
"actor_id": actor_id,
"page": page,
@ -485,26 +501,24 @@ class PermissionRulesView(BaseView):
# JSON API - action parameter is required
action = request.args.get("action")
if not action:
return Response.json({"error": "action parameter is required"}, status=400)
return Response.error("action parameter is required", 400)
if action not in self.ds.actions:
return Response.json({"error": f"Unknown action: {action}"}, status=404)
return Response.error(f"Unknown action: {action}", 404)
actor = request.actor if isinstance(request.actor, dict) else None
try:
page = int(request.args.get("page", "1"))
page_size = int(request.args.get("page_size", "50"))
page = int(request.args.get("_page", "1"))
if page < 1:
raise ValueError
except ValueError:
return Response.json(
{"error": "page and page_size must be integers"}, status=400
return Response.error("_page must be a positive integer", 400)
try:
page_size = parse_size_limit(
request.args.get("_size"), default=50, maximum=200
)
if page < 1:
return Response.json({"error": "page must be >= 1"}, status=400)
if page_size < 1:
return Response.json({"error": "page_size must be >= 1"}, status=400)
max_page_size = 200
if page_size > max_page_size:
page_size = max_page_size
except ValueError as ex:
return Response.error(str(ex), 400)
offset = (page - 1) * page_size
from datasette.utils.actions_sql import build_permission_rules_sql
@ -555,16 +569,17 @@ class PermissionRulesView(BaseView):
def build_page_url(page_number):
pairs = []
for key in request.args:
if key in {"page", "page_size"}:
if key in {"_page", "_size"}:
continue
for value in request.args.getlist(key):
pairs.append((key, value))
pairs.append(("page", str(page_number)))
pairs.append(("page_size", str(page_size)))
pairs.append(("_page", str(page_number)))
pairs.append(("_size", str(page_size)))
query = urllib.parse.urlencode(pairs)
return f"{request.path}?{query}"
response = {
"ok": True,
"action": action,
"actor_id": (actor or {}).get("id") if actor else None,
"page": page,
@ -587,15 +602,15 @@ class PermissionRulesView(BaseView):
async def _check_permission_for_actor(ds, action, parent, child, actor):
"""Shared logic for checking permissions. Returns a dict with check results."""
if action not in ds.actions:
return {"error": f"Unknown action: {action}"}, 404
return error_body(f"Unknown action: {action}", 404), 404
if child and not parent:
return {"error": "parent is required when child is provided"}, 400
return error_body("parent is required when child is provided", 400), 400
# Use the action's properties to create the appropriate resource object
action_obj = ds.actions.get(action)
if not action_obj:
return {"error": f"Unknown action: {action}"}, 400
return error_body(f"Unknown action: {action}", 400), 400
# Global actions (no resource_class) don't have a resource
if action_obj.resource_class is None:
@ -610,11 +625,12 @@ async def _check_permission_for_actor(ds, action, parent, child, actor):
resource_obj = action_obj.resource_class(parent)
else:
# This shouldn't happen given validation in Action.__post_init__
return {"error": f"Invalid action configuration: {action}"}, 500
return error_body(f"Invalid action configuration: {action}", 500), 500
allowed = await ds.allowed(action=action, resource=resource_obj, actor=actor)
response = {
"ok": True,
"action": action,
"allowed": bool(allowed),
"resource": {
@ -651,7 +667,7 @@ class PermissionCheckView(BaseView):
# JSON API - action parameter is required
action = request.args.get("action")
if not action:
return Response.json({"error": "action parameter is required"}, status=400)
return Response.error("action parameter is required", 400)
parent = request.args.get("parent")
child = request.args.get("child")
@ -1198,7 +1214,7 @@ class JumpView(BaseView):
match["display_name"] = row["display_name"]
matches.append(match)
return Response.json({"matches": matches, "truncated": truncated})
return Response.json({"ok": True, "matches": matches, "truncated": truncated})
class SchemaBaseView(BaseView):
@ -1220,7 +1236,7 @@ class SchemaBaseView(BaseView):
headers = {}
if self.ds.cors:
add_cors_headers(headers)
return Response.json(data, headers=headers)
return Response.json({"ok": True, **data}, headers=headers)
def format_error_response(self, error_message, format_, status=404):
"""Format error response based on requested format."""
@ -1229,7 +1245,7 @@ class SchemaBaseView(BaseView):
if self.ds.cors:
add_cors_headers(headers)
return Response.json(
{"ok": False, "error": error_message}, status=status, headers=headers
error_body(error_message, status), status=status, headers=headers
)
else:
return Response.text(error_message, status=status)
@ -1305,17 +1321,17 @@ class DatabaseSchemaView(SchemaBaseView):
database_name = request.url_vars["database"]
format_ = request.url_vars.get("format") or "html"
# Check if database exists
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Check view-database permission
# Permission check comes first, so actors without view-database
# cannot distinguish existing databases from missing ones
await self.ds.ensure_permission(
action="view-database",
resource=DatabaseResource(database=database_name),
actor=request.actor,
)
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
schema = await self.get_database_schema(database_name)
if format_ == "json":
@ -1349,6 +1365,9 @@ class TableSchemaView(SchemaBaseView):
actor=request.actor,
)
if database_name not in self.ds.databases:
return self.format_error_response("Database not found", format_)
# Get schema for the table
db = self.ds.databases[database_name]
result = await db.execute(

View file

@ -2,10 +2,10 @@ from urllib.parse import parse_qsl, urlencode
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import stored_query_to_dict
from datasette.utils import sqlite3, tilde_decode
from datasette.utils import UNSTABLE_API_MESSAGE, sqlite3, tilde_decode
from datasette.utils.asgi import Response
from .base import BaseView, _error
from .base import BaseView
from .query_helpers import (
QueryValidationError,
_as_bool,
@ -34,12 +34,14 @@ class QueryParametersView(BaseView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
@ -47,8 +49,16 @@ class QueryParametersView(BaseView):
try:
parameters = _derived_query_parameters(request.args.get("sql") or "")
except QueryValidationError as ex:
return _block_framing(_error([ex.message], ex.status))
return _block_framing(Response.json({"ok": True, "parameters": parameters}))
return _block_framing(Response.error([ex.message], ex.status))
return _block_framing(
Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": parameters,
}
)
)
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
@ -82,11 +92,12 @@ class QueryListView(BaseView):
limit = _query_list_limit(
request.args.get("_size"),
default=20 if format_ == "html" else 50,
maximum=self.ds.max_returned_rows,
)
is_write = _as_optional_bool(request.args.get("is_write"), "is_write")
is_private = _as_optional_bool(request.args.get("is_private"), "is_private")
except QueryValidationError as ex:
return _error([ex.message], ex.status)
return Response.error([ex.message], ex.status)
page = await self.ds.list_queries(
database,
@ -111,9 +122,9 @@ class QueryListView(BaseView):
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = "{}?{}".format(
query_list_path,
urlencode(pairs),
next_url = self.ds.absolute_url(
request,
"{}?{}".format(request.path, urlencode(pairs)),
)
current_filters = {
@ -199,7 +210,6 @@ class QueryListView(BaseView):
"queries": page.queries,
"next": page.next,
"next_url": next_url,
"has_more": page.has_more,
"limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries),
@ -298,28 +308,30 @@ class QueryCreateAnalyzeView(BaseView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
return _block_framing(
Response.error(["Permission denied: need execute-sql"], 403)
)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _block_framing(_error(["Permission denied: need store-query"], 403))
return _block_framing(
Response.error(["Permission denied: need store-query"], 403)
)
invalid_keys = set(request.args) - {"sql"}
if invalid_keys:
return _block_framing(
_error(
Response.error(
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
400,
)
)
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _query_create_analysis_data(self.ds, db, sql, request.actor)
)
)
analysis = await _query_create_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))
class QueryStoreView(QueryCreateView):
@ -346,13 +358,13 @@ class QueryStoreView(QueryCreateView):
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need execute-sql"], 403)
return Response.error(["Permission denied: need execute-sql"], 403)
if not await self.ds.allowed(
action="store-query",
resource=DatabaseResource(db.name),
actor=request.actor,
):
return _error(["Permission denied: need store-query"], 403)
return Response.error(["Permission denied: need store-query"], 403)
is_json = False
query_data = {}
@ -369,7 +381,7 @@ class QueryStoreView(QueryCreateView):
return await self._error_response(
request, db, query_data, ex.message, ex.status
)
return _error([ex.message], ex.status)
return Response.error([ex.message], ex.status)
prepared.pop("analysis")
name = prepared.pop("name")
@ -378,13 +390,18 @@ class QueryStoreView(QueryCreateView):
except sqlite3.IntegrityError as ex:
if not is_json and isinstance(query_data, dict):
return await self._error_response(request, db, query_data, str(ex), 400)
return _error([str(ex)], 400)
return Response.error([str(ex)], 400)
query = await self.ds.get_query(db.name, name)
assert query is not None
if is_json:
return Response.json(
{"ok": True, "query": stored_query_to_dict(query)}, status=201
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
},
status=201,
)
self.ds.add_message(request, "Query saved", self.ds.INFO)
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
@ -398,14 +415,20 @@ class QueryDefinitionView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
query = await self.ds.get_query(db.name, query_name)
if query is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="view-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
return Response.error(["Permission denied"], 403)
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
}
)
class QueryUpdateView(BaseView):
@ -416,15 +439,17 @@ class QueryUpdateView(BaseView):
query_name = tilde_decode(request.url_vars["query"])
existing = await self.ds.get_query(db.name, query_name)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need update-query"], 403)
return Response.error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return _error(["Trusted queries cannot be updated using the API"], 403)
return Response.error(
["Trusted queries cannot be updated using the API"], 403
)
try:
data, _ = await _json_or_form_payload(request)
@ -450,7 +475,7 @@ class QueryUpdateView(BaseView):
self.ds, request, db, existing, update
)
except QueryValidationError as ex:
return _error([ex.message], ex.status)
return Response.error([ex.message], ex.status)
await self.ds.update_query(db.name, query_name, **update_kwargs)
if data.get("return"):
@ -507,32 +532,32 @@ class QueryEditView(BaseView):
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return _error(["Trusted queries cannot be edited"], 403)
return Response.error(["Trusted queries cannot be edited"], 403)
return await self._render_form(request, db, existing)
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="update-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need update-query"], 403)
return Response.error(["Permission denied: need update-query"], 403)
if existing.is_trusted:
return _error(["Trusted queries cannot be edited"], 403)
return Response.error(["Trusted queries cannot be edited"], 403)
data, _ = await _json_or_form_payload(request)
if not isinstance(data, dict):
return _error(["Invalid form submission"], 400)
return Response.error(["Invalid form submission"], 400)
sql = data.get("sql")
sql = existing.sql if sql is None else sql.strip()
title = data.get("title") or ""
@ -604,12 +629,16 @@ class QueryDeleteView(BaseView):
async def get(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
await self.ds.ensure_permission(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
return await self.render(
["query_delete.html"],
request,
@ -624,13 +653,17 @@ class QueryDeleteView(BaseView):
async def post(self, request):
db, query_name, existing = await self._load(request)
if existing is None:
return _error(["Query not found: {}".format(query_name)], 404)
return Response.error(["Query not found: {}".format(query_name)], 404)
if not await self.ds.allowed(
action="delete-query",
resource=QueryResource(db.name, query_name),
actor=request.actor,
):
return _error(["Permission denied: need delete-query"], 403)
return Response.error(["Permission denied: need delete-query"], 403)
if existing.is_trusted:
return Response.error(
["Trusted queries cannot be deleted using the API"], 403
)
data, is_json = await _json_or_form_payload(request)
await self.ds.remove_query(db.name, query_name)

View file

@ -60,7 +60,7 @@ from dataclasses import dataclass, field
from datasette.extras import ExtraScope
from . import Context, from_extra
from .base import BaseView, DatasetteError, _error, stream_csv
from .base import BaseView, DatasetteError, stream_csv
from .database import QueryView
from .table_create_alter import (
ALTER_TABLE_COLUMN_TYPES,
@ -72,6 +72,7 @@ from .table_create_alter import (
from .table_extras import (
TABLE_EXTRA_BUNDLES,
TableExtraContext,
count_is_truncated,
precompute_database_action_permissions,
precompute_table_action_permissions,
resolve_table_extras,
@ -106,7 +107,6 @@ class TableContext(Context):
human_description_en: str = from_extra()
is_view: bool = from_extra()
metadata: dict = from_extra()
next_url: str = from_extra()
primary_keys: list = from_extra()
private: bool = from_extra()
query: dict = from_extra()
@ -123,6 +123,11 @@ class TableContext(Context):
metadata={"help": "True if the data for this page was retrieved without errors"}
)
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
next_url: str = field(
metadata={
"help": "Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`."
}
)
count_truncated: bool = field(
metadata={
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
@ -950,9 +955,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:
@ -1036,7 +1039,7 @@ class TableInsertView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
return Response.error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
@ -1044,7 +1047,7 @@ class TableInsertView(BaseView):
# Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return _error(["Table not found: {}".format(table_name)], 404)
return Response.error(["Table not found: {}".format(table_name)], 404)
if upsert:
# Must have insert-row AND upsert-row permissions
@ -1060,7 +1063,7 @@ class TableInsertView(BaseView):
actor=request.actor,
)
):
return _error(
return Response.error(
["Permission denied: need both insert-row and update-row"], 403
)
else:
@ -1070,10 +1073,10 @@ class TableInsertView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.error(["Permission denied"], 403)
if not db.is_mutable:
return _error(["Database is immutable"], 403)
return Response.error(["Database is immutable"], 403)
pks = await db.primary_keys(table_name)
@ -1082,20 +1085,20 @@ class TableInsertView(BaseView):
request, db, table_name, pks, upsert
)
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if errors:
return _error(errors, 400)
return Response.error(errors, 400)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
# Validate column types
ct_errors = await _validate_column_types(
self.ds, database_name, table_name, rows
)
if ct_errors:
return _error(ct_errors, 400)
return Response.error(ct_errors, 400)
num_rows = len(rows)
@ -1109,14 +1112,16 @@ class TableInsertView(BaseView):
alter = extras.get("alter")
if upsert and (ignore or replace):
return _error(["Upsert does not support ignore or replace"], 400)
return Response.error(["Upsert does not support ignore or replace"], 400)
if replace and not await self.ds.allowed(
action="update-row",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(['Permission denied: need update-row to use "replace"'], 403)
return Response.error(
['Permission denied: need update-row to use "replace"'], 403
)
initial_schema = None
if alter:
@ -1126,7 +1131,7 @@ class TableInsertView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied for alter-table"], 403)
return Response.error(["Permission denied for alter-table"], 403)
# Track initial schema to check if it changed later
initial_schema = await db.execute_fn(
lambda conn: sqlite_utils.Database(conn)[table_name].schema
@ -1166,7 +1171,7 @@ class TableInsertView(BaseView):
try:
rows = await db.execute_write_fn(insert_or_upsert_rows, request=request)
except Exception as e:
return _error([str(e)])
return Response.error([str(e)])
result = {"ok": True}
if should_return:
if upsert:
@ -1247,7 +1252,7 @@ class TableSetColumnTypeView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
return Response.error([e.args[0]], 404)
database_name = resolved.db.name
table_name = resolved.table
@ -1257,43 +1262,39 @@ class TableSetColumnTypeView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
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)
return Response.error(["Permission denied"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
return Response.error(["JSON must be a dictionary"], 400)
invalid_keys = set(data.keys()) - {"column", "column_type"}
if invalid_keys:
return _error(
return Response.error(
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
400,
)
if "column" not in data:
return _error(['"column" is required'], 400)
return Response.error(['"column" is required'], 400)
column = data["column"]
if not isinstance(column, str):
return _error(['"column" must be a string'], 400)
return Response.error(['"column" must be a string'], 400)
if "column_type" not in data:
return _error(['"column_type" is required'], 400)
return Response.error(['"column_type" is required'], 400)
column_details = await self.ds._get_resource_column_details(
database_name, table_name
)
if column not in column_details:
return _error(["Column not found: {}".format(column)], 400)
return Response.error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"]
if column_type_data is None:
@ -1310,11 +1311,11 @@ class TableSetColumnTypeView(BaseView):
)
if not isinstance(column_type_data, dict):
return _error(['"column_type" must be an object or null'], 400)
return Response.error(['"column_type" must be an object or null'], 400)
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
if invalid_column_type_keys:
return _error(
return Response.error(
[
'Invalid column_type parameter: "{}"'.format(
'", "'.join(sorted(invalid_column_type_keys))
@ -1324,24 +1325,24 @@ class TableSetColumnTypeView(BaseView):
)
if "type" not in column_type_data:
return _error(['"column_type.type" is required'], 400)
return Response.error(['"column_type.type" is required'], 400)
column_type = column_type_data["type"]
if not isinstance(column_type, str):
return _error(['"column_type.type" must be a string'], 400)
return Response.error(['"column_type.type" must be a string'], 400)
config = column_type_data.get("config")
if config is not None and not isinstance(config, dict):
return _error(['"column_type.config" must be a dictionary'], 400)
return Response.error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types:
return _error(["Unknown column type: {}".format(column_type)], 400)
return Response.error(["Unknown column type: {}".format(column_type)], 400)
try:
await self.ds.set_column_type(
database_name, table_name, column, column_type, config
)
except ValueError as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
return Response.json(
{
@ -1365,22 +1366,22 @@ class TableDropView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
return Response.error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
# Table must exist
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return _error(["Table not found: {}".format(table_name)], 404)
return Response.error(["Table not found: {}".format(table_name)], 404)
if not await self.ds.allowed(
action="drop-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.error(["Permission denied"], 403)
if not db.is_mutable:
return _error(["Database is immutable"], 403)
return Response.error(["Database is immutable"], 403)
confirm = False
try:
data = await request.json()
@ -1388,7 +1389,7 @@ class TableDropView(BaseView):
except json.JSONDecodeError:
pass
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if not confirm:
return Response.json(
@ -1565,7 +1566,7 @@ class TableAutocompleteView(BaseView):
and value_as_boolean(initial_arg)
)
if not q and not initial:
return Response.json({"rows": []})
return Response.json({"ok": True, "rows": []})
params = {
"q": q,
"like": "%{}%".format(_escape_like(q)),
@ -1628,10 +1629,13 @@ class TableAutocompleteView(BaseView):
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
)
except QueryInterrupted:
return Response.json({"rows": []})
return Response.json({"ok": True, "rows": []})
return Response.json(
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
{
"ok": True,
"rows": _autocomplete_response_rows(results.rows, pks, label_column),
}
)
@ -2290,10 +2294,16 @@ async def table_view_data(
# Resolve extras
extras = extra_names_from_request(request)
if not extra_extras:
# Data formats reject unknown extras; the HTML path (which passes
# extra_extras={"_html"}) resolves internal extras of its own
table_extra_registry.validate_requested(extras, ExtraScope.TABLE)
if any(k for k in request.args.keys() if k == "_facet" or k.startswith("_facet_")):
extras.add("facet_results")
if request.args.get("_shape") == "object":
extras.add("primary_keys")
if "count" in extras:
extras.add("count_truncated")
if extra_extras:
extras.update(extra_extras)
@ -2348,6 +2358,7 @@ async def table_view_data(
data = {
"ok": True,
"next": next_value and str(next_value) or None,
"next_url": next_url,
}
data.update(
await resolve_table_extras(
@ -2372,7 +2383,7 @@ async def table_view_data(
data["rows"] = transformed_rows
if context_for_html_hack:
data["count_truncated"] = _count_truncated_for_table_page(
data["count_truncated"] = count_is_truncated(
datasette, db, database_name, table_name, count_sql, data.get("count")
)
data.update(extra_context_from_filters)
@ -2440,24 +2451,6 @@ async def table_view_data(
return data, rows[:page_size], columns, expanded_columns, sql, next_url
def _count_truncated_for_table_page(
datasette, db, database_name, table_name, count_sql, count
):
if count != db.count_limit + 1:
return False
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
try:
datasette.inspect_data[database_name]["tables"][table_name]["count"]
return False
except KeyError:
pass
return True
async def _next_value_and_url(
datasette,
db,

View file

@ -29,7 +29,7 @@ from datasette.utils import (
from datasette.utils.asgi import NotFound, PayloadTooLarge, Response
from datasette.utils.sqlite import sqlite_hidden_table_names
from .base import BaseView, _error
from .base import BaseView
CREATE_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
CREATE_TABLE_SQLITE_TYPES = {
@ -805,22 +805,22 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.error(["Permission denied"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)])
return Response.error(["Invalid JSON: {}".format(e)])
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be an object"])
return Response.error(["JSON must be an object"])
try:
create_request = CreateTableRequest.model_validate(data)
except ValidationError as e:
return _error(_create_table_pydantic_errors(e))
return Response.error(_create_table_pydantic_errors(e))
ignore = create_request.ignore
replace = create_request.replace
@ -832,7 +832,7 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need update-row"], 403)
return Response.error(["Permission denied: need update-row"], 403)
table_name = create_request.table
table_exists = await db.table_exists(table_name)
@ -846,11 +846,11 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need insert-row"], 403)
return Response.error(["Permission denied: need insert-row"], 403)
try:
rows = decode_write_json_rows(rows)
except WriteJsonValueError as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
alter = False
if rows:
@ -865,7 +865,9 @@ class TableCreateView(BaseView):
resource=DatabaseResource(database=database_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
return Response.error(
["Permission denied: need alter-table"], 403
)
alter = True
pk = create_request.pk
@ -881,7 +883,7 @@ class TableCreateView(BaseView):
elif len(actual_pks) > 1 and pks and set(pks) != set(actual_pks):
bad_pks = True
if bad_pks:
return _error(["pk cannot be changed for existing table"])
return Response.error(["pk cannot be changed for existing table"])
pks = actual_pks
initial_schema = None
@ -924,7 +926,7 @@ class TableCreateView(BaseView):
try:
schema = await db.execute_write_fn(create_table, request=request)
except Exception as e:
return _error([str(e)])
return Response.error([str(e)])
if initial_schema is not None and initial_schema != schema:
await self.ds.track_event(
@ -999,7 +1001,7 @@ class DatabaseForeignKeyTargetsView(BaseView):
actor=request.actor,
)
if not (can_create_table or can_alter_table):
return _error(["Permission denied: need create-table"], 403)
return Response.error(["Permission denied: need create-table"], 403)
hidden_tables = await db.execute_fn(
lambda conn: set(sqlite_hidden_table_names(conn))
@ -1028,21 +1030,21 @@ class TableForeignKeySuggestionsView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
return Response.error([e.args[0]], 404)
db = resolved.db
database_name = db.name
table_name = resolved.table
if resolved.is_view:
return _error(["Cannot suggest foreign keys for a view"], 400)
return Response.error(["Cannot suggest foreign keys for a view"], 400)
if not await self.ds.allowed(
action="alter-table",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
return Response.error(["Permission denied: need alter-table"], 403)
source_columns, targets, current_by_column = await db.execute_fn(
lambda conn: _foreign_key_suggestion_metadata(conn, table_name)
@ -1150,7 +1152,7 @@ class TableAlterView(BaseView):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
return Response.error([e.args[0]], 404)
db = resolved.db
database_name = db.name
@ -1161,29 +1163,25 @@ class TableAlterView(BaseView):
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied: need alter-table"], 403)
return Response.error(["Permission denied: need alter-table"], 403)
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)
return Response.error(["Database is immutable"], 403)
try:
data = await request.json()
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
return Response.error(["Invalid JSON: {}".format(e)], 400)
except PayloadTooLarge as e:
return _error([str(e)], 413)
return Response.error([str(e)], 413)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
return Response.error(["JSON must be a dictionary"], 400)
try:
alter_request = AlterTableRequest.model_validate(data)
except ValidationError as e:
return _error(_pydantic_errors(e), 400)
return Response.error(_pydantic_errors(e), 400)
def alter_table(conn):
before_schema = _table_schema_from_conn(conn, table_name)
@ -1332,7 +1330,7 @@ class TableAlterView(BaseView):
alter_table, request=request
)
except Exception as e:
return _error([str(e)], 400)
return Response.error([str(e)], 400)
altered = before_schema != after_schema
if altered:

View file

@ -141,6 +141,39 @@ class CountExtra(Extra):
return count
def count_is_truncated(datasette, db, database_name, table_name, count_sql, count):
if count != db.count_limit + 1:
return False
if (
not db.is_mutable
and datasette.inspect_data
and count_sql == f"select count(*) from {table_name} "
):
try:
datasette.inspect_data[database_name]["tables"][table_name]["count"]
return False
except KeyError:
pass
return True
class CountTruncatedExtra(Extra):
description = "True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count."
example = ExtraExample("/fixtures/facetable.json?_extra=count,count_truncated")
scopes = {ExtraScope.TABLE}
expensive = True
async def resolve(self, context, count):
return count_is_truncated(
context.datasette,
context.db,
context.database_name,
context.table_name,
context.count_sql,
count,
)
class FacetInstancesProvider(Provider):
scopes = {ExtraScope.TABLE}
@ -288,21 +321,6 @@ class HumanDescriptionEnExtra(Extra):
return human_description_en
class NextUrlExtra(Extra):
description = "Full URL for the next page of results"
example = ExtraExample(
"/fixtures/facetable.json?_size=1&_extra=next_url",
note=(
"``null`` if there are no more pages of results. "
"See :ref:`json_api_pagination`."
),
)
scopes = {ExtraScope.TABLE}
async def resolve(self, context):
return context.next_url
class ColumnsExtra(Extra):
description = "List of column names returned by this table, row or query."
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
@ -1217,7 +1235,6 @@ TABLE_EXTRA_BUNDLES = {
"count",
"count_sql",
"human_description_en",
"next_url",
"metadata",
"query",
"columns",
@ -1246,13 +1263,13 @@ TABLE_EXTRA_BUNDLES = {
TABLE_EXTRA_CLASSES = [
CountExtra,
CountTruncatedExtra,
CountSqlExtra,
FacetResultsExtra,
FacetsTimedOutExtra,
SuggestedFacetsExtra,
FacetInstancesProvider,
HumanDescriptionEnExtra,
NextUrlExtra,
ColumnsExtra,
AllColumnsExtra,
PrimaryKeysExtra,

View file

@ -991,7 +991,9 @@ The ``/-/create-token`` page cannot be accessed by actors that are authenticated
Datasette plugins that implement their own form of API token authentication should follow this convention.
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting.
If a request presents a token that a token handler recognizes but rejects - an invalid signature, a malformed payload or an expired token - Datasette responds with a ``401`` status, the :ref:`standard JSON error format <json_api_errors>` and a ``WWW-Authenticate: Bearer error="invalid_token"`` header. This means API clients can distinguish "your token needs to be renewed" (``401``) from "your token does not grant this permission" (``403``). A ``Bearer`` token that no registered handler recognizes at all is ignored, since it may be intended for an authentication plugin.
You can disable the signed token feature entirely using the :ref:`allow_signed_tokens <setting_allow_signed_tokens>` setting. Requests presenting a ``dstok_`` token while the feature is disabled receive a ``401``.
.. _authentication_cli_create_token:
@ -1149,6 +1151,8 @@ It also provides an interface for running hypothetical permission checks against
This is designed to help administrators and plugin authors understand exactly how permission checks are being carried out, in order to effectively configure Datasette's permission system.
These debug endpoints are exempt from the :ref:`JSON API stability promise <json_api_stability>` - their JSON shapes may change in future releases.
.. _AllowedResourcesView:
Allowed resources view
@ -1158,7 +1162,7 @@ The ``/-/allowed`` endpoint displays resources that the current actor can access
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/allowed.json``) to get the raw JSON response instead.
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair.
Pass ``?action=view-table`` (or another action) to select the action. Optional ``parent=`` and ``child=`` query parameters can narrow the results to a specific database/table pair. Results are paginated: ``?_size=`` sets the page size (default 50, maximum 200, ``max`` for the maximum) and ``?_page=`` selects a page.
This endpoint is publicly accessible to help users understand their own permissions. The potentially sensitive ``reason`` field is only shown to users with the ``permissions-debug`` permission - it shows the plugins and explanatory reasons that were responsible for each decision.
@ -1171,7 +1175,7 @@ The ``/-/rules`` endpoint displays all permission rules (both allow and deny) fo
This endpoint provides an interactive HTML form interface. Add ``.json`` to the URL path (e.g. ``/-/rules.json?action=view-table``) to get the raw JSON response instead.
Pass ``?action=`` as a query parameter to specify which action to check.
Pass ``?action=`` as a query parameter to specify which action to check. The ``?_size=`` and ``?_page=`` pagination parameters work the same as on ``/-/allowed``.
This endpoint requires the ``permissions-debug`` permission.

View file

@ -17,6 +17,42 @@ Unreleased
- The table and row JSON APIs now support ``?_extra=column_details`` for returning SQLite schema details for columns, including declared type, SQLite affinity, primary key, ``NOT NULL``, default and hidden-column metadata.
- POST bodies that Datasette reads fully into memory - such as JSON submitted to the write API - are now capped by the new :ref:`setting_max_post_body_bytes` setting, defaulting to 2MB. Oversized requests are rejected with an HTTP 413 error as soon as the limit is exceeded, protecting smaller servers from memory exhaustion. File uploads are unaffected - ``request.form()`` streams those to disk and has its own separate limits.
This release also includes the results of a `detailed consistency review <https://github.com/simonw/datasette/pull/2824>`__ of Datasette's JSON API in preparation for the 1.0 stable release. Several of these changes are backwards-incompatible with previous 1.0 alphas. The new :ref:`API stability documentation <json_api_stability>` describes exactly which parts of the JSON API are covered by the 1.0 stability promise.
JSON API: breaking changes
~~~~~~~~~~~~~~~~~~~~~~~~~~
- JSON error responses now use a single canonical format across every endpoint: ``{"ok": false, "error": "...", "errors": [...], "status": 400}``. The ``error`` key joins all error messages together, ``errors`` is the full list of messages and ``status`` always matches the HTTP status code. The legacy ``title`` key is no longer included in JSON errors (it remains available to the HTML error template), and endpoints that previously returned bare ``{"error": ...}`` objects have been updated. See :ref:`json_api_errors`.
- Every JSON object success response now includes ``"ok": true``, including introspection endpoints such as ``/-/versions`` and ``/-/settings``.
- ``/-/plugins.json``, ``/-/databases.json`` and ``/-/actions.json`` now return objects - ``{"ok": true, "plugins": [...]}`` and equivalents - instead of top-level JSON arrays, so these responses can gain additional keys in the future without a breaking change. The ``datasette plugins`` CLI command still outputs a plain array.
- ``/-/databases`` now only lists databases the current actor is allowed to view. It previously listed every attached database, including their filesystem paths, to any actor with ``view-instance``.
- Requests with an invalid or expired ``Authorization: Bearer`` token now receive a ``401`` status with the standard error body and a ``WWW-Authenticate: Bearer error="invalid_token"`` header, instead of being silently treated as unauthenticated. Bearer tokens that no registered token handler recognizes are still ignored, so authentication plugins with their own token formats keep working. Plugin :ref:`token handlers <plugin_hook_register_token_handler>` can raise the new ``datasette.TokenInvalid`` exception to trigger the same behavior.
- Permission errors for JSON requests now return the standard JSON error format with a ``403`` status. The default forbidden handling previously rendered an HTML error page even for ``.json`` requests.
- ``POST`` to a write canned query now returns a ``400`` error when the SQL fails to execute, instead of a ``200`` status with ``"ok": false`` in the body. The error response includes the standard error keys plus a ``"redirect"`` key.
- The :ref:`row update API <RowUpdateView>` with ``"return": true`` now responds with a ``"rows"`` list, matching insert and upsert, instead of a singular ``"row"`` object.
- Row delete write failures - such as a constraint violation raised by a trigger - now return ``400`` instead of ``500``, matching the other write endpoints.
- ``/<database>/-/query.json`` with a missing or blank ``?sql=`` parameter now returns a ``400`` error, as the CSV format already did, instead of a ``200`` with empty rows.
- Unknown ``?_extra=`` names now return a ``400`` error for JSON and other data formats, instead of being silently ignored. HTML pages continue to ignore unknown names.
- Table JSON responses now include ``next_url`` alongside ``next`` by default - both are ``null`` on the final page. The now-redundant ``?_extra=next_url`` parameter has been removed.
- The stored query list JSON no longer includes ``has_more`` - ``"next": null`` is the end-of-results signal across the whole API. This change also uncovered and fixed a bug where the query list ``next_url`` pointed at the HTML page and was a relative path; it is now an absolute URL that preserves the requested format.
- Stored query JSON objects no longer duplicate the list of parameter names as both ``params`` and ``parameters`` - only ``parameters`` remains. The query create and update APIs no longer accept ``params`` as an input alias either; ``params`` is still the documented key for :ref:`queries defined in configuration <queries_named_parameters>`.
- Page size parameters are now consistent across the API: the stored query lists accept ``?_size=max`` and return a ``400`` error for values over the maximum instead of silently clamping them, and the ``/-/allowed`` and ``/-/rules`` permission debug endpoints renamed their ``page`` and ``page_size`` parameters to ``_page`` and ``_size``, matching the underscore grammar used by every other Datasette system parameter.
- ``/-/threads`` now requires the ``permissions-debug`` permission, since it exposes runtime internals such as file paths. It previously only required ``view-instance``.
- Trusted stored queries - those defined in configuration - can no longer be deleted through the JSON API or web interface, matching the existing restriction on editing them.
- The ``/<database>/-/schema`` endpoints now check the ``view-database`` permission before checking whether the database exists, so unauthorized actors can no longer probe for the existence of databases.
- SQL time limit errors in JSON responses are now a plain text message. The error string previously embedded an HTML fragment.
- The undocumented homepage JSON at ``/.json`` now returns ``databases`` as a list of objects rather than an object keyed by database name, matching every other collection in the API.
- The legacy ``.jsono`` format extension, long since superseded by ``?_shape=``, has been removed.
JSON API: other improvements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- The :ref:`write API <json_api_write>` endpoints now parse the request body as JSON regardless of the ``Content-Type`` header, so ``curl -d`` invocations work without remembering to set it. Invalid JSON is a ``400`` error. Cross-site request forgery remains prevented by Datasette's ``Origin`` and ``Sec-Fetch-Site`` checks. This also fixes a ``500`` error from the insert API when the ``Content-Type`` header was missing entirely.
- New ``Response.error(messages, status=400)`` helper for plugins that need to return a JSON error in Datasette's standard format. See :ref:`internals_response`.
- New ``count_truncated`` extra for table JSON, included automatically whenever ``count`` is requested. ``true`` means the count reached Datasette's counting limit and the real number of rows may be higher. See :ref:`json_api_extra`.
- JSON endpoints that are not part of the documented stable API now declare themselves with an ``"unstable"`` key in their responses.
- New documentation covering the grammar for :ref:`boolean query string arguments <json_api_table_arguments>`, the reason :ref:`upsert <TableUpsertView>` returns ``200`` where insert returns ``201``, and advice for plugin authors on :ref:`naming secret configuration keys <plugins_configuration_secret>` so that ``/-/config`` redacts them automatically.
.. _v1_0_a35:
1.0a35 (2026-06-23)

View file

@ -284,7 +284,7 @@ For example:
content_type="application/xml; charset=utf-8",
)
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)`` or ``Response.redirect(...)`` helper methods:
The quickest way to create responses is using the ``Response.text(...)``, ``Response.html(...)``, ``Response.json(...)``, ``Response.error(...)`` or ``Response.redirect(...)`` helper methods:
.. code-block:: python
@ -295,6 +295,8 @@ The quickest way to create responses is using the ``Response.text(...)``, ``Resp
text_response = Response.text(
"This will become utf-8 encoded text"
)
# A JSON error in Datasette's standard error format:
error_response = Response.error("Cannot do that", 400)
# Redirects are served as 302, unless you pass status=301:
redirect_response = Response.redirect(
"https://latest.datasette.io/"
@ -304,6 +306,8 @@ Each of these responses will use the correct corresponding content-type - ``text
Each of the helper methods take optional ``status=`` and ``headers=`` arguments, documented above.
``Response.error(messages, status=400)`` returns a JSON error in the :ref:`standard Datasette error format <json_api_errors>`. ``messages`` can be a single string or a list of strings. Use this for JSON-only endpoints; if your error should content-negotiate between JSON and HTML, raise ``Forbidden``, ``NotFound``, ``BadRequest`` or ``DatasetteError`` instead and Datasette's error handling will build the appropriate response.
.. _internals_response_asgi_send:
Returning a response with .asgi_send(send)

View file

@ -7,6 +7,10 @@ Datasette includes some pages and JSON API endpoints for introspecting the curre
Each of these pages can be viewed in your browser. Add ``.json`` to the URL to get back the contents as JSON.
JSON responses that return an object include an ``"ok": true`` key, consistent with the rest of the :ref:`JSON API <json_api>`.
The introspection endpoints documented on this page are covered by the :ref:`JSON API stability promise <json_api_stability>`, with the exception of the debug endpoints ``/-/threads`` and ``/-/actions``, whose shapes may change in future releases.
.. _JsonDataView_metadata:
/-/metadata
@ -37,6 +41,7 @@ Shows the version of Datasette, Python and SQLite. `Versions example <https://la
.. code-block:: json
{
"ok": true,
"datasette": {
"version": "0.60"
},
@ -75,15 +80,18 @@ Shows a list of currently installed plugins and their versions. `Plugins example
.. code-block:: json
[
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
{
"ok": true,
"plugins": [
{
"name": "datasette_cluster_map",
"static": true,
"templates": false,
"version": "0.10",
"hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"]
}
]
}
Add ``?all=1`` to include details of the default plugins baked into Datasette.
@ -97,6 +105,7 @@ Shows the :ref:`settings` for this instance of Datasette. `Settings example <htt
.. code-block:: json
{
"ok": true,
"default_facet_size": 30,
"default_page_size": 100,
"facet_suggest_time_limit_ms": 50,
@ -115,6 +124,7 @@ Shows the :ref:`configuration <configuration>` for this instance of Datasette. T
.. code-block:: json
{
"ok": true,
"settings": {
"template_debug": true,
"trace_debug": true,
@ -129,20 +139,47 @@ Any keys that include the one of the following substrings in their names will be
/-/databases
------------
Shows currently attached databases. `Databases example <https://latest.datasette.io/-/databases>`_:
Shows currently attached databases that the current actor is allowed to view, based on the ``view-database`` permission. `Databases example <https://latest.datasette.io/-/databases>`_:
.. code-block:: json
[
{
"hash": null,
"is_memory": false,
"is_mutable": true,
"name": "fixtures",
"path": "fixtures.db",
"size": 225280
}
]
{
"ok": true,
"databases": [
{
"hash": null,
"is_memory": false,
"is_mutable": true,
"name": "fixtures",
"path": "fixtures.db",
"size": 225280
}
]
}
.. _JsonDataView_actions:
/-/actions
----------
Shows all actions registered with the permission system, including those added by plugins. Requires the ``permissions-debug`` permission.
.. code-block:: json
{
"ok": true,
"actions": [
{
"name": "view-instance",
"abbr": "vi",
"description": "View Datasette instance",
"takes_parent": false,
"takes_child": false,
"resource_class": null,
"also_requires": null
}
]
}
.. _JumpView:
@ -160,6 +197,7 @@ The endpoint supports a ``?q=`` query parameter for filtering items by name.
.. code-block:: json
{
"ok": true,
"matches": [
{
"name": "fixtures",
@ -188,6 +226,7 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
.. code-block:: json
{
"ok": true,
"matches": [
{
"name": "fixtures: facetable",
@ -215,11 +254,12 @@ Without those query string arguments, the page lists up to five tables with dete
/-/threads
----------
Shows details of threads and ``asyncio`` tasks. `Threads example <https://latest.datasette.io/-/threads>`_:
Shows details of threads and ``asyncio`` tasks. This endpoint requires the ``permissions-debug`` permission, since it exposes runtime internals. `Threads example <https://latest.datasette.io/-/threads>`_:
.. code-block:: json
{
"ok": true,
"num_threads": 2,
"threads": [
{
@ -251,6 +291,7 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
.. code-block:: json
{
"ok": true,
"actor": {
"id": 1,
"username": "some-user"

View file

@ -9,6 +9,53 @@ through the Datasette user interface can also be accessed as JSON via the API.
To access the API for a page, either click on the ``.json`` link on that page or
edit the URL and add a ``.json`` extension to it.
.. _json_api_stability:
API stability
-------------
Datasette 1.0 makes a stability promise for its JSON API: the endpoints,
parameters and response keys documented here and on the pages this
documentation links to will not change in backwards-incompatible ways for
the duration of the 1.x release series.
Stability means:
- Documented endpoints will keep their URLs, methods, parameters and
permission requirements.
- Documented response keys will keep their names and types. New keys may be
**added** in any release - clients should ignore keys they do not
recognize.
- The documented ``?_extra=`` names, ``?_shape=`` values and
:ref:`column filter operators <table_arguments>` are stable.
- Pagination tokens - the ``"next"`` key and ``?_next=`` parameter - are
**opaque strings**. Pass them back exactly as you received them; their
internal structure is not part of the API and can change at any time.
- The :ref:`standard error format <json_api_errors>` and the
:ref:`API token format and restriction semantics <CreateTokenView>` are
stable, including the action abbreviations stored inside signed tokens.
Some JSON endpoints are **exempt** from this promise:
- Endpoints that are not documented include this marker key in their
responses and can change at any time::
"unstable": "This API is not part of Datasette's stable interface and may change at any time"
This currently covers the instance homepage (``/.json``), the stored
query ``analyze``/``store``/``definition`` endpoints, ``/-/query/parameters``,
``/-/execute-write/analyze`` and the JSON returned by the ``/-/permissions``
debug playground.
- Debug and support endpoints are documented so you can use them, but their
JSON shapes are not frozen: :ref:`/-/threads <JsonDataView_threads>`,
:ref:`/-/actions <JsonDataView_actions>`,
the :ref:`permission debug endpoints <PermissionsDebugView>`
(``/-/allowed``, ``/-/rules``, ``/-/check``) and the
:ref:`table autocomplete endpoint <TableAutocompleteView>`.
- Response keys explicitly labeled as unstable in this documentation, such
as the ``"analysis"`` block returned by :ref:`execute-write <ExecuteWriteView>`
and the ``debug`` and ``request`` extras.
.. _json_api_default:
Default representation
@ -42,13 +89,49 @@ looks like this:
"truncated": false
}
``"ok"`` is always ``true`` if an error did not occur.
``"ok"`` is always ``true`` if an error did not occur. Every Datasette JSON endpoint that returns an object includes this key on success.
The ``"rows"`` key is a list of objects, each one representing a row.
The ``"truncated"`` key lets you know if the query was truncated. This can happen if a SQL query returns more than 1,000 results (or the :ref:`setting_max_returned_rows` setting).
For table pages, an additional key ``"next"`` may be present. This indicates that the next page in the pagination set can be retrieved using ``?_next=VALUE``.
For table pages, two additional keys are present: ``"next"``, an opaque token that can be used to retrieve the next page using ``?_next=TOKEN``, and ``"next_url"``, the full URL of that next page. Both are ``null`` on the final page. See :ref:`json_api_pagination`.
.. _json_api_errors:
Error responses
---------------
Every JSON error response from Datasette uses the same format:
.. code-block:: json
{
"ok": false,
"error": "Table not found",
"errors": [
"Table not found"
],
"status": 404
}
- ``"ok"`` is always ``false`` for an error.
- ``"errors"`` is a list of one or more error message strings. Endpoints that
validate multiple things at once - such as the :ref:`insert API <TableInsertView>` -
may return several messages here.
- ``"error"`` is all of those messages joined with ``"; "``, for
convenience when displaying a single string.
- ``"status"`` matches the HTTP status code of the response.
Some endpoints add extra context keys. For example, a SQL error from a
:ref:`custom query <json_api_custom_sql>` also includes the empty
``"rows"`` and ``"truncated"`` keys of the response it was unable to
produce.
Permission errors use the same format: a request that fails a permission
check receives a ``403`` with this JSON error body when the URL ends in
``.json`` or the request sends an ``Accept: application/json`` or
``Content-Type: application/json`` header.
.. _json_api_custom_sql:
@ -92,6 +175,7 @@ options:
{
"ok": true,
"next": null,
"next_url": null,
"rows": [
[3, "Detroit"],
[2, "Los Angeles"],
@ -192,6 +276,10 @@ Here is an example Python function built using `requests <https://requests.readt
Special JSON arguments
----------------------
Boolean query string arguments - such as ``?_labels=`` and
``?_json_infinity=`` - accept ``on``, ``true`` or ``1`` for true and
``off``, ``false`` or ``0`` for false.
Every Datasette endpoint that can return JSON also accepts the following
query string arguments:
@ -245,7 +333,9 @@ These can be repeated or comma-separated:
::
?_extra=columns&_extra=count,next_url
?_extra=columns&_extra=count,count_sql
Requesting an ``_extra`` name that does not exist returns a ``400`` error in the :ref:`standard error format <json_api_errors>`, for example ``{"ok": false, "error": "Unknown _extra: nope", ...}``.
.. [[[cog
from json_api_doc import table_extras
@ -266,6 +356,15 @@ The available table extras are listed below.
15
``count_truncated``
True if the count hit Datasette's counting limit, meaning the real number of matching rows is at least the reported count. (May execute additional queries.)
``GET /fixtures/facetable.json?_extra=count,count_truncated``
.. code-block:: json
false
``count_sql``
SQL query string used to calculate the total count for the current table view, including active filters.
@ -338,17 +437,6 @@ The available table extras are listed below.
"where state = \"CA\" sorted by pk"
``next_url``
Full URL for the next page of results
``GET /fixtures/facetable.json?_size=1&_extra=next_url``
``null`` if there are no more pages of results. See :ref:`json_api_pagination`.
.. code-block:: json
"http://localhost/fixtures/facetable.json?_size=1&_extra=next_url&_next=1"
``columns``
List of column names returned by this table, row or query.
@ -1175,7 +1263,6 @@ The following extras are available for arbitrary SQL query responses and stored,
"description_html": null,
"hide_sql": false,
"fragment": null,
"params": [],
"parameters": [],
"is_write": false,
"is_private": false,
@ -1570,6 +1657,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:
@ -1605,7 +1694,7 @@ Unsupported SQL operations are rejected by default. ``VACUUM`` is not allowed in
A successful response includes a message, the SQLite ``rowcount``, a ``"rows"``
list, a ``"truncated"`` flag and a summary of the operations that were executed:
The shape of the ``"analysis"`` block is not yet considered a stable API and may change in future Datasette releases.
The shape of the ``"analysis"`` block is not part of the :ref:`stable API <json_api_stability>` and may change in future Datasette releases.
.. code-block:: json
@ -1665,15 +1754,17 @@ the execute-write returning row limit, which defaults to 10:
]
}
Errors use the standard Datasette error format:
Errors use the :ref:`standard Datasette error format <json_api_errors>`:
.. code-block:: json
{
"ok": false,
"error": "Permission denied: need execute-write-sql",
"errors": [
"Permission denied: need execute-write-sql"
]
],
"status": 403
}
.. _TableInsertView:
@ -1769,9 +1860,11 @@ If any of your rows have a primary key that is already in use, you will get an e
{
"ok": false,
"error": "UNIQUE constraint failed: new_table.id",
"errors": [
"UNIQUE constraint failed: new_table.id"
]
],
"status": 400
}
Pass ``"ignore": true`` to ignore these errors and insert the other rows:
@ -1846,7 +1939,7 @@ The above example will:
Similar to ``/-/insert``, a ``row`` key with an object can be used instead of a ``rows`` array to upsert a single row.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body. This is deliberately different from the ``201`` returned by :ref:`insert <TableInsertView>`: an upsert may update existing rows without creating anything, so it does not claim resource creation.
Add ``"return": true`` to the request body to return full copies of the affected rows after they have been inserted or updated:
@ -1903,9 +1996,11 @@ When using upsert you must provide the primary key column (or columns if the tab
{
"ok": false,
"error": "Row 0 is missing primary key column(s): \"id\"",
"errors": [
"Row 0 is missing primary key column(s): \"id\""
]
],
"status": 400
}
If your table does not have an explicit primary key you should pass the SQLite ``rowid`` key instead.
@ -1960,14 +2055,16 @@ The returned JSON will look like this:
{
"ok": true,
"row": {
"id": 1,
"title": "New title",
"other_column": "Will be present here too"
}
"rows": [
{
"id": 1,
"title": "New title",
"other_column": "Will be present here too"
}
]
}
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Pass ``"alter: true`` to automatically add any missing columns to the table. This requires the :ref:`actions_alter_table` permission.
@ -1988,7 +2085,7 @@ To delete a row, make a ``POST`` to ``/<database>/<table>/<row-pks>/-/delete``.
If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableCreateView:
@ -2170,9 +2267,11 @@ If you pass a row to the create endpoint with a primary key that already exists
{
"ok": false,
"error": "UNIQUE constraint failed: creatures.id",
"errors": [
"UNIQUE constraint failed: creatures.id"
]
],
"status": 400
}
You can avoid this error by passing the same ``"ignore": true`` or ``"replace": true`` options to the create endpoint as you can to the :ref:`insert endpoint <TableInsertView>`.
@ -2408,7 +2507,7 @@ A successful response returns the new schema and the previous schema. If the req
"operations_applied": 11
}
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableSetColumnTypeView:
@ -2472,7 +2571,7 @@ To clear an existing column type assignment, set ``column_type`` to ``null``:
This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones.
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
.. _TableDropView:
@ -2509,4 +2608,4 @@ If you pass the following POST body:
Then the table will be dropped and a status ``200`` response of ``{"ok": true}`` will be returned.
Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
Any errors will use the :ref:`standard error format <json_api_errors>`, with a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.

View file

@ -95,7 +95,7 @@ Use the :ref:`ExecuteWriteView` JSON API to execute writable SQL programmaticall
Stored query browsers
---------------------
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database.
The ``/-/queries`` page lists stored queries across every database visible to the current actor. The ``/database-name/-/queries`` page lists stored queries for a single database. The JSON versions accept ``?_size=`` (default 50, ``max`` for the :ref:`setting_max_returned_rows` limit) and a ``?_next=`` pagination token.
These pages support search, pagination and filters for read-only or writable queries and private or public queries. Adding a ``.json`` extension to either URL returns the same list as JSON.
@ -169,11 +169,13 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
.. code-block:: json
{
"ok": true,
"schemas": [
{
"database": "content",
"schema": "create table posts ..."
}
]
}
.. _DatabaseSchemaView:
@ -181,11 +183,11 @@ Use ``/-/schema.json`` to get the same information as JSON, which looks like thi
Database schema
---------------
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"`` and ``"schema"`` keys.
Use ``/database-name/-/schema`` to see the complete schema for a specific database. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"`` and ``"schema"`` keys.
.. _TableSchemaView:
Table schema
------------
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"database"``, ``"table"``, and ``"schema"`` keys.
Use ``/database-name/table-name/-/schema`` to see the schema for a specific table. The ``.md`` and ``.json`` extensions work here too. The JSON returns an object with ``"ok"``, ``"database"``, ``"table"``, and ``"schema"`` keys.

View file

@ -1685,6 +1685,8 @@ forbidden(datasette, request, message)
Plugins can use this to customize how Datasette responds when a 403 Forbidden error occurs - usually because a page failed a permission check, see :ref:`authentication_permissions`.
Datasette's default behavior returns the :ref:`standard JSON error format <json_api_errors>` with a 403 status when the request path ends in ``.json`` or the request has an ``Accept: application/json`` or ``Content-Type: application/json`` header; other requests get an HTML error page.
If a plugin hook wishes to react to the error, it should return a :ref:`Response object <internals_response>`.
This example returns a redirect to a ``/-/login`` page:
@ -2544,6 +2546,10 @@ The default ``SignedTokenHandler`` uses itsdangerous signed tokens (``dstok_`` p
async def verify_token(self, datasette, token):
# Look up token in database, return actor dict or None
# if this handler does not recognize the token. Raise
# datasette.TokenInvalid for a token this handler
# recognizes but rejects (revoked, expired) - Datasette
# will respond with a 401 error.
...

View file

@ -459,6 +459,8 @@ Secret configuration values
Some plugins may need configuration that should stay secret - API keys for example. There are two ways in which you can store secret configuration values.
The :ref:`/-/config <JsonDataView_config>` introspection endpoint redacts the values of any configuration keys whose names contain one of these substrings: ``secret``, ``key``, ``password``, ``token``, ``hash`` or ``dsn``. Name your plugin's secret configuration keys accordingly - for example ``api_key`` or ``client_secret`` - so they are automatically redacted there.
**As environment variables**. If your secret lives in an environment variable that is available to the Datasette process, you can indicate that the configuration value should be read from that environment variable like so:
.. [[[cog

View file

@ -657,7 +657,7 @@ There are three options for specifying that you would like the response to your
- Include ``?_json=1`` in the URL that you POST to
- Include ``"_json": 1`` in your JSON body, or ``&_json=1`` in your form encoded body
The JSON response will look like this:
A successful JSON response will look like this:
.. code-block:: json
@ -667,7 +667,21 @@ The JSON response will look like this:
"redirect": "/data/add_name"
}
The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
If the SQL fails to execute - for example a constraint violation - the response uses the :ref:`standard error format <json_api_errors>` with a ``400`` status, plus the ``"redirect"`` key from the query configuration:
.. code-block:: json
{
"ok": false,
"error": "UNIQUE constraint failed: docs.id",
"errors": [
"UNIQUE constraint failed: docs.id"
],
"status": 400,
"redirect": null
}
The ``"message"``, ``"error"`` and ``"redirect"`` values here take into account ``on_success_message``, ``on_success_message_sql``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set.
.. _pagination:

View file

@ -329,7 +329,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
Pagination token for the next page, or None
``next_url`` - ``str``
Full URL for the next page of results
Full URL for the next page of results, or None if there are no more pages. See :ref:`json_api_pagination`.
``ok`` - ``bool``
True if the data for this page was retrieved without errors

View file

@ -1,5 +1,6 @@
from datasette.app import Datasette
from datasette.plugins import DEFAULT_PLUGINS
from datasette.utils import UNSTABLE_API_MESSAGE
from datasette.utils.sqlite import sqlite_version
from datasette.version import __version__
from .fixtures import make_app_client, EXPECTED_PLUGINS
@ -26,8 +27,9 @@ async def test_homepage(ds_client):
"title",
]
databases = data.get("databases")
assert databases.keys() == {"fixtures": 0}.keys()
d = databases["fixtures"]
assert isinstance(databases, list)
assert [d["name"] for d in databases] == ["fixtures"]
d = databases[0]
assert d["name"] == "fixtures"
assert isinstance(d["tables_count"], int)
assert isinstance(len(d["tables_and_views_truncated"]), int)
@ -42,8 +44,7 @@ async def test_homepage_sort_by_relationships(ds_client):
response = await ds_client.get("/.json?_sort=relationships")
assert response.status_code == 200
tables = [
t["name"]
for t in response.json()["databases"]["fixtures"]["tables_and_views_truncated"]
t["name"] for t in response.json()["databases"][0]["tables_and_views_truncated"]
]
assert tables == [
"simple_primary_key",
@ -250,8 +251,10 @@ def test_no_files_uses_memory_database(app_client_no_files):
response = app_client_no_files.get("/.json")
assert response.status == 200
assert {
"databases": {
"_memory": {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": [
{
"name": "_memory",
"hash": None,
"color": "a6c7b9",
@ -266,7 +269,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
"views_count": 0,
"private": False,
},
},
],
"metadata": {},
} == response.json
# Try that SQL query
@ -323,20 +326,15 @@ def test_sql_time_limit(app_client_shorter_time_limit):
"/fixtures/-/query.json?sql=select+sleep(0.5)",
)
assert 400 == response.status
expected_message = (
"SQL query took too long. The time limit is"
" controlled by the sql_time_limit_ms setting."
)
assert response.json == {
"ok": False,
"error": (
"<p>SQL query took too long. The time limit is controlled by the\n"
'<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>\n'
"configuration option.</p>\n"
'<textarea style="width: 90%">select sleep(0.5)</textarea>\n'
"<script>\n"
'let ta = document.querySelector("textarea");\n'
'ta.style.height = ta.scrollHeight + "px";\n'
"</script>"
),
"error": expected_message,
"errors": [expected_message],
"status": 400,
"title": "SQL Interrupted",
}
@ -350,7 +348,7 @@ async def test_custom_sql_time_limit(ds_client):
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
)
assert response.status_code == 400
assert response.json()["title"] == "SQL Interrupted"
assert response.json()["error"].startswith("SQL query took too long.")
@pytest.mark.asyncio
@ -546,7 +544,7 @@ async def test_row_extra_render_cell():
def test_databases_json(app_client_two_attached_databases_one_immutable):
response = app_client_two_attached_databases_one_immutable.get("/-/databases.json")
databases = response.json
databases = response.json["databases"]
assert 2 == len(databases)
extra_database, fixtures_database = databases
assert "extra database" == extra_database["name"]
@ -562,8 +560,12 @@ def test_databases_json(app_client_two_attached_databases_one_immutable):
@pytest.mark.asyncio
async def test_threads_json(ds_client):
response = await ds_client.get("/-/threads.json")
expected_keys = {"threads", "num_threads"}
ds_client.ds.root_enabled = True
try:
response = await ds_client.get("/-/threads.json", actor={"id": "root"})
finally:
ds_client.ds.root_enabled = False
expected_keys = {"ok", "threads", "num_threads"}
if sys.version_info >= (3, 7, 0):
expected_keys.update({"tasks", "num_tasks"})
data = response.json()
@ -578,13 +580,13 @@ async def test_plugins_json(ds_client):
response = await ds_client.get("/-/plugins.json")
# Filter out TrackEventPlugin
actual_plugins = sorted(
[p for p in response.json() if p["name"] != "TrackEventPlugin"],
[p for p in response.json()["plugins"] if p["name"] != "TrackEventPlugin"],
key=lambda p: p["name"],
)
assert EXPECTED_PLUGINS == actual_plugins
# Try with ?all=1
response = await ds_client.get("/-/plugins.json?all=1")
names = {p["name"] for p in response.json()}
names = {p["name"] for p in response.json()["plugins"]}
assert names.issuperset(p["name"] for p in EXPECTED_PLUGINS)
assert names.issuperset(DEFAULT_PLUGINS)
@ -616,7 +618,7 @@ async def test_actions_json(ds_client):
try:
ds_client.ds.root_enabled = True
response = await ds_client.get("/-/actions.json", actor={"id": "root"})
data = response.json()
data = response.json()["actions"]
finally:
ds_client.ds.root_enabled = original_root_enabled
assert isinstance(data, list)
@ -648,6 +650,7 @@ async def test_actions_json(ds_client):
async def test_settings_json(ds_client):
response = await ds_client.get("/-/settings.json")
assert response.json() == {
"ok": True,
"default_page_size": 50,
"default_facet_size": 30,
"default_allow_sql": True,
@ -717,7 +720,7 @@ def test_config_cache_size(app_client_larger_cache_size):
def test_config_force_https_urls():
with make_app_client(settings={"force_https_urls": True}) as client:
response = client.get(
"/fixtures/facetable.json?_size=3&_facet=state&_extra=next_url,suggested_facets"
"/fixtures/facetable.json?_size=3&_facet=state&_extra=suggested_facets"
)
assert response.json["next_url"].startswith("https://")
assert response.json["facet_results"]["results"]["state"]["results"][0][
@ -812,7 +815,9 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
# https://github.com/simonw/datasette/issues/597
assert ["foo-bar", "foo", "fixtures"] == [
d["name"]
for d in app_client_conflicting_database_names.get("/-/databases.json").json
for d in app_client_conflicting_database_names.get("/-/databases.json").json[
"databases"
]
]
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
data = app_client_conflicting_database_names.get(path).json
@ -883,8 +888,9 @@ async def test_tilde_encoded_database_names(db_name):
ds = Datasette()
ds.add_memory_database(db_name)
response = await ds.client.get("/.json")
assert db_name in response.json()["databases"].keys()
path = response.json()["databases"][db_name]["path"]
databases_by_name = {d["name"]: d for d in response.json()["databases"]}
assert db_name in databases_by_name
path = databases_by_name[db_name]["path"]
# And the JSON for that database
response2 = await ds.client.get(path + ".json")
assert response2.status_code == 200
@ -923,7 +929,7 @@ async def test_config_json(config, expected):
"/-/config.json should return redacted configuration"
ds = Datasette(config=config)
response = await ds.client.get("/-/config.json")
assert response.json() == expected
assert response.json() == {"ok": True, **expected}
@pytest.mark.asyncio
@ -1019,7 +1025,7 @@ async def test_config_json(config, expected):
async def test_upgrade_metadata(metadata, expected_config, expected_metadata):
ds = Datasette(metadata=metadata)
response = await ds.client.get("/-/config.json")
assert response.json() == expected_config
assert response.json() == {"ok": True, **expected_config}
response2 = await ds.client.get("/-/metadata.json")
assert response2.json() == expected_metadata

View file

@ -1,6 +1,6 @@
from datasette.app import Datasette
from datasette.events import RenameTableEvent
from datasette.utils import escape_sqlite, sqlite3
from datasette.utils import error_body, escape_sqlite, sqlite3
from .utils import last_event
import pytest
import re
@ -169,7 +169,10 @@ async def test_base64_write_api_insert_upsert_update_decode_blobs(ds_write):
headers=_headers(token),
)
assert update_response.status_code == 200
assert update_response.json()["row"]["data"] == {"$base64": True, "encoded": "/wAB"}
assert update_response.json()["rows"][0]["data"] == {
"$base64": True,
"encoded": "/wAB",
}
rows = (await db.execute("""
select
@ -344,10 +347,9 @@ async def test_insert_rows_post_body_too_large(tmp_path_factory):
headers=_headers(token),
)
assert response.status_code == 413
assert response.json() == {
"ok": False,
"errors": ["Request body exceeded maximum size of 100 bytes"],
}
assert response.json() == error_body(
["Request body exceeded maximum size of 100 bytes"], 413
)
# A small body should still work
response2 = await ds.client.post(
"/data/docs/-/insert",
@ -380,8 +382,8 @@ async def test_insert_rows_post_body_too_large(tmp_path_factory):
"/data/docs/-/insert",
{"rows": [{"title": "Test"} for i in range(10)]},
"bad_token",
403,
["Permission denied"],
401,
["Invalid token signature"],
),
(
"/data/docs/-/insert",
@ -392,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",
[],
@ -580,20 +575,17 @@ 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",
},
)
actor_response = (
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
).json()
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
token_permissions
)
if special_case != "bad_token":
actor_response = (
await ds_write.client.get("/-/actor.json", headers=kwargs["headers"])
).json()
assert set((actor_response["actor"] or {}).get("_r", {}).get("a") or []) == set(
token_permissions
)
if special_case == "invalid_json":
del kwargs["json"]
@ -966,7 +958,12 @@ async def test_update_row_invalid_key(ds_write):
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {"ok": False, "errors": ["Invalid keys: bad_key"]}
assert response.json() == {
"ok": False,
"error": "Invalid keys: bad_key",
"errors": ["Invalid keys: bad_key"],
"status": 400,
}
@pytest.mark.asyncio
@ -1285,10 +1282,9 @@ async def test_alter_table_foreign_key_requires_fk_table_for_fk_column(ds_write)
headers=_headers(write_token(ds_write, permissions=["at"])),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["operations.0.add_foreign_key.args: fk_column requires fk_table"],
}
assert response.json() == error_body(
["operations.0.add_foreign_key.args: fk_column requires fk_table"], 400
)
@pytest.mark.asyncio
@ -1312,10 +1308,9 @@ async def test_alter_table_foreign_key_without_fk_column_requires_single_pk(ds_w
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
assert response.json() == error_body(
["Could not detect single primary key for table 'accounts'"], 400
)
@pytest.mark.asyncio
@ -1381,10 +1376,7 @@ async def test_foreign_key_suggestions_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
assert response.json() == error_body(["Permission denied: need alter-table"], 403)
@pytest.mark.asyncio
@ -1495,10 +1487,7 @@ async def test_foreign_key_targets_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need create-table"],
}
assert response.json() == error_body(["Permission denied: need create-table"], 403)
@pytest.mark.asyncio
@ -1521,10 +1510,7 @@ async def test_alter_table_permission_denied(ds_write):
headers=_headers(token),
)
assert response.status_code == 403
assert response.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
assert response.json() == error_body(["Permission denied: need alter-table"], 403)
@pytest.mark.asyncio
@ -1668,9 +1654,9 @@ async def test_update_row(ds_write, input, expected_errors, use_return):
assert response.json()["ok"] is True
if not use_return:
assert "row" not in response.json()
assert "rows" not in response.json()
else:
returned_row = response.json()["row"]
returned_row = response.json()["rows"][0]
assert returned_row["id"] == pk
for k, v in input.items():
assert returned_row[k] == v
@ -2203,6 +2189,9 @@ async def test_create_table(
)
assert response.status_code == expected_status
data = response.json()
if expected_response.get("ok") is False:
# Error expectations list their messages; derive the canonical envelope
expected_response = error_body(expected_response["errors"], expected_status)
if isinstance(expected_response, dict) and "schema" in expected_response:
assert data.get("schema") in schema_variants(expected_response["schema"])
expected_response = dict(expected_response, schema=data.get("schema"))
@ -2405,13 +2394,12 @@ async def test_create_table_column_validation(ds_write, column, expected_error):
)
if expected_error:
assert response.status_code == 400
assert response.json() == {"ok": False, "errors": [expected_error]}
assert response.json() == error_body([expected_error], 400)
else:
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'owners'"],
}
assert response.json() == error_body(
["Could not detect single primary key for table 'owners'"], 400
)
@pytest.mark.asyncio
@ -2449,10 +2437,9 @@ async def test_create_table_foreign_key_without_fk_column_requires_single_pk(ds_
headers=_headers(token),
)
assert response.status_code == 400
assert response.json() == {
"ok": False,
"errors": ["Could not detect single primary key for table 'accounts'"],
}
assert response.json() == error_body(
["Could not detect single primary key for table 'accounts'"], 400
)
@pytest.mark.asyncio
@ -2602,10 +2589,9 @@ async def test_create_table_error_if_pk_changed(ds_write):
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == {
"ok": False,
"errors": ["pk cannot be changed for existing table"],
}
assert second_response.json() == error_body(
["pk cannot be changed for existing table"], 400
)
@pytest.mark.asyncio
@ -2629,10 +2615,9 @@ async def test_create_table_error_rows_twice_with_duplicates(ds_write):
headers=_headers(token),
)
assert second_response.status_code == 400
assert second_response.json() == {
"ok": False,
"errors": ["UNIQUE constraint failed: test_create_twice.id"],
}
assert second_response.json() == error_body(
["UNIQUE constraint failed: test_create_twice.id"], 400
)
@pytest.mark.asyncio
@ -2655,6 +2640,8 @@ async def test_method_not_allowed(ds_write, path):
assert response.json() == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
@ -2722,10 +2709,9 @@ async def test_create_using_alter_against_existing_table(
)
if not has_alter_permission:
assert response2.status_code == 403
assert response2.json() == {
"ok": False,
"errors": ["Permission denied: need alter-table"],
}
assert response2.json() == error_body(
["Permission denied: need alter-table"], 403
)
else:
assert response2.status_code == 201

View file

@ -236,7 +236,9 @@ def test_auth_create_token(
@pytest.mark.asyncio
async def test_auth_create_token_not_allowed_for_tokens(ds_client):
ds_tok = ds_client.ds.sign({"a": "test", "token": "dstok"}, "token")
ds_tok = ds_client.ds.sign(
{"a": "test", "token": "dstok", "t": int(time.time())}, "token"
)
response = await ds_client.get(
"/-/create-token",
headers={"Authorization": "Bearer dstok_{}".format(ds_tok)},
@ -294,7 +296,7 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
try:
if should_work:
data = response.json()
assert data.keys() == {"actor"}
assert data.keys() == {"ok", "actor"}
actor = data["actor"]
expected_keys = {"id", "token"}
if scenario != "valid_unlimited_token":
@ -304,8 +306,16 @@ async def test_auth_with_dstok_token(ds_client, scenario, should_work):
assert actor["token"] == "dstok"
if scenario != "valid_unlimited_token":
assert isinstance(actor["token_expires"], int)
elif scenario == "no_token":
# No credentials presented - request proceeds as anonymous
assert response.json() == {"ok": True, "actor": None}
else:
assert response.json() == {"actor": None}
# Invalid credentials presented - hard 401
assert response.status_code == 401
data = response.json()
assert data["ok"] is False
assert data["status"] == 401
assert response.headers["www-authenticate"].startswith("Bearer")
finally:
ds_client.ds._settings["allow_signed_tokens"] = True
@ -337,10 +347,11 @@ def test_cli_create_token(app_client, expires):
}
if expires and expires > 0:
expected_actor["token_expires"] = details["t"] + expires
assert response.json == {"actor": expected_actor}
assert response.json == {"ok": True, "actor": expected_actor}
else:
expected_actor = None
assert response.json == {"actor": expected_actor}
# Expired token - hard 401
assert response.status == 401
assert response.json["ok"] is False
@pytest.mark.asyncio

View file

@ -25,13 +25,14 @@ async def test_autocomplete_single_pk_exact_match_and_label_order():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 2}, "label": "Longer non-label pk match"},
{"pks": {"id": 20}, "label": "2"},
{"pks": {"id": 21}, "label": "22"},
{"pks": {"id": 3}, "label": "A label containing 2"},
{"pks": {"id": 200}, "label": "A"},
]
],
}
@ -52,12 +53,12 @@ async def test_autocomplete_blank_q_returns_no_results():
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete?q=")
assert response.status_code == 200
assert response.json() == {"rows": []}
assert response.json() == {"ok": True, "rows": []}
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
assert response.status_code == 200
assert response.json() == {"rows": []}
assert response.json() == {"ok": True, "rows": []}
@pytest.mark.asyncio
@ -81,11 +82,12 @@ async def test_autocomplete_initial_returns_latest_rows():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
]
],
}
response = await ds.client.get(
@ -94,11 +96,12 @@ async def test_autocomplete_initial_returns_latest_rows():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 3}, "label": "Cleo"},
{"pks": {"id": 2}, "label": "Bob"},
{"pks": {"id": 1}, "label": "Alice"},
]
],
}
@ -121,9 +124,10 @@ async def test_autocomplete_escapes_like_characters():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"id": 1}, "label": "100% real"},
]
],
}
@ -149,11 +153,12 @@ async def test_autocomplete_compound_pk_searches_all_pk_columns():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"country": "mx", "code": "ca"}, "label": "Campeche"},
{"pks": {"country": "us", "code": "ca"}, "label": "California"},
{"pks": {"country": "ca", "code": "bc"}, "label": "British Columbia"},
]
],
}
@ -184,9 +189,10 @@ async def test_autocomplete_primary_key_called_label():
assert response.status_code == 200
assert response.json() == {
"ok": True,
"rows": [
{"pks": {"label": "abc"}, "label": "Display value"},
]
],
}
@ -246,8 +252,9 @@ async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
assert timeout_was_simulated
data = response.json()
assert data == {
"ok": True,
"rows": [
{"pks": {"id": f"item-1999{i:02d}"}, "label": f"name 1999{i:02d}"}
for i in range(10)
]
],
}

View file

@ -53,6 +53,8 @@ async def test_get_view():
assert json.loads(post_json_response.body) == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
assert post_json_response.status == 405

View file

@ -385,7 +385,9 @@ def test_setting_boolean_validation_false_values(value):
)
# Should be forbidden (setting is false)
assert result.exit_code == 1, result.output
assert "Forbidden" in result.output
error = json.loads(result.output)
assert error["ok"] is False
assert error["status"] == 403
@pytest.mark.parametrize("value", ("on", "true", "1"))
@ -425,8 +427,9 @@ def test_setting_default_allow_sql(default_allow_sql):
assert json.loads(result.output)["rows"][0] == {"21": 21}
else:
assert result.exit_code == 1, result.output
# This isn't JSON at the moment, maybe it should be though
assert "Forbidden" in result.output
error = json.loads(result.output)
assert error["ok"] is False
assert error["status"] == 403
def test_sql_errors_logged_to_stderr():
@ -444,7 +447,7 @@ def test_serve_create(tmpdir):
cli, [str(db_path), "--create", "--get", "/-/databases.json"]
)
assert result.exit_code == 0, result.output
databases = json.loads(result.output)
databases = json.loads(result.output)["databases"]
assert {
"name": "does_not_exist_yet",
"is_mutable": True,
@ -493,7 +496,7 @@ def test_serve_duplicate_database_names(tmpdir):
conn.close()
result = runner.invoke(cli, [db_1_path, db_2_path, "--get", "/-/databases.json"])
assert result.exit_code == 0, result.output
databases = json.loads(result.output)
databases = json.loads(result.output)["databases"]
assert {db["name"] for db in databases} == {"db", "db_2"}
@ -585,7 +588,7 @@ def test_duplicate_database_files_error(tmpdir):
cli, ["serve", other_db_path, str(config_dir), "--get", "/-/databases.json"]
)
assert result4.exit_code == 0
databases = json.loads(result4.output)
databases = json.loads(result4.output)["databases"]
assert {db["name"] for db in databases} == {"other", "data"}
# Test that multiple directories raise an error

View file

@ -95,7 +95,10 @@ def test_serve_with_get_and_token():
],
)
assert 0 == result2.exit_code, result2.output
assert json.loads(result2.output) == {"actor": {"id": "root", "token": "dstok"}}
assert json.loads(result2.output) == {
"ok": True,
"actor": {"id": "root", "token": "dstok"},
}
def test_serve_with_get_exit_code_for_error():
@ -130,8 +133,9 @@ def test_serve_get_actor():
)
assert result.exit_code == 0
assert json.loads(result.output) == {
"ok": True,
"actor": {
"id": "root",
"extra": "x",
}
},
}

View file

@ -9,7 +9,7 @@ from datasette.column_types import (
)
from datasette.hookspecs import hookimpl
from datasette.plugins import pm
from datasette.utils import sqlite3
from datasette.utils import error_body, sqlite3
from datasette.utils import StartupError
import markupsafe
import pytest
@ -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":
@ -426,7 +416,7 @@ async def test_set_column_type_api_errors(
kwargs["json"] = body
response = await ds_ct.client.post("/data/posts/-/set-column-type", **kwargs)
assert response.status_code == expected_status
assert response.json() == {"ok": False, "errors": expected_errors}
assert response.json() == error_body(expected_errors, expected_status)
@pytest.mark.asyncio

View file

@ -109,9 +109,10 @@ def test_settings(config_dir_client):
def test_plugins(config_dir_client):
response = config_dir_client.get("/-/plugins.json")
assert 200 == response.status
assert "hooray.py" in {p["name"] for p in response.json}
assert "non_py_file.txt" not in {p["name"] for p in response.json}
assert "mypy_cache" not in {p["name"] for p in response.json}
plugins = response.json["plugins"]
assert "hooray.py" in {p["name"] for p in plugins}
assert "non_py_file.txt" not in {p["name"] for p in plugins}
assert "mypy_cache" not in {p["name"] for p in plugins}
def test_templates_and_plugin(config_dir_client):
@ -136,7 +137,7 @@ def test_static_directory_browsing_not_allowed(config_dir_client):
def test_databases(config_dir_client):
response = config_dir_client.get("/-/databases.json")
assert 200 == response.status
databases = response.json
databases = response.json["databases"]
assert 4 == len(databases)
databases.sort(key=lambda d: d["name"])
for db, expected_name in zip(databases, ("demo", "immutable", "j", "k")):

View file

@ -248,7 +248,7 @@ async def test_homepage():
async def test_actor_is_null():
ds = Datasette(memory=True)
response = await ds.client.get("/-/actor.json")
assert response.json() == {"actor": None}
assert response.json() == {"ok": True, "actor": None}
# -- end test_actor_is_null --
@ -258,5 +258,5 @@ async def test_signed_cookie_actor():
ds = Datasette(memory=True)
cookies = {"ds_actor": ds.client.actor_cookie({"id": "root"})}
response = await ds.client.get("/-/actor.json", cookies=cookies)
assert response.json() == {"actor": {"id": "root"}}
assert response.json() == {"ok": True, "actor": {"id": "root"}}
# -- end test_signed_cookie_actor --

749
tests/test_error_shape.py Normal file
View file

@ -0,0 +1,749 @@
"""
Tests for the canonical JSON error shape.
Every JSON error response from Datasette should use one shape:
{
"ok": false,
"error": "<all messages joined with '; '>",
"errors": ["<message>", ...],
"status": <int matching the HTTP status code>
}
Additional context keys (for example "rows" and "truncated" on SQL errors)
are permitted, but "ok", "error", "errors" and "status" must always be
present and the legacy "title" key must not be.
https://github.com/simonw/datasette/issues - 1.0 API consistency
"""
import pytest
import time
from datasette.app import Datasette
from datasette.utils import sqlite3
def assert_canonical_error(response, expected_status):
assert response.status_code == expected_status
data = response.json()
assert data["ok"] is False
assert isinstance(data["error"], str)
assert data["error"]
assert isinstance(data["errors"], list)
assert data["errors"]
assert all(isinstance(message, str) for message in data["errors"])
assert data["error"] == "; ".join(data["errors"])
assert data["status"] == expected_status
assert "title" not in data
return data
@pytest.fixture
def ds_error_shape(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette([db_path])
ds.root_enabled = True
yield ds
ds.close()
# Shape 1: the exception handler (handle_exception.py)
@pytest.mark.asyncio
async def test_not_found_error_shape(ds_client):
response = await ds_client.get("/fixtures/no_such_table.json")
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_datasette_error_with_title_omits_title_key(ds_client):
# DatasetteError(title="Invalid SQL") previously leaked a "title" key
response = await ds_client.get(
"/fixtures/-/query.json?sql=update+facetable+set+state+=+1"
)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Statement must be a SELECT"]
# Shape 2: the _error() helper (views/base.py) - write API and friends
@pytest.mark.asyncio
async def test_write_api_validation_error_shape(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "token": "dstok", "t": 0},
namespace="token",
)
)
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"rows": [{"nope": 1}, {"also_nope": 2}]},
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
},
)
data = assert_canonical_error(response, 400)
# Multiple messages: errors keeps them all, error joins them
assert len(data["errors"]) == 2
assert data["errors"][0].startswith("Row 0")
assert data["errors"][1].startswith("Row 1")
@pytest.mark.asyncio
async def test_write_api_permission_denied_shape(ds_error_shape):
response = await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"rows": [{"title": "hello"}]},
headers={"Content-Type": "application/json"},
)
assert_canonical_error(response, 403)
# Shape 3: the JSON renderer (renderer.py)
@pytest.mark.asyncio
async def test_sql_error_shape_keeps_context_keys(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+*+from+no_such_table"
)
data = assert_canonical_error(response, 400)
# Renderer errors keep their context keys
assert data["rows"] == []
assert "truncated" in data
@pytest.mark.asyncio
async def test_invalid_shape_error_shape(ds_client):
response = await ds_client.get("/fixtures/-/query.json?sql=select+1&_shape=bananas")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Invalid _shape: bananas"]
@pytest.mark.asyncio
async def test_shape_object_on_query_is_a_400_error(ds_client):
# Previously returned HTTP 200 with an ok: false body
response = await ds_client.get("/fixtures/-/query.json?sql=select+1&_shape=object")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_shape=object is only available on tables"]
# Shape 4: bare {"error": ...} from the permission debug endpoints
@pytest.mark.asyncio
async def test_allowed_missing_action_error_shape(ds_client):
response = await ds_client.get("/-/allowed.json")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["action parameter is required"]
@pytest.mark.asyncio
async def test_allowed_unknown_action_error_shape(ds_client):
response = await ds_client.get("/-/allowed.json?action=no_such_action")
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_check_unknown_action_error_shape(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/check.json?action=no_such_action",
actor={"id": "root"},
)
assert_canonical_error(response, 404)
@pytest.mark.asyncio
async def test_rules_missing_action_error_shape(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/rules.json",
actor={"id": "root"},
)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["action parameter is required"]
# Other stragglers
@pytest.mark.asyncio
async def test_method_not_allowed_error_shape(ds_client):
response = await ds_client.post("/fixtures.json")
assert_canonical_error(response, 405)
@pytest.mark.asyncio
async def test_schema_unknown_database_error_shape(ds_client):
response = await ds_client.get("/no_such_db/-/schema.json")
assert_canonical_error(response, 404)
# Forbidden responses (the default forbidden() hook)
@pytest.fixture
def ds_forbidden(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette(
[db_path],
config={"databases": {"data": {"tables": {"docs": {"allow": {"id": "root"}}}}}},
)
ds.root_enabled = True
yield ds
ds.close()
@pytest.mark.asyncio
async def test_forbidden_json_path_returns_canonical_json(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs.json")
data = assert_canonical_error(response, 403)
assert "permission" in data["error"].lower()
@pytest.mark.asyncio
async def test_forbidden_accept_json_returns_canonical_json(ds_forbidden):
response = await ds_forbidden.client.get(
"/data/docs", headers={"Accept": "application/json"}
)
assert_canonical_error(response, 403)
@pytest.mark.asyncio
async def test_forbidden_html_path_still_returns_html(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs")
assert response.status_code == 403
assert response.headers["content-type"].startswith("text/html")
@pytest.mark.asyncio
async def test_forbidden_json_path_allowed_actor_still_works(ds_forbidden):
response = await ds_forbidden.client.get("/data/docs.json", actor={"id": "root"})
assert response.status_code == 200
assert response.json()["ok"] is True
# Write canned queries: SQL failures must not return HTTP 200
@pytest.fixture
def ds_write_query(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette(
[db_path],
config={
"databases": {
"data": {
"queries": {
"add_doc": {
"sql": (
"insert into docs (id, title)" " values (:id, :title)"
),
"write": True,
},
"add_doc_custom_error": {
"sql": (
"insert into docs (id, title)" " values (:id, :title)"
),
"write": True,
"on_error_message": "Custom error message",
"on_error_redirect": "/data",
},
}
}
}
},
)
yield ds
ds.close()
@pytest.mark.asyncio
async def test_write_query_success_returns_200(ds_write_query):
response = await ds_write_query.client.post(
"/data/add_doc",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["message"] == "Query executed, 1 row affected"
assert data["redirect"] is None
@pytest.mark.asyncio
async def test_write_query_sql_failure_returns_400(ds_write_query):
for _ in range(2):
response = await ds_write_query.client.post(
"/data/add_doc",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
data = assert_canonical_error(response, 400)
assert "UNIQUE constraint failed" in data["error"]
# The redirect context key from the canned query flow is preserved
assert data["redirect"] is None
@pytest.mark.asyncio
async def test_write_query_failure_uses_on_error_message_and_redirect(
ds_write_query,
):
for _ in range(2):
response = await ds_write_query.client.post(
"/data/add_doc_custom_error",
json={"id": 1, "title": "One"},
headers={"Accept": "application/json"},
)
data = assert_canonical_error(response, 400)
assert data["error"] == "Custom error message"
assert data["redirect"] == "/data"
@pytest.mark.asyncio
async def test_write_query_forbidden_is_canonical_403(ds_write_query):
# An untrusted write query run by an actor without execute-write-sql
# raises Forbidden, handled by the forbidden() hook
await ds_write_query.invoke_startup()
await ds_write_query.add_query(
"data",
name="untrusted_add",
sql="insert into docs (id, title) values (:id, :title)",
is_write=True,
is_trusted=False,
source="user",
owner_id="someone",
)
response = await ds_write_query.client.post(
"/data/untrusted_add",
json={"id": 5, "title": "Five"},
headers={"Accept": "application/json"},
actor={"id": "someone"},
)
assert_canonical_error(response, 403)
@pytest.mark.asyncio
async def test_write_query_rejected_operation_is_canonical_403(ds_write_query):
# A rejected operation (VACUUM) raises QueryWriteRejected, handled by
# the dedicated branch in QueryView.post - root has execute-write-sql
ds_write_query.root_enabled = True
await ds_write_query.invoke_startup()
await ds_write_query.add_query(
"data",
name="vacuum_it",
sql="vacuum",
is_write=True,
is_trusted=False,
source="user",
owner_id="root",
)
response = await ds_write_query.client.post(
"/data/vacuum_it",
json={},
headers={"Accept": "application/json"},
actor={"id": "root"},
)
data = assert_canonical_error(response, 403)
assert data["redirect"] is None
# Row delete write failures must be 400, matching row update
@pytest.mark.asyncio
async def test_row_delete_write_failure_is_400(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.execute("insert into docs (id, title) values (1, 'One')")
conn.execute(
"create trigger no_delete before delete on docs "
"begin select raise(abort, 'deletes are blocked'); end"
)
conn.commit()
conn.close()
ds = Datasette([db_path])
ds.root_enabled = True
try:
response = await ds.client.post(
"/data/docs/1/-/delete",
json={},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
data = assert_canonical_error(response, 400)
assert "deletes are blocked" in data["error"]
finally:
ds.close()
# Invalid bearer tokens must produce 401, not silent anonymous access
@pytest.mark.asyncio
async def test_expired_token_returns_401(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "t": int(time.time()) - 2000, "d": 1000},
namespace="token",
)
)
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
data = assert_canonical_error(response, 401)
assert "expired" in data["error"].lower()
assert response.headers["www-authenticate"].startswith("Bearer")
@pytest.mark.asyncio
async def test_bad_signature_token_returns_401(ds_error_shape):
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer dstok_garbage"}
)
assert_canonical_error(response, 401)
assert response.headers["www-authenticate"].startswith("Bearer")
@pytest.mark.asyncio
async def test_unrecognized_token_prefix_stays_anonymous(ds_error_shape):
# No registered handler claims this token - it might belong to a
# plugin's actor_from_request hook, so it must not hard-fail
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer sometoken_abc"}
)
assert response.status_code == 200
assert response.json() == {"ok": True, "actor": None}
@pytest.mark.asyncio
async def test_valid_token_still_authenticates(ds_error_shape):
token = "dstok_{}".format(
ds_error_shape.sign(
{"a": "root", "t": int(time.time())},
namespace="token",
)
)
response = await ds_error_shape.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
assert response.status_code == 200
assert response.json()["actor"]["id"] == "root"
@pytest.mark.asyncio
async def test_bad_token_beats_valid_cookie(ds_error_shape):
# A malformed Authorization header is a hard error even if a valid
# ds_actor cookie is also present
response = await ds_error_shape.client.get(
"/-/actor.json",
headers={"Authorization": "Bearer dstok_garbage"},
cookies={"ds_actor": ds_error_shape.client.actor_cookie({"id": "root"})},
)
assert_canonical_error(response, 401)
@pytest.mark.asyncio
async def test_token_when_signed_tokens_disabled_returns_401(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.close()
ds = Datasette([db_path], settings={"allow_signed_tokens": False})
try:
token = "dstok_{}".format(
ds.sign({"a": "root", "t": int(time.time())}, namespace="token")
)
response = await ds.client.get(
"/-/actor.json", headers={"Authorization": "Bearer {}".format(token)}
)
data = assert_canonical_error(response, 401)
assert "not enabled" in data["error"]
finally:
ds.close()
# GET /db/-/query without SQL: 400 for data formats, HTML editor stays 200
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/fixtures/-/query.json",
"/fixtures/-/query.json?sql=",
),
)
async def test_query_json_without_sql_is_400(ds_client, path):
response = await ds_client.get(path)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["?sql= is required"]
@pytest.mark.asyncio
async def test_query_html_without_sql_is_still_the_editor(ds_client):
response = await ds_client.get("/fixtures/-/query")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
# Write API return:true responses use "rows" consistently
@pytest.mark.asyncio
async def test_row_update_return_uses_rows_list(ds_error_shape):
await ds_error_shape.client.post(
"/data/docs/-/insert",
json={"row": {"id": 1, "title": "One"}},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
response = await ds_error_shape.client.post(
"/data/docs/1/-/update",
json={"update": {"title": "Updated"}, "return": True},
headers={"Content-Type": "application/json"},
actor={"id": "root"},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert "row" not in data
assert data["rows"] == [{"id": 1, "title": "Updated"}]
# Schema endpoints: no existence oracle, no 500 on unknown database
@pytest.mark.asyncio
async def test_schema_endpoints_no_existence_oracle(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key)")
conn.close()
ds = Datasette([db_path], default_deny=True)
ds.root_enabled = True
try:
# An actor without view-database cannot distinguish an existing
# database from a missing one
denied_existing = await ds.client.get("/data/-/schema.json")
denied_missing = await ds.client.get("/nope/-/schema.json")
assert denied_existing.status_code == denied_missing.status_code == 403
# An authorized actor sees the real thing
root_existing = await ds.client.get("/data/-/schema.json", actor={"id": "root"})
assert root_existing.status_code == 200
root_missing = await ds.client.get("/nope/-/schema.json", actor={"id": "root"})
assert root_missing.status_code == 404
finally:
ds.close()
@pytest.mark.asyncio
async def test_table_schema_unknown_database_is_404_not_500(ds_client):
response = await ds_client.get("/no_such_db/some_table/-/schema.json")
assert_canonical_error(response, 404)
# Unknown _extra names are a 400, not silently ignored
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/fixtures/facetable.json?_extra=nope",
"/fixtures/facetable.json?_extra=count,nope",
"/fixtures/simple_primary_key/1.json?_extra=nope",
"/fixtures/-/query.json?sql=select+1&_extra=nope",
),
)
async def test_unknown_extra_is_400(ds_client, path):
response = await ds_client.get(path)
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: nope"]
@pytest.mark.asyncio
async def test_html_only_extra_via_json_is_400(ds_client):
# display_rows exists for the HTML view but is not part of the JSON API
response = await ds_client.get("/fixtures/facetable.json?_extra=display_rows")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: display_rows"]
@pytest.mark.asyncio
async def test_unknown_extra_ignored_on_html_pages(ds_client):
response = await ds_client.get("/fixtures/facetable?_extra=nope")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
# /-/threads exposes runtime internals and requires permissions-debug
@pytest.mark.asyncio
async def test_threads_requires_permissions_debug(ds_error_shape):
denied = await ds_error_shape.client.get("/-/threads.json")
assert_canonical_error(denied, 403)
allowed = await ds_error_shape.client.get("/-/threads.json", actor={"id": "root"})
assert allowed.status_code == 200
assert allowed.json()["ok"] is True
# _size is the one page-size parameter, with uniform validation
@pytest.mark.asyncio
async def test_query_list_size_supports_max_keyword(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=max")
assert response.status_code == 200
# ds_client runs with max_returned_rows=100
assert response.json()["limit"] == 100
@pytest.mark.asyncio
async def test_query_list_size_rejects_out_of_range(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=5000")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_size must be <= 100"]
@pytest.mark.asyncio
async def test_query_list_size_rejects_non_integer(ds_client):
response = await ds_client.get("/fixtures/-/queries.json?_size=bananas")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["_size must be a positive integer"]
@pytest.mark.asyncio
@pytest.mark.parametrize("endpoint", ("allowed", "rules"))
async def test_debug_endpoints_use_size_and_page_parameters(ds_error_shape, endpoint):
base = "/-/{}.json?action=view-instance".format(endpoint)
ok = await ds_error_shape.client.get(
base + "&_size=1&_page=1", actor={"id": "root"}
)
assert ok.status_code == 200
assert ok.json()["page_size"] == 1
max_size = await ds_error_shape.client.get(
base + "&_size=max", actor={"id": "root"}
)
assert max_size.status_code == 200
assert max_size.json()["page_size"] == 200
too_big = await ds_error_shape.client.get(base + "&_size=500", actor={"id": "root"})
data = assert_canonical_error(too_big, 400)
assert data["errors"] == ["_size must be <= 200"]
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
# SQL Interrupted errors carry plain text in JSON, not an HTML fragment
@pytest.mark.asyncio
async def test_sql_interrupted_json_error_is_plain_text(ds_client):
response = await ds_client.get(
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5"
)
data = assert_canonical_error(response, 400)
assert "<" not in data["error"]
assert data["error"].startswith("SQL query took too long.")
@pytest.mark.asyncio
async def test_sql_interrupted_html_page_keeps_rich_error(ds_client):
response = await ds_client.get(
"/fixtures/-/query?sql=select+sleep(0.01)&_timelimit=5"
)
assert response.status_code == 400
assert "<textarea" in response.text
@pytest.mark.asyncio
async def test_next_url_extra_no_longer_exists(ds_client):
# next_url is always present in table JSON; the extra was removed
response = await ds_client.get("/fixtures/facetable.json?_extra=next_url")
data = assert_canonical_error(response, 400)
assert data["errors"] == ["Unknown _extra: next_url"]

View file

@ -1363,12 +1363,12 @@ async def test_permission_debug_tabs_with_query_string(ds_client):
# Test /-/allowed with query string
response = await ds_client.get(
"/-/allowed?action=view-table&page_size=50", actor=actor
"/-/allowed?action=view-table&_size=50", actor=actor
)
assert response.status_code == 200
# Check that Rules and Check tabs have the query string
assert 'href="/-/rules?action=view-table&amp;page_size=50"' in response.text
assert 'href="/-/check?action=view-table&amp;page_size=50"' in response.text
assert 'href="/-/rules?action=view-table&amp;_size=50"' in response.text
assert 'href="/-/check?action=view-table&amp;_size=50"' in response.text
# Playground and Actions should not have query string
assert 'href="/-/permissions"' in response.text
assert 'href="/-/actions"' in response.text

View file

@ -167,7 +167,7 @@ def test_static_rejects_path_traversal(tmp_path, monkeypatch):
@pytest.mark.asyncio
async def test_datasette_constructor():
ds = Datasette()
databases = (await ds.client.get("/-/databases.json")).json()
databases = (await ds.client.get("/-/databases.json")).json()["databases"]
assert databases == [
{
"name": "_memory",
@ -184,11 +184,12 @@ async def test_datasette_constructor():
@pytest.mark.asyncio
async def test_num_sql_threads_zero():
ds = Datasette([], memory=True, settings={"num_sql_threads": 0})
ds.root_enabled = True
db = ds.add_database(Database(ds, memory_name="test_num_sql_threads_zero"))
await db.execute_write("create table t(id integer primary key)")
await db.execute_write("insert into t (id) values (1)")
response = await ds.client.get("/-/threads.json")
assert response.json() == {"num_threads": 0, "threads": []}
response = await ds.client.get("/-/threads.json", actor={"id": "root"})
assert response.json() == {"ok": True, "num_threads": 0, "threads": []}
response2 = await ds.client.get("/test_num_sql_threads_zero/t.json?_shape=array")
assert response2.json() == [{"id": 1}]

View file

@ -318,7 +318,7 @@ async def test_actor_parameter_sets_cookie(datasette):
"""Passing actor= should sign a ds_actor cookie and authenticate the request."""
response = await datasette.client.get("/-/actor.json", actor={"id": "root"})
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
assert response.json() == {"ok": True, "actor": {"id": "root"}}
@pytest.mark.asyncio
@ -327,7 +327,7 @@ async def test_actor_parameter_works_with_request_method(datasette):
"GET", "/-/actor.json", actor={"id": "root"}
)
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
assert response.json() == {"ok": True, "actor": {"id": "root"}}
@pytest.mark.asyncio
@ -362,7 +362,7 @@ async def test_actor_parameter_merges_with_other_cookies(datasette):
cookies={"unrelated": "value"},
)
assert response.status_code == 200
assert response.json() == {"actor": {"id": "root"}}
assert response.json() == {"ok": True, "actor": {"id": "root"}}
@pytest.mark.asyncio

View file

@ -1,4 +1,5 @@
from datasette.utils.asgi import Response
import json
import pytest
@ -52,3 +53,26 @@ async def test_response_set_cookie():
},
{"type": "http.response.body", "body": b""},
] == events
def test_response_error_single_message():
response = Response.error("Method not allowed", 405)
assert response.status == 405
assert response.content_type == "application/json; charset=utf-8"
assert json.loads(response.body) == {
"ok": False,
"error": "Method not allowed",
"errors": ["Method not allowed"],
"status": 405,
}
def test_response_error_message_list_and_default_status():
response = Response.error(["First problem", "Second problem"])
assert response.status == 400
assert json.loads(response.body) == {
"ok": False,
"error": "First problem; Second problem",
"errors": ["First problem", "Second problem"],
"status": 400,
}

View file

@ -137,9 +137,7 @@ async def test_allowed_json_pagination():
await ds.refresh_schemas()
# Test page 1
response = await ds.client.get(
"/-/allowed.json?action=view-table&page_size=10&page=1"
)
response = await ds.client.get("/-/allowed.json?action=view-table&_size=10&_page=1")
assert response.status_code == 200
data = response.json()
assert data["page"] == 1
@ -147,9 +145,7 @@ async def test_allowed_json_pagination():
assert len(data["items"]) == 10
# Test page 2
response = await ds.client.get(
"/-/allowed.json?action=view-table&page_size=10&page=2"
)
response = await ds.client.get("/-/allowed.json?action=view-table&_size=10&_page=2")
assert response.status_code == 200
data = response.json()
assert data["page"] == 2
@ -157,10 +153,10 @@ async def test_allowed_json_pagination():
# Verify items are different between pages
response1 = await ds.client.get(
"/-/allowed.json?action=view-table&page_size=10&page=1"
"/-/allowed.json?action=view-table&_size=10&_page=1"
)
response2 = await ds.client.get(
"/-/allowed.json?action=view-table&page_size=10&page=2"
"/-/allowed.json?action=view-table&_size=10&_page=2"
)
items1 = {(item["parent"], item["child"]) for item in response1.json()["items"]}
items2 = {(item["parent"], item["child"]) for item in response2.json()["items"]}
@ -323,7 +319,7 @@ async def test_rules_json_pagination():
# Test basic pagination structure - just verify it returns paginated results
response = await ds.client.get(
"/-/rules.json?action=view-table&page_size=2&page=1",
"/-/rules.json?action=view-table&_size=2&_page=1",
actor={"id": "root"},
)
assert response.status_code == 200

View file

@ -3,6 +3,7 @@ from asgiref.sync import async_to_sync
from datasette.app import Datasette
from datasette.cli import cli
from datasette.default_permissions import restrictions_allow_action
from datasette.utils import UNSTABLE_API_MESSAGE
from .fixtures import assert_permissions_checked, make_app_client
from click.testing import CliRunner
from bs4 import BeautifulSoup as Soup
@ -739,6 +740,8 @@ async def test_actor_restricted_permissions(
"path": expected_path,
}
expected = {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"action": permission,
"allowed": expected_result,
"resource": expected_resource,
@ -1115,7 +1118,7 @@ def test_cli_create_token(options, expected):
],
)
assert 0 == result2.exit_code, result2.output
assert json.loads(result2.output) == {"actor": expected}
assert json.loads(result2.output) == {"ok": True, "actor": expected}
_visible_tables_re = re.compile(r">\/((\w+)\/(\w+))\.json<\/a> - Get rows for")
@ -1799,3 +1802,35 @@ async def test_root_allow_block_with_table_restricted_actor():
actor=admin_actor,
)
assert result is True
@pytest.mark.asyncio
async def test_databases_json_respects_view_database(tmp_path_factory):
# https://github.com/simonw/datasette - /-/databases should not list
# databases the actor is not allowed to view
db_directory = tmp_path_factory.mktemp("dbs")
from datasette.utils import sqlite3 as _sqlite3
paths = []
for name in ("public", "private"):
path = str(db_directory / "{}.db".format(name))
conn = _sqlite3.connect(path)
conn.execute("vacuum")
conn.close()
paths.append(path)
ds = Datasette(
paths,
config={"databases": {"private": {"allow": {"id": "root"}}}},
)
ds.root_enabled = True
await ds.invoke_startup()
try:
anon_response = await ds.client.get("/-/databases.json")
assert anon_response.status_code == 200
anon_names = {db["name"] for db in anon_response.json()["databases"]}
assert anon_names == {"public"}
root_response = await ds.client.get("/-/databases.json", actor={"id": "root"})
root_names = {db["name"] for db in root_response.json()["databases"]}
assert root_names == {"public", "private"}
finally:
ds.close()

View file

@ -783,9 +783,13 @@ async def test_hook_permission_resources_sql():
@pytest.mark.asyncio
async def test_actor_json(ds_client):
assert (await ds_client.get("/-/actor.json")).json() == {"actor": None}
assert (await ds_client.get("/-/actor.json")).json() == {
"ok": True,
"actor": None,
}
assert (await ds_client.get("/-/actor.json?_bot2=1")).json() == {
"actor": {"id": "bot2", "1+1": 2}
"ok": True,
"actor": {"id": "bot2", "1+1": 2},
}
@ -1478,7 +1482,7 @@ async def test_plugin_is_installed():
datasette.pm.register(DummyPlugin(), name="DummyPlugin")
response = await datasette.client.get("/-/plugins.json")
assert response.status_code == 200
installed_plugins = {p["name"] for p in response.json()}
installed_plugins = {p["name"] for p in response.json()["plugins"]}
assert "DummyPlugin" in installed_plugins
finally:

View file

@ -8,6 +8,7 @@ from bs4 import BeautifulSoup as Soup
from datasette.app import Datasette
from datasette.resources import DatabaseResource, QueryResource
from datasette.stored_queries import StoredQuery, StoredQueryPage
from datasette.utils import UNSTABLE_API_MESSAGE
from datasette.utils.asgi import Forbidden
from datasette.utils.sqlite import sqlite3, supports_returning
@ -879,7 +880,7 @@ async def test_query_list_html_defaults_to_twenty_and_shows_pagination():
assert response.text.count('aria-label="Query pagination"') == 1
assert "Demo query 20" in response.text
assert "Demo query 21" not in response.text
assert 'href="/data/-/queries?_next=' in response.text
assert 'href="http://localhost/data/-/queries?_next=' in response.text
assert len(json_response.json()["queries"]) == 25
@ -1125,6 +1126,47 @@ async def test_query_update_api_rejects_config_only_fields():
assert query.on_success_message_sql is None
@pytest.mark.asyncio
async def test_query_api_rejects_params_alias():
# "params" is a datasette.yaml configuration key, not an API input -
# the API only accepts "parameters"
ds = Datasette(memory=True, default_deny=True)
ds.root_enabled = True
db = ds.add_memory_database("query_params_alias", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
store_response = await ds.client.post(
"/data/-/queries/store",
actor={"id": "root"},
json={
"query": {
"name": "by_name",
"sql": "select * from dogs where name = :name",
"params": ["name"],
}
},
)
assert store_response.status_code == 400
assert store_response.json()["errors"] == ["Invalid keys: params"]
assert await ds.get_query("data", "by_name") is None
await ds.add_query(
"data",
"editable",
"select * from dogs where name = :name",
source="user",
owner_id="root",
)
update_response = await ds.client.post(
"/data/editable/-/update",
actor={"id": "root"},
json={"update": {"params": ["name"]}},
)
assert update_response.status_code == 400
assert update_response.json()["errors"] == ["Invalid keys: params"]
@pytest.mark.asyncio
async def test_query_update_api_rejects_trusted_queries_but_internal_update_allowed():
ds = Datasette(
@ -2140,7 +2182,11 @@ async def test_query_parameters_endpoint_uses_get_sql_only():
)
assert response.status_code == 200
assert response.json() == {"ok": True, "parameters": ["name", "id"]}
assert response.json() == {
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": ["name", "id"],
}
assert permission_denied_response.status_code == 403
assert permission_denied_response.json()["errors"] == [
"Permission denied: need execute-sql"
@ -3093,9 +3139,9 @@ async def test_untrusted_stored_write_query_rejects_virtual_table_control_insert
)
assert denied_response.status_code == 403
assert denied_response.json()["message"] == (
assert denied_response.json()["errors"] == [
"Writes to virtual tables are not allowed in user-supplied SQL"
)
]
assert (
await db.execute("select count(*) from docs where docs match 'hello'")
).first()[0] == 1
@ -3742,3 +3788,81 @@ async def test_stored_write_query_with_truncated_returning_message():
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["message"] == "Query executed"
@pytest.mark.asyncio
async def test_query_delete_api_rejects_trusted_queries():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-query": {"id": "editor"},
"delete-query": {"id": "editor"},
},
"queries": {
"trusted_report": {
"sql": "select 1 as one",
},
},
}
}
},
)
ds.add_memory_database("query_delete_trusted_api", name="data")
await ds.invoke_startup()
response = await ds.client.post(
"/data/trusted_report/-/delete",
actor={"id": "editor"},
json={},
)
assert response.status_code == 403
assert response.json()["errors"] == [
"Trusted queries cannot be deleted using the API"
]
# The query must still exist
assert await ds.get_query("data", "trusted_report") is not None
# The HTML confirmation page refuses too
get_response = await ds.client.get(
"/data/trusted_report/-/delete",
actor={"id": "editor"},
)
assert get_response.status_code == 403
# datasette.remove_query() remains available for internal use
await ds.remove_query("data", "trusted_report")
assert await ds.get_query("data", "trusted_report") is None
@pytest.mark.asyncio
async def test_stored_query_json_uses_parameters_not_params():
ds = Datasette(
memory=True,
config={
"databases": {
"data": {
"queries": {
"with_params": {
"sql": "select :name as name, :age as age",
"params": ["name", "age"],
},
},
}
}
},
)
ds.add_memory_database("query_parameters_key", name="data")
await ds.invoke_startup()
definition = (await ds.client.get("/data/with_params/-/definition")).json()
assert definition["query"]["parameters"] == ["name", "age"]
assert "params" not in definition["query"]
listing = (await ds.client.get("/data/-/queries.json")).json()
query = [q for q in listing["queries"] if q["name"] == "with_params"][0]
assert query["parameters"] == ["name", "age"]
assert "params" not in query

View file

@ -73,7 +73,7 @@ async def ds_with_route():
@pytest.mark.asyncio
async def test_db_with_route_databases(ds_with_route):
response = await ds_with_route.client.get("/-/databases.json")
assert response.json()[0] == {
assert response.json()["databases"][0] == {
"name": "original-name",
"route": "custom-route-name",
"path": None,

View file

@ -0,0 +1,170 @@
"""
Tests for the canonical JSON success envelope.
Every JSON object returned by a Datasette endpoint on success should include
"ok": true. (Endpoints that return a top-level array are being converted to
objects separately - see /-/plugins, /-/databases, /-/actions.)
"""
import pytest
from datasette.app import Datasette
from datasette.utils import sqlite3, UNSTABLE_API_MESSAGE
@pytest.fixture
def ds_envelope(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "data.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table docs (id integer primary key, title text)")
conn.close()
ds = Datasette([db_path])
ds.root_enabled = True
yield ds
ds.close()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/.json",
"/-/.json",
"/-/versions.json",
"/-/settings.json",
"/-/config.json",
"/-/actor.json",
"/-/jump.json",
"/-/schema.json",
"/fixtures/-/schema.json",
"/fixtures/facetable/-/schema.json",
"/-/allowed.json?action=view-instance",
"/fixtures/facet_cities/-/autocomplete?_initial=1",
),
)
async def test_success_object_has_ok_true(ds_client, path):
response = await ds_client.get(path)
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert data["ok"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/-/rules.json?action=view-instance",
"/-/check.json?action=view-instance",
"/-/threads.json",
),
)
async def test_permission_debug_success_has_ok_true(ds_envelope, path):
response = await ds_envelope.client.get(path, actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
@pytest.mark.asyncio
async def test_permissions_post_success_has_ok_true(ds_envelope):
response = await ds_envelope.client.post(
"/-/permissions",
data={"actor": '{"id": "root"}', "permission": "view-instance"},
actor={"id": "root"},
)
assert response.status_code == 200
assert response.json()["ok"] is True
@pytest.mark.asyncio
async def test_plugins_json_is_object(ds_client):
response = await ds_client.get("/-/plugins.json")
assert response.status_code == 200
data = response.json()
assert set(data.keys()) == {"ok", "plugins"}
assert data["ok"] is True
assert isinstance(data["plugins"], list)
# ?all=1 should include Datasette's default plugins in the same shape
response_all = await ds_client.get("/-/plugins.json?all=1")
all_plugins = response_all.json()["plugins"]
assert len(all_plugins) > len(data["plugins"])
@pytest.mark.asyncio
async def test_databases_json_is_object(ds_client):
response = await ds_client.get("/-/databases.json")
assert response.status_code == 200
data = response.json()
assert set(data.keys()) == {"ok", "databases"}
assert data["ok"] is True
assert isinstance(data["databases"], list)
assert "fixtures" in {db["name"] for db in data["databases"]}
@pytest.mark.asyncio
async def test_actions_json_is_object(ds_envelope):
response = await ds_envelope.client.get("/-/actions.json", actor={"id": "root"})
assert response.status_code == 200
data = response.json()
assert set(data.keys()) == {"ok", "actions"}
assert data["ok"] is True
assert isinstance(data["actions"], list)
assert "view-instance" in {action["name"] for action in data["actions"]}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"path",
(
"/.json",
"/-/.json",
"/fixtures/-/queries/analyze?sql=select+1",
"/fixtures/-/query/parameters?sql=select+:name",
"/fixtures/-/execute-write/analyze?sql=delete+from+facetable",
),
)
async def test_undocumented_endpoints_report_unstable(ds_client, path):
ds_client.ds.root_enabled = True
try:
response = await ds_client.get(path, actor={"id": "root"})
finally:
ds_client.ds.root_enabled = False
assert response.status_code == 200
assert response.json()["unstable"] == UNSTABLE_API_MESSAGE
@pytest.mark.asyncio
async def test_query_store_and_definition_report_unstable(ds_envelope):
store = await ds_envelope.client.post(
"/data/-/queries/store",
json={"query": {"name": "unstable_check", "sql": "select 1"}},
actor={"id": "root"},
)
assert store.status_code == 201
assert store.json()["unstable"] == UNSTABLE_API_MESSAGE
definition = await ds_envelope.client.get(
"/data/unstable_check/-/definition", actor={"id": "root"}
)
assert definition.status_code == 200
assert definition.json()["unstable"] == UNSTABLE_API_MESSAGE
@pytest.mark.asyncio
async def test_permissions_post_reports_unstable(ds_envelope):
response = await ds_envelope.client.post(
"/-/permissions",
data={"actor": '{"id": "root"}', "permission": "view-instance"},
actor={"id": "root"},
)
assert response.status_code == 200
assert response.json()["unstable"] == UNSTABLE_API_MESSAGE
@pytest.mark.asyncio
async def test_documented_endpoints_do_not_report_unstable(ds_client):
for path in ("/-/versions.json", "/fixtures.json", "/fixtures/facetable.json"):
response = await ds_client.get(path)
assert response.status_code == 200
assert "unstable" not in response.json()

View file

@ -31,8 +31,8 @@ async def test_table_not_exists_json(ds_client):
assert (await ds_client.get("/fixtures/blah.json")).json() == {
"ok": False,
"error": "Table not found",
"errors": ["Table not found"],
"status": 404,
"title": None,
}
@ -123,8 +123,8 @@ async def test_html_only_extras_are_not_available_via_json(ds_client, extra):
# These extras exist for the HTML view; their values are not JSON
# serializable so they are internal, not part of the JSON API
response = await ds_client.get(f"/fixtures/facetable.json?_extra={extra}")
assert response.status_code == 200
assert extra not in response.json()
assert response.status_code == 400
assert response.json()["errors"] == [f"Unknown _extra: {extra}"]
@pytest.mark.asyncio
@ -180,9 +180,11 @@ def test_query_extra_query_reports_bound_params():
assert response.json["query"]["params"] == {}
def test_query_extra_query_does_not_echo_querystring_without_sql():
def test_query_extra_query_does_not_echo_querystring():
with make_app_client() as client:
response = client.get("/fixtures/-/query.json?_extra=query&foo=bar")
response = client.get(
"/fixtures/-/query.json?sql=select+1&_extra=query&foo=bar"
)
assert response.status == 200
assert response.json["query"]["params"] == {}
@ -242,8 +244,8 @@ async def test_table_shape_invalid(ds_client):
assert response.json() == {
"ok": False,
"error": "Invalid _shape: invalid",
"errors": ["Invalid _shape: invalid"],
"status": 400,
"title": None,
}
@ -321,10 +323,6 @@ async def test_paginate_tables_and_views(
fetched = []
count = 0
while path:
if "?" in path:
path += "&_extra=next_url"
else:
path += "?_extra=next_url"
response = await ds_client.get(path)
assert response.status_code == 200
count += 1
@ -357,9 +355,7 @@ async def test_validate_page_size(ds_client, path, expected_error):
@pytest.mark.asyncio
async def test_page_size_zero(ds_client):
"""For _size=0 we return the counts, empty rows and no continuation token"""
response = await ds_client.get(
"/fixtures/no_primary_key.json?_size=0&_extra=count,next_url"
)
response = await ds_client.get("/fixtures/no_primary_key.json?_size=0&_extra=count")
assert response.status_code == 200
assert [] == response.json()["rows"]
assert 202 == response.json()["count"]
@ -370,7 +366,7 @@ async def test_page_size_zero(ds_client):
@pytest.mark.asyncio
async def test_paginate_compound_keys(ds_client):
fetched = []
path = "/fixtures/compound_three_primary_keys.json?_shape=objects&_extra=next_url"
path = "/fixtures/compound_three_primary_keys.json?_shape=objects"
page = 0
while path:
page += 1
@ -391,7 +387,9 @@ async def test_paginate_compound_keys(ds_client):
@pytest.mark.asyncio
async def test_paginate_compound_keys_with_extra_filters(ds_client):
fetched = []
path = "/fixtures/compound_three_primary_keys.json?content__contains=d&_shape=objects&_extra=next_url"
path = (
"/fixtures/compound_three_primary_keys.json?content__contains=d&_shape=objects"
)
page = 0
while path:
page += 1
@ -444,7 +442,7 @@ async def test_paginate_compound_keys_with_extra_filters(ds_client):
],
)
async def test_sortable(ds_client, query_string, sort_key, human_description_en):
path = f"/fixtures/sortable.json?_shape=objects&_extra=human_description_en,next_url&{query_string}"
path = f"/fixtures/sortable.json?_shape=objects&_extra=human_description_en&{query_string}"
fetched = []
page = 0
while path:
@ -635,8 +633,8 @@ async def test_searchable_invalid_column(ds_client):
assert response.json() == {
"ok": False,
"error": "Cannot search by that column",
"errors": ["Cannot search by that column"],
"status": 400,
"title": None,
}
@ -775,7 +773,7 @@ async def test_table_filter_extra_where_invalid(ds_client):
"/fixtures/facetable.json?_where=_neighborhood=Dogpatch'"
)
assert response.status_code == 400
assert "Invalid SQL" == response.json()["title"]
assert "unrecognized token" in response.json()["error"]
def test_table_filter_extra_where_disabled_if_no_sql_allowed():
@ -848,7 +846,7 @@ def test_page_size_matching_max_returned_rows(
app_client_returned_rows_matches_page_size,
):
fetched = []
path = "/fixtures/no_primary_key.json?_extra=next_url"
path = "/fixtures/no_primary_key.json"
while path:
response = app_client_returned_rows_matches_page_size.get(path)
fetched.extend(response.json["rows"])
@ -1592,6 +1590,7 @@ async def test_col_nocol_errors(ds_client, path, expected_error):
{
"ok": True,
"next": None,
"next_url": None,
"columns": ["id", "content", "content2"],
"rows": [{"id": "1", "content": "hey", "content2": "world"}],
"truncated": False,
@ -1602,9 +1601,11 @@ async def test_col_nocol_errors(ds_client, path, expected_error):
{
"ok": True,
"next": None,
"next_url": None,
"rows": [{"id": "1", "content": "hey", "content2": "world"}],
"truncated": False,
"count": 1,
"count_truncated": False,
},
),
),
@ -1691,3 +1692,58 @@ async def test_extra_render_cell():
finally:
ds.pm.unregister(name="TestRenderCellPlugin")
@pytest.mark.asyncio
async def test_count_truncated_included_with_count_extra(tmp_path_factory):
from datasette.app import Datasette
from datasette.utils import sqlite3
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "counts.db")
conn = sqlite3.connect(db_path)
conn.execute("vacuum")
conn.execute("create table big (id integer primary key)")
conn.execute("create table small (id integer primary key)")
conn.executemany("insert into big (id) values (?)", [(i,) for i in range(10)])
conn.executemany("insert into small (id) values (?)", [(i,) for i in range(3)])
conn.commit()
conn.close()
ds = Datasette([db_path])
ds.get_database("counts").count_limit = 5
try:
response = await ds.client.get("/counts/big.json?_extra=count")
data = response.json()
# Count is capped at count_limit + 1 and flagged as truncated
assert data["count"] == 6
assert data["count_truncated"] is True
response = await ds.client.get("/counts/small.json?_extra=count")
data = response.json()
assert data["count"] == 3
assert data["count_truncated"] is False
# count_truncated can also be requested on its own
response = await ds.client.get("/counts/big.json?_extra=count_truncated")
assert response.json()["count_truncated"] is True
finally:
ds.close()
@pytest.mark.asyncio
async def test_next_url_included_by_default(ds_client):
response = await ds_client.get("/fixtures/compound_three_primary_keys.json")
data = response.json()
assert data["next"] is not None
assert data["next_url"].endswith(
"/fixtures/compound_three_primary_keys.json?_next="
+ urllib.parse.quote(data["next"], safe="")
)
# Follow to the last page - next and next_url are both null there
while data["next"]:
response = await ds_client.get(
"/fixtures/compound_three_primary_keys.json?_next=" + data["next"]
)
data = response.json()
assert data["next"] is None
assert data["next_url"] is None

View file

@ -1665,7 +1665,7 @@ async def test_row_update_sets_message():
json={"update": {"name": long_name}, "return": True},
)
assert response.status_code == 200
assert response.json()["row"]["name"] == long_name
assert response.json()["rows"][0]["name"] == long_name
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
["Updated row 1 ({})".format(truncated_name), ds.INFO]
]
@ -2015,8 +2015,8 @@ async def test_sort_errors(ds_client, json, params, error):
assert response.json() == {
"ok": False,
"error": error,
"errors": [error],
"status": 400,
"title": None,
}
else:
assert error in response.text
@ -2349,6 +2349,7 @@ async def test_foreign_key_labels_obey_permissions(config):
assert root_b.json() == {
"ok": True,
"next": None,
"next_url": None,
"rows": [{"id": 1, "name": "world", "a_id": {"value": 1, "label": "hello"}}],
"truncated": False,
}
@ -2356,6 +2357,7 @@ async def test_foreign_key_labels_obey_permissions(config):
assert anon_b.json() == {
"ok": True,
"next": None,
"next_url": None,
"rows": [{"id": 1, "name": "world", "a_id": 1}],
"truncated": False,
}

View file

@ -5,7 +5,12 @@ Tests for the register_token_handler plugin hook.
from datasette.app import Datasette
from datasette.hookspecs import hookimpl
from datasette.plugins import pm
from datasette.tokens import TokenHandler, TokenRestrictions, SignedTokenHandler
from datasette.tokens import (
TokenHandler,
TokenInvalid,
TokenRestrictions,
SignedTokenHandler,
)
import pytest
@ -66,10 +71,10 @@ async def test_verify_token_unknown_returns_none(datasette):
@pytest.mark.asyncio
async def test_verify_token_bad_signature_returns_none(datasette):
"""verify_token() should return None for tokens with bad signatures."""
result = await datasette.verify_token("dstok_tampered_data_here")
assert result is None
async def test_verify_token_bad_signature_raises(datasette):
"""verify_token() should raise TokenInvalid for tokens with bad signatures."""
with pytest.raises(TokenInvalid):
await datasette.verify_token("dstok_tampered_data_here")
@pytest.mark.asyncio
@ -334,5 +339,6 @@ async def test_signed_tokens_disabled():
ds = Datasette(settings={"allow_signed_tokens": False})
with pytest.raises(ValueError, match="Signed tokens are not enabled"):
await ds.create_token("test_actor", handler="signed")
# verify_token should return None rather than raising
assert await ds.verify_token("dstok_anything") is None
# verify_token should raise TokenInvalid for a dstok_ token
with pytest.raises(TokenInvalid, match="not enabled"):
await ds.verify_token("dstok_anything")