diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 17c8702f..1d921f02 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -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: diff --git a/datasette/views/execute_write.py b/datasette/views/execute_write.py index b7e8288e..6806e69d 100644 --- a/datasette/views/execute_write.py +++ b/datasette/views/execute_write.py @@ -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)) diff --git a/datasette/views/index.py b/datasette/views/index.py index 8c5534cb..67296cd1 100644 --- a/datasette/views/index.py +++ b/datasette/views/index.py @@ -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(), }, diff --git a/datasette/views/special.py b/datasette/views/special.py index 6afb6437..28d4d6a1 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -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) diff --git a/datasette/views/stored_queries.py b/datasette/views/stored_queries.py index d1f151dd..ea49d983 100644 --- a/datasette/views/stored_queries.py +++ b/datasette/views/stored_queries.py @@ -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): diff --git a/existing-api.md b/existing-api.md index 8d390f76..2f9996dc 100644 --- a/existing-api.md +++ b/existing-api.md @@ -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//-/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()` diff --git a/stable-api-recommendations.md b/stable-api-recommendations.md index d64b3fb8..baac80f7 100644 --- a/stable-api-recommendations.md +++ b/stable-api-recommendations.md @@ -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: diff --git a/tests/test_api.py b/tests/test_api.py index 17edc0e5..8ce38fad 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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", diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 32606789..f8d2c808 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -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, diff --git a/tests/test_queries.py b/tests/test_queries.py index 11828c4e..7f29a3fb 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -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" diff --git a/tests/test_success_envelope.py b/tests/test_success_envelope.py index 97cfd72a..68b042e5 100644 --- a/tests/test_success_envelope.py +++ b/tests/test_success_envelope.py @@ -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()