Skip to content

Commit

Permalink
register_routes() plugin hook (#819)
Browse files Browse the repository at this point in the history
Fixes #215
  • Loading branch information
simonw authored Jun 9, 2020
1 parent d392dc1 commit f5e79ad
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 4 deletions.
21 changes: 21 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from .database import Database, QueryInterrupted

from .utils import (
async_call_with_supported_arguments,
escape_css_string,
escape_sqlite,
format_bytes,
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion datasette/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion datasette/utils/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Contents
--------

.. toctree::
:maxdepth: 2
:maxdepth: 3

getting_started
installation
Expand Down
50 changes: 49 additions & 1 deletion docs/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,55 @@ And here is an example ``can_render`` function which returns ``True`` only if th
Examples: `datasette-atom <https://github.com/simonw/datasette-atom>`_, `datasette-ics <https://github.com/simonw/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<name>.*)$"), 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()
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"prepare_connection",
"prepare_jinja2_environment",
"register_facet_classes",
"register_routes",
"render_cell",
],
},
Expand Down
25 changes: 25 additions & 0 deletions tests/plugins/my_plugin.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<name>.*)$", two),
(r"/three/$", three),
]
15 changes: 15 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

0 comments on commit f5e79ad

Please sign in to comment.