datasette/stable-api-recommendations.md
Claude f091b6dab1
Filter /-/databases by view-database permission
/-/databases previously listed every attached database (including
filesystem paths and sizes) to any actor with view-instance, while the
homepage and every other endpoint filtered by view-database. The
endpoint now only lists databases the current actor is allowed to view.

JsonDataView data callbacks may now be async.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
2026-07-04 14:00:42 +00:00

20 KiB
Raw Blame History

Datasette 1.0 Stable API — Consistency and Completeness Review

This review is based on existing-api.md, which documents the JSON API as actually implemented in this codebase (1.0a35), derived from source. The goal here is to identify everything that should be made consistent, fixed, or explicitly scoped out before the 1.0 stability promise takes effect — because after 1.0, every inconsistency below becomes a compatibility commitment.

Findings are grouped by theme. Each carries a priority:

  • P1 — should block 1.0: breaking to fix later, or a correctness/security concern.
  • P2 — strongly recommended: fixable later only via awkward additive changes.
  • P3 — nice to have / documentation decision: can be resolved by documenting the behavior as intentional.

1. Error responses: four shapes is three too many (P1) — IMPLEMENTED

Status: implemented. All four shapes now delegate to a shared error_body() helper (datasette/utils/__init__.py) producing {"ok": false, "error": "<joined>", "errors": [...], "status": <int>}. The title key is no longer emitted in JSON; the bare {"error": ...} debug-endpoint shape is gone; _shape=object misuse now returns HTTP 400 (part of §1b). Covered by tests/test_error_shape.py and documented in the "Error responses" section of docs/json_api.rst. Still open from this section's sub-items: §1a (Forbidden → HTML), the write canned-query 200 (§1b), and the §1c status outliers.

The API currently produces four distinct JSON error shapes depending on which internal layer generates the error:

Shape Producer Example endpoints
{"ok": false, "error", "status", "title"} exception handler (handle_exception.py:50-53) 404s and DatasetteErrors on any .json path
{"ok": false, "errors": [...]} _error() helper (views/base.py:183-184) all write endpoints, stored-query endpoints, execute-write
{"ok": false, "error", "rows": [], "truncated": false} JSON renderer (renderer.py:52-56) SQL errors on table/query reads
{"error": "..."} (no ok) permission debug views (views/special.py) /-/allowed, /-/rules, /-/check, POST /-/permissions

Additionally, write canned queries report failure via a fifth vocabulary: {"ok": false, "message": ..., "redirect": ...} with HTTP 200 (views/database.py:678-690).

A 1.0 client cannot write a single error handler today. Recommendation: pick one canonical error object — the singular/plural tension is easiest to resolve as:

{"ok": false, "error": "human-readable summary", "errors": ["detail", "..."], "status": 400}

where errors is optional and error is always present — and route every error path through it (including the forbidden and handle_exception defaults). At minimum, eliminate the bare {"error": ...} shape and the status/title keys nobody else emits (title is a template-rendering concern that leaked into the API).

1a. Forbidden returns an HTML 403 to JSON clients (P1)

Read endpoints that deny access via ensure_permission/check_visibility raise Forbidden, and the default forbidden() hook renders an HTML error page even for .json requests (forbidden.py:4-19, app.py:2895-2904). So:

  • GET /db/table.json without view-table → 403 HTML
  • POST /db/table/-/insert without insert-row → 403 JSON

A JSON client gets unparseable output precisely when it most needs a machine-readable answer. Recommendation: the default forbidden handler must return the canonical JSON error when the path ends in .json or the request prefers JSON, mirroring handle_exception.

1b. Errors that return HTTP 200 (P1)

  • _shape=object on a query or pk-less table → {"ok": false, "error": "_shape=object is only available on tables"} with 200 (renderer.py:73-90), while an unknown _shape value returns 400 (renderer.py:101-108). Same class of error, different status.
  • Write canned-query SQL failure → 200 {"ok": false, "message": ...} (views/database.py:683-690), while the equivalent failure on /-/execute-write returns 400.

Recommendation: all ok: false responses should carry a 4xx/5xx status. (/-/execute-write/analyze returning ok: false with 200 for "analysis completed, SQL is invalid" is defensible but should then not reuse the ok key — see §2.)

1c. Wrong-status outliers (P2)

  • Row delete write failures return 500 (views/row.py:757) while row update write failures return 400 (views/row.py:832-835). Same failure class, different status; pick 400 (or 409 for constraint violations) for both.
  • Invalid or expired bearer tokens silently degrade the request to anonymous, so clients see a 403 permission error (or worse, anonymous-permitted data) rather than a 401 (tokens.py:147-193). For 1.0, a malformed/expired Authorization: Bearer dstok_... header should produce 401 with a distinguishable error, so clients can tell "renew your token" apart from "you lack permission".

