mirror of
https://github.com/simonw/datasette.git
synced 2026-07-09 09:04:42 +02:00
Fable review of JSON API consistency and subsequent improvements
Merge PR #2824
This commit is contained in:
commit
27a5be1326
58 changed files with 2189 additions and 597 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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(), {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
...
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")):
|
||||
|
|
|
|||
|
|
@ -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
749
tests/test_error_shape.py
Normal 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"]
|
||||
|
|
@ -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&page_size=50"' in response.text
|
||||
assert 'href="/-/check?action=view-table&page_size=50"' in response.text
|
||||
assert 'href="/-/rules?action=view-table&_size=50"' in response.text
|
||||
assert 'href="/-/check?action=view-table&_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
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
170
tests/test_success_envelope.py
Normal file
170
tests/test_success_envelope.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue