Skip to content

Commit 27a5be1

Browse files
authored
Fable review of JSON API consistency and subsequent improvements
Merge PR #2824
2 parents b6f5fd5 + b7bbde0 commit 27a5be1

58 files changed

Lines changed: 2189 additions & 597 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

datasette/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datasette.permissions import Permission # noqa
22
from datasette.version import __version_info__, __version__ # noqa
33
from datasette.events import Event # noqa
4-
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
4+
from datasette.tokens import TokenHandler, TokenInvalid, TokenRestrictions # noqa
55
from datasette.utils.asgi import ( # noqa
66
Forbidden,
77
NotFound,

datasette/app.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
baseconv,
112112
call_with_supported_arguments,
113113
detect_json1,
114+
add_cors_headers,
114115
display_actor,
115116
escape_css_string,
116117
escape_sqlite,
@@ -130,6 +131,7 @@
130131
redact_keys,
131132
row_sql_params_pks,
132133
)
134+
from .tokens import TokenInvalid
133135
from .utils.asgi import (
134136
AsgiLifespan,
135137
Forbidden,
@@ -910,7 +912,9 @@ async def verify_token(self, token: str) -> dict | None:
910912
Verify an API token by trying all registered token handlers.
911913
912914
Returns an actor dict from the first handler that recognizes the
913-
token, or None if no handler accepts it.
915+
token, or None if no handler accepts it. A handler may raise
916+
TokenInvalid for a token it recognizes but rejects (bad signature,
917+
expired) - Datasette turns that into a 401 response.
914918
"""
915919
for token_handler in self._token_handlers():
916920
result = await token_handler.verify_token(self, token)
@@ -2173,6 +2177,18 @@ def _connected_databases(self):
21732177
for name, d in self.databases.items()
21742178
]
21752179

2180+
async def _connected_databases_for_actor(self, actor):
2181+
page = await self.allowed_resources("view-database", actor)
2182+
allowed_names = {resource.parent async for resource in page.all()}
2183+
return [
2184+
database
2185+
for database in self._connected_databases()
2186+
if database["name"] in allowed_names
2187+
]
2188+
2189+
async def _databases_data(self, request):
2190+
return {"databases": await self._connected_databases_for_actor(request.actor)}
2191+
21762192
def _versions(self):
21772193
conn = sqlite3.connect(":memory:")
21782194
self._prepare_connection(conn, "_memory")
@@ -2519,8 +2535,8 @@ def _routes(self):
25192535
def add_route(view, regex):
25202536
routes.append((regex, view))
25212537

2522-
add_route(IndexView.as_view(self), r"/(\.(?P<format>jsono?))?$")
2523-
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>jsono?))?$")
2538+
add_route(IndexView.as_view(self), r"/(\.(?P<format>json))?$")
2539+
add_route(IndexView.as_view(self), r"/-/(\.(?P<format>json))?$")
25242540
add_route(permanent_redirect("/-/"), r"/-$")
25252541
add_route(favicon, "/favicon.ico")
25262542

@@ -2556,7 +2572,10 @@ def add_route(view, regex):
25562572
)
25572573
add_route(
25582574
JsonDataView.as_view(
2559-
self, "plugins.json", self._plugins, needs_request=True
2575+
self,
2576+
"plugins.json",
2577+
lambda request: {"plugins": self._plugins(request)},
2578+
needs_request=True,
25602579
),
25612580
r"/-/plugins(\.(?P<format>json))?$",
25622581
)
@@ -2569,11 +2588,18 @@ def add_route(view, regex):
25692588
r"/-/config(\.(?P<format>json))?$",
25702589
)
25712590
add_route(
2572-
JsonDataView.as_view(self, "threads.json", self._threads),
2591+
JsonDataView.as_view(
2592+
self, "threads.json", self._threads, permission="permissions-debug"
2593+
),
25732594
r"/-/threads(\.(?P<format>json))?$",
25742595
)
25752596
add_route(
2576-
JsonDataView.as_view(self, "databases.json", self._connected_databases),
2597+
JsonDataView.as_view(
2598+
self,
2599+
"databases.json",
2600+
self._databases_data,
2601+
needs_request=True,
2602+
),
25772603
r"/-/databases(\.(?P<format>json))?$",
25782604
)
25792605
add_route(
@@ -2586,7 +2612,7 @@ def add_route(view, regex):
25862612
JsonDataView.as_view(
25872613
self,
25882614
"actions.json",
2589-
self._actions,
2615+
lambda: {"actions": self._actions()},
25902616
template="debug_actions.html",
25912617
permission="permissions-debug",
25922618
),
@@ -2876,13 +2902,24 @@ async def route_path(self, scope, receive, send, path):
28762902
# Handle authentication
28772903
default_actor = scope.get("actor") or None
28782904
actor = None
2905+
token_error = None
28792906
results = pm.hook.actor_from_request(datasette=self.ds, request=request)
28802907
for result in results:
2881-
result = await await_me_maybe(result)
2908+
try:
2909+
result = await await_me_maybe(result)
2910+
except TokenInvalid as ex:
2911+
# A presented token was recognized but rejected - fail the
2912+
# request with a 401 even if another credential is valid,
2913+
# but keep awaiting the remaining coroutines first
2914+
if token_error is None:
2915+
token_error = ex
2916+
continue
28822917
if result and actor is None:
28832918
actor = result
28842919
# Don't break — we must await all coroutines to avoid
28852920
# "coroutine was never awaited" warnings
2921+
if token_error is not None:
2922+
return await self.handle_401(request, send, token_error)
28862923
scope_modifications["actor"] = actor or default_actor
28872924
scope = dict(scope, **scope_modifications)
28882925

@@ -2914,6 +2951,15 @@ async def route_path(self, scope, receive, send, path):
29142951
except Exception as exception:
29152952
return await self.handle_exception(request, send, exception)
29162953

2954+
async def handle_401(self, request, send, exception):
2955+
# A presented bearer token was recognized by a handler but rejected.
2956+
# Bearer tokens are API credentials, so this is always JSON.
2957+
headers = {"www-authenticate": 'Bearer error="invalid_token"'}
2958+
if self.ds.cors:
2959+
add_cors_headers(headers)
2960+
response = Response.error([str(exception)], 401, headers=headers)
2961+
await response.asgi_send(send)
2962+
29172963
async def handle_404(self, request, send, exception=None):
29182964
# If path contains % encoding, redirect to tilde encoding
29192965
if "%" in request.path:

datasette/extras.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from asyncinject import Registry
77

8+
from datasette.utils.asgi import BadRequest
9+
810

911
def extra_names_from_request(request):
1012
extra_bits = request.args.getlist("_extra")
@@ -113,6 +115,17 @@ def _allowed_names_for_scope(self, scope, include_internal):
113115
self._allowed_names[key] = names
114116
return names
115117

118+
def validate_requested(self, requested, scope):
119+
"""
120+
Raise BadRequest if any requested extra name is not a public extra
121+
for this scope. Used by data formats such as .json - HTML pages
122+
silently ignore unknown names instead.
123+
"""
124+
allowed = self._allowed_names_for_scope(scope, include_internal=False)
125+
unknown = sorted(name for name in requested if name not in allowed)
126+
if unknown:
127+
raise BadRequest("Unknown _extra: {}".format(", ".join(unknown)))
128+
116129
async def resolve(self, requested, context, scope, include_internal=False):
117130
allowed_names = self._allowed_names_for_scope(scope, include_internal)
118131
requested_names = [name for name in requested if name in allowed_names]

datasette/forbidden.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
11
from datasette import hookimpl, Response
2+
from .utils import add_cors_headers
23

34

45
@hookimpl(trylast=True)
56
def forbidden(datasette, request, message):
67
async def inner():
8+
if (
9+
request.path.split("?")[0].endswith(".json")
10+
or "application/json" in (request.headers.get("accept") or "")
11+
or request.headers.get("content-type") == "application/json"
12+
):
13+
headers = {}
14+
if datasette.cors:
15+
add_cors_headers(headers)
16+
return Response.error(message, 403, headers=headers)
717
return Response.html(
818
await datasette.render_template(
919
"error.html",

datasette/handle_exception.py

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from datasette import hookimpl, Response
2-
from .utils import add_cors_headers
2+
from .utils import add_cors_headers, error_body
33
from .utils.asgi import (
44
Base400,
55
)
@@ -28,6 +28,7 @@ async def inner():
2828
rich.get_console().print_exception(show_locals=True)
2929

3030
title = None
31+
plain_message = None
3132
if isinstance(exception, Base400):
3233
status = exception.status
3334
info = {}
@@ -36,6 +37,7 @@ async def inner():
3637
status = exception.status
3738
info = exception.error_dict
3839
message = exception.message
40+
plain_message = exception.plain_message
3941
if exception.message_is_html:
4042
message = Markup(message)
4143
title = exception.title
@@ -45,6 +47,13 @@ async def inner():
4547
message = str(exception)
4648
traceback.print_exc()
4749
templates = [f"{status}.html", "error.html"]
50+
headers = {}
51+
if datasette.cors:
52+
add_cors_headers(headers)
53+
if request.path.split("?")[0].endswith(".json"):
54+
body = dict(info)
55+
body.update(error_body(plain_message or message, status))
56+
return Response.json(body, status=status, headers=headers)
4857
info.update(
4958
{
5059
"ok": False,
@@ -53,24 +62,18 @@ async def inner():
5362
"title": title,
5463
}
5564
)
56-
headers = {}
57-
if datasette.cors:
58-
add_cors_headers(headers)
59-
if request.path.split("?")[0].endswith(".json"):
60-
return Response.json(info, status=status, headers=headers)
61-
else:
62-
environment = datasette.get_jinja_environment(request)
63-
template = environment.select_template(templates)
64-
return Response.html(
65-
await template.render_async(
66-
dict(
67-
info,
68-
urls=datasette.urls,
69-
menu_links=lambda: [],
70-
)
71-
),
72-
status=status,
73-
headers=headers,
74-
)
65+
environment = datasette.get_jinja_environment(request)
66+
template = environment.select_template(templates)
67+
return Response.html(
68+
await template.render_async(
69+
dict(
70+
info,
71+
urls=datasette.urls,
72+
menu_links=lambda: [],
73+
)
74+
),
75+
status=status,
76+
headers=headers,
77+
)
7578

7679
return inner

datasette/renderer.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
from datasette.extras import extra_names_from_request
33
from datasette.utils import (
4+
error_body,
45
value_as_boolean,
56
remove_infinites,
67
CustomJSONEncoder,
@@ -52,8 +53,7 @@ def json_renderer(request, args, data, error, truncated=None):
5253
if error:
5354
shape = "objects"
5455
status_code = 400
55-
data["error"] = error
56-
data["ok"] = False
56+
data.update(error_body(error, status_code))
5757

5858
if truncated is not None:
5959
data["truncated"] = truncated
@@ -87,7 +87,8 @@ def json_renderer(request, args, data, error, truncated=None):
8787
object_rows[pk_string] = row
8888
data = object_rows
8989
if shape_error:
90-
data = {"ok": False, "error": shape_error}
90+
status_code = 400
91+
data = error_body(shape_error, status_code)
9192
elif shape == "array":
9293
data = data["rows"]
9394

@@ -100,12 +101,7 @@ def json_renderer(request, args, data, error, truncated=None):
100101
data["rows"] = [list(row.values()) for row in data["rows"]]
101102
else:
102103
status_code = 400
103-
data = {
104-
"ok": False,
105-
"error": f"Invalid _shape: {shape}",
106-
"status": 400,
107-
"title": None,
108-
}
104+
data = error_body(f"Invalid _shape: {shape}", status_code)
109105

110106
# Don't include "columns" in output
111107
# https://github.com/simonw/datasette/issues/2136

datasette/stored_queries.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
6262
"description_html": query.description_html,
6363
"hide_sql": query.hide_sql,
6464
"fragment": query.fragment,
65-
"params": list(query.parameters),
6665
"parameters": list(query.parameters),
6766
"is_write": query.is_write,
6867
"is_private": query.is_private,
@@ -84,7 +83,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
8483
return {
8584
"queries": [stored_query_to_dict(query) for query in page.queries],
8685
"next": page.next,
87-
"has_more": page.has_more,
8886
"limit": page.limit,
8987
}
9088

datasette/templates/debug_actions.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ <h1>Registered actions</h1>
99
{% include "_permissions_debug_tabs.html" %}
1010

1111
<p style="margin-bottom: 2em;">
12-
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
12+
This Datasette instance has registered {{ data.actions|length }} action{{ data.actions|length != 1 and "s" or "" }}.
1313
Actions are used by the permission system to control access to different features.
1414
</p>
1515

@@ -26,7 +26,7 @@ <h1>Registered actions</h1>
2626
</tr>
2727
</thead>
2828
<tbody>
29-
{% for action in data %}
29+
{% for action in data.actions %}
3030
<tr>
3131
<td><strong>{{ action.name }}</strong></td>
3232
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>

datasette/templates/debug_allowed.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ <h1>Allowed resources</h1>
4949

5050
<div class="form-section">
5151
<label for="page_size">Page size:</label>
52-
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
52+
<input type="number" id="page_size" name="_size" value="50" min="1" max="200" style="max-width: 100px;">
5353
<small>Number of results per page (max 200)</small>
5454
</div>
5555

@@ -88,7 +88,7 @@ <h2>Results</h2>
8888
(function() {
8989
const params = populateFormFromURL();
9090
const action = params.get('action');
91-
const page = params.get('page');
91+
const page = params.get('_page');
9292
if (action) {
9393
fetchResults(page ? parseInt(page) : 1);
9494
}
@@ -102,14 +102,14 @@ <h2>Results</h2>
102102
const params = new URLSearchParams();
103103

104104
for (const [key, value] of formData.entries()) {
105-
if (value && key !== 'page_size') {
105+
if (value && key !== '_size' && key !== '_page') {
106106
params.append(key, value);
107107
}
108108
}
109109

110110
const pageSize = document.getElementById('page_size').value || '50';
111-
params.append('page', page.toString());
112-
params.append('page_size', pageSize);
111+
params.append('_page', page.toString());
112+
params.append('_size', pageSize);
113113

114114
try {
115115
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {

0 commit comments

Comments
 (0)