Every stored-query object carried the same list of parameter names twice, as both "params" and "parameters". Output objects now carry only "parameters", consistent with /-/query/parameters and the two analyze endpoints (and distinct from the "params" bound-values dictionary used by the query extra and /-/execute-write). "params" remains an accepted input alias for query creation, update and datasette.yaml config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
22 KiB
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>}. Thetitlekey is no longer emitted in JSON; the bare{"error": ...}debug-endpoint shape is gone;_shape=objectmisuse now returns HTTP 400 (part of §1b). Covered bytests/test_error_shape.pyand documented in the "Error responses" section ofdocs/json_api.rst. §1a (Forbidden→ JSON) and §1b (write canned-query 200) are now also implemented. Still open from this section's sub-items: 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) — ✅ IMPLEMENTED
Status: implemented — the default
forbidden()hook now returns the canonical JSON error for requests whose path ends in.jsonor that sendAccept: application/json/Content-Type: application/json.
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.jsonwithoutview-table→ 403 HTMLPOST /db/table/-/insertwithoutinsert-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) — ✅ IMPLEMENTED
Status: implemented.
_shape=objectmisuse returns 400 (done with §1), and write canned-query SQL failures now return 400 with the canonical error shape (plus theredirectcontext key); theQueryWriteRejected403 branch also uses the canonical shape.
_shape=objecton 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_shapevalue 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-writereturns 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) — ✅ IMPLEMENTED
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.✅ Done — delete now returns 400, matching update and the rest of the write API.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✅ Done — token handlers can raiseAuthorization: Bearer dstok_...header should produce 401 with a distinguishable error, so clients can tell "renew your token" apart from "you lack permission".TokenInvalid; Datasette responds 401 with the canonical body and aWWW-Authenticate: Bearer error="invalid_token"header. Unrecognized token prefixes still fall through to anonymous so auth plugins keep working.
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(JsonDataViewinjects 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 bytests/test_success_envelope.py. The sub-findings §2a (collection representations), §2b (_extra/_shapecoverage) 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
okkey:/-/versions,/-/settings,/-/config,/-/threads,/-/actor,/-/jump,/-/schemavariants ({"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:
- (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. - (P2) Add
ok: trueto every JSON-object success response, or explicitly document thatokonly exists on data endpoints. Half-consistency is the worst outcome. - (P2)
/db/-/schema.json({"database", "schema"}) and/db/table/-/schema.jsonshould 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 nook).
2a. Collection representations disagree (P2)
- Homepage
/.jsonreturnsdatabasesas an object keyed by name (index.py:147-161);/-/databases.jsonreturns an array; the database page returnstablesas an array. Choose arrays-of-objects everywhere (objects-keyed-by-name break when names need ordering or pagination). - Insert/upsert with
return: truerespond withrows(plural, list); row update withreturn: trueresponds withrow(singular, object) (views/row.py:837-844). Pick one (rowseverywhere, 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:
- The same
_nextparameter 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. has_moreexists on query lists but not table pages;totalexists on debug endpoints but not elsewhere. Standardize the pagination block (suggest:next,next_url— nullable — everywhere; treathas_moreasnext != null).- Page-size parameters:
_size(default 100,maxkeyword allowed) on tables;_size(default 50 JSON, clamped 1–1000, nomaxkeyword) 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
createdcount. - 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-typedemandContent-Type: application/json(400 otherwise);/-/createparses 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_jsonbody 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 orAccept: application/json") would cover all of them. - Endpoints named like actions but served over GET:
/-/queries/analyze,/-/execute-write/analyze,/-/foreign-key-suggestions,/-/query/parametersare all GET (correct, they are reads) — fine, butanalyzeunder 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.jsonor content type is JSON — a JSON POST to/-/queries/analyzegets JSON, a form POST gets text).
5. Naming and parameter conventions (P2/P3)
✅ Done — output objects carry onlyparamsandparametersare duplicate keys in every stored-query object (stored_queries.py:55-80). Delete one before 1.0 (suggest keepingparameters; the write side already accepts both on input).parameters(matching/-/query/parametersand the analyze endpoints);paramsremains an accepted input alias for API creation anddatasette.yamlconfig.- 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. ✅ Removed: the homepage routes only accept.jsonosurvives on the homepage route (identical output to.json) and as a row-view redirect. Remove it at 1.0; it is pure legacy..jsonand the row-view redirect is gone._jsonis overloaded: on GET it is a renderer option naming a column to parse as JSON (repeatable); on canned-query POST a_jsonbody 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)✅ Done — the endpoint now filters through/-/databases.jsonignores per-database permissions — it lists every attached database (name, path on disk, size) to any actor holdingview-instance(app.py:2157-2169), while the homepage and every other endpoint filter byview-database. On a public instance with private databases this leaks filesystem paths and database names. Filter it, or gate it behindpermissions-debug.allowed_resources("view-database", actor).- (P2)
/db/-/schemachecks existence before permission (views/special.py:1308-1317): an actor withoutview-databasecan 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)
/-/threadsexposes runtime internals (thread idents, asyncio task reprs including file paths) behind onlyview-instance. Considerpermissions-debug, alongside/-/actionswhich already requires it. - (P3)
/-/configredaction 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 aredact_keysplugin hook. - (P3) Database-level checks on
/-/create(insert-row/update-row checked againstDatabaseResource, 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-levelircannot use/-/createwith rows.
7. Completeness gaps for a 1.0 JSON API (P2/P3)
- (P2) No JSON API to create tokens.
/-/create-tokenis an HTML form only (has_json_alternate = False, form-encoded POST). Any automation that wants to mint scoped tokens must shell out todatasette 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. - (P2) Row JSON cannot expand foreign-key labels.
_labelsworks 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_extravalues). - (P2) No machine-readable "which write features does this instance/table
support" endpoint. Clients must probe (
/-/inserton 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.jsoncovers the permission half already.) - (P3) Table list pagination.
/db.jsoninlines 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. - (P3)
Link: rel=nextheader 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: Linksuggests it is meant to be part of the API).
8. Behavior that looks like a bug and should be resolved before freezing
Trusted queries: update is blocked, delete is not.✅ Done — both the POST endpoint and the HTML confirmation page now return 403QueryUpdateViewrejectsis_trustedqueries with 403 (stored_queries.py:426-427) butQueryDeleteView.postnever checksis_trusted— an actor withdelete-querycan 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."Trusted queries cannot be deleted using the API";datasette.remove_query()remains available for internal use.GET✅ Done — all data formats now return 400; the HTML SQL editor page is unchanged./db/-/querywith no?sql=returns 200{"ok": true, "rows": []}while.csvon the same request returns 400"?sql= is required". The JSON behavior masks caller bugs; return 400 on both._shape=objectHTTP 200 error (§1b) — almost certainly unintended.Row delete 500 (§1c) — inconsistent with every sibling endpoint.✅ Done — now 400.- The "SQL Interrupted" error embeds an HTML fragment in the JSON
errorvalue (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,_extrapublic 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 (_rabbreviations 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, thedebugandrequestextras (thedebugextra already self-describes as unstable),/-/apiand/-/jump(UI support endpoints),/-/autocompleteand/-/fragment(UI support),/-/foreign-key-suggestionsand/-/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=extrasself-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 theextrasoutput. - 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)
One canonical JSON error shape; retire the other three (§1).✅ Done.✅ Done.Forbidden→ JSON 403 for JSON requests (§1a).No✅ Done.ok: falsewith HTTP 200 (§1b:_shape=object, write canned-query SQL errors).Wrap✅ Done./-/plugins,/-/databases,/-/actionstop-level arrays in objects (§2).Filter✅ Done./-/databases.jsonbyview-databaseor gate it behindpermissions-debug(§6).401 (not silent-anonymous) for invalid/expired bearer tokens (§1c).✅ Done.- Publish explicit stability tiers, including extras and pagination-token opacity (§9).
- Resolve the looks-like-a-bug list (§8), especially
trusted-query delete and row-delete 500(both done).
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.