Powerful Django Routing in 80 Lines of Python
Django's default way of routing always felt a bit clunky to me. Of course, having a big urls.py
, which shows all routes might be nice, but there's always ./manage.py show_urls
, at least if you have django-extensions installed (which you should!).
In this post, we're going to develop a somewhat more "dynamic" alternative, which borrows the @decorator-style route definitions from Flask, but extends them with guard-clauses - an idea inspired by the Rocket's request-guards.
Context is King
Doing classic server-side rendering with Django, maybe enhanced with some HTMX, you will soon notice how important the template context is. For a particular view, you will typically have multiple routes that allow the user to interact with the view. Adding HTMX into the mix often requires even more routes to render parts of your view in response to an HTMX request.
There's usually a big overlap in the context that's created in those routes - after all they are all interacting with the same page. So, naturally, you'll start to introduce some kind of abstraction to keep your code nice and tidy.
I'd go so far and argue that the context is central to organizing Django views and could be leveraged for more than just template rendering.
The goal API
Now that we have some context (heh), here's what we want to achieve:
- Make it easy to register new routes without changing the
urls.py
all the time. - Make it easy to have multiple handlers for the same route, distinguishing them by something related to the request (i.e. GET/POST or the existince of a specific query parameter).
- Integrate seamlessly into Django.
I came up with the following API:
def make_context(request):
return {
"request": request,
"method": request.method,
}
router = Router(make_context)
# Handle GET request
@router.guard(
router.path("", name="detail"),
method="GET"
)
def handle_get(context):
request = context["request"]
return TemplateResponse(request, "template.html", context)
# Handle DELETE request
@router.guard(
router.path("", name="detail"),
method="DELETE"
)
def handle_delete(context):
# delete logic here
request = context["request"]
return TemplateResponse(request, "template.html", context)
And in your urls.py
simply write:
from views import router
urls = [
path("", include(router.urls))
]
As explained earlier, this concept uses the context - or rather a context factory - as a central mechanism. For a single page, you'll typically define all of the context you are ever going to need here. This might seem a bit wasteful, but it leads to a coherent use of variables throughout your views and gives your code a clear structure.
In the simplest case, as shown above, you can just set values directly in the context. Alternatively, you can use the fact, that the Django Template Language (DTL) will call any callable that's evaluated as part of the variable resolution:
def make_context(request):
def get_form():
return MyCoolForm()
def get_expensive_value():
return 1
return {
"request": request,
"method": request.method,
"form": get_form,
"expensive_value": get_expensive_value,
}
I'd even go a step further and add dependency injection into the mix, but that's a topic for another post.
Implementing the API
So, let's first look at those guard
and path
methods.
Without further ado, here's the (slightly simplified) code:
from django.urls import path
@dataclass
class Router:
context_factory: Callable[..., dict[str, Any]]
registry: Any = field(default_factory=lambda: defaultdict(list))
paths: list[Any] = field(default_factory=list)
def guard(self, *paths, **expectations):
def decorator(decoratee):
for p in paths:
self.registry[p.name].append((expectations, decoratee))
self.paths.append(p)
return decoratee
return decorator
def path(self, *args, **kwargs):
return path(*args, view=self.view, **kwargs)
def view(self, request, **kwargs):
...
As you can see, this is pretty basic. Of course, I have left out the fun part, namely the view
method. The nice thing is, that the path
method just uses the regular old path
function that Django already provides. You could easily extend that to support re_path
also. The only thing the path
method does, is bind the routers view
method to the view
argument.
The guard
method is not really complex either. It basically just records the given paths and builds a mapping of path.name
to the provided expectations
(which where method="GET"
above) and the decorated function itself.
Now, the fun part: actually executing the view:
@dataclass
class Router:
...
def view(self, request, **kwargs):
context = self.context_factory(request, **kwargs) # 1
url_name = request.resolver_match.url_name
for expectations, handler in self.registry[url_name]: # 2
for key, expected_value in expectations.items(): # 3
try:
value = cget(context, key) # 4
except KeyError:
break
if callable(expected_value): # 5
if not expected_value(value):
break
else:
if value != expected_value: # 6
break
else:
return handler(context) # 7
In plain English, the code does the following:
- Build the context.
- Loop through all possible handlers, based on the resolved url name
- Loop through the saved
expectations
(remembermethod="GET"
earlier) - Lookup the
key
in the context (using a helper function, which basically just mimics the way the Django Template Language looks up keys in a context - i.e. calling callables) - If
expected_value
is itself callable, call it with the result of the lookup. This enables us to support more complex guards, i.e.method=lambda m: m in ("GET", "POST")
. - If
expected_value
is not callable, just compare it to the value in the context. - If none of the above checks fail, the loop exits normally, so the
else
statement will be executed and the result of running the handler with the context will be returned.
This simple API is actually remarkably powerful. For example, it is really simple, to have a separate "view" for valid forms, if you want that. And while we're at it, only render a specific partial, when requested via a query parameter:
def make_context(request):
def get_form():
if request.method == "POST":
return MyForm(request.POST)
return MyForm()
return {
"request": request,
"query": request.GET,
"form": get_form
}
router = Router(make_context)
@router(
router.path("", name="detail"),
method="POST",
form=lambda f: f.is_valid(),
)
def handle_valid_form(context):
return ...
@router(
router.path("", name="detail"),
query=lambda q: "partial" in q,
)
def handle_partial(context):
partial = r.GET["partial"]
return TemplateResponse(context["request"], f"template.html#{partial}", context)
Conclusion
Since I have started experimenting with this pattern, I have not found any real downsides. On the surface it might look a bit magic, but since the context is provided by you - the user of the library - you get to choose how to name your variables, how granular you want to make your context, etc. It could even be possible to add some typing to this, though I have not tried yet.
I've packaged this up in a library called djang-routerific. There's no documentation or tests yet, but I'm planning to work on this a bit more in the future.