First draft of insert row write API, refs #1851

This commit is contained in:
Simon Willison 2022-10-26 20:57:02 -07:00
commit 51c436fed2
5 changed files with 119 additions and 11 deletions

View file

@ -9,7 +9,7 @@ import time
@hookimpl(tryfirst=True) @hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource): def permission_allowed(datasette, actor, action, resource):
async def inner(): async def inner():
if action in ("permissions-debug", "debug-menu"): if action in ("permissions-debug", "debug-menu", "insert-row"):
if actor and actor.get("id") == "root": if actor and actor.get("id") == "root":
return True return True
elif action == "view-instance": elif action == "view-instance":

View file

@ -28,7 +28,7 @@ from datasette.utils import (
urlsafe_components, urlsafe_components,
value_as_boolean, value_as_boolean,
) )
from datasette.utils.asgi import BadRequest, Forbidden, NotFound from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
from datasette.filters import Filters from datasette.filters import Filters
from .base import DataView, DatasetteError, ureg from .base import DataView, DatasetteError, ureg
from .database import QueryView from .database import QueryView
@ -103,15 +103,71 @@ class TableView(DataView):
canned_query = await self.ds.get_canned_query( canned_query = await self.ds.get_canned_query(
database_name, table_name, request.actor database_name, table_name, request.actor
) )
assert canned_query, "You may only POST to a canned query" if canned_query:
return await QueryView(self.ds).data( return await QueryView(self.ds).data(
request, request,
canned_query["sql"], canned_query["sql"],
metadata=canned_query, metadata=canned_query,
editable=False, editable=False,
canned_query=table_name, canned_query=table_name,
named_parameters=canned_query.get("params"), named_parameters=canned_query.get("params"),
write=bool(canned_query.get("write")), write=bool(canned_query.get("write")),
)
else:
# Handle POST to a table
return await self.table_post(request, database_name, table_name)
async def table_post(self, request, database_name, table_name):
# Table must exist (may handle table creation in the future)
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
raise NotFound("Table not found: {}".format(table_name))
# Must have insert-row permission
if not await self.ds.permission_allowed(
request.actor, "insert-row", resource=(database_name, table_name)
):
raise Forbidden("Permission denied")
if request.headers.get("content-type") != "application/json":
# TODO: handle form-encoded data
raise BadRequest("Must send JSON data")
data = json.loads(await request.post_body())
if "row" not in data:
raise BadRequest('Must send "row" data')
row = data["row"]
if not isinstance(row, dict):
raise BadRequest("row must be a dictionary")
# Verify all columns exist
columns = await db.table_columns(table_name)
pks = await db.primary_keys(table_name)
for key in row:
if key not in columns:
raise BadRequest("Column not found: {}".format(key))
if key in pks:
raise BadRequest(
"Cannot insert into primary key column: {}".format(key)
)
# Perform the insert
sql = "INSERT INTO [{table}] ({columns}) VALUES ({values})".format(
table=escape_sqlite(table_name),
columns=", ".join(escape_sqlite(c) for c in row),
values=", ".join("?" for c in row),
)
cursor = await db.execute_write(sql, list(row.values()))
# Return the new row
rowid = cursor.lastrowid
new_row = (
await db.execute(
"SELECT * FROM [{table}] WHERE rowid = ?".format(
table=escape_sqlite(table_name)
),
[rowid],
)
).first()
return Response.json(
{
"row": dict(new_row),
},
status=201,
) )
async def columns_to_select(self, table_columns, pks, request): async def columns_to_select(self, table_columns, pks, request):

View file

@ -547,6 +547,18 @@ Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` pa
Default *allow*. Default *allow*.
.. _permissions_insert_row:
insert-row
----------
Actor is allowed to insert rows into a table.
``resource`` - tuple: (string, string)
The name of the database, then the name of the table
Default *deny*.
.. _permissions_execute_sql: .. _permissions_execute_sql:
execute-sql execute-sql

View file

@ -229,6 +229,8 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
database files (default=True) database files (default=True)
allow_signed_tokens Allow users to create and use signed API tokens allow_signed_tokens Allow users to create and use signed API tokens
(default=True) (default=True)
max_signed_tokens_ttl Maximum allowed expiry time for signed API tokens
(default=0)
suggest_facets Calculate and display suggested facets suggest_facets Calculate and display suggested facets
(default=True) (default=True)
default_cache_ttl Default HTTP cache TTL (used in Cache-Control: default_cache_ttl Default HTTP cache TTL (used in Cache-Control:

View file

@ -455,3 +455,41 @@ You can find this near the top of the source code of those pages, looking like t
The JSON URL is also made available in a ``Link`` HTTP header for the page:: The JSON URL is also made available in a ``Link`` HTTP header for the page::
Link: https://latest.datasette.io/fixtures/sortable.json; rel="alternate"; type="application/json+datasette" Link: https://latest.datasette.io/fixtures/sortable.json; rel="alternate"; type="application/json+datasette"
.. _json_api_write:
The JSON write API
------------------
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`.
.. _json_api_write_insert_row:
Inserting a single row
~~~~~~~~~~~~~~~~~~~~~~
This requires the :ref:`permissions_insert_row` permission.
::
POST /<database>/<table>
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
{
"row": {
"column1": "value1",
"column2": "value2"
}
}
If successful, this will return a ``201`` status code and the newly inserted row, for example:
.. code-block:: json
{
"row": {
"id": 1,
"column1": "value1",
"column2": "value2"
}
}