2. Success envelope: ok is not universal, arrays are not extensible (P1/P2) — IMPLEMENTED (§2a-2c open)

Status: recommendations 1-3 are implemented. Every JSON-object success response now includes "ok": true (JsonDataView injects it for dict responses; homepage, jump, schema, permission-debug and autocomplete views set it explicitly), and the three top-level-array endpoints now return objects: /-/plugins{"ok": true, "plugins": [...]}, /-/databases{"ok": true, "databases": [...]}, /-/actions{"ok": true, "actions": [...]}. Covered by tests/test_success_envelope.py. The sub-findings §2a (collection representations), §2b (_extra/_shape coverage) and §2c (count truncation) remain open.

Endpoints disagree about the success envelope:

  • Have ok: true: table/row/query reads, database view, all write endpoints, stored-query endpoints, /-/allowed-style debug data.
  • No ok key: /-/versions, /-/settings, /-/config, /-/threads, /-/actor, /-/jump, /-/schema variants ({"database", "schema"}, {"schemas": [...]}), table /-/schema.json, /-/autocomplete ({"rows": []}), homepage /.json.
  • Top-level JSON arrays: /-/plugins, /-/databases, /-/actions (app.py:2247-2304). A top-level array can never grow a sibling key (pagination, warnings, ok) without a breaking change.

Recommendations:

  1. (P1) Wrap the three array endpoints in objects before 1.0: {"ok": true, "plugins": [...]} etc. This is the single cheapest future-proofing fix in this list.
  2. (P2) Add ok: true to every JSON-object success response, or explicitly document that ok only exists on data endpoints. Half-consistency is the worst outcome.
  3. (P2) /db/-/schema.json ({"database", "schema"}) and /db/table/-/schema.json should match the envelope style of their sibling endpoints (they are also the only data endpoints whose 404 uses the exception shape but whose success has no ok).

2a. Collection representations disagree (P2)

  • Homepage /.json returns databases as an object keyed by name (index.py:147-161); /-/databases.json returns an array; the database page returns tables as an array. Choose arrays-of-objects everywhere (objects-keyed-by-name break when names need ordering or pagination).
  • Insert/upsert with return: true respond with rows (plural, list); row update with return: true responds with row (singular, object) (views/row.py:837-844). Pick one (rows everywhere, even for one row, matches the read API).

2b. _extra/_shape support is uneven (P2)

The extras system (?_extra=, scope-registered) is the 1.0 mechanism for response shaping — but it only exists on table, row and query endpoints. The database view builds JSON by hand and supports neither _extra nor _shape (views/database.py:189-212); the homepage likewise. Either extend extras to database/instance scope before 1.0 or document clearly that shaping is a table/row/query feature. Also decide the contract for unknown _extra names, which are currently silently ignored (extras.py:116-122) — silent ignoring means typos return the default payload with no signal; recommend a 400 or a warnings key.

2c. Count truncation is invisible in JSON (P2)

The count extra is computed with a limit 10001 subquery, so count: 10001 actually means "at least 10001" — the count_truncated flag exists but only in the HTML template context, never in JSON (views/table.py: 2334-2337). Expose it (e.g. make count be null + add count_estimate, or add count_truncated to the JSON) before clients start trusting the number.


3. Pagination: three mechanisms, two contracts (P2)

Endpoint Mechanism Token Extras
Table .json keyset tilde-encoded pk/sort values in _next next always in body, next_url via _extra, Link: rel=next header
SQL view .json offset integer in the same _next parameter same envelope
/-/queries lists keyset cursor in _next next, next_url, has_more in body
/-/allowed, /-/rules page numbers page/page_size total, next_url, previous_url

Concerns:

  1. The same _next parameter means "start after key" on tables but "row offset" on views. Offset pagination over views is also O(n) and skews under concurrent writes. If unifiable, unify; if not, document loudly.
  2. has_more exists on query lists but not table pages; total exists on debug endpoints but not elsewhere. Standardize the pagination block (suggest: next, next_url — nullable — everywhere; treat has_more as next != null).
  3. Page-size parameters: _size (default 100, max keyword allowed) on tables; _size (default 50 JSON, clamped 11000, no max keyword) on query lists; page_size (default 50, silently capped at 200) on debug endpoints. Align names, defaults and the cap behavior (silent capping vs 400) as far as practical.

4. HTTP semantics (P2)

  • 201 vs 200: insert → 201, upsert → 200 (views/table.py:1194), create table → 201, store query → 201. Insert-201/upsert-200 is defensible (upsert may not create) but it is undocumented subtlety; state it, or return 200 for both with an explicit created count.
  • Destructive-action confirmation is asymmetric: table drop requires {"confirm": true} and has a preview response (views/table.py:1346-1365); row delete executes immediately and ignores the body; query delete executes immediately. Decide the 1.0 rule (suggestion: confirmation only for schema-destroying operations, i.e. keep as is — but document it as a deliberate contract).
  • Content-type enforcement is inconsistent: /-/insert, /-/upsert, /-/alter, /-/set-column-type demand Content-Type: application/json (400 otherwise); /-/create parses the body as JSON regardless of content type; execute-write and the query CRUD endpoints accept both JSON and form encodings. Pick one rule for JSON-only endpoints.
  • JSON-vs-HTML negotiation on POST differs per endpoint: execute-write and canned queries key off Accept: application/json / a _json body field; the write API keys off nothing (always JSON); query store keys off request content type. A single documented rule ("responses are JSON if the request body was JSON or Accept: application/json") would cover all of them.
  • Endpoints named like actions but served over GET: /-/queries/analyze, /-/execute-write/analyze, /-/foreign-key-suggestions, /-/query/parameters are all GET (correct, they are reads) — fine, but analyze under a POST-shaped path invites wrong calls; make sure 405 responses for POST on these return the JSON 405 shape (they do only when the path ends .json or content type is JSON — a JSON POST to /-/queries/analyze gets JSON, a form POST gets text).

5. Naming and parameter conventions (P2/P3)

  • params and parameters are duplicate keys in every stored-query object (stored_queries.py:55-80). Delete one before 1.0 (suggest keeping parameters; the write side already accepts both on input).
  • Three names for the same concept across error/message payloads: error, errors, message. See §1.
  • Boolean query parameters have at least three grammars: _nl=on, _labels=on/off, ?all=1, is_write=1|0|true|false|t|f|yes|no|on|off, _nocount=1. Adopt one accepted set (the query-list parser at query_helpers.py:81-94 is a good candidate) and apply it everywhere.
  • .jsono survives on the homepage route (identical output to .json) and as a row-view redirect. Remove it at 1.0; it is pure legacy. Removed: the homepage routes only accept .json and the row-view redirect is gone.
  • _json is overloaded: on GET it is a renderer option naming a column to parse as JSON (repeatable); on canned-query POST a _json body field forces a JSON response. Two unrelated meanings for one name.
  • The reserved /-/ namespace is applied consistently across routes — this is in good shape. The one gap: table names matching ^-$-adjacent shapes are protected by tilde-encoding; keep a test asserting /-/ can never be shadowed by user data.

6. Permissions and security consistency (P1/P2)

  • (P1) /-/databases.json ignores per-database permissions — it lists every attached database (name, path on disk, size) to any actor holding view-instance (app.py:2157-2169), while the homepage and every other endpoint filter by view-database. On a public instance with private databases this leaks filesystem paths and database names. Filter it, or gate it behind permissions-debug. Done — the endpoint now filters through allowed_resources("view-database", actor).
  • (P2) /db/-/schema checks existence before permission (views/special.py:1308-1317): an actor without view-database can distinguish "database exists" (403) from "does not exist" (404). Standardize on permission-check-first (as the table view does) so unauthorized actors get a uniform response.
  • (P2) /-/threads exposes runtime internals (thread idents, asyncio task reprs including file paths) behind only view-instance. Consider permissions-debug, alongside /-/actions which already requires it.
  • (P3) /-/config redaction is substring-based on six key names (app.py:2502-2505); plugins storing secrets under other names leak. Worth a note in plugin authoring docs plus a redact_keys plugin hook.
  • (P3) Database-level checks on /-/create (insert-row/update-row checked against DatabaseResource, not the about-to-exist table — table_create_alter.py:819-856) vs table-level checks on /-/insert. Correct by necessity, but document that a token restricted to table-level ir cannot use /-/create with rows.

7. Completeness gaps for a 1.0 JSON API (P2/P3)

  1. (P2) No JSON API to create tokens. /-/create-token is an HTML form only (has_json_alternate = False, form-encoded POST). Any automation that wants to mint scoped tokens must shell out to datasette create-token. An intentional JSON mode (actor-authenticated, same restriction vocabulary) rounds out the write API story — or explicitly document token minting as CLI/Python-only.
  2. (P2) Row JSON cannot expand foreign-key labels. _labels works on table JSON but is silently ignored on row JSON (views/row.py:445-475 expands only for HTML). Either support it or return 400 for unsupported parameters; silent ignoring is the worst option (see also §2b on unknown _extra values).
  3. (P2) No machine-readable "which write features does this instance/table support" endpoint. Clients must probe (/-/insert on an immutable database → 403). The API explorer computes exactly this data for HTML (views/special.py:863-990); exposing it as JSON would let clients degrade gracefully. (/-/allowed.json covers the permission half already.)
  4. (P3) Table list pagination. /db.json inlines all tables (with counts) and the homepage truncates to 5 per database; a 10,000-table database has no paginated table listing. Acceptable for 1.0 if documented; the internal catalog tables would support a real endpoint later.
  5. (P3) Link: rel=next header exists on table JSON only. Harmless, but either add it to the other paginated endpoints or drop it from the contract (Access-Control-Expose-Headers: Link suggests it is meant to be part of the API).

8. Behavior that looks like a bug and should be resolved before freezing

  1. Trusted queries: update is blocked, delete is not. QueryUpdateView rejects is_trusted queries with 403 (stored_queries.py:426-427) but QueryDeleteView.post never checks is_trusted — an actor with delete-query can delete a config-defined trusted query via the API (it will resync on restart, making the behavior confusing rather than catastrophic). Align delete with update.
  2. GET /db/-/query with no ?sql= returns 200 {"ok": true, "rows": []} while .csv on the same request returns 400 "?sql= is required". The JSON behavior masks caller bugs; return 400 on both.
  3. _shape=object HTTP 200 error (§1b) — almost certainly unintended.
  4. Row delete 500 (§1c) — inconsistent with every sibling endpoint.
  5. The "SQL Interrupted" error embeds an HTML fragment in the JSON error value (views/database.py:805-820). Error strings in the JSON API should be plain text.

9. Define stability tiers explicitly (P1 — documentation, not code)

Not everything under /-/ can or should carry a 1.0 guarantee. Recommend shipping 1.0 with an explicit three-tier contract, per endpoint:

  • Stable (semver-protected): table/row/query reads (.json, _shape, _extra public names, filters, pagination tokens as opaque strings), the write API (/-/insert, /-/upsert, /-/alter, /-/drop, /-/set-column-type, row /-/update, /-/delete, /-/create, /-/execute-write), stored-query CRUD + execution, /-/versions, /-/plugins, /-/settings, /-/actor, /-/databases, schema endpoints, token format & restriction semantics (_r abbreviations are wire format now — they are stored inside issued tokens and cannot change silently).
  • Unstable/debug (documented as exempt): /-/threads, /-/actions, /-/permissions, /-/allowed, /-/rules, /-/check, /-/messages, /-/allow-debug, /-/patterns, /-/debug/autocomplete, the debug and request extras (the debug extra already self-describes as unstable), /-/api and /-/jump (UI support endpoints), /-/autocomplete and /-/fragment (UI support), /-/foreign-key-suggestions and /-/foreign-key-targets (heuristic outputs).
  • Internal: anything HTML-only (/-/edit, /-/create-token, /-/logout, /-/auth-token).

Two details make tiering urgent rather than optional:

  • Extras are enumerable by clients (?_extra=extras self-describes the registry), so every public extra name is de-facto API. Mark each extra stable or unstable in its class definition and surface that in the extras output.
  • Pagination tokens leak implementation (tilde-encoded pk values for tables, plain integers for views). Declare them opaque now so the view token can become keyset later without a "breaking" change.

10. Summary of P1 items (the pre-1.0 checklist)

  1. One canonical JSON error shape; retire the other three (§1). Done.
  2. Forbidden → JSON 403 for JSON requests (§1a).
  3. No ok: false with HTTP 200 (§1b: _shape=object, write canned-query SQL errors).
  4. Wrap /-/plugins, /-/databases, /-/actions top-level arrays in objects (§2). Done.
  5. Filter /-/databases.json by view-database or gate it behind permissions-debug (§6). Done.
  6. 401 (not silent-anonymous) for invalid/expired bearer tokens (§1c).
  7. Publish explicit stability tiers, including extras and pagination-token opacity (§9).
  8. Resolve the looks-like-a-bug list (§8), especially trusted-query delete and row-delete 500.

Everything in P2 is worth doing now because each item is breaking-to-fix later; each P3 can be resolved by a sentence of documentation declaring the current behavior intentional.