Skip to content

Commit

Permalink
/db/table/-/drop API, closes #1874
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Oct 30, 2022
1 parent 4f16e14 commit 2865d39
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 11 deletions.
6 changes: 5 additions & 1 deletion datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
PermissionsDebugView,
MessagesDebugView,
)
from .views.table import TableView, TableInsertView
from .views.table import TableView, TableInsertView, TableDropView
from .views.row import RowView
from .renderer import json_renderer
from .url_builder import Urls
Expand Down Expand Up @@ -1276,6 +1276,10 @@ def add_route(view, regex):
TableInsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/insert$",
)
add_route(
TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
)
return [
# Compile any strings to regular expressions
((re.compile(pattern) if isinstance(pattern, str) else pattern), view)
Expand Down
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", "insert-row"):
if action in ("permissions-debug", "debug-menu", "insert-row", "drop-table"):
if actor and actor.get("id") == "root":
return True
elif action == "view-instance":
Expand Down
40 changes: 36 additions & 4 deletions datasette/views/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'


def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)


class Row:
def __init__(self, cells):
self.cells = cells
Expand Down Expand Up @@ -1147,9 +1151,6 @@ def _errors(errors):
return rows, errors, extra

async def post(self, request):
def _error(messages, status=400):
return Response.json({"ok": False, "errors": messages}, status=status)

database_route = tilde_decode(request.url_vars["database"])
try:
db = self.ds.get_database(route=database_route)
Expand Down Expand Up @@ -1181,7 +1182,8 @@ def insert_rows(conn):
rowids.append(table.insert(row).last_rowid)
return list(
table.rows_where(
"rowid in ({})".format(",".join("?" for _ in rowids)), rowids
"rowid in ({})".format(",".join("?" for _ in rowids)),
rowids,
)
)
else:
Expand All @@ -1192,3 +1194,33 @@ def insert_rows(conn):
if should_return:
result["inserted"] = rows
return Response.json(result, status=201)


class TableDropView(BaseView):
name = "table-drop"

def __init__(self, datasette):
self.ds = datasette

async def post(self, request):
database_route = tilde_decode(request.url_vars["database"])
try:
db = self.ds.get_database(route=database_route)
except KeyError:
return _error(["Database not found: {}".format(database_route)], 404)
database_name = db.name
table_name = tilde_decode(request.url_vars["table"])
# Table must exist
db = self.ds.get_database(database_name)
if not await db.table_exists(table_name):
return _error(["Table not found: {}".format(table_name)], 404)
if not await self.ds.permission_allowed(
request.actor, "drop-table", resource=(database_name, table_name)
):
return _error(["Permission denied"], 403)
# Drop table
def drop_table(conn):
sqlite_utils.Database(conn)[table_name].drop()

await db.execute_write_fn(drop_table)
return Response.json({"ok": True}, status=200)
12 changes: 12 additions & 0 deletions docs/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,18 @@ Actor is allowed to insert rows into a table.

Default *deny*.

.. _permissions_drop_table:

drop-table
----------

Actor is allowed to drop a database 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
17 changes: 17 additions & 0 deletions docs/json_api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,20 @@ To return the newly inserted rows, add the ``"return_rows": true`` key to the re
}
This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option.

.. _TableDropView:

Dropping tables
~~~~~~~~~~~~~~~

To drop a table, make a ``POST`` to ``/<database>/<table>/-/drop``. This requires the :ref:`permissions_drop_table` permission.

::

POST /<database>/<table>/-/drop
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>

If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body.

Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error.
48 changes: 43 additions & 5 deletions tests/test_api_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ def ds_write(tmp_path_factory):
db.close()


def write_token(ds, actor_id="root"):
return "dstok_{}".format(
ds.sign(
{"a": actor_id, "token": "dstok", "t": int(time.time())}, namespace="token"
)
)


@pytest.mark.asyncio
async def test_write_row(ds_write):
token = write_token(ds_write)
Expand Down Expand Up @@ -188,9 +196,39 @@ async def test_write_row_errors(
assert response.json()["errors"] == expected_errors


def write_token(ds):
return "dstok_{}".format(
ds.sign(
{"a": "root", "token": "dstok", "t": int(time.time())}, namespace="token"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm"))
async def test_drop_table(ds_write, scenario):
if scenario == "no_token":
token = "bad_token"
elif scenario == "no_perm":
token = write_token(ds_write, actor_id="not-root")
else:
token = write_token(ds_write)
should_work = scenario == "has_perm"

path = "/data/{}/-/drop".format("docs" if scenario != "bad_table" else "bad_table")
response = await ds_write.client.post(
path,
headers={
"Authorization": "Bearer {}".format(token),
"Content-Type": "application/json",
},
)
if should_work:
assert response.status_code == 200
assert response.json() == {"ok": True}
assert (await ds_write.client.get("/data/docs")).status_code == 404
else:
assert (
response.status_code == 403
if scenario in ("no_token", "bad_token")
else 404
)
assert response.json()["ok"] is False
assert (
response.json()["errors"] == ["Permission denied"]
if scenario == "no_token"
else ["Table not found: bad_table"]
)
assert (await ds_write.client.get("/data/docs")).status_code == 200

0 comments on commit 2865d39

Please sign in to comment.