From f5e79adf26d0daa3831e3fba022f1b749a9efdee Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 8 Jun 2020 20:12:06 -0700 Subject: [PATCH] register_routes() plugin hook (#819) Fixes #215 --- datasette/app.py | 21 ++++++++++++++++ datasette/hookspecs.py | 5 ++++ datasette/utils/__init__.py | 12 ++++++++- datasette/utils/asgi.py | 2 +- docs/index.rst | 2 +- docs/plugins.rst | 50 ++++++++++++++++++++++++++++++++++++- tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 25 +++++++++++++++++++ tests/test_plugins.py | 15 +++++++++++ 9 files changed, 129 insertions(+), 4 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 79f52a5418..120091f7f3 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -39,6 +39,7 @@ from .database import Database, QueryInterrupted from .utils import ( + async_call_with_supported_arguments, escape_css_string, escape_sqlite, format_bytes, @@ -783,6 +784,10 @@ def app(self): "Returns an ASGI app function that serves the whole of Datasette" routes = [] + for routes_to_add in pm.hook.register_routes(): + for regex, view_fn in routes_to_add: + routes.append((regex, wrap_view(view_fn, self))) + def add_route(view, regex): routes.append((regex, view)) @@ -1048,3 +1053,19 @@ def _cleaner_task_str(task): # running at /Users/simonw/Dropbox/Development/datasette/venv-3.7.5/lib/python3.7/site-packages/uvicorn/main.py:361> # Clean up everything up to and including site-packages return _cleaner_task_str_re.sub("", s) + + +def wrap_view(view_fn, datasette): + async def asgi_view_fn(scope, receive, send): + response = await async_call_with_supported_arguments( + view_fn, + scope=scope, + receive=receive, + send=send, + request=Request(scope, receive), + datasette=datasette, + ) + if response is not None: + await response.asgi_send(send) + + return asgi_view_fn diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index d5fd232fe7..ab3e131ce5 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -60,6 +60,11 @@ def register_facet_classes(): "Register Facet subclasses" +@hookspec +def register_routes(): + "Register URL routes: return a list of (regex, view_function) pairs" + + @hookspec def actor_from_request(datasette, request): "Return an actor dictionary based on the incoming request" diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index 7c1f34e06b..492686381b 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -842,7 +842,7 @@ def parse_metadata(content): raise BadMetadataError("Metadata is not valid JSON or YAML") -def call_with_supported_arguments(fn, **kwargs): +def _gather_arguments(fn, kwargs): parameters = inspect.signature(fn).parameters.keys() call_with = [] for parameter in parameters: @@ -853,9 +853,19 @@ def call_with_supported_arguments(fn, **kwargs): ) ) call_with.append(kwargs[parameter]) + return call_with + + +def call_with_supported_arguments(fn, **kwargs): + call_with = _gather_arguments(fn, kwargs) return fn(*call_with) +async def async_call_with_supported_arguments(fn, **kwargs): + call_with = _gather_arguments(fn, kwargs) + return await fn(*call_with) + + def actor_matches_allow(actor, allow): actor = actor or {} if allow is None: diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index bca9c9ab34..349f2a0aa2 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -399,7 +399,7 @@ def html(cls, body, status=200, headers=None): @classmethod def text(cls, body, status=200, headers=None): return cls( - body, + str(body), status=status, headers=headers, content_type="text/plain; charset=utf-8", diff --git a/docs/index.rst b/docs/index.rst index 03988c8e60..5334386fd0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,7 +31,7 @@ Contents -------- .. toctree:: - :maxdepth: 2 + :maxdepth: 3 getting_started installation diff --git a/docs/plugins.rst b/docs/plugins.rst index 73d2eabd8c..caca00199b 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -835,6 +835,55 @@ And here is an example ``can_render`` function which returns ``True`` only if th Examples: `datasette-atom `_, `datasette-ics `_ +.. _plugin_register_routes: + +register_routes() +~~~~~~~~~~~~~~~~~ + +Register additional view functions to execute for specified URL routes. + +Return a list of ``(regex, async_view_function)`` pairs, something like this: + +.. code-block:: python + + from datasette.utils.asgi import Response + import html + + + async def hello_from(scope): + name = scope["url_route"]["kwargs"]["name"] + return Response.html("Hello from {}".format( + html.escape(name) + )) + + + @hookimpl + def register_routes(): + return [ + (r"^/hello-from/(?P.*)$"), hello_from) + ] + +The view functions can take a number of different optional arguments. The corresponding argument will be passed to your function depending on its named parameters - a form of dependency injection. + +The optional view function arguments are as follows: + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``request`` - Request object + The current HTTP :ref:`internals_request`. + +``scope`` - dictionary + The incoming ASGI scope dictionary. + +``send`` - function + The ASGI send function. + +``receive`` - function + The ASGI receive function. + +The function can either return a ``Response`` or it can return nothing and instead respond directly to the request using the ASGI ``receive`` function (for advanced uses only). + .. _plugin_register_facet_classes: register_facet_classes() @@ -901,7 +950,6 @@ The plugin hook can then be used to register the new facet class like this: def register_facet_classes(): return [SpecialFacet] - .. _plugin_asgi_wrapper: asgi_wrapper(datasette) diff --git a/tests/fixtures.py b/tests/fixtures.py index e9175b57d9..a51a869da6 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -46,6 +46,7 @@ "prepare_connection", "prepare_jinja2_environment", "register_facet_classes", + "register_routes", "render_cell", ], }, diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 4689371038..5780317802 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -1,6 +1,7 @@ from datasette import hookimpl from datasette.facets import Facet from datasette.utils import path_with_added_args +from datasette.utils.asgi import asgi_send_json, Response import base64 import pint import json @@ -142,3 +143,27 @@ def permission_allowed(actor, action): return True elif action == "this_is_denied": return False + + +@hookimpl +def register_routes(): + async def one(datasette): + return Response.text( + (await datasette.get_database().execute("select 1 + 1")).first()[0] + ) + + async def two(request, scope): + name = scope["url_route"]["kwargs"]["name"] + greeting = request.args.get("greeting") + return Response.text("{} {}".format(greeting, name)) + + async def three(scope, send): + await asgi_send_json( + send, {"hello": "world"}, status=200, headers={"x-three": "1"} + ) + + return [ + (r"/one/$", one), + (r"/two/(?P.*)$", two), + (r"/three/$", three), + ] diff --git a/tests/test_plugins.py b/tests/test_plugins.py index c782b87bc3..c7bb4859d6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -544,3 +544,18 @@ def test_actor_json(app_client): assert {"actor": {"id": "bot2", "1+1": 2}} == app_client.get( "/-/actor.json/?_bot2=1" ).json + + +@pytest.mark.parametrize( + "path,body", [("/one/", "2"), ("/two/Ray?greeting=Hail", "Hail Ray"),] +) +def test_register_routes(app_client, path, body): + response = app_client.get(path) + assert 200 == response.status + assert body == response.text + + +def test_register_routes_asgi(app_client): + response = app_client.get("/three/") + assert {"hello": "world"} == response.json + assert "1" == response.headers["x-three"]