From 72ac2fd32cb80a52fc4965872eb0146c3a3f99e3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 14 Sep 2020 14:23:18 -0700 Subject: [PATCH] JSON API for writable canned queries, closes #880 --- datasette/utils/testing.py | 28 ++++++++++++++--------- datasette/views/database.py | 22 ++++++++++++++++-- docs/sql_queries.rst | 37 ++++++++++++++++++++++++++++++ tests/test_canned_queries.py | 44 +++++++++++++++++++++++++++++------- 4 files changed, 110 insertions(+), 21 deletions(-) diff --git a/datasette/utils/testing.py b/datasette/utils/testing.py index eb87fded..6fc4c633 100644 --- a/datasette/utils/testing.py +++ b/datasette/utils/testing.py @@ -55,6 +55,7 @@ class TestClient: redirect_count=0, content_type="application/x-www-form-urlencoded", cookies=None, + headers=None, csrftoken_from=None, ): cookies = cookies or {} @@ -72,13 +73,14 @@ class TestClient: if post_data: body = urlencode(post_data, doseq=True) return await self._request( - path, - allow_redirects, - redirect_count, - "POST", - cookies, - body, - content_type, + path=path, + allow_redirects=allow_redirects, + redirect_count=redirect_count, + method="POST", + cookies=cookies, + headers=headers, + post_body=body, + content_type=content_type, ) async def _request( @@ -88,6 +90,7 @@ class TestClient: redirect_count=0, method="GET", cookies=None, + headers=None, post_body=None, content_type=None, ): @@ -99,14 +102,17 @@ class TestClient: raw_path = path.encode("latin-1") else: raw_path = quote(path, safe="/:,").encode("latin-1") - headers = [[b"host", b"localhost"]] + asgi_headers = [[b"host", b"localhost"]] + if headers: + for key, value in headers.items(): + asgi_headers.append([key.encode("utf-8"), value.encode("utf-8")]) if content_type: - headers.append((b"content-type", content_type.encode("utf-8"))) + asgi_headers.append((b"content-type", content_type.encode("utf-8"))) if cookies: sc = SimpleCookie() for key, value in cookies.items(): sc[key] = value - headers.append([b"cookie", sc.output(header="").encode("utf-8")]) + asgi_headers.append([b"cookie", sc.output(header="").encode("utf-8")]) scope = { "type": "http", "http_version": "1.0", @@ -114,7 +120,7 @@ class TestClient: "path": unquote(path), "raw_path": raw_path, "query_string": query_string, - "headers": headers, + "headers": asgi_headers, } instance = ApplicationCommunicator(self.asgi_app, scope) diff --git a/datasette/views/database.py b/datasette/views/database.py index 62fa14c1..8cd37fdb 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -219,10 +219,17 @@ class QueryView(DataView): params[key] = str(value) else: params = dict(parse_qsl(body, keep_blank_values=True)) + # Should we return JSON? + should_return_json = ( + request.headers.get("accept") == "application/json" + or request.args.get("_json") + or params.get("_json") + ) if canned_query: params_for_query = MagicParameters(params, request, self.ds) else: params_for_query = params + ok = None try: cursor = await self.ds.databases[database].execute_write( sql, params_for_query, block=True @@ -234,12 +241,23 @@ class QueryView(DataView): ) message_type = self.ds.INFO redirect_url = metadata.get("on_success_redirect") + ok = True except Exception as e: message = metadata.get("on_error_message") or str(e) message_type = self.ds.ERROR redirect_url = metadata.get("on_error_redirect") - self.ds.add_message(request, message, message_type) - return self.redirect(request, redirect_url or request.path) + ok = False + if should_return_json: + return Response.json( + { + "ok": ok, + "message": message, + "redirect": redirect_url, + } + ) + else: + self.ds.add_message(request, message, message_type) + return self.redirect(request, redirect_url or request.path) else: async def extra_template(): diff --git a/docs/sql_queries.rst b/docs/sql_queries.rst index dd7743cf..1206db9b 100644 --- a/docs/sql_queries.rst +++ b/docs/sql_queries.rst @@ -326,6 +326,43 @@ The form presented at ``/mydatabase/add_message`` will have just a field for ``m Additional custom magic parameters can be added by plugins using the :ref:`plugin_hook_register_magic_parameters` hook. +.. _canned_queries_json_api: + +JSON API for writable canned queries +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Writable canned queries can also be accessed using a JSON API. You can POST data to them using JSON, and you can request that their response is returned to you as JSON. + +To submit JSON to a writable canned query, encode key/value parameters as a JSON document:: + + POST /mydatabase/add_message + + {"message": "Message goes here"} + +You can also continue to submit data using regular form encoding, like so:: + + POST /mydatabase/add_message + + message=Message+goes+here + +There are three options for specifying that you would like the response to your request to return JSON data, as opposed to an HTTP redirect to another page. + +- Set an ``Accept: application/json`` header on your request +- Include ``?_json=1`` in the URL that you POST to +- Include ``"_json": 1`` in your JSON body, or ``&_json=1`` in your form encoded body + +The JSON response will look like this: + +.. code-block:: json + + { + "ok": true, + "message": "Query executed, 1 row affected", + "redirect": "/data/add_name" + } + +The ``"message"`` and ``"redirect"`` values here will take into account ``on_success_message``, ``on_success_redirect``, ``on_error_message`` and ``on_error_redirect``, if they have been set. + .. _pagination: Pagination diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index 3b1f40bd..67e9f822 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -176,6 +176,33 @@ def test_json_post_body(canned_write_client): assert rows == [{"rowid": 1, "name": "['Hello', 'there']"}] +@pytest.mark.parametrize( + "headers,body,querystring", + ( + (None, "name=NameGoesHere", "?_json=1"), + ({"Accept": "application/json"}, "name=NameGoesHere", None), + (None, "name=NameGoesHere&_json=1", None), + (None, '{"name": "NameGoesHere", "_json": 1}', None), + ), +) +def test_json_response(canned_write_client, headers, body, querystring): + response = canned_write_client.post( + "/data/add_name" + (querystring or ""), + body=body, + allow_redirects=False, + headers=headers, + ) + assert 200 == response.status + assert response.headers["content-type"] == "application/json; charset=utf-8" + assert response.json == { + "ok": True, + "message": "Query executed, 1 row affected", + "redirect": "/data/add_name?success", + } + rows = canned_write_client.get("/data/names.json?_shape=array").json + assert rows == [{"rowid": 1, "name": "NameGoesHere"}] + + def test_canned_query_permissions_on_database_page(canned_write_client): # Without auth only shows three queries query_names = { @@ -196,7 +223,14 @@ def test_canned_query_permissions_on_database_page(canned_write_client): cookies={"ds_actor": canned_write_client.actor_cookie({"id": "root"})}, ) assert 200 == response.status - assert [ + query_names_and_private = sorted( + [ + {"name": q["name"], "private": q["private"]} + for q in response.json["queries"] + ], + key=lambda q: q["name"], + ) + assert query_names_and_private == [ {"name": "add_name", "private": False}, {"name": "add_name_specify_id", "private": False}, {"name": "canned_read", "private": False}, @@ -204,13 +238,7 @@ def test_canned_query_permissions_on_database_page(canned_write_client): {"name": "from_async_hook", "private": False}, {"name": "from_hook", "private": False}, {"name": "update_name", "private": False}, - ] == sorted( - [ - {"name": q["name"], "private": q["private"]} - for q in response.json["queries"] - ], - key=lambda q: q["name"], - ) + ] def test_canned_query_permissions(canned_write_client):