mirror of
https://github.com/simonw/datasette.git
synced 2026-07-08 16:44:34 +02:00
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:
parent
5cb2bc6909
commit
afa7b1ba0d
11 changed files with 142 additions and 20 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue