Add unstable marker to undocumented JSON endpoints

JSON endpoints that are not part of the documented API now include
"unstable": "This API is not part of Datasette's stable interface and
may change at any time" in their responses, making the stability tier
machine-readable. Applied to the homepage (/.json and /-/.json),
/db/-/queries/analyze, POST /db/-/queries/store,
/db/<query>/-/definition, /db/-/query/parameters,
/db/-/execute-write/analyze and the POST /-/permissions playground
response. The message lives in datasette.utils.UNSTABLE_API_MESSAGE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
Claude 2026-07-04 17:01:53 +00:00
commit afa7b1ba0d
No known key found for this signature in database
11 changed files with 142 additions and 20 deletions

View file

@ -1294,6 +1294,11 @@ async def derive_named_parameters(db: "Database", sql: str) -> List[str]:
return named_parameters(sql)
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:

View file

@ -2,7 +2,7 @@ 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
@ -500,8 +500,6 @@ class ExecuteWriteAnalyzeView(BaseView):
)
)
sql = request.args.get("sql") or ""
return _block_framing(
Response.json(
await _execute_write_analysis_data(self.ds, db, sql, request.actor)
)
)
analysis = await _execute_write_analysis_data(self.ds, db, sql, request.actor)
analysis["unstable"] = UNSTABLE_API_MESSAGE
return _block_framing(Response.json(analysis))

View file

@ -6,6 +6,7 @@ from datasette.utils import (
await_me_maybe,
make_slot_function,
CustomJSONEncoder,
UNSTABLE_API_MESSAGE,
)
from datasette.utils.asgi import Response
from datasette.version import __version__
@ -152,6 +153,7 @@ class IndexView(BaseView):
json.dumps(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"databases": databases,
"metadata": await self.ds.get_instance_metadata(),
},

View file

@ -6,6 +6,7 @@ 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,
add_cors_headers,
await_me_maybe,
@ -295,6 +296,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)

View file

@ -2,7 +2,7 @@ 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
@ -48,7 +48,15 @@ class QueryParametersView(BaseView):
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.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"parameters": parameters,
}
)
)
def _query_list_url(path, query_string, *, set_args=None, remove_args=None):
@ -315,11 +323,9 @@ class QueryCreateAnalyzeView(BaseView):
)
)
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):
@ -384,7 +390,12 @@ class QueryStoreView(QueryCreateView):
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)))
@ -405,7 +416,13 @@ class QueryDefinitionView(BaseView):
actor=request.actor,
):
return _error(["Permission denied"], 403)
return Response.json({"ok": True, "query": stored_query_to_dict(query)})
return Response.json(
{
"ok": True,
"unstable": UNSTABLE_API_MESSAGE,
"query": stored_query_to_dict(query),
}
)
class QueryUpdateView(BaseView):

View file

@ -43,7 +43,7 @@ directory: every claim below is based on the route table in `datasette/app.py`
- Success content type: `application/json; charset=utf-8`
(`_shape=array&_nl=on` responses use `text/plain`).
### Success envelope
### Success envelope and stability marker
Every JSON endpoint that returns an object includes `"ok": true` on
success. `JsonDataView` injects it automatically for dict responses
@ -52,6 +52,18 @@ autocomplete views add it explicitly. The former top-level-array endpoints
(`/-/plugins`, `/-/databases`, `/-/actions`) now return objects wrapping
their arrays (`{"ok": true, "plugins": [...]}` etc.).
JSON endpoints that are **not part of the documented API** include a
marker key (`UNSTABLE_API_MESSAGE` in utils/__init__.py):
```json
"unstable": "This API is not part of Datasette's stable interface and may change at any time"
```
Currently: the homepage (`/.json`, `/-/.json`), `/db/-/queries/analyze`,
`POST /db/-/queries/store`, `/db/<query>/-/definition`,
`/db/-/query/parameters`, `/db/-/execute-write/analyze` and the
`POST /-/permissions` playground response.
### Error shape (canonical)
Every JSON error response uses one canonical shape, built by `error_body()`

View file

@ -378,7 +378,13 @@ Concerns:
---
## 9. Define stability tiers explicitly (P1 — documentation, not code)
## 9. Define stability tiers explicitly (P1 — documentation, not code) — partially implemented
> **Status:** undocumented JSON endpoints now self-describe with an
> `"unstable": "This API is not part of Datasette's stable interface and
> may change at any time"` key (homepage, queries analyze/store/definition,
> query parameters, execute-write analyze, permissions playground POST).
> The written tier documentation remains to be done.
Not everything under `/-/` can or should carry a 1.0 guarantee. Recommend
shipping 1.0 with an explicit three-tier contract, per endpoint:

View file

@ -43,8 +43,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"][0]["tables_and_views_truncated"]
t["name"] for t in response.json()["databases"][0]["tables_and_views_truncated"]
]
assert tables == [
"simple_primary_key",
@ -252,6 +251,10 @@ def test_no_files_uses_memory_database(app_client_no_files):
assert response.status == 200
assert {
"ok": True,
"unstable": (
"This API is not part of Datasette's stable interface"
" and may change at any time"
),
"databases": [
{
"name": "_memory",

View file

@ -740,6 +740,10 @@ async def test_actor_restricted_permissions(
}
expected = {
"ok": True,
"unstable": (
"This API is not part of Datasette's stable interface"
" and may change at any time"
),
"action": permission,
"allowed": expected_result,
"resource": expected_resource,

View file

@ -2140,7 +2140,14 @@ 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": "{}".format(
"This API is not part of Datasette's stable interface"
" and may change at any time"
),
"parameters": ["name", "id"],
}
assert permission_denied_response.status_code == 403
assert permission_denied_response.json()["errors"] == [
"Permission denied: need execute-sql"

View file

@ -112,3 +112,64 @@ async def test_actions_json_is_object(ds_envelope):
assert data["ok"] is True
assert isinstance(data["actions"], list)
assert "view-instance" in {action["name"] for action in data["actions"]}
UNSTABLE_MESSAGE = (
"This API is not part of Datasette's stable interface and may change at any time"
)
@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_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_MESSAGE
definition = await ds_envelope.client.get(
"/data/unstable_check/-/definition", actor={"id": "root"}
)
assert definition.status_code == 200
assert definition.json()["unstable"] == UNSTABLE_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_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()