mirror of
https://github.com/simonw/datasette.git
synced 2026-07-08 16:44:34 +02:00
/.json previously returned databases as an object keyed by database name, unlike /-/databases.json and every other collection in the API. It now returns a list of database objects. The HTML template already consumed the list. This endpoint remains undocumented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
1231 lines
57 KiB
Markdown
1231 lines
57 KiB
Markdown
# Datasette JSON API — As Implemented
|
||
|
||
This document describes the JSON API of this Datasette codebase (version `1.0a35`) as
|
||
derived directly from the source code. It intentionally ignores the existing `docs/`
|
||
directory: every claim below is based on the route table in `datasette/app.py`
|
||
(`Datasette._routes()`, app.py:2507-2767) and the view implementations in
|
||
`datasette/views/`.
|
||
|
||
## Contents
|
||
|
||
- [Cross-cutting behavior](#cross-cutting-behavior)
|
||
- [Instance endpoints](#instance-endpoints)
|
||
- [Database endpoints](#database-endpoints)
|
||
- [Table and row read endpoints](#table-and-row-read-endpoints)
|
||
- [The write API](#the-write-api)
|
||
- [Stored (canned) queries API](#stored-canned-queries-api)
|
||
- [Authentication and tokens](#authentication-and-tokens)
|
||
- [Appendix: registered actions (permissions)](#appendix-registered-actions-permissions)
|
||
|
||
---
|
||
|
||
## Cross-cutting behavior
|
||
|
||
### URL formats and content negotiation
|
||
|
||
- Most read endpoints are registered with an optional format suffix:
|
||
`/(...)(\.(?P<format>json))?$`. The bare path returns HTML; the `.json`
|
||
extension returns JSON.
|
||
- Table, row and query routes accept any `\w+` format extension; formats other
|
||
than the built-in `html`, `json`, `csv`, `blob` must be provided by a plugin
|
||
via `register_output_renderer`, otherwise the request 404s.
|
||
- HTML responses include a `Link: <...>; rel="alternate";
|
||
type="application/json+datasette"` header pointing at the `.json` variant
|
||
(views/base.py:141-159), unless the view opts out with
|
||
`has_json_alternate = False`.
|
||
- Database, table, row and query names in paths are **tilde-encoded**
|
||
(a percent-encoding variant using `~` as the escape character;
|
||
utils/__init__.py `_TILDE_ENCODING_SAFE`). Multi-column primary keys in row
|
||
URLs are comma-separated.
|
||
- JSON responses are always compact `json.dumps` output serialized by
|
||
`CustomJSONEncoder`; there is no pretty-printing query parameter. Binary
|
||
values are serialized as `{"$base64": true, "encoded": "..."}`.
|
||
- Success content type: `application/json; charset=utf-8`
|
||
(`_shape=array&_nl=on` responses use `text/plain`).
|
||
|
||
### Success envelope
|
||
|
||
Every JSON endpoint that returns an object includes `"ok": true` on
|
||
success. `JsonDataView` injects it automatically for dict responses
|
||
(views/special.py); the homepage, jump, schema, permission-debug and
|
||
autocomplete views add it explicitly. The former top-level-array endpoints
|
||
(`/-/plugins`, `/-/databases`, `/-/actions`) now return objects wrapping
|
||
their arrays (`{"ok": true, "plugins": [...]}` etc.).
|
||
|
||
### Error shape (canonical)
|
||
|
||
Every JSON error response uses one canonical shape, built by `error_body()`
|
||
(utils/__init__.py):
|
||
|
||
```json
|
||
{
|
||
"ok": false,
|
||
"error": "all messages joined with '; '",
|
||
"errors": ["message", "..."],
|
||
"status": 404
|
||
}
|
||
```
|
||
|
||
- `errors` is a list of one or more message strings (multi-message
|
||
validation errors, e.g. per-row insert errors, list them all).
|
||
- `error` is the messages joined with `"; "`.
|
||
- `status` always matches the HTTP status code.
|
||
|
||
The shape is produced by four code paths, all delegating to `error_body()`:
|
||
|
||
1. **Exception handler** (handle_exception.py) — `NotFound`,
|
||
`DatasetteError`, `BadRequest` etc. on `.json` paths. `DatasetteError`
|
||
`error_dict` context keys are merged in; the legacy `title` key is no
|
||
longer emitted in JSON (it survives in the HTML error template context).
|
||
2. **The `_error()` helper** (views/base.py:183-184) — the write API,
|
||
stored-query API, execute-write and permission-denied paths.
|
||
3. **JSON renderer errors** (renderer.py) — SQL errors on table/query
|
||
endpoints return HTTP 400 with the canonical keys **plus** the context
|
||
keys of the response it could not produce:
|
||
|
||
```json
|
||
{"ok": false, "error": "no such table: x", "errors": ["no such table: x"],
|
||
"status": 400, "rows": [], "truncated": false}
|
||
```
|
||
|
||
Invalid `_shape=` values and `_shape=object` misuse (on queries or
|
||
pk-less tables) also return canonical 400 errors.
|
||
4. **Permission debug endpoints** (`/-/allowed`, `/-/rules`, `/-/check`,
|
||
POST `/-/permissions`) — canonical shape (previously bare
|
||
`{"error": ...}` objects).
|
||
|
||
Method-not-allowed responses return HTTP 405 with the canonical shape when
|
||
the path ends in `.json` or the request content type is `application/json`;
|
||
plain text otherwise (views/base.py).
|
||
|
||
**`Forbidden` handling:** when a view raises `Forbidden` (e.g. via
|
||
`ensure_permission`), the default `forbidden()` plugin hook returns the
|
||
canonical JSON error with status 403 when the path ends in `.json` or the
|
||
request carries an `Accept: application/json` / `Content-Type:
|
||
application/json` header; other requests get an HTML error page
|
||
(forbidden.py). Endpoints that check permissions themselves return
|
||
`_error(..., 403)` JSON directly.
|
||
|
||
### CORS
|
||
|
||
When Datasette is started with `--cors`, responses gain
|
||
(utils/__init__.py:1297-1302):
|
||
|
||
```
|
||
Access-Control-Allow-Origin: *
|
||
Access-Control-Allow-Headers: Authorization, Content-Type
|
||
Access-Control-Expose-Headers: Link
|
||
Access-Control-Allow-Methods: GET, POST, HEAD, OPTIONS
|
||
Access-Control-Max-Age: 3600
|
||
```
|
||
|
||
### CSRF / cross-origin protection
|
||
|
||
Datasette uses header-based cross-origin protection
|
||
(`CrossOriginProtectionMiddleware`, csrf.py:67-178) rather than CSRF tokens
|
||
for API calls. For non-GET/HEAD/OPTIONS requests:
|
||
|
||
1. Requests carrying `Authorization: Bearer ...` **and no `Cookie` header**
|
||
bypass the check entirely (csrf.py:98-110).
|
||
2. Otherwise `Sec-Fetch-Site` must be `same-origin` or `none`; other values → 403.
|
||
3. If neither `Sec-Fetch-Site` nor `Origin` is present (curl, API clients),
|
||
the request passes.
|
||
4. Fallback: `Origin` must exactly match the request scheme/host/port → else 403.
|
||
|
||
Plain JSON API clients (no cookies, no browser headers) are never blocked;
|
||
`Content-Type: application/json` itself plays no role in the CSRF decision.
|
||
|
||
### Settings that govern the API
|
||
|
||
From `SETTINGS` (app.py:197-287): `default_page_size` (100),
|
||
`max_returned_rows` (1000), `max_insert_rows` (100), `sql_time_limit_ms`
|
||
(1000), `default_facet_size` (30), `facet_time_limit_ms` (200),
|
||
`allow_facet` (true), `allow_download` (true), `allow_signed_tokens` (true),
|
||
`default_allow_sql` (true), `max_signed_tokens_ttl` (0), `default_cache_ttl`
|
||
(5), `allow_csv_stream` (true), `max_csv_mb` (100), `force_https_urls`
|
||
(false), `trace_debug` (false), `base_url` ("/").
|
||
|
||
### The JSON renderer: `_shape`, `_nl`, `_json`, `_json_infinity`
|
||
|
||
`json_renderer` (renderer.py:31-126) processes `.json` output for table, row
|
||
and query views (but **not** for the instance/database/debug endpoints, which
|
||
build JSON directly):
|
||
|
||
- **`_shape`** (default `objects`):
|
||
- `objects` — `{"ok": true, "rows": [{col: val}, ...], "truncated": false, ...}`
|
||
- `arrays` — same envelope, each row a list of values
|
||
- `array` — response body is a bare JSON array of row objects
|
||
- `arrayfirst` — bare JSON array of the first column's values
|
||
- `object` — table views only: an object keyed by primary-key string.
|
||
On queries or tables without primary keys: a canonical 400 error
|
||
(`_shape=object is only available on tables` /
|
||
`_shape=object not available for tables with no primary keys`).
|
||
- anything else — canonical HTTP 400 error `Invalid _shape: x`
|
||
- **`_nl=on`** — with `_shape=array` only: newline-delimited JSON, `text/plain`.
|
||
- **`_json=COLUMN`** (repeatable) — parse that column's string values with
|
||
`json.loads` so they nest as JSON; parse failures leave the value unchanged.
|
||
- **`_json_infinity=1`** — preserve `Infinity`/`-Infinity`; by default they
|
||
are replaced with `null`.
|
||
- `columns` is stripped from dict-shaped output unless `?_extra=columns` was
|
||
requested (renderer.py:110-113).
|
||
- If a SQL error occurred, `_shape` is ignored, HTTP status is 400 and the
|
||
envelope carries the canonical error keys alongside `rows`/`truncated`.
|
||
|
||
### The `?_extra=` system
|
||
|
||
Table, row and query JSON responses support `?_extra=` (repeatable and/or
|
||
comma-separated, extras.py:9-14) to add keys to the response. Extras are
|
||
scope-registered (`ExtraScope.TABLE` / `ROW` / `QUERY`) and only **public**
|
||
extras are available over JSON (extras.py:73-92). Unknown extra names (and
|
||
internal HTML-only names) on data formats return 400
|
||
`Unknown _extra: <names>`; HTML pages ignore them. The available names per
|
||
scope are listed with the relevant endpoints below.
|
||
|
||
---
|
||
|
||
## Instance endpoints
|
||
|
||
Most of these are implemented with `JsonDataView` (views/special.py:30-79):
|
||
GET-only; bare path renders an HTML page (`show_json.html`), `.json` returns
|
||
the data; permission defaults to `view-instance` and denial raises
|
||
`Forbidden` → **HTML** 403 page.
|
||
|
||
### GET /
|
||
|
||
Routes: `/(\.(?P<format>jsono?))?$` and `/-/(\.(?P<format>jsono?))?$`
|
||
(app.py:2517-2518); `/-` permanently redirects to `/-/`. `IndexView`
|
||
(views/index.py:22-189). `GET /.json`, `/.jsono` and `/-/.json` return JSON.
|
||
|
||
- **Permission:** `view-instance` (denied → 403). Databases and tables are
|
||
further filtered by `view-database` / `view-table` for the actor.
|
||
- **Parameters:** `_sort=relationships` sorts each database's truncated table
|
||
list by foreign-key relationship count.
|
||
- **JSON response** (index.py:147-161) — includes `ok: true` plus:
|
||
- `databases` — a **list** of database objects (undocumented API, subject
|
||
to change). Each item: `name`, `hash` (or null), `color`, `path`,
|
||
`tables_and_views_truncated` (up to 5 items: `name`, `columns`,
|
||
`primary_keys`, `count` (int or null), `hidden`, `fts_table`,
|
||
`num_relationships_for_sorting`, `private`; view items are just
|
||
`{"name", "private"}`), `tables_and_views_more` (bool), `tables_count`,
|
||
`table_rows_sum`, `show_table_row_counts`, `hidden_table_rows_sum`,
|
||
`hidden_tables_count`, `views_count`, `private`.
|
||
- `metadata` — instance metadata object.
|
||
|
||
### GET /-/versions(.json)
|
||
|
||
`JsonDataView` over `Datasette._versions` (app.py:2548-2551, 2171-2245).
|
||
Permission `view-instance`. No parameters.
|
||
|
||
Response keys: `python` (`{version, full}`), `datasette` (`{version}` plus
|
||
optional `note`), `asgi` (`"3.0"`), `uvicorn` (string or null), `sqlite`
|
||
(`{version, fts_versions, extensions, compile_options}`; `extensions`
|
||
includes `json1` and optionally `spatialite`), `pysqlite3` (only when
|
||
running under pysqlite3).
|
||
|
||
### GET /-/plugins(.json)
|
||
|
||
app.py:2552-2557, `Datasette._plugins` (app.py:2247-2266). Permission
|
||
`view-instance`.
|
||
|
||
- **Parameters:** `?all=1` — include Datasette's built-in default plugins
|
||
(filtered out by default).
|
||
- **Response:** `{"ok": true, "plugins": [...]}` — each plugin is
|
||
`{"name", "static", "templates", "version", "hooks"}`, sorted by name.
|
||
|
||
### GET /-/settings(.json)
|
||
|
||
app.py:2558-2561. Permission `view-instance`. No parameters. Returns a flat
|
||
object mapping every setting name (see [Settings](#settings-that-govern-the-api))
|
||
to its effective value.
|
||
|
||
### GET /-/config(.json)
|
||
|
||
app.py:2562-2565. Permission `view-instance`. No parameters. Returns the full
|
||
`datasette.yaml` configuration dict passed through
|
||
`redact_keys(config, ("secret", "key", "password", "token", "hash", "dsn"))`
|
||
(app.py:2502-2505) — any dict key containing one of those substrings has its
|
||
value replaced by `"***"` (utils/__init__.py:1532-1556).
|
||
|
||
### GET /-/threads(.json)
|
||
|
||
app.py:2566-2569, `Datasette._threads` (app.py:2268-2285). Permission
|
||
**`permissions-debug`** (exposes runtime internals). No parameters.
|
||
|
||
Response: `num_threads`, `threads` (list of `{name, ident, daemon}`),
|
||
`num_tasks`, `tasks` (asyncio task repr strings). When the
|
||
`num_sql_threads` setting is 0 the response is exactly
|
||
`{"num_threads": 0, "threads": []}`.
|
||
|
||
### GET /-/databases(.json)
|
||
|
||
app.py:2570-2573, `Datasette._connected_databases` (app.py:2157-2169).
|
||
Permission `view-instance`. No parameters.
|
||
|
||
Response: `{"ok": true, "databases": [...]}` — each database is
|
||
`{"name", "route", "path", "size", "is_mutable", "is_memory", "hash"}`.
|
||
Only databases the actor is allowed to `view-database` are listed.
|
||
|
||
### GET /-/actor(.json)
|
||
|
||
app.py:2574-2579, registered with `permission=None` — **accessible to any
|
||
request including anonymous**. No parameters.
|
||
|
||
Response: `{"ok": true, "actor": {...}}` or `{"ok": true, "actor": null}` (app.py:2287-2288).
|
||
|
||
### GET /-/actions(.json)
|
||
|
||
app.py:2580-2589. Permission **`permissions-debug`**. No parameters.
|
||
|
||
Response: `{"ok": true, "actions": [...]}` — each action is
|
||
`{"name", "abbr", "description", "takes_parent", "takes_child",
|
||
"resource_class", "also_requires"}`, sorted by name (app.py:2290-2304).
|
||
|
||
### GET /-/auth-token
|
||
|
||
`AuthTokenView` (app.py:2590-2593, views/special.py:198-217). GET only, no
|
||
`.json` variant, HTML/redirect only.
|
||
|
||
- **Parameter:** `token` — the one-time secret printed by `datasette --root`.
|
||
- Match → invalidates the token, sets the signed `ds_actor` cookie to
|
||
`{"id": "root"}` and 302-redirects to the homepage. Mismatch or reuse →
|
||
`Forbidden` → 403 HTML.
|
||
|
||
### GET/POST /-/create-token
|
||
|
||
`CreateTokenView` (app.py:2594-2597, views/special.py:727-856). **HTML form
|
||
endpoint only — there is no JSON request/response mode in this codebase**
|
||
(`has_json_alternate = False`; the POST body must be form-encoded, a JSON
|
||
content type raises `BadRequest` → 400).
|
||
|
||
- **Gates** (each failure → `Forbidden` → 403): `allow_signed_tokens` must be
|
||
on; request must have an actor with an `id`; the actor must not itself be
|
||
token-derived.
|
||
- **POST fields:** `expire_type` (`""`/`minutes`/`hours`/`days`),
|
||
`expire_duration` (positive int), plus restriction checkboxes named
|
||
`all:<action>`, `database:<db>:<action>`,
|
||
`resource:<db>:<table>:<action>`.
|
||
- **Response:** HTML page containing the new `dstok_` token.
|
||
- Programmatic alternatives: `datasette create-token` CLI or
|
||
`datasette.create_token()`.
|
||
|
||
### GET /-/api
|
||
|
||
`ApiExplorerView` (app.py:2598-2601, views/special.py:859-1020). HTML API
|
||
explorer, GET only. Permission `view-instance` (403 on denial).
|
||
|
||
### GET /-/jump(.json)
|
||
|
||
`JumpView` (app.py:2602-2605, views/special.py:1023-1201). The route allows
|
||
an optional `.json` suffix but the view **always returns JSON**.
|
||
|
||
- **Permission:** none checked directly; results are filtered via
|
||
`allowed_resources_sql` for the current actor (default items come from the
|
||
`jump_items_sql` plugin hook).
|
||
- **Parameter:** `q` — whitespace-split terms matched as a case-insensitive
|
||
`%term1%term2%` LIKE pattern.
|
||
- **Response:** `{"ok": true, "matches": [...], "truncated": bool}`; each match:
|
||
`name`, `url`, `type` (`database`/`table`/`view`/`query`/plugin-defined),
|
||
`description`, optional `display_name`. Capped at 100 matches.
|
||
|
||
### GET /-/schema(.json|.md)
|
||
|
||
`InstanceSchemaView` (app.py:2610-2613, views/special.py:1257-1293).
|
||
|
||
- **Permission:** no explicit check; only databases the actor can
|
||
`view-database` are included (others silently omitted).
|
||
- **Formats:** no extension → HTML; `.json` →
|
||
`{"ok": true, "schemas": [{"database": name, "schema": "..."}]}`; `.md` →
|
||
`text/markdown` rendering.
|
||
|
||
### GET/POST /-/logout
|
||
|
||
`LogoutView` (app.py:2614-2617, views/special.py:220-238). HTML endpoint.
|
||
GET renders a confirmation page (or redirects if anonymous); POST deletes the
|
||
`ds_actor` cookie and 302-redirects to `/`.
|
||
|
||
### GET/POST /-/permissions
|
||
|
||
`PermissionsDebugView` (app.py:2618-2621, views/special.py:241-295). No
|
||
`.json` route. Both methods require `view-instance` **and**
|
||
`permissions-debug` (403 on denial).
|
||
|
||
- **GET** — HTML permission-check log; `?filter=all|exclude-yours|only-yours`.
|
||
- **POST** — form-encoded `actor` (JSON string), `permission`, optional
|
||
`resource_1`, `resource_2`; returns **JSON**
|
||
`{"action", "allowed", "resource": {"parent", "child", "path"}}` plus
|
||
`actor_id` when present. Errors: unknown action → 404; child without
|
||
parent → 400 (both canonical error shape).
|
||
|
||
### GET /-/allowed(.json)
|
||
|
||
`AllowedResourcesView` (app.py:2622-2625, views/special.py:298-460). Bare
|
||
path always renders the HTML form; `.json` returns JSON.
|
||
|
||
- **Permission:** none — reports the **current actor's own** allowed
|
||
resources. Items gain a `reason` field if the actor also holds
|
||
`permissions-debug`.
|
||
- **Parameters:** `action` (required; missing → 400 canonical error, unknown
|
||
→ 404), `parent`, `child` (requires `parent`), `page` (default 1),
|
||
`page_size` (default 50, silently capped at 200).
|
||
- **Response:** `{"action", "actor_id", "page", "page_size", "total",
|
||
"items": [{"parent", "child", "resource"}]}` with optional `next_url` /
|
||
`previous_url`.
|
||
|
||
### GET /-/rules(.json)
|
||
|
||
`PermissionRulesView` (app.py:2626-2629, views/special.py:463-584).
|
||
Permission `view-instance` **and** `permissions-debug`. Parameters and error
|
||
shapes as `/-/allowed`. Response items:
|
||
`{"parent", "child", "resource", "allow" (1|0), "reason", "source_plugin"}`.
|
||
|
||
### GET /-/check(.json)
|
||
|
||
`PermissionCheckView` (app.py:2630-2633, views/special.py:633-662).
|
||
Permission `permissions-debug`. Parameters `action` (required), `parent`,
|
||
`child`. Checks the **current request's actor**; response
|
||
`{"action", "allowed", "resource": {...}}` plus `actor_id`.
|
||
|
||
### GET/POST /-/messages
|
||
|
||
`MessagesDebugView` (app.py:2634-2637, views/special.py:703-724). HTML debug
|
||
tool for flash messages; permission `view-instance`; POST is form-encoded
|
||
(`message`, `message_type` = INFO/WARNING/ERROR/all) and 302-redirects.
|
||
|
||
### GET /-/allow-debug
|
||
|
||
`AllowDebugView` (app.py:2638-2641, views/special.py:665-700). GET only, HTML
|
||
only, **no permission required**. Parameters `actor` and `allow` (JSON
|
||
strings); renders the result of `actor_matches_allow()` in the page.
|
||
|
||
### GET /-/patterns
|
||
|
||
Pattern portfolio page (app.py:2642-2645). HTML only; not part of the JSON API.
|
||
|
||
### GET /-/debug/autocomplete
|
||
|
||
`AutocompleteDebugView` (app.py:2646-2649, views/special.py:94-195). HTML
|
||
debug page for the table autocomplete API; permission `view-instance` plus
|
||
`view-table` when `?database=&table=` are supplied.
|
||
|
||
---
|
||
|
||
## Database endpoints
|
||
|
||
### GET /\<database\>.db
|
||
|
||
Downloads the raw SQLite file. Route → `database_download`
|
||
(app.py:2650-2653; views/database.py:533-570).
|
||
|
||
- **Permission:** `view-database-download` (denied → `Forbidden` → 403 HTML).
|
||
- **Other gates:** unknown database → 404 `"Invalid database"`; in-memory
|
||
database → 404; `allow_download` off **or** mutable database →
|
||
`Forbidden("Database download is forbidden")`; no file path → 404.
|
||
- **Response:** streamed `application/octet-stream` with a
|
||
`content-disposition` attachment; immutable databases with a known hash set
|
||
`Etag` and honor `If-None-Match` → 304.
|
||
|
||
### GET /\<database\>(.json)
|
||
|
||
`DatabaseView` (app.py:2654-2657; views/database.py:71-277). Only `html` and
|
||
`json` formats are accepted; any other extension → 404 `"Invalid format: ..."`.
|
||
|
||
- **Permission:** `view-database` via `check_visibility` (denied →
|
||
`Forbidden` → 403 HTML). Table/view listings are filtered by `view-table`;
|
||
stored queries by `view-query`.
|
||
- **Parameters:**
|
||
- `?sql=` — non-blank value 302-redirects to `/<database>/-/query?...`
|
||
preserving the query string and format.
|
||
- No `?_extra=` and no `_shape` support — the JSON is built directly and
|
||
returned via `Response.json`, bypassing the JSON renderer
|
||
(views/database.py:189-212).
|
||
- **JSON response** (all keys always present):
|
||
- `ok` — always `true`
|
||
- `database` — name; `private` — bool; `path` — URL path; `size` — bytes
|
||
- `tables` — list (includes hidden tables), each:
|
||
`name`, `columns` (names), `primary_keys`, `count` (int or null,
|
||
time-boxed), `count_truncated` (bool — count is a capped lower bound),
|
||
`hidden`, `fts_table`, `foreign_keys` (`{incoming: [...], outgoing: [...]}`
|
||
of `{other_table, column, other_column}`), `private`
|
||
- `hidden_count` — number of hidden tables
|
||
- `views` — list of `{name, private}`
|
||
- `queries` — **up to 5** stored queries (canonical stored-query objects,
|
||
see the stored-queries section); `queries_more` (bool);
|
||
`queries_count` (total visible)
|
||
- `allow_execute_sql` — bool for this actor
|
||
- `table_columns` — `{table: [columns]}`, empty `{}` unless
|
||
`allow_execute_sql` (views map to `[]`)
|
||
- `metadata` — database metadata dict
|
||
|
||
### GET /\<database\>/-/query(.json) — arbitrary SQL
|
||
|
||
`QueryView` (app.py:2691-2694; views/database.py:573-1130). The same class
|
||
also executes stored queries dispatched from the table route (see stored
|
||
queries section).
|
||
|
||
- **Permission:** `execute-sql` on the database via `check_visibility`
|
||
(denied → `Forbidden` → 403 HTML).
|
||
- **Parameters:**
|
||
- `sql` — SQL to run. Must pass `validate_sql_select`
|
||
(utils/__init__.py:345-354): after stripping `--` comment lines it must
|
||
start with `select`, `with` or an `explain` variant, and must not contain
|
||
`pragma` (except allowlisted `pragma_*()` table-valued functions).
|
||
Failure → 400 `DatasetteError` titled `"Invalid SQL"` → JSON
|
||
`{"ok": false, "error": "Statement must be a SELECT", "status": 400,
|
||
"title": "Invalid SQL"}`.
|
||
- Any other `name=value` pair supplies the `:name` named parameter; missing
|
||
parameters default to `""`. Names starting with `_` are excluded.
|
||
- `_timelimit` — per-request SQL time limit in ms.
|
||
- `_shape`, `_nl`, `_json`, `_json_infinity` — see the JSON renderer section.
|
||
- `_extra` — QUERY-scope extras: `columns`, `debug`, `request`,
|
||
`render_cell`, `query` (`{"sql", "params"}`), `metadata`, `database`,
|
||
`database_color`, `private`, `extras`.
|
||
- **Response** (default shape):
|
||
`{"ok": true, "rows": [{col: val}, ...], "truncated": false}` plus any
|
||
requested extras. `truncated: true` when the result hit `max_returned_rows`.
|
||
- **Errors:**
|
||
- SQLite errors (e.g. `no such table`) are **not** raised — they surface as
|
||
HTTP 400 `{"ok": false, "error": "<message>", "rows": [], "truncated": false}`.
|
||
- Time limit → 400 titled `"SQL Interrupted"` (the `error` value contains
|
||
an HTML fragment).
|
||
- `?sql=` omitted or blank → 400 `"?sql= is required"` for all data
|
||
formats (`.json`, `.csv`, plugin formats). The HTML page remains the
|
||
SQL editor.
|
||
- `.csv` streams CSV; unknown extensions → 404.
|
||
|
||
### GET /\<database\>/-/query/parameters
|
||
|
||
`QueryParametersView` (app.py:2687-2690; views/stored_queries.py:26-51).
|
||
|
||
- **Permission:** `execute-sql` → 403 JSON
|
||
`{"ok": false, "errors": ["Permission denied: need execute-sql"]}`.
|
||
- **Parameters:** only `sql` (default `""`); any other key → 400
|
||
`"Invalid keys: ..."`.
|
||
- **Response:** 200 `{"ok": true, "parameters": ["name1", ...]}`. SQL with a
|
||
parameter beginning `_` → 400 `"Magic parameters are not allowed"`.
|
||
- Responses carry `Content-Security-Policy: frame-ancestors 'none'` and
|
||
`X-Frame-Options: DENY`.
|
||
|
||
### POST /\<database\>/-/create
|
||
|
||
`TableCreateView` (app.py:2658; views/table_create_alter.py:785-962).
|
||
GET → 405. Body is parsed as JSON regardless of content type; invalid JSON →
|
||
400 `{"ok": false, "errors": ["Invalid JSON: ..."]}`.
|
||
|
||
- **Permissions** (all denials → 403 canonical error JSON,
|
||
all checked at the **database** level):
|
||
- `create-table` — always required (`["Permission denied"]`)
|
||
- `insert-row` — if `rows`/`row` provided (`need insert-row`)
|
||
- `update-row` — if `replace: true` (`need update-row`)
|
||
- `alter-table` — if `alter: true` on an **existing** table
|
||
(`need alter-table`); when the table does not exist yet and rows are
|
||
supplied, alter is enabled automatically.
|
||
- **Request schema** (pydantic `CreateTableRequest`, extra keys forbidden →
|
||
400 `"Invalid keys: a, b"`):
|
||
- `table` (required) — must match `^(?!sqlite_)[^\n]+$`
|
||
- `rows` (list of objects) / `row` (single object) — mutually exclusive
|
||
- `columns` — list of `{name, type, fk_table, fk_column, not_null,
|
||
default, default_expr}`; mutually exclusive with `rows`/`row`; `type` one
|
||
of `text`/`integer`/`float`/`blob` (default `text`); `default` and
|
||
`default_expr` mutually exclusive; `default_expr` one of
|
||
`current_timestamp`, `current_date`, `current_time`, `current_unixtime`,
|
||
`current_unixtime_ms`. At least one of `columns`/`rows`/`row` required.
|
||
- `pk` (string) / `pks` (list) — mutually exclusive. For an existing table
|
||
a differing pk → 400 `"pk cannot be changed for existing table"`.
|
||
- `ignore` / `replace` (bools) — mutually exclusive; require `row`/`rows`
|
||
and `pk`/`pks`.
|
||
- `alter` (bool) — add missing columns when inserting into an existing table.
|
||
- **Success** — **201**:
|
||
```json
|
||
{"ok": true, "database": "...", "table": "...",
|
||
"table_url": "https://.../db/table", "table_api_url": "https://.../db/table.json",
|
||
"schema": "CREATE TABLE ...", "row_count": 2}
|
||
```
|
||
`row_count` only when rows were inserted. Write failures → 400
|
||
`{"ok": false, "errors": ["<sqlite message>"]}`. Emits `create-table` /
|
||
`insert-rows` / `alter-table` events.
|
||
|
||
### POST /\<database\>/-/execute-write
|
||
|
||
`ExecuteWriteView` (app.py:2679-2682; views/execute_write.py:236-476). GET on
|
||
the same path renders an HTML form (requires `execute-write-sql`).
|
||
|
||
- **Permission (POST):** `execute-write-sql` → 403
|
||
`{"ok": false, "errors": ["Permission denied: need execute-write-sql"]}`;
|
||
immutable database → 403 `["Database is immutable"]`.
|
||
- **Per-statement permissions:** the SQL is analyzed
|
||
(`decision_for_write_sql_operation`, write_sql.py:63-189) and each
|
||
operation must pass:
|
||
|
||
| Operation | Requirement |
|
||
|---|---|
|
||
| `select` / internal ops / function calls | ignored |
|
||
| read of a table | `view-table` on that table |
|
||
| `insert` or `update` | **all of** `insert-row`, `update-row`, `delete-row` on the table |
|
||
| `delete` | `delete-row` |
|
||
| `create table` | `create-table` on the database |
|
||
| `alter table`, `create index`, `drop index` | `alter-table` on the table |
|
||
| `drop table` | `drop-table` |
|
||
| `VACUUM`, virtual-table writes, shadow-table writes | rejected outright (403) |
|
||
| statements touching attached databases | rejected (403) |
|
||
|
||
- **Body:** JSON (`{"sql": ..., "params": {...}}` — only those two keys) or
|
||
form-encoded (`sql` plus one field per parameter, `_sql_param_` prefix
|
||
stripped). Validation errors (400): `"SQL is required"`,
|
||
`"params must be a dictionary"`, `"Unknown parameters: a, b"`,
|
||
`"Magic parameters are not allowed"`, `"Could not analyze query: ..."`,
|
||
`"Use /-/query for read-only SQL; this endpoint only executes writes"`.
|
||
- **JSON is returned when** the body was JSON, `Accept: application/json`, or
|
||
a truthy `_json` field is present; otherwise HTML.
|
||
- **Success** — 200:
|
||
```json
|
||
{"ok": true, "message": "Query executed, 1 row affected", "rowcount": 1,
|
||
"rows": [], "truncated": false,
|
||
"analysis": [{"operation": "insert", "database": "db", "table": "t",
|
||
"required_permission": "insert-row, update-row, delete-row",
|
||
"source": null}]}
|
||
```
|
||
`rows` is populated by `RETURNING` clauses. SQLite errors → 400
|
||
`{"ok": false, "errors": ["<message>"]}`. Anti-framing headers on all
|
||
responses.
|
||
|
||
### GET /\<database\>/-/execute-write/analyze
|
||
|
||
`ExecuteWriteAnalyzeView` (app.py:2675-2678; views/execute_write.py:479-507).
|
||
|
||
- **Permission:** `execute-write-sql` → 403 `errors` JSON.
|
||
- **Parameters:** only `sql` allowed (else 400 `"Invalid keys: ..."`).
|
||
- **Response** — 200 even when analysis fails (`ok: false` in body):
|
||
`{"ok", "parameters", "analysis_error", "analysis_rows":
|
||
[{operation, database, table, required_permission, source, allowed}],
|
||
"execute_disabled", "execute_disabled_reason"}`. `allowed` is a per-actor
|
||
permission check result (true/false/null).
|
||
|
||
### GET /\<database\>/-/foreign-key-targets
|
||
|
||
`DatabaseForeignKeyTargetsView` (app.py:2659-2662;
|
||
views/table_create_alter.py:965-1005).
|
||
|
||
- **Parameter:** `table` (optional) — only used for the permission check.
|
||
- **Permission:** `create-table` on the database, **or** `alter-table` on
|
||
`?table=` when it names an existing table. Neither → 403
|
||
`{"ok": false, "errors": ["Permission denied: need create-table"]}`.
|
||
- **Response:** 200 `{"ok": true, "database": "...", "targets":
|
||
[{"fk_table", "fk_column", "type"}]}` — every non-hidden table with exactly
|
||
one primary-key column; `type` is the pk's SQLite type affinity.
|
||
|
||
### GET /\<database\>/-/schema(.json|.md)
|
||
|
||
`DatabaseSchemaView` (app.py:2683-2686; views/special.py:1296-1329).
|
||
|
||
- **Permission:** `view-database` (denied → `Forbidden` → 403 HTML).
|
||
- **Unknown database** → 404; for `.json`:
|
||
`{"ok": false, "error": "Database not found"}`. The permission check runs
|
||
first, so unauthorized actors cannot probe for database existence.
|
||
- **Responses:** `.json` → 200 `{"ok": true, "database": "<name>", "schema": "<SQL>"}`
|
||
(concatenated `sqlite_master.sql` joined with `;\n`); `.md` →
|
||
`text/markdown`; no extension → HTML.
|
||
|
||
---
|
||
|
||
## Table and row read endpoints
|
||
|
||
### GET /\<database\>/\<table\>.json
|
||
|
||
Route `r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$"` →
|
||
`table_view` (app.py:2711-2714; views/table.py:1670). Serves both tables and
|
||
SQL views. GET/HEAD only — POST returns a plain-text 405. If the name is
|
||
neither a table nor a view but matches a stored query, the request is
|
||
dispatched to `QueryView` (views/table.py:1703-1712).
|
||
|
||
**Permission:** `view-table` via `check_visibility`; denial raises
|
||
`Forbidden` → **HTML** 403 page even for `.json`. Unknown table →
|
||
`TableNotFound` → 404 (JSON error shape for `.json` paths).
|
||
|
||
**Default JSON keys** (views/table.py:2308-2332 + renderer):
|
||
|
||
| Key | Meaning |
|
||
|---|---|
|
||
| `ok` | `true` when data was retrieved without error |
|
||
| `next` | pagination token string, or `null` on the last page |
|
||
| `next_url` | absolute URL of the next page, or `null` on the last page |
|
||
| `rows` | list of row objects `{column: value}` (default `_shape=objects`) |
|
||
| `truncated` | always present; `false` for table pages |
|
||
|
||
`columns` is computed but removed unless `?_extra=columns` was requested.
|
||
When there is a next page the response carries a
|
||
`Link: <next_url>; rel="next"` header (views/table.py:1911-1912).
|
||
|
||
**`?_extra=` options** (TABLE scope; registry
|
||
views/table_extras.py:1197-1235; unknown names silently ignored):
|
||
|
||
| `_extra=` | Returns |
|
||
|---|---|
|
||
| `count` | total matching-row count, computed with a `limit 10001` subquery so it caps at 10001; `null` with `_nocount` or on count timeout. Requesting `count` implicitly includes `count_truncated` |
|
||
| `count_truncated` | `true` when `count` hit the counting limit (the real count is at least the reported value) |
|
||
| `count_sql` | the SQL used for the count |
|
||
| `facet_results` | `{"results": {name: facet}, "timed_out": [...]}`; each facet: `{name, type, hideable, toggle_url, results: [{value, label, count, toggle_url, selected}], truncated}` |
|
||
| `facets_timed_out` | facet names that exceeded `facet_time_limit_ms` |
|
||
| `suggested_facets` | `[{name, toggle_url, (type)}]`; empty when suggestion is disabled or paginating |
|
||
| `human_description_en` | English description of filters + sort |
|
||
| `next_url` | absolute URL of the next page or `null` |
|
||
| `columns` | column names of the returned rows |
|
||
| `all_columns` | all table columns regardless of `_col`/`_nocol` |
|
||
| `primary_keys` | pk column names (empty for rowid tables and views) |
|
||
| `display_columns` | HTML-oriented column metadata |
|
||
| `render_cell` | per-row plugin-rendered HTML strings |
|
||
| `debug` | `{url_vars, resolved, nofacet, nosuggest}` — explicitly unstable |
|
||
| `request` | `{url, path, full_path, host, args}` |
|
||
| `query` | `{sql, params}` of the main query |
|
||
| `column_types` | `{column: {type, config}}` assigned column types |
|
||
| `set_column_type_ui` | UI helper, `null` unless actor has `set-column-type` |
|
||
| `metadata` | table metadata dict including column descriptions |
|
||
| `extras` | self-describing list of all available extras |
|
||
| `database`, `table`, `database_color` | identity/display values |
|
||
| `renderers` | `{format_name: url}` of formats that can render this data |
|
||
| `custom_table_templates` | template lookup list |
|
||
| `sorted_facet_results` | facets as a display-ordered list |
|
||
| `table_definition` | `CREATE TABLE` SQL |
|
||
| `view_definition` | `CREATE VIEW` SQL, `null` for tables |
|
||
| `is_view` | boolean |
|
||
| `private` | `true` if visible to this actor but not anonymously |
|
||
| `expandable_columns` | `[[foreign_key, label_column_or_null], ...]` |
|
||
| `form_hidden_args` | pairs of `_`-prefixed args for HTML forms |
|
||
|
||
Non-public extras (`actions`, `filters`, `display_rows`) are HTML-only and
|
||
never appear in JSON. `_extra=_html` expands to the full HTML bundle
|
||
(views/table_extras.py:1162-1194). Any `_facet*` argument implicitly adds
|
||
`facet_results`; `_shape=object` implicitly adds `primary_keys`
|
||
(views/table.py:2252-2256). There is **no** `filtered_table_rows_count`
|
||
extra — it was replaced by `count`.
|
||
|
||
**Column filters `?<column>__<op>=<value>`** (filters.py:260-427). Any
|
||
querystring key not starting with `_` is a filter; bare `?column=value` means
|
||
`exact`. Columns whose names start with `_` can be filtered as
|
||
`?_col__exact=`. Operators:
|
||
|
||
| op | SQL |
|
||
|---|---|
|
||
| `exact` | `"col" = :p` (default) |
|
||
| `not` | `"col" != :p` |
|
||
| `contains` / `notcontains` | `like '%v%'` / `not like '%v%'` |
|
||
| `endswith` / `startswith` | `like '%v'` / `like 'v%'` |
|
||
| `gt` / `gte` / `lt` / `lte` | `>` `>=` `<` `<=` (numeric strings cast to int) |
|
||
| `like` / `notlike` | raw `like` / `not like` pattern |
|
||
| `glob` | `glob` |
|
||
| `in` / `notin` | comma-separated list, or JSON array if the value starts with `[` |
|
||
| `arraycontains` / `arraynotcontains` | `[not] in (select value from json_each("col"))` (requires JSON1) |
|
||
| `date` | `date("col") = :p` |
|
||
| `isnull` / `notnull` | `is null` / `is not null` (no value) |
|
||
| `isblank` / `notblank` | `(is null or = '')` / opposite (no value) |
|
||
|
||
**Special (underscore) parameters:**
|
||
|
||
| Param | Behavior |
|
||
|---|---|
|
||
| `_where=SQL` | extra raw where clause (repeatable); requires `execute-sql` else 403 `"_where= is not allowed"` |
|
||
| `_search=q` | FTS against the table's FTS table |
|
||
| `_search_<column>=q` | FTS restricted to one column; 400 if invalid |
|
||
| `_searchmode=raw` | pass the query straight to `match` |
|
||
| `_fts_table=` / `_fts_pk=` | override the FTS table / pk used for joins |
|
||
| `_through={"table","column","value"}` | filter via an incoming foreign key (repeatable, JSON value) |
|
||
| `_sort=col` / `_sort_desc=col` | sort; 400 if both given or column not sortable |
|
||
| `_next=token` | pagination token |
|
||
| `_size=N\|max` | page size; default `default_page_size` (100); `max` = `max_returned_rows` (1000); 400 on invalid |
|
||
| `_col=name` (repeatable) | return only pks + these columns; 400 on invalid |
|
||
| `_nocol=name` (repeatable) | exclude columns; 400 if invalid or a pk |
|
||
| `_labels=on` | expand every FK column into `{"value", "label"}` |
|
||
| `_label=col` (repeatable) | expand only the named FK column(s) |
|
||
| `_facet=col` | request a facet; 400 `"_facet= is not allowed"` when `allow_facet` off |
|
||
| `_facet_array=col` / `_facet_date=col` | typed facets |
|
||
| `_facet_size=N\|max` | facet bucket count, default 30, capped at `max_returned_rows` |
|
||
| `_nocount=1` | skip count (`count` extra → null) |
|
||
| `_nofacet=1` | skip facets and suggestions |
|
||
| `_nosuggest=1` | skip facet suggestions only |
|
||
| `_shape=` | see renderer section; `array`/`object` also force `_nocount` and `_nofacet` |
|
||
| `_nl=on` | NDJSON with `_shape=array` |
|
||
| `_json=col` / `_json_infinity=1` | renderer options |
|
||
| `_timelimit=ms` | custom SQL time limit |
|
||
| `_ttl=seconds` | `Cache-Control: max-age=N` (`0` → `no-cache`); default `default_cache_ttl` (5) |
|
||
| `_trace=1` | append `_trace` key (requires `trace_debug` setting) |
|
||
| `_extra=` | see above |
|
||
|
||
**Pagination** is keyset-based for tables: `page_size + 1` rows are fetched;
|
||
`next` is built from the last row of the page — comma-joined tilde-encoded
|
||
primary-key values, prefixed by the sort value when sorted (`$null` for null
|
||
sort values) (views/table.py:2041-2111, 2421-2482). `next_url` is the
|
||
absolute URL with `_next` replaced.
|
||
|
||
### GET /\<database\>/\<view_name\>.json (SQL views)
|
||
|
||
Same code path with `is_view=True`. Differences:
|
||
|
||
- No primary keys: `primary_keys` → `[]`; `_shape=object` fails; base query
|
||
has no `order by`.
|
||
- **Pagination is offset-based**: `_next` is an integer offset applied as
|
||
`limit N offset M` (views/table.py:2047-2049, 2438-2439) — unlike the
|
||
keyset tokens used for tables.
|
||
- `view_definition` returns the `CREATE VIEW` SQL; `table_definition` is null.
|
||
|
||
### GET /\<database\>/\<table\>/\<pks\>.json
|
||
|
||
`RowView` (app.py:2715-2718; views/row.py:137). `<pks>` is comma-separated
|
||
tilde-encoded primary key values (rowid for rowid tables).
|
||
|
||
- **Permission:** `view-table` (denied → `Forbidden` → 403 HTML). Missing row
|
||
→ 404 `"Record not found: [...]"`.
|
||
- **Default JSON keys:** `ok`, `database`, `table`, `rows` (single-element
|
||
list), `primary_keys`, `primary_key_values`, `query_ms`,
|
||
`truncated: false`; `columns` only with `?_extra=columns`.
|
||
- **`?_extra=` (ROW scope):** `columns`, `primary_keys`, `render_cell`,
|
||
`debug`, `request`, `query`, `column_types`, `metadata`, `extras`,
|
||
`database`, `table`, `database_color`, `private`, `foreign_key_tables`
|
||
(incoming FKs with `count` and `link`; single-pk rows only).
|
||
- **Foreign-key label expansion does not apply to row JSON** — `_labels` has
|
||
no effect here; expansion happens only in the HTML path
|
||
(views/row.py:445-475).
|
||
- `_shape`, `_json`, `_nl`, `_json_infinity`, `_ttl` apply.
|
||
|
||
### The .blob format
|
||
|
||
`/<database>/<table>/<pks>.blob?_blob_column=col` (also on query pages) —
|
||
fetches raw binary bytes (blob_renderer.py:10-61). `_blob_column` required
|
||
(400 if missing/invalid); optional `_blob_hash` must equal the value's
|
||
SHA-256 (else 400 `"Link has expired..."`). Returns `application/binary` as a
|
||
download attachment. In JSON output, binary cells appear as
|
||
`{"$base64": true, "encoded": "..."}`.
|
||
|
||
### GET /\<database\>/\<table\>/-/schema(.json|.md)
|
||
|
||
`TableSchemaView` (app.py:2751-2754; views/special.py:1332-1378).
|
||
|
||
- **Permission:** `view-table` via `ensure_permission` (denied → 403 HTML).
|
||
- **Responses:** `.json` → 200 `{"ok": true, "database", "table", "schema"}`;
|
||
`.md` → `text/markdown`; no extension → HTML. Missing table → 404
|
||
`{"ok": false, "error": "Table not found"}` for `.json`.
|
||
|
||
### GET /\<database\>/\<table\>/-/fragment
|
||
|
||
`TableFragmentView` (app.py:2739-2742; views/table.py:1385-1418).
|
||
**HTML-only** — returns the `_table.html` partial; no JSON variant. Accepts
|
||
table querystring parameters plus `_row=<pk-path>` to render a single row.
|
||
|
||
### GET /\<database\>/\<table\>/-/autocomplete
|
||
|
||
`TableAutocompleteView` (app.py:2743-2746; views/table.py:1492-1595). Tables
|
||
only — views get 400 `"Autocomplete is only available for tables"`.
|
||
|
||
- **Permission:** `view-table` (denied → `Forbidden` → 403).
|
||
- **Parameters:** `q` (matched with escaped `LIKE %q%` against pk columns and
|
||
the label column) and `_initial` (truthy: with empty `q`, return the 10
|
||
most recent rows). Neither → `{"ok": true, "rows": []}`.
|
||
- **Response:** `{"rows": [{"pks": {pk_name: value}, "label": "..."}]}` — max
|
||
10 items; 500 ms query budget with fallbacks, timing out to
|
||
`{"ok": true, "rows": []}`.
|
||
|
||
---
|
||
|
||
## The write API
|
||
|
||
All write endpoints return errors via `_error()` (the canonical error
|
||
shape) and check permissions with
|
||
`datasette.allowed()` directly, so their 403s are JSON (unlike the
|
||
`Forbidden`-raising read endpoints). Routes: app.py:2719-2762.
|
||
|
||
### POST /\<database\>/\<table\>/-/insert
|
||
|
||
`TableInsertView` (views/table.py:907-1194).
|
||
|
||
- **Permissions:** `insert-row` on the table (denied → 403
|
||
`["Permission denied"]`); `update-row` additionally required for
|
||
`replace: true` (403 `need update-row to use "replace"`); `alter-table`
|
||
additionally required for `alter: true` (403
|
||
`Permission denied for alter-table`). Immutable database → 403
|
||
`Database is immutable`.
|
||
- **Request** — requires `Content-Type: application/json` (else 400
|
||
`"Invalid content-type, must be application/json"`). Body:
|
||
|
||
| Field | Rules |
|
||
|---|---|
|
||
| `row` | single object; mutually exclusive with `rows`; forces `return: true` |
|
||
| `rows` | list of objects; max `max_insert_rows` (default 100), else 400 `"Too many rows, maximum allowed is 100"` |
|
||
| `ignore` | skip rows whose pk already exists; mutually exclusive with `replace` |
|
||
| `replace` | replace rows with matching pks (needs `update-row`) |
|
||
| `alter` | add missing columns (needs `alter-table`) |
|
||
| `return` | include inserted rows in the response |
|
||
|
||
One of `row`/`rows` required. Unknown keys → 400 `"Invalid parameter: ..."`.
|
||
Unless `alter`, row keys must be existing columns → per-row 400
|
||
`"Row 0 has invalid columns: x, y"`. Values are validated against assigned
|
||
column types.
|
||
- **Response** — **201** `{"ok": true}`; with `return: true` also `rows`
|
||
(the rows as stored, re-fetched by rowid). SQLite errors during the write →
|
||
400 with the message. Emits `insert-rows` (and possibly `alter-table`)
|
||
events.
|
||
|
||
### POST /\<database\>/\<table\>/-/upsert
|
||
|
||
`TableUpsertView` — subclasses insert (views/table.py:1197-1201).
|
||
|
||
- **Permissions:** **both** `insert-row` and `update-row` (403
|
||
`need both insert-row and update-row`); `alter: true` needs `alter-table`.
|
||
- **Request:** same as insert, except `ignore`/`replace` are rejected (400
|
||
`"Upsert does not support ignore or replace"`) and **every row must contain
|
||
the table's primary key(s)** (per-row 400
|
||
`Row 0 is missing primary key column(s): "id"` / `has null primary key`).
|
||
- **Response** — **200** (note: insert returns 201) `{"ok": true}`; with
|
||
`return: true`, `rows` re-fetched by pk. Emits `upsert-rows`.
|
||
|
||
### POST /\<database\>/\<table\>/-/alter
|
||
|
||
`TableAlterView` (views/table_create_alter.py:1130-1353).
|
||
|
||
- **Permission:** `alter-table` (403 `need alter-table`); immutable → 403.
|
||
- **Request:** `{"operations": [{"op": ..., "args": {...}}, ...]}` — a
|
||
non-empty list, validated by pydantic (extra keys forbidden anywhere;
|
||
errors → 400 `location: message`):
|
||
|
||
| `op` | `args` |
|
||
|---|---|
|
||
| `add_column` | `name` (required), `type` (`text`/`integer`/`float`/`blob`, default `text`), `not_null`, `default` xor `default_expr`; `not_null: true` requires a default |
|
||
| `rename_column` | `name`, `to` |
|
||
| `rename_table` | `to` (must not start `sqlite_`) |
|
||
| `alter_column` | `name` + at least one of `type`, `not_null`, `default`, `default_expr` |
|
||
| `drop_column` | `name` |
|
||
| `set_primary_key` | `columns` (non-empty list) |
|
||
| `reorder_columns` | `columns` (non-empty list) |
|
||
| `add_foreign_key` | `column`, `fk_table`, optional `fk_column` |
|
||
| `drop_foreign_key` | `column` |
|
||
| `set_foreign_keys` | `foreign_keys`: list of `{column, fk_table, fk_column?}` |
|
||
|
||
`default_expr` must be one of the five `current_*` keywords. Operations are
|
||
applied in a single write transaction; any failure → 400.
|
||
- **Response** — 200:
|
||
```json
|
||
{"ok": true, "database": "...", "table": "<possibly renamed>",
|
||
"table_url": "...", "table_api_url": "...",
|
||
"altered": true, "schema": "...", "before_schema": "...",
|
||
"operations_applied": 2}
|
||
```
|
||
|
||
### POST /\<database\>/\<table\>/-/drop
|
||
|
||
`TableDropView` (views/table.py:1320-1382).
|
||
|
||
- **Permission:** `drop-table` (403 `Permission denied`); immutable → 403.
|
||
- **Confirmation flow:** without `{"confirm": true}` in the body, nothing is
|
||
dropped and a 200 preview is returned:
|
||
`{"ok": true, "database", "table", "row_count",
|
||
"message": "Pass \"confirm\": true to confirm"}`. With `confirm: true` →
|
||
200 `{"ok": true}`. Emits `drop-table`.
|
||
|
||
### POST /\<database\>/\<table\>/-/set-column-type
|
||
|
||
`TableSetColumnTypeView` (views/table.py:1204-1317). Assigns a Datasette
|
||
*column type* (metadata stored in the internal `column_types` table) — it
|
||
does not change the SQLite schema.
|
||
|
||
- **Permission:** `set-column-type` (403 `Permission denied`).
|
||
- **Request** (JSON content type required): `{"column": "name",
|
||
"column_type": {"type": "url", "config": {...}?} | null}`. Unknown
|
||
keys/invalid structure → detailed 400 errors; unknown type → 400
|
||
`"Unknown column type: x"`. Default registered types (via the
|
||
`register_column_types` hook): `url`, `email`, `json`, `textarea`.
|
||
- **Response** — 200 `{"ok": true, "database", "table", "column",
|
||
"column_type": {...} | null}`.
|
||
|
||
### GET /\<database\>/\<table\>/-/foreign-key-suggestions
|
||
|
||
`TableForeignKeySuggestionsView` (views/table_create_alter.py:1008-1127).
|
||
**GET only** (read-only despite living beside the write endpoints).
|
||
|
||
- **Permission:** `alter-table` (403 `need alter-table`); views → 400
|
||
`"Cannot suggest foreign keys for a view"`.
|
||
- **Response** — 200: `{"ok": true, "database", "table",
|
||
"row_check": {attempted, status, row_limit, sampled_rows, checked_options},
|
||
"columns": [{column, type, affinity, current,
|
||
"suggestions": [{fk_table, fk_column, confidence, sampled_values, reasons}],
|
||
"options": [...]}]}`. Samples up to 500 rows within 50 ms/200 ms budgets.
|
||
|
||
### POST /\<database\>/\<table\>/\<pks\>/-/update
|
||
|
||
`RowUpdateView` (views/row.py:781-870).
|
||
|
||
- **Permissions:** `update-row` (403 `Permission denied`); `alter: true`
|
||
additionally requires `alter-table` (403
|
||
`Permission denied for alter-table`).
|
||
- **404s:** `Database not found: x` / `Table not found: x` /
|
||
`Record not found: [pks]`.
|
||
- **Request:** `{"update": {column: value, ...}, "return"?: true,
|
||
"alter"?: true}`. Missing/non-dict `update` → 400
|
||
`"JSON must contain an update dictionary"`; unknown keys → 400
|
||
`"Invalid keys: ..."`; write failures (bad column, constraint violation) →
|
||
400 with the message.
|
||
- **Response** — 200 `{"ok": true}`; with `return: true`,
|
||
`{"ok": true, "rows": [{...}]}` — a single-item list, matching
|
||
insert/upsert. Emits `update-row`.
|
||
|
||
### POST /\<database\>/\<table\>/\<pks\>/-/delete
|
||
|
||
`RowDeleteView` (views/row.py:738-778).
|
||
|
||
- **Permission:** `delete-row` (403 `Permission denied`). 404s as update.
|
||
- **Request:** no body required (any body is ignored — there is no
|
||
confirmation step, unlike table drop).
|
||
- **Response** — 200 `{"ok": true}`; with `?_redirect_to_table` a `redirect`
|
||
key is added. A failure during the write returns 400 with the message,
|
||
matching update. Emits `delete-row`.
|
||
|
||
---
|
||
|
||
## Stored (canned) queries API
|
||
|
||
Stored queries live in the internal database's `queries` table
|
||
(utils/internal_db.py:116-133). Queries defined in `datasette.yaml` are
|
||
synced in at startup with `source="config"` and `is_trusted` defaulting to
|
||
true; queries created via the API get `source="user"`, `is_trusted=false`,
|
||
`owner_id` = actor id.
|
||
|
||
**Canonical stored-query JSON object** (`stored_query_to_dict`,
|
||
stored_queries.py:55-80):
|
||
|
||
```json
|
||
{
|
||
"database": "...", "name": "...", "sql": "...",
|
||
"title": null, "description": null, "description_html": null,
|
||
"hide_sql": false, "fragment": null,
|
||
"parameters": ["p"],
|
||
"is_write": false, "is_private": true, "is_trusted": false,
|
||
"source": "user", "owner_id": "...",
|
||
"on_success_message": null, "on_success_message_sql": null,
|
||
"on_success_redirect": null,
|
||
"on_error_message": null, "on_error_redirect": null,
|
||
"private": true
|
||
}
|
||
```
|
||
|
||
`private` appears only in list responses. On input (create/update and
|
||
`datasette.yaml`), `params` is accepted as an alias for `parameters`.
|
||
|
||
**Default permission rules for queries** (default_permissions/defaults.py):
|
||
`view-query` is default-allow, but private queries are visible only to their
|
||
owner; the owner may `update-query`/`delete-query` their `source='user'`
|
||
queries.
|
||
|
||
### GET /-/queries(.json) and GET /\<database\>/-/queries(.json)
|
||
|
||
`GlobalQueryListView` / `QueryListView` (app.py:2606-2609, 2663-2666;
|
||
views/stored_queries.py:69-238). The global variant lists queries across all
|
||
databases (`database`/`database_color` are null, `show_database` true).
|
||
|
||
- **Permissions:** no single gate; results filtered per query by
|
||
`view-query` (private queries appear only for their owner).
|
||
- **Parameters:** `_size` (default 20 HTML / **50 JSON**, clamped 1–1000;
|
||
non-integer → 400), `_next` (cursor), `q` (substring search over
|
||
name/title/description/sql), `is_write` / `is_private` (booleans; invalid →
|
||
400 `"is_write must be 0 or 1"`), `source`, `owner_id`.
|
||
- **Response** — 200:
|
||
`{"ok": true, "database", "database_color", "queries": [...], "next",
|
||
"next_url", "has_more", "limit", "show_private_note",
|
||
"show_trusted_note", "query_list_path", "show_database",
|
||
"facets": [{title, items: [{label, count, href, active}]}],
|
||
"filters": {q, is_write, is_private, source, owner_id}}`.
|
||
|
||
### GET /\<database\>/-/queries/analyze
|
||
|
||
`QueryCreateAnalyzeView` (app.py:2667-2670; views/stored_queries.py:290-322).
|
||
**GET only** despite being an "analyze" action — POST → 405.
|
||
|
||
- **Permissions:** `execute-sql` then `store-query` (each denial → 403
|
||
`errors` JSON).
|
||
- **Parameters:** only `sql` (others → 400 `"Invalid keys: ..."`).
|
||
- **Response** — 200: `{"ok", "parameters", "analysis_error",
|
||
"analysis_rows": [{operation, database, table, required_permission,
|
||
source, allowed}], "has_sql", "analysis_is_write", "save_disabled"}`.
|
||
|
||
### POST /\<database\>/-/queries/store
|
||
|
||
`QueryStoreView` (app.py:2671-2674; views/stored_queries.py:325-388). GET on
|
||
the same path renders the HTML create form.
|
||
|
||
- **Permissions:** `execute-sql` + `store-query` (403 `errors` JSON).
|
||
- **Request:** JSON bodies must wrap the fields:
|
||
`{"query": {...fields...}}`; form bodies pass fields flat. Fields:
|
||
`name` (required; `^[^/\.\n]+$`; conflicts with tables/views or existing
|
||
queries → 400), `sql` (required; read SQL must pass `validate_sql_select`;
|
||
write SQL must pass per-operation permission checks), `title`,
|
||
`description`, `hide_sql`, `fragment`, `parameters`/`params` (must exactly
|
||
match the SQL's named parameters; magic parameters rejected),
|
||
`is_private` (**default true**), and — only for write SQL —
|
||
`on_success_message`, `on_success_redirect`, `on_error_message`,
|
||
`on_error_redirect`. `is_write` is derived from SQL analysis;
|
||
`is_trusted`, `description_html` and `on_success_message_sql` cannot be
|
||
set through this API.
|
||
- **Response:** JSON request → **201** `{"ok": true, "query": {...}}`; form
|
||
request → 302 redirect.
|
||
|
||
### GET /\<database\>/\<query\>/-/definition
|
||
|
||
`QueryDefinitionView` (app.py:2695-2698; views/stored_queries.py:391-408).
|
||
|
||
- **Permission:** `view-query` (403 `["Permission denied"]`).
|
||
- **Response:** 200 `{"ok": true, "query": {...}}`; 404
|
||
`["Query not found: x"]`.
|
||
|
||
### GET/POST /\<database\>/\<query\>/-/edit
|
||
|
||
`QueryEditView` (app.py:2699-2702) — **HTML form endpoint**
|
||
(`has_json_alternate = False`), not part of the JSON API. Programmatic
|
||
updates use `/-/update`.
|
||
|
||
### POST /\<database\>/\<query\>/-/update
|
||
|
||
`QueryUpdateView` (app.py:2703-2706; views/stored_queries.py:411-465).
|
||
|
||
- **Permissions:** `update-query` (403 `need update-query`); trusted queries
|
||
→ 403 `"Trusted queries cannot be updated using the API"`; changing `sql`
|
||
additionally requires `execute-sql`.
|
||
- **Request:** `{"update": {...partial fields...}, "return"?: true}` — other
|
||
top-level keys → 400. Updatable fields: `sql`, `title`, `description`,
|
||
`hide_sql`, `fragment`, `parameters`/`params`, `is_private`, `on_*`
|
||
fields (write SQL only). New SQL is re-analyzed and `is_write` recomputed.
|
||
- **Response:** 200 `{"ok": true}` (plus `query` with `return: true`); 404
|
||
`"Query not found: x"`.
|
||
|
||
### POST /\<database\>/\<query\>/-/delete
|
||
|
||
`QueryDeleteView` (app.py:2707-2710; views/stored_queries.py:594-644). GET
|
||
renders an HTML confirmation page.
|
||
|
||
- **Permission:** `delete-query` (403 `need delete-query`). Trusted
|
||
queries → 403 `"Trusted queries cannot be deleted using the API"`,
|
||
matching update.
|
||
- **Response:** JSON request → 200 `{"ok": true}`; form → 302; 404
|
||
`"Query not found: x"`. No `confirm` field required (unlike table drop).
|
||
|
||
### GET/POST /\<database\>/\<query-name\>(.json) — executing a stored query
|
||
|
||
No dedicated route: the table route resolves the name, and on `TableNotFound`
|
||
the request is dispatched to `QueryView` when a stored query matches
|
||
(views/table.py:1698-1712). Covers both config-defined and API-stored
|
||
queries.
|
||
|
||
**GET (read queries)** — `QueryView.get` (views/database.py:695-1130):
|
||
|
||
- **Permissions:** `view-query` (denied → `Forbidden` → 403 HTML). Read
|
||
queries then require `execute-sql` unless `is_trusted`. Write queries are
|
||
**not executed** on GET — JSON returns empty `rows`; HTML shows a POST form.
|
||
- **Parameters:** each named `:param` is read from the query string (missing
|
||
→ `""`); `_timelimit`; renderer options (`_shape`, `_nl`, `_json`,
|
||
`_json_infinity`); `_extra` (QUERY scope).
|
||
- **Response:** `{"ok": true, "rows": [...], "truncated": false}` + extras.
|
||
SQL errors → 400 with `error` in the envelope.
|
||
|
||
**POST (write queries)** — `QueryView.post` (views/database.py:574-693):
|
||
|
||
- **Permissions:** `view-query`; then, unless `is_trusted`:
|
||
`execute-write-sql` on the database **plus** per-operation write
|
||
permissions (same table as `/-/execute-write`). Rejection → 403
|
||
`{"ok": false, "message": "...", "redirect": null}` for JSON clients.
|
||
Immutable database → 403.
|
||
- **Body:** form-encoded or JSON `param=value` pairs (values coerced to
|
||
strings).
|
||
- **JSON is returned when** `Accept: application/json`, `?_json=1`, or a
|
||
`_json` body field is present; otherwise 302 + flash message.
|
||
- **Magic parameters** (`:_<prefix>_<key>`, resolved server-side; registered
|
||
via `register_magic_parameters`, default_magic_parameters.py):
|
||
`_now_epoch`, `_now_date_utc`, `_now_datetime_utc`, `_actor_<key>`,
|
||
`_random_chars_<N>`, `_cookie_<name>`, `_header_<name>` (underscores →
|
||
hyphens). User-stored queries cannot contain magic parameters — they are a
|
||
feature of config/trusted queries.
|
||
- **Response:** success → 200
|
||
`{"ok": true, "message": "...", "redirect": "..."|null}` — `message`
|
||
honors `on_success_message_sql` / `on_success_message`, falling back to
|
||
`"Query executed"` or `"Query executed, N rows affected"`. SQL failure →
|
||
**400** canonical error (message honors `on_error_message`) plus a
|
||
`redirect` context key from `on_error_redirect`. Operation rejection
|
||
(`QueryWriteRejected`, e.g. VACUUM) → 403 canonical error plus
|
||
`redirect: null`.
|
||
|
||
---
|
||
|
||
## Authentication and tokens
|
||
|
||
### Bearer tokens (`dstok_`)
|
||
|
||
Signed API tokens are sent as `Authorization: Bearer dstok_...`. The
|
||
`actor_from_signed_api_token` hook (default_permissions/tokens.py:25-40)
|
||
passes the token to `datasette.verify_token()`, which tries every handler
|
||
registered via `register_token_handler`; the default is
|
||
`SignedTokenHandler` (tokens.py:117-193).
|
||
|
||
- **Format:** `dstok_` + itsdangerous-signed payload (namespace `token`)
|
||
containing `a` (actor id), `t` (creation Unix time), optional `d`
|
||
(duration seconds), optional `_r` (restrictions).
|
||
- **Verification:** a `dstok_`-prefixed token that fails verification —
|
||
`allow_signed_tokens` off, invalid signature, missing/non-integer `t`,
|
||
malformed `d`, or expired — raises `TokenInvalid`, and the request fails
|
||
with **401**, the canonical error body and a
|
||
`WWW-Authenticate: Bearer error="invalid_token"` header (even if a valid
|
||
`ds_actor` cookie is also present). Tokens with prefixes no registered
|
||
handler recognizes are ignored (they may belong to an auth plugin). The
|
||
effective duration is `d` capped by `max_signed_tokens_ttl` (default 0 =
|
||
no cap; a non-zero setting also imposes a TTL on tokens without `d`).
|
||
- **Resulting actor:** `{"id": <a>, "token": "dstok"}` plus `"_r"` and
|
||
`"token_expires"` when applicable.
|
||
|
||
**Restrictions (`_r`)** (default_permissions/restrictions.py):
|
||
|
||
- `"a"`: list of actions allowed on any resource
|
||
- `"d"`: `{database_name: [actions]}`
|
||
- `"r"`: `{database_name: {table_name: [actions]}}`
|
||
|
||
Actions are stored as abbreviations when available (see appendix); checks
|
||
accept either the full name or the abbreviation. Restrictions are an
|
||
allowlist filter layered on top of normal permission resolution — a
|
||
restricted token can never do more than its allowlist, and never more than
|
||
the underlying actor could do anyway.
|
||
|
||
### Token creation
|
||
|
||
- **`/-/create-token`** is an HTML form endpoint only (see the instance
|
||
section) — there is no JSON API to mint tokens in this codebase.
|
||
- Programmatic alternatives: the `datasette create-token` CLI command and
|
||
the `datasette.create_token()` Python API.
|
||
- `/-/auth-token` is the one-time `--root` login mechanism, unrelated to API
|
||
tokens.
|
||
|
||
### Cookie authentication
|
||
|
||
Browser sessions use the signed `ds_actor` cookie (set by `/-/auth-token`,
|
||
plugins, or login flows; cleared by `/-/logout`). API POSTs from browsers are
|
||
subject to the cross-origin checks described in
|
||
[CSRF](#csrf--cross-origin-protection).
|
||
|
||
---
|
||
|
||
## Appendix: registered actions (permissions)
|
||
|
||
From `datasette/default_actions.py` (registered via the `register_actions`
|
||
hook). Token restrictions store the abbreviation when available.
|
||
|
||
| Action | Abbr | Resource level | Notes |
|
||
|---|---|---|---|
|
||
| `view-instance` | `vi` | global | |
|
||
| `permissions-debug` | `pd` | global | gates the debug endpoints |
|
||
| `debug-menu` | `dm` | global | UI only |
|
||
| `view-database` | `vd` | database | |
|
||
| `view-database-download` | `vdd` | database | `also_requires="view-database"` |
|
||
| `execute-sql` | `es` | database | `also_requires="view-database"`; denied when the `default_allow_sql` setting is off |
|
||
| `execute-write-sql` | `ews` | database | `also_requires="view-database"` |
|
||
| `create-table` | `ct` | database | |
|
||
| `store-query` | `sq` | database | `also_requires="execute-sql"` |
|
||
| `view-table` | `vt` | table | |
|
||
| `insert-row` | `ir` | table | |
|
||
| `delete-row` | `dr` | table | |
|
||
| `update-row` | `ur` | table | |
|
||
| `alter-table` | `at` | table | |
|
||
| `set-column-type` | `sct` | table | |
|
||
| `drop-table` | `dt` | table | |
|
||
| `view-query` | `vq` | query | default-allow; private queries restricted to their owner |
|
||
| `update-query` | `uq` | query | query owner allowed by default (source=`user` only) |
|
||
| `delete-query` | `dq` | query | query owner allowed by default (source=`user` only) |
|