Skip to content

Commit

Permalink
First draft of insert row write API, refs #1851
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Oct 27, 2022
1 parent 382a871 commit 51c436f
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 11 deletions.
2 changes: 1 addition & 1 deletion datasette/default_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
@hookimpl(tryfirst=True)
def permission_allowed(datasette, actor, action, resource):
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":
return True
elif action == "view-instance":
Expand Down
76 changes: 66 additions & 10 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
urlsafe_components,
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 .base import DataView, DatasetteError, ureg
from .database import QueryView
Expand Down Expand Up @@ -103,15 +103,71 @@ async def post(self, request):
canned_query = await self.ds.get_canned_query(
database_name, table_name, request.actor
)
assert canned_query, "You may only POST to a canned query"
return await QueryView(self.ds).data(
request,
canned_query["sql"],
metadata=canned_query,
editable=False,
canned_query=table_name,
named_parameters=canned_query.get("params"),
write=bool(canned_query.get("write")),
if canned_query:
return await QueryView(self.ds).data(
request,
canned_query["sql"],
metadata=canned_query,
editable=False,
canned_query=table_name,
named_parameters=canned_query.get("params"),
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):
Expand Down
12 changes: 12 additions & 0 deletions docs/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,18 @@ Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` pa

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:

execute-sql
Expand Down
2 changes: 2 additions & 0 deletions docs/cli-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ These can be passed to ``datasette serve`` using ``datasette serve --setting nam
database files (default=True)
allow_signed_tokens Allow users to create and use signed API tokens
(default=True)
max_signed_tokens_ttl Maximum allowed expiry time for signed API tokens
(default=0)
suggest_facets Calculate and display suggested facets
(default=True)
default_cache_ttl Default HTTP cache TTL (used in Cache-Control:
Expand Down
38 changes: 38 additions & 0 deletions docs/json_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

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"
}
}

0 comments on commit 51c436f

Please sign in to comment